using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Collections;
using Unity.Mathematics;
namespace ProjectM.Tests
{
///
/// Pure unit tests for , the Burst-safe auto-target assist
/// helper. No ECS world is needed: the method is a deterministic, allocation-free function over
/// a of candidate XZ world positions, so each case just allocates a
/// Temp array, invokes Resolve, asserts the returned planar direction, and disposes. Covers the
/// contract cases: candidate inside the cone+range steers the shot, candidates behind / outside
/// the cone or out of range fall back to the raw aim, the nearer of two candidates wins, ties
/// break by smallest index, near-zero aim is returned unchanged, and an empty candidate set is a
/// no-op. Netcode-free and version-independent, mirroring PlayerMoveSystemTests.
///
public class AutoTargetTests
{
// Tolerance for comparing normalized planar directions.
const float Tol = 1e-4f;
static void AssertDirEqual(float2 expected, float2 actual, string message)
{
Assert.AreEqual(expected.x, actual.x, Tol, message + " (x)");
Assert.AreEqual(expected.y, actual.y, Tol, message + " (y)");
}
[Test]
public void Resolve_CandidateDirectlyAhead_WithinConeAndRange_PointsAtIt()
{
// Shooter at origin aiming +Z; a candidate sits straight ahead, well inside range and
// exactly on the aim axis, so the resolved direction must point at it (== raw aim here).
var from = float3.zero;
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray(new[] { new float3(0f, 0f, 5f) }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(new float2(0f, 1f), dir, "Candidate dead ahead should be targeted.");
}
[Test]
public void Resolve_OffAxisCandidate_WithinCone_SteersTowardsIt()
{
// Candidate is offset on +X but within the 35-degree cone of a +Z aim. The shot should
// bend toward the candidate rather than keep the raw aim.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
// ~21.8 degrees off the +Z axis: within a 35-degree half-angle cone.
var target = new float3(2f, 0f, 5f);
using var candidates = new NativeArray(new[] { target }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
var expected = math.normalize(new float2(target.x, target.z));
AssertDirEqual(expected, dir, "In-cone off-axis candidate should be targeted.");
}
[Test]
public void Resolve_CandidateBehind_ReturnsRawAim()
{
// Candidate is directly behind the shooter (-Z) while aiming +Z: outside any forward cone.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray(new[] { new float3(0f, 0f, -5f) }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "A candidate behind the shooter must not be targeted.");
}
[Test]
public void Resolve_CandidateOutsideCone_ReturnsRawAim()
{
// Candidate is 90 degrees to the side (+X) of a +Z aim: well outside a 35-degree cone.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray(new[] { new float3(5f, 0f, 0f) }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "A candidate outside the cone must not be targeted.");
}
[Test]
public void Resolve_CandidateOutsideRange_ReturnsRawAim()
{
// Candidate is dead ahead (in cone) but beyond autoTargetRange.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray(new[] { new float3(0f, 0f, 20f) }, Allocator.Temp); // range is 12, so out of reach.
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "A candidate beyond range must not be targeted.");
}
[Test]
public void Resolve_TwoCandidates_NearerChosen()
{
// Both candidates lie on the +Z aim axis and inside range; the nearer one must win.
var from = float3.zero;
var rawAim = new float2(0f, 1f);
var near = new float3(1f, 0f, 4f);
var far = new float3(-1f, 0f, 9f);
// Deliberately list the far one first to prove distance, not order, decides.
using var candidates = new NativeArray(new[] { far, near }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(45f), candidates);
var expected = math.normalize(new float2(near.x, near.z));
AssertDirEqual(expected, dir, "The nearer in-cone candidate should be targeted.");
}
[Test]
public void Resolve_EqualDistanceCandidates_BreaksTieBySmallestIndex()
{
// Two candidates at the same planar distance and both in-cone: the contract specifies the
// tie is broken by the smallest candidate index (candidates[0]).
var from = float3.zero;
var rawAim = new float2(0f, 1f);
var first = new float3(1f, 0f, 5f);
var second = new float3(-1f, 0f, 5f); // same planar distance as `first`.
using var candidates = new NativeArray(new[] { first, second }, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(45f), candidates);
var expected = math.normalize(new float2(first.x, first.z));
AssertDirEqual(expected, dir, "Equal-distance ties must resolve to the lowest index.");
}
[Test]
public void Resolve_EmptyCandidateArray_ReturnsRawAim()
{
// No candidates at all: the raw aim is returned unchanged.
var from = float3.zero;
var rawAim = math.normalize(new float2(0.3f, 1f));
using var candidates = new NativeArray(0, Allocator.Temp);
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "An empty candidate set must return the raw aim.");
}
[Test]
public void Resolve_CandidateAtShooterPosition_IsSkipped_ReturnsRawAim()
{
// A candidate at (effectively) zero planar distance from the shooter must be skipped
// (no well-defined bearing); with only that candidate present, raw aim is returned.
var from = new float3(3f, 1f, 3f);
var rawAim = new float2(0f, 1f);
using var candidates = new NativeArray(new[] { new float3(from.x, 0f, from.z) }, Allocator.Temp); // same XZ as the shooter (Y ignored).
var dir = AutoTarget.Resolve(from, rawAim, autoTargetRange: 12f,
coneHalfAngleRadians: math.radians(35f), candidates);
AssertDirEqual(rawAim, dir, "A zero-distance candidate must be skipped.");
}
}
}