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."); } } }