diff --git a/.gitignore b/.gitignore index e4661501..6d0f1f77 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # dotenv files .env +# Stryker.NET mutation-testing output +StrykerOutput/ + # User-specific files *.rsuser *.suo diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md new file mode 100644 index 00000000..ea1f67d2 --- /dev/null +++ b/docs/mutation-testing.md @@ -0,0 +1,86 @@ +# Mutation Testing + +CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local +tool in `dotnet-tools.json`. Coverage is added project by project; a project +with no `stryker-config.json` next to its test project has not been scoped +yet (see "Follow-ups" below for what's outstanding). + +## Running it + +Restore the tool once per checkout: + +```powershell +dotnet tool restore +``` + +Run a full mutation pass from a covered test project's directory, for +example CultMath: + +```powershell +cd packages/cultmath/tests/CultMath.Tests +dotnet stryker -t mtp +``` + +`-t mtp` selects Stryker's Microsoft.Testing.Platform runner. It is required +for test projects on `xunit.v3` (CultMath.Tests is one): Stryker's default +`vstest` runner's coverage capture does not work against xunit.v3's native +test host and the run stalls retrying test sessions indefinitely. Projects +still on `xunit` v2 with `xunit.runner.visualstudio` can use the default +`vstest` runner. + +For a single cut, scope the run to what changed instead of the whole +project: + +```powershell +dotnet stryker -t mtp --since: +``` + +Reports land in `StrykerOutput/` next to the test project (git-ignored). +Open the `reports/mutation-report.html` file for a browsable view, or read +`reports/mutation-report.json` for a scriptable one. + +## Triage rules + +The mutation score is not a gate (`break` threshold is 0). Every surviving +mutant is a finding about the tests, not a build failure, and gets triaged +into one of: + +- **missing behavioural test** — name the test that should exist and what + branch, edge case, or component it would exercise. +- **degenerate fixture** — the code path is exercised, but the fixture's + values make two different mutants produce the same observable result (a + zero component that erases a `+`/`-` distinction, an already-normalized + input that erases a `*`/`/` distinction, all-equal or all-same-sign inputs + that erase a boundary or `&&`/`||` distinction). Fix the fixture, don't + invent a new one that only kills that one mutant. +- **equivalent** — the mutant cannot change behaviour. Float-threshold + boundary flips (`>` to `>=`) are equivalent by default. A reasoned + equivalence claim (for example, `>>` and `>>>` on an already-unsigned + operand) needs its one-line reason recorded, not assumed. + +Do not write a hand-rolled mutation suite or anchor test as a substitute. +Where the tool can't reach (native/shader/editor code), the defence is a +behavioural test at the layer the rule is decided, not a committed mutation +harness. + +## Known tool caveat + +Stryker 5.0.0's `mtp` runner is documented as preview. Its per-test coverage +capture was found to report false "Survived" verdicts specifically for +mutations inside `static readonly` field initializers that a test does +cover (confirmed by hand-mutating `bool3.@false` and `bool4.@true` and +running the full suite directly with `dotnet test`, both of which fail +against the mutation that Stryker reported as surviving). Treat a survivor +inside a `static readonly` field initializer as unproven until confirmed +with a direct `dotnet test` run against the same mutation; do not triage it +as a missing test without that check. + +## Follow-ups + +CultMath.Tests is the only project currently scoped. `GameCult.Caching`, +`GameCult.Geometry`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`), +`GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes +(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each need their own +`stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for +Rust, `StrykerJS` for TypeScript, `mutmut` for Python) and a baseline pass. +None of that is wired into CI yet. diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 00000000..6da5949f --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-stryker": { + "version": "5.0.0", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs new file mode 100644 index 00000000..62280c74 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs @@ -0,0 +1,427 @@ +using System; +using CultMath; +using Xunit; + +namespace CultMath.Tests; + +// Stryker's mtp baseline on hands/adopt-stryker found BatchMath undefended in three ways: +// both existing tests used 2-3 particles while LaneCount >= 4, so the vector body never ran +// and only the scalar tail executed; both held y at 0 for every particle, so every y-axis +// mutation was invisible; and the guard branches (early return, negative deltaTime, length +// mismatch) were never exercised. These tests read LaneCount at runtime, use +// LaneCount * 3 + 1 particles (a non-empty vector body AND a non-empty remainder), give every +// particle distinct nonzero x and y, and pin results against a scalar reference computed here +// rather than by calling the code under test. +public sealed class BatchMathTests +{ + private const float Tolerance = 1e-4f; + + [Fact] + public void RadialFalloffMatchesScalarReferenceAcrossVectorBodyAndRemainder() + { + var lanes = BatchMath.LaneCount; + var count = lanes * 3 + 1; + Assert.True(count > lanes, "fixture must exceed one lane to exercise the vector body"); + Assert.True((count % lanes) != 0, "fixture must leave a non-empty scalar remainder"); + + var px = new float[count]; + var py = new float[count]; + var ax = new float[count]; + var ay = new float[count]; + // Distinct, asymmetric per-particle values: x and y follow different formulas so an + // x/y swap mutation is observable, and no particle sits at the falloff center. + for (var i = 0; i < count; i++) + { + px[i] = i * 1.3f - 4.0f; + py[i] = i * -0.7f + 2.5f; + } + + const float centerX = 3.0f; + const float centerY = -1.0f; + const float strength = 5.0f; + // Chosen so some particles fall inside the radius and some fall outside it, exercising + // both the ConditionalSelect(inRange, ...) vector path and the scalar "continue". + const float radius = 6.0f; + + // The scalar remainder is exactly one particle (index count-1). The generator above puts + // it far outside the radius, which would leave any y-axis mutation in the scalar tail's + // "in range" arithmetic unobserved (the only remainder particle never reaches it). Pin it + // inside the radius with a nonzero, non-degenerate y offset instead. + px[count - 1] = centerX + 2.0f; + py[count - 1] = centerY + 1.5f; + + BatchMath.AddRadialFalloffAcceleration2D(px, py, centerX, centerY, strength, radius, ax, ay); + + var insideCount = 0; + var outsideCount = 0; + for (var i = 0; i < count; i++) + { + var dx = centerX - px[i]; + var dy = centerY - py[i]; + var distanceSquared = Math.Max(dx * dx + dy * dy, 1.0e-8f); + var distance = MathF.Sqrt(distanceSquared); + + if (distance > radius) + { + outsideCount++; + Assert.Equal(0.0f, ax[i], Tolerance); + Assert.Equal(0.0f, ay[i], Tolerance); + continue; + } + + insideCount++; + var falloff = Math.Max(0.0f, 1.0f - distance / radius); + var magnitude = strength * falloff; + var expectedAx = dx / distance * magnitude; + var expectedAy = dy / distance * magnitude; + Assert.Equal(expectedAx, ax[i], Tolerance); + Assert.Equal(expectedAy, ay[i], Tolerance); + } + + Assert.True(insideCount > 0, "fixture must include particles inside the radius"); + Assert.True(outsideCount > 0, "fixture must include particles outside the radius"); + } + + [Fact] + public void RadialFalloffAccumulatesOntoExistingAcceleration() + { + // Guards against a mutation that assigns instead of accumulating: seed a nonzero + // acceleration and confirm the falloff contribution is added to it, not overwritten. + var px = new[] { 0.0f }; + var py = new[] { 0.0f }; + var ax = new[] { 10.0f }; + var ay = new[] { -20.0f }; + + BatchMath.AddRadialFalloffAcceleration2D(px, py, 1.0f, 0.0f, 4.0f, 8.0f, ax, ay); + + var dx = 1.0f - 0.0f; + var dy = 0.0f - 0.0f; + var distance = MathF.Sqrt(dx * dx + dy * dy); + var falloff = Math.Max(0.0f, 1.0f - distance / 8.0f); + var magnitude = 4.0f * falloff; + Assert.Equal(10.0f + dx / distance * magnitude, ax[0], Tolerance); + Assert.Equal(-20.0f + dy / distance * magnitude, ay[0], Tolerance); + } + + [Fact] + public void RadialFalloffValidatesEqualSpanLengths() + { + var px = new float[3]; + var py = new float[2]; + var ax = new float[3]; + var ay = new float[3]; + + Assert.Throws(() => + BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 1.0f, 1.0f, ax, ay)); + } + + [Fact] + public void RadialFalloffIsNoOpWhenRadiusIsNonPositive() + { + var px = new[] { 1.0f, 2.0f }; + var py = new[] { 3.0f, -4.0f }; + var ax = new[] { 5.0f, 6.0f }; + var ay = new[] { 7.0f, 8.0f }; + + BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 9.0f, 0.0f, ax, ay); + + Assert.Equal(new[] { 5.0f, 6.0f }, ax); + Assert.Equal(new[] { 7.0f, 8.0f }, ay); + } + + [Fact] + public void RadialFalloffIsNoOpWhenStrengthIsZero() + { + var px = new[] { 1.0f, 2.0f }; + var py = new[] { 3.0f, -4.0f }; + var ax = new[] { 5.0f, 6.0f }; + var ay = new[] { 7.0f, 8.0f }; + + BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 0.0f, 10.0f, ax, ay); + + Assert.Equal(new[] { 5.0f, 6.0f }, ax); + Assert.Equal(new[] { 7.0f, 8.0f }, ay); + } + + [Fact] + public void RadialFalloffIsNoOpWhenSpansAreEmpty() + { + var px = Array.Empty(); + var py = Array.Empty(); + var ax = Array.Empty(); + var ay = Array.Empty(); + + // Must not throw for a valid but degenerate zero-length batch. + BatchMath.AddRadialFalloffAcceleration2D(px, py, 0.0f, 0.0f, 1.0f, 1.0f, ax, ay); + + Assert.Empty(ax); + Assert.Empty(ay); + } + + [Fact] + public void EulerIntegrationMatchesScalarReferenceAcrossVectorBodyAndRemainder() + { + var lanes = BatchMath.LaneCount; + var count = lanes * 3 + 1; + Assert.True((count % lanes) != 0, "fixture must leave a non-empty scalar remainder"); + + const float deltaTime = 0.25f; + var dynamicMask = new float[count]; + var px = new float[count]; + var py = new float[count]; + var vx = new float[count]; + var vy = new float[count]; + var ax = new float[count]; + var ay = new float[count]; + + for (var i = 0; i < count; i++) + { + // Every third particle is frozen (mask 0) so the mask branch is exercised across + // both the vector body and the remainder, not only at the array's edges. + dynamicMask[i] = i % 3 == 0 ? 0.0f : 1.0f; + px[i] = i * 0.4f - 1.0f; + py[i] = i * -0.6f + 3.0f; + vx[i] = i * 0.2f + 1.0f; + vy[i] = i * -0.3f - 2.0f; + ax[i] = i * 0.5f - 2.5f; + ay[i] = i * -0.9f + 1.5f; + } + + // The scalar remainder is exactly one particle (index count-1). The mask pattern above + // would leave it frozen for lane counts divisible by 3, which masks every y-axis (and + // x-axis) mutation in the scalar tail's arithmetic to zero regardless of the mutation. + // Force it dynamic so the remainder actually exercises the scalar tail's math. + dynamicMask[count - 1] = 1.0f; + + // Independent copies for the scalar reference; the arrays under test are mutated in place. + var expectedPx = (float[])px.Clone(); + var expectedPy = (float[])py.Clone(); + var expectedVx = (float[])vx.Clone(); + var expectedVy = (float[])vy.Clone(); + + BatchMath.IntegrateSemiImplicitEuler2D(deltaTime, dynamicMask, px, py, vx, vy, ax, ay); + + var anyFrozen = false; + var anyMoving = false; + for (var i = 0; i < count; i++) + { + var mask = dynamicMask[i]; + expectedVx[i] += ax[i] * deltaTime * mask; + expectedVy[i] += ay[i] * deltaTime * mask; + expectedPx[i] += expectedVx[i] * deltaTime * mask; + expectedPy[i] += expectedVy[i] * deltaTime * mask; + + if (mask == 0.0f) anyFrozen = true; else anyMoving = true; + + Assert.Equal(expectedVx[i], vx[i], Tolerance); + Assert.Equal(expectedVy[i], vy[i], Tolerance); + Assert.Equal(expectedPx[i], px[i], Tolerance); + Assert.Equal(expectedPy[i], py[i], Tolerance); + } + + Assert.True(anyFrozen, "fixture must include masked-off (frozen) particles"); + Assert.True(anyMoving, "fixture must include dynamic particles"); + } + + [Fact] + public void EulerIntegrationRejectsNonPositiveDeltaTime() + { + var dynamicMask = new[] { 1.0f }; + var px = new[] { 0.0f }; + var py = new[] { 0.0f }; + var vx = new[] { 0.0f }; + var vy = new[] { 0.0f }; + var ax = new[] { 0.0f }; + var ay = new[] { 0.0f }; + + Assert.Throws(() => + BatchMath.IntegrateSemiImplicitEuler2D(0.0f, dynamicMask, px, py, vx, vy, ax, ay)); + Assert.Throws(() => + BatchMath.IntegrateSemiImplicitEuler2D(-1.0f, dynamicMask, px, py, vx, vy, ax, ay)); + } + + [Fact] + public void EulerIntegrationValidatesEqualSpanLengths() + { + var dynamicMask = new float[3]; + var px = new float[3]; + var py = new float[3]; + var vx = new float[2]; + var vy = new float[3]; + var ax = new float[3]; + var ay = new float[3]; + + Assert.Throws(() => + BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, px, py, vx, vy, ax, ay)); + } + + [Fact] + public void Float2RadialFalloffMatchesScalarReferenceWithNonzeroYAndOutOfRangeParticles() + { + var count = 5; + var positions = new float2[count]; + for (var i = 0; i < count; i++) + { + positions[i] = new float2(i * 1.1f - 2.0f, i * -0.8f + 1.0f); + } + + var center = new float2(1.5f, -0.5f); + const float strength = 3.0f; + const float radius = 3.0f; + var acceleration = new float2[count]; + + BatchMath.AddRadialFalloffAcceleration2D(positions, center, strength, radius, acceleration); + + var insideCount = 0; + var outsideCount = 0; + for (var i = 0; i < count; i++) + { + var delta = center - positions[i]; + var distanceSquared = Math.Max(delta.x * delta.x + delta.y * delta.y, 1.0e-8f); + var distance = MathF.Sqrt(distanceSquared); + + if (distance > radius) + { + outsideCount++; + Assert.Equal(0.0f, acceleration[i].x, Tolerance); + Assert.Equal(0.0f, acceleration[i].y, Tolerance); + continue; + } + + insideCount++; + var falloff = Math.Max(0.0f, 1.0f - distance / radius); + var expected = delta / distance * (strength * falloff); + Assert.Equal(expected.x, acceleration[i].x, Tolerance); + Assert.Equal(expected.y, acceleration[i].y, Tolerance); + } + + Assert.True(insideCount > 0, "fixture must include particles inside the radius"); + Assert.True(outsideCount > 0, "fixture must include particles outside the radius"); + } + + [Fact] + public void Float2RadialFalloffAccumulatesOntoExistingAcceleration() + { + // Guards against a mutation that assigns instead of accumulating (BatchMath.cs:115): + // seed a nonzero, per-element-distinct acceleration and confirm the falloff + // contribution is added to it, not overwritten. An out-of-range particle must be left + // exactly at its seed value, not reset to zero. + var count = 5; + var positions = new float2[count]; + var seedAcceleration = new float2[count]; + for (var i = 0; i < count; i++) + { + positions[i] = new float2(i * 1.1f - 2.0f, i * -0.8f + 1.0f); + seedAcceleration[i] = new float2(i * 2.3f + 4.0f, i * -1.7f - 6.0f); + } + + var center = new float2(1.5f, -0.5f); + const float strength = 3.0f; + const float radius = 3.0f; + var acceleration = (float2[])seedAcceleration.Clone(); + + BatchMath.AddRadialFalloffAcceleration2D(positions, center, strength, radius, acceleration); + + var insideCount = 0; + var outsideCount = 0; + for (var i = 0; i < count; i++) + { + var delta = center - positions[i]; + var distanceSquared = Math.Max(delta.x * delta.x + delta.y * delta.y, 1.0e-8f); + var distance = MathF.Sqrt(distanceSquared); + + if (distance > radius) + { + outsideCount++; + Assert.Equal(seedAcceleration[i].x, acceleration[i].x, Tolerance); + Assert.Equal(seedAcceleration[i].y, acceleration[i].y, Tolerance); + continue; + } + + insideCount++; + var falloff = Math.Max(0.0f, 1.0f - distance / radius); + var contribution = delta / distance * (strength * falloff); + Assert.Equal(seedAcceleration[i].x + contribution.x, acceleration[i].x, Tolerance); + Assert.Equal(seedAcceleration[i].y + contribution.y, acceleration[i].y, Tolerance); + } + + Assert.True(insideCount > 0, "fixture must include particles inside the radius"); + Assert.True(outsideCount > 0, "fixture must include particles outside the radius"); + } + + [Fact] + public void Float2EulerIntegrationMatchesScalarReferenceWithNonzeroYAndMask() + { + const float deltaTime = 0.2f; + var dynamicMask = new[] { 1.0f, 0.0f, 1.0f }; + var position = new[] { new float2(0.0f, 0.0f), new float2(1.0f, 1.0f), new float2(-2.0f, 3.0f) }; + var velocity = new[] { new float2(1.0f, -1.0f), new float2(2.0f, 2.0f), new float2(-1.0f, 0.5f) }; + var acceleration = new[] { new float2(0.5f, 1.5f), new float2(3.0f, -3.0f), new float2(2.0f, -0.5f) }; + + var expectedPosition = (float2[])position.Clone(); + var expectedVelocity = (float2[])velocity.Clone(); + + BatchMath.IntegrateSemiImplicitEuler2D(deltaTime, dynamicMask, position, velocity, acceleration); + + for (var i = 0; i < position.Length; i++) + { + var mask = dynamicMask[i]; + expectedVelocity[i] += acceleration[i] * deltaTime * mask; + expectedPosition[i] += expectedVelocity[i] * deltaTime * mask; + + Assert.Equal(expectedVelocity[i].x, velocity[i].x, Tolerance); + Assert.Equal(expectedVelocity[i].y, velocity[i].y, Tolerance); + Assert.Equal(expectedPosition[i].x, position[i].x, Tolerance); + Assert.Equal(expectedPosition[i].y, position[i].y, Tolerance); + } + } + + [Fact] + public void Float2EulerIntegrationRejectsNonPositiveDeltaTime() + { + var dynamicMask = new[] { 1.0f }; + var position = new[] { float2.zero }; + var velocity = new[] { float2.zero }; + var acceleration = new[] { float2.zero }; + + Assert.Throws(() => + BatchMath.IntegrateSemiImplicitEuler2D(0.0f, dynamicMask, position, velocity, acceleration)); + } + + [Fact] + public void Float2EulerIntegrationValidatesEqualSpanLengths() + { + var dynamicMask = new float[3]; + var position = new float2[3]; + var velocity = new float2[2]; + var acceleration = new float2[3]; + + Assert.Throws(() => + BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, position, velocity, acceleration)); + } + + [Fact] + public void Float2RadialFalloffValidatesEqualSpanLengths() + { + var positions = new float2[3]; + var acceleration = new float2[2]; + + Assert.Throws(() => + BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 1.0f, 1.0f, acceleration)); + } + + [Fact] + public void Float2RadialFalloffIsNoOpWhenRadiusIsNonPositiveOrStrengthIsZero() + { + var positions = new[] { new float2(1.0f, 3.0f), new float2(2.0f, -4.0f) }; + var acceleration = new[] { new float2(5.0f, 7.0f), new float2(6.0f, 8.0f) }; + + BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 9.0f, 0.0f, acceleration); + Assert.Equal(new float2(5.0f, 7.0f), acceleration[0]); + Assert.Equal(new float2(6.0f, 8.0f), acceleration[1]); + + BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 0.0f, 10.0f, acceleration); + Assert.Equal(new float2(5.0f, 7.0f), acceleration[0]); + Assert.Equal(new float2(6.0f, 8.0f), acceleration[1]); + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs b/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs new file mode 100644 index 00000000..a211d266 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs @@ -0,0 +1,182 @@ +using CultMath; +using Xunit; + +namespace CultMath.Tests; + +// math.first_order_intercept_time / first_order_intercept had one happy-path test before this +// (MathTests.FirstOrderInterceptSolvesSimpleClosingTarget). Stryker's baseline on +// hands/adopt-stryker found L264 (zero relative velocity), L270 (the a-approx-zero linear +// fallback), L280/L283 (the quadratic determinant and its roots) and L285 (the t1>0 branch) +// all undefended by fixtures that never varied relative velocity, never went linear, never +// lacked a solution, and never used off-axis/off-origin shooter and target state. +// +// For the closing cases (a genuine positive root exists), the assertion is behavioural: the +// returned aim point, fired at shotSpeed from a moving shooter, and the target's own motion +// must put the shot and the target at the same place at the returned time. That is verified by +// computing both positions independently in the test from math.first_order_intercept's own +// contract (targetPosition + time * targetRelativeVelocity is the aim point; the shot's actual +// world position folds in the shooter's velocity), not by re-deriving a second copy of the +// production formula. +// +// For the no-solution branches, there is no physical coincidence to check (the fallback is not +// a root of the intercept equation), so the assertion instead pins the contract read directly +// from the source at math.cs:264-298: velocitySquared < 0.001 => 0; determinant < 0 => 0; +// determinant > 0 with both roots <= 0 => 0 (via max(t2, 0)). +public sealed class FirstOrderInterceptTests +{ + private const int Precision = 4; + + [Fact] + public void ZeroRelativeVelocityReturnsZeroTime() + { + // Target and shooter share a velocity: targetRelativeVelocity is exactly zero, well + // under the 0.001 lengthsq guard, regardless of either party's absolute speed. + var time = math.first_order_intercept_time( + shotSpeed: 50.0f, + targetRelativePosition: new float3(12.0f, -7.0f, 3.0f), + targetRelativeVelocity: float3.zero); + + Assert.Equal(0.0f, time); + + var aimPoint = math.first_order_intercept( + shooterPosition: new float3(1.0f, 2.0f, 3.0f), + shooterVelocity: new float3(4.0f, 4.0f, 4.0f), + shotSpeed: 50.0f, + targetPosition: new float3(13.0f, -5.0f, 6.0f), + targetVelocity: new float3(4.0f, 4.0f, 4.0f)); + + // Contract: with time 0, the aim point is exactly the target's current position. + Assert.Equal(13.0f, aimPoint.x, Precision); + Assert.Equal(-5.0f, aimPoint.y, Precision); + Assert.Equal(6.0f, aimPoint.z, Precision); + } + + [Fact] + public void LinearFallbackWhenRelativeSpeedMatchesShotSpeedProducesPhysicalIntercept() + { + // velocitySquared == shotSpeed*shotSpeed makes a == 0 (|a| < 0.001), forcing the + // linear branch: time = -lengthsq(pos) / (2 * dot(vel, pos)), clamped to >= 0. + var shooterPosition = new float3(2.0f, -3.0f, 1.0f); + var shooterVelocity = new float3(1.0f, 0.5f, -0.5f); + var targetRelativePosition = new float3(10.0f, 0.0f, 0.0f); + var targetRelativeVelocity = new float3(-2.0f, 0.0f, 0.0f); + const float shotSpeed = 2.0f; // |targetRelativeVelocity| == 2 == shotSpeed => a == 0 + + var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); + + // -100 / (2 * -20) = 2.5, computed independently of the production formula's code path. + Assert.Equal(2.5f, time, Precision); + + var targetPosition = shooterPosition + targetRelativePosition; + var targetVelocity = shooterVelocity + targetRelativeVelocity; + + var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity); + AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time); + } + + [Fact] + public void NegativeDeterminantReturnsZeroWhenTargetOutrunsTheProjectile() + { + // Target moving fast and mostly sideways, with a slow shot, so no real root of the + // intercept quadratic exists. b = 2*dot(vel, pos) is deliberately nonzero (-40): if it + // were 0, a mutant that drops the "determinant < 0" branch entirely and falls through to + // `max(-b / (2a), 0)` would coincidentally also compute 0 and this test would not + // distinguish the two. + var time = math.first_order_intercept_time( + shotSpeed: 1.0f, + targetRelativePosition: new float3(10.0f, 0.0f, 0.0f), + targetRelativeVelocity: new float3(-2.0f, 30.0f, 0.0f)); + + Assert.Equal(0.0f, time); + } + + [Fact] + public void BothQuadraticRootsPositiveReturnsTheEarlierOne() + { + // velocitySquared > shotSpeed*shotSpeed (a > 0): the target is relatively faster than + // the shot, but closing almost head-on, so both quadratic roots are real and positive + // (lengthsq(pos) >= 0 forces same-signed roots whenever a > 0). This is the only shape + // that reaches the `t1 > 0` branch's `t2 > 0 ? min(t1, t2) : t1` line at all; every other + // fixture in this file keeps t1 <= 0. + var shooterPosition = new float3(4.0f, -2.0f, 1.0f); + var shooterVelocity = new float3(0.5f, -0.5f, 0.0f); + var shotSpeed = 5.0f; + var targetRelativePosition = new float3(10.0f, 0.0f, 0.0f); + var targetRelativeVelocity = new float3(-20.0f, 0.0f, 0.0f); + + var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); + + // a = 400 - 25 = 375, b = -400, c = 100, determinant = 10000, root = 100: + // t1 = (400 + 100) / 750 = 0.6667, t2 = (400 - 100) / 750 = 0.4. Both positive; the + // earlier one (0.4) is the physically correct intercept time. + Assert.Equal(0.4f, time, Precision); + + var targetPosition = shooterPosition + targetRelativePosition; + var targetVelocity = shooterVelocity + targetRelativeVelocity; + var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity); + AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time); + } + + [Fact] + public void RecedingTargetWithBothRootsNonPositiveReturnsZero() + { + // Target moving directly away from the shooter, faster than a slow shot: a real + // determinant exists but both quadratic roots are negative, so t1 > 0 is false and the + // function falls through to max(t2, 0) == 0. + var time = math.first_order_intercept_time( + shotSpeed: 1.0f, + targetRelativePosition: new float3(10.0f, 0.0f, 0.0f), + targetRelativeVelocity: new float3(5.0f, 0.0f, 0.0f)); + + Assert.Equal(0.0f, time); + } + + [Fact] + public void ClosingCaseWithOffOriginOffAxisPositionsProducesPhysicallyConsistentIntercept() + { + // Neither shooter nor target sits at the origin, no vector is axis-aligned, and both + // parties are moving. This is the fixture shape ("degenerate fixtures are how these + // survived") the earlier single test never exercised. + var shooterPosition = new float3(2.0f, 3.0f, -1.0f); + var shooterVelocity = new float3(1.0f, -1.0f, 0.5f); + var shotSpeed = 10.0f; + var targetPosition = new float3(15.0f, 8.0f, 4.0f); + var targetVelocity = new float3(-3.0f, 2.0f, 1.0f); + + var targetRelativePosition = targetPosition - shooterPosition; + var targetRelativeVelocity = targetVelocity - shooterVelocity; + var time = math.first_order_intercept_time(shotSpeed, targetRelativePosition, targetRelativeVelocity); + + Assert.True(time > 0.0f, "fixture must produce a genuine closing solution"); + + var aimPoint = math.first_order_intercept(shooterPosition, shooterVelocity, shotSpeed, targetPosition, targetVelocity); + AssertShotAndTargetCoincideAtTime(shooterPosition, shooterVelocity, shotSpeed, aimPoint, targetPosition, targetVelocity, time); + } + + /// + /// Fires from , inheriting + /// and adding toward , and asserts the + /// shot's world position at coincides with the target's own actual + /// motion ( + time * ). + /// This is the behavioural contract first_order_intercept exists to serve: aim here, and the + /// target will be there when the shot arrives. + /// + private static void AssertShotAndTargetCoincideAtTime( + float3 shooterPosition, + float3 shooterVelocity, + float shotSpeed, + float3 aimPoint, + float3 targetPosition, + float3 targetVelocity, + float time) + { + var direction = math.normalize(aimPoint - shooterPosition); + var shotVelocity = shooterVelocity + shotSpeed * direction; + var shotPositionAtTime = shooterPosition + time * shotVelocity; + var targetPositionAtTime = targetPosition + time * targetVelocity; + + Assert.Equal(targetPositionAtTime.x, shotPositionAtTime.x, Precision); + Assert.Equal(targetPositionAtTime.y, shotPositionAtTime.y, Precision); + Assert.Equal(targetPositionAtTime.z, shotPositionAtTime.z, Precision); + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs new file mode 100644 index 00000000..9335784d --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs @@ -0,0 +1,583 @@ +using System; +using CultMath; +using Xunit; + +namespace CultMath.Tests; + +// Hand-verified on hands/adopt-stryker: weakening float3.Equals from `&&` to `||` passes the +// whole existing suite. The same shape of Equals/GetHashCode, ==/!=, and /<=/>= exists (with +// no shared base type) across float2/3/4, double2/3, int2/3/4, bool2/3/4, quaternion, +// float2x2 and float3x3. These tests are data-driven per component position rather than one +// method per type: each type supplies a base value and, per component index, a value that +// differs at exactly that index and agrees everywhere else, plus (for the four ordered numeric +// families) a left/right pair whose relational result differs per component. bool2/3/4 and +// quaternion have no ordering operators and are not included in the comparison-operator cases. +// float2x2/float3x3 have no == / != operators (Equals and GetHashCode only) and are covered +// separately, at row granularity. +public sealed class VectorEqualityTests +{ + // ---- shared, type-agnostic assertions ------------------------------------------------- + + private static void AssertEqualsAndHashCodeContract(T a, T b, bool expectedEqual, string label) + where T : IEquatable + { + Assert.True(a.Equals(b) == expectedEqual, $"{label}: a.Equals(b) expected {expectedEqual}"); + Assert.True(b.Equals(a) == expectedEqual, $"{label}: b.Equals(a) expected {expectedEqual} (symmetry)"); + if (expectedEqual) + { + Assert.True(a.GetHashCode() == b.GetHashCode(), $"{label}: equal values must share a hash code"); + } + } + + private static void AssertElementwiseEqualityOperators( + TLeft a, + TRight b, + int count, + Func expectedEqualAt, + Func eq, + Func neq, + Func at, + string label) + { + var eqResult = eq(a, b); + var neqResult = neq(a, b); + for (var i = 0; i < count; i++) + { + var expectedEqual = expectedEqualAt(i); + Assert.True(at(eqResult, i) == expectedEqual, $"{label}: == component {i} expected {expectedEqual}"); + Assert.True(at(neqResult, i) == !expectedEqual, $"{label}: != component {i} expected {!expectedEqual}"); + } + } + + private static void AssertElementwiseComparisonOperators( + TLeft left, + TRight right, + double[] leftVals, + double[] rightVals, + Func lt, + Func gt, + Func le, + Func ge, + Func at, + string label) + { + var ltResult = lt(left, right); + var gtResult = gt(left, right); + var leResult = le(left, right); + var geResult = ge(left, right); + for (var i = 0; i < leftVals.Length; i++) + { + var less = leftVals[i] < rightVals[i]; + var greater = leftVals[i] > rightVals[i]; + Assert.True(at(ltResult, i) == less, $"{label}: < component {i} (left={leftVals[i]}, right={rightVals[i]})"); + Assert.True(at(gtResult, i) == greater, $"{label}: > component {i} (left={leftVals[i]}, right={rightVals[i]})"); + Assert.True(at(leResult, i) == !greater, $"{label}: <= component {i} (left={leftVals[i]}, right={rightVals[i]})"); + Assert.True(at(geResult, i) == !less, $"{label}: >= component {i} (left={leftVals[i]}, right={rightVals[i]})"); + } + } + + // ---- float2 ----------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Float2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new float2(3.0f, -7.0f); + var b = a; + b[index] += 5.0f; + + AssertEqualsAndHashCodeContract(a, a, true, "float2 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"float2 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 vector==vector"); + + // Scalar overloads: a scalar equal to exactly one component, on both operand sides. + var scalar = a[index]; + AssertElementwiseEqualityOperators( + a, scalar, 2, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 vector==scalar"); + AssertElementwiseEqualityOperators( + scalar, a, 2, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float2 scalar==vector"); + } + + [Fact] + public void Float2ComparisonOperatorsDifferPerComponent() + { + var left = new float2(1.0f, 5.0f); + var right = new float2(2.0f, 3.0f); + var leftVals = new double[] { 1.0, 5.0 }; + var rightVals = new double[] { 2.0, 3.0 }; + + AssertElementwiseComparisonOperators( + left, right, leftVals, rightVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 vector-vector"); + + const float scalar = 2.0f; + AssertElementwiseComparisonOperators( + left, scalar, leftVals, new double[] { scalar, scalar }, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 vector-scalar"); + AssertElementwiseComparisonOperators( + scalar, left, new double[] { scalar, scalar }, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 scalar-vector"); + + // A value compared against an exact copy of itself: every component ties. This is the + // complementary fixture to the no-tie one above. A tie is where < and <= (or > and >=) + // actually disagree, so it catches a strict/non-strict boundary swap (e.g. < mutated to + // <=) that a fixture with no equal components cannot: unlike a threshold compared against + // an arbitrary float, an exact self-tie is neither rare nor equivalent here, since these + // are general-purpose comparison operators (bounds/containment checks compare against + // literal endpoints routinely, as CultMath's own touching-edges rect test does). + AssertElementwiseComparisonOperators( + left, left, leftVals, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied vector-vector"); + + // The scalar overloads need their own tie: a uniform vector against a scalar equal to + // every component, so every component of the vector-scalar and scalar-vector overloads + // ties too (the vector-vector tie above does not cover them). + var uniform = new float2(4.0f, 4.0f); + var uniformVals = new double[] { 4.0, 4.0 }; + AssertElementwiseComparisonOperators( + uniform, 4.0f, uniformVals, uniformVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied vector-scalar"); + AssertElementwiseComparisonOperators( + 4.0f, uniform, uniformVals, uniformVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float2 tied scalar-vector"); + } + + // ---- float3 ----------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Float3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new float3(3.0f, -7.0f, 4.5f); + var b = a; + b[index] += 5.0f; + + AssertEqualsAndHashCodeContract(a, a, true, "float3 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"float3 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 vector==vector"); + + var scalar = a[index]; + AssertElementwiseEqualityOperators( + a, scalar, 3, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 vector==scalar"); + AssertElementwiseEqualityOperators( + scalar, a, 3, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float3 scalar==vector"); + } + + [Fact] + public void Float3ComparisonOperatorsDifferPerComponent() + { + // No component is tied: a tie makes strict < and strict > agree (both false), which + // would hide a direction-flip mutation (< swapped for >) at that component. + var left = new float3(1.0f, 5.0f, 7.0f); + var right = new float3(2.0f, 3.0f, 4.0f); + var leftVals = new double[] { 1.0, 5.0, 7.0 }; + var rightVals = new double[] { 2.0, 3.0, 4.0 }; + + AssertElementwiseComparisonOperators( + left, right, leftVals, rightVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 vector-vector"); + + const float scalar = 4.0f; + AssertElementwiseComparisonOperators( + left, scalar, leftVals, new double[] { scalar, scalar, scalar }, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 vector-scalar"); + AssertElementwiseComparisonOperators( + scalar, left, new double[] { scalar, scalar, scalar }, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 scalar-vector"); + + // Tied against itself (see the float2 tied-fixture comment above). + AssertElementwiseComparisonOperators( + left, left, leftVals, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied vector-vector"); + + // The scalar overloads need their own tie (see the float2 uniform-vector comment above). + var uniform = new float3(4.0f, 4.0f, 4.0f); + var uniformVals = new double[] { 4.0, 4.0, 4.0 }; + AssertElementwiseComparisonOperators( + uniform, 4.0f, uniformVals, uniformVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied vector-scalar"); + AssertElementwiseComparisonOperators( + 4.0f, uniform, uniformVals, uniformVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float3 tied scalar-vector"); + } + + // ---- float4 ----------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void Float4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new float4(3.0f, -7.0f, 4.5f, 9.0f); + var b = a; + b[index] += 5.0f; + + AssertEqualsAndHashCodeContract(a, a, true, "float4 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"float4 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 vector==vector"); + + var scalar = a[index]; + AssertElementwiseEqualityOperators( + a, scalar, 4, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 vector==scalar"); + AssertElementwiseEqualityOperators( + scalar, a, 4, i => a[i] == scalar, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "float4 scalar==vector"); + } + + [Fact] + public void Float4ComparisonOperatorsDifferPerComponent() + { + // No component is tied (see the float3 comment above for why a tie hides a + // direction-flip mutation at that component). + var left = new float4(1.0f, 5.0f, 7.0f, 2.0f); + var right = new float4(2.0f, 3.0f, 4.0f, 9.0f); + var leftVals = new double[] { 1.0, 5.0, 7.0, 2.0 }; + var rightVals = new double[] { 2.0, 3.0, 4.0, 9.0 }; + + AssertElementwiseComparisonOperators( + left, right, leftVals, rightVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 vector-vector"); + + const float scalar = 4.0f; + AssertElementwiseComparisonOperators( + left, scalar, leftVals, new double[] { scalar, scalar, scalar, scalar }, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 vector-scalar"); + AssertElementwiseComparisonOperators( + scalar, left, new double[] { scalar, scalar, scalar, scalar }, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 scalar-vector"); + + // Tied against itself (see the float2 tied-fixture comment above). + AssertElementwiseComparisonOperators( + left, left, leftVals, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied vector-vector"); + + // The scalar overloads need their own tie (see the float2 uniform-vector comment above). + var uniform = new float4(4.0f, 4.0f, 4.0f, 4.0f); + var uniformVals = new double[] { 4.0, 4.0, 4.0, 4.0 }; + AssertElementwiseComparisonOperators( + uniform, 4.0f, uniformVals, uniformVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied vector-scalar"); + AssertElementwiseComparisonOperators( + 4.0f, uniform, uniformVals, uniformVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "float4 tied scalar-vector"); + } + + // ---- double2 ---------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Double2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new double2(3.0, -7.0); + var b = a; + b[index] += 5.0; + + AssertEqualsAndHashCodeContract(a, a, true, "double2 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"double2 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "double2 =="); + } + + [Fact] + public void Double2ComparisonOperatorsDifferPerComponent() + { + var left = new double2(1.0, 5.0); + var right = new double2(2.0, 3.0); + var leftVals = new double[] { 1.0, 5.0 }; + var rightVals = new double[] { 2.0, 3.0 }; + + AssertElementwiseComparisonOperators( + left, right, leftVals, rightVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double2 vector-vector"); + + // Tied against itself (see the float2 tied-fixture comment above). + AssertElementwiseComparisonOperators( + left, left, leftVals, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double2 tied vector-vector"); + } + + // ---- double3 ---------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Double3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new double3(3.0, -7.0, 4.5); + var b = a; + b[index] += 5.0; + + AssertEqualsAndHashCodeContract(a, a, true, "double3 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"double3 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "double3 =="); + } + + [Fact] + public void Double3ComparisonOperatorsDifferPerComponent() + { + // No component is tied (see the float3 comment above). + var left = new double3(1.0, 5.0, 7.0); + var right = new double3(2.0, 3.0, 4.0); + var leftVals = new double[] { 1.0, 5.0, 7.0 }; + var rightVals = new double[] { 2.0, 3.0, 4.0 }; + + AssertElementwiseComparisonOperators( + left, right, leftVals, rightVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double3 vector-vector"); + + // Tied against itself (see the float2 tied-fixture comment above). + AssertElementwiseComparisonOperators( + left, left, leftVals, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "double3 tied vector-vector"); + } + + // ---- int2 ------------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Int2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new int2(3, -7); + var b = a; + b[index] += 5; + + AssertEqualsAndHashCodeContract(a, a, true, "int2 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"int2 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int2 =="); + } + + [Fact] + public void Int2ComparisonOperatorsDifferPerComponent() + { + var left = new int2(1, 5); + var right = new int2(2, 3); + var leftVals = new double[] { 1, 5 }; + var rightVals = new double[] { 2, 3 }; + + AssertElementwiseComparisonOperators( + left, right, leftVals, rightVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int2 vector-vector"); + + // Tied against itself (see the float2 tied-fixture comment above); integers make an + // exact tie the routine case, not the rare one. + AssertElementwiseComparisonOperators( + left, left, leftVals, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int2 tied vector-vector"); + } + + // ---- int3 ------------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Int3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new int3(3, -7, 4); + var b = a; + b[index] += 5; + + AssertEqualsAndHashCodeContract(a, a, true, "int3 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"int3 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int3 =="); + } + + [Fact] + public void Int3ComparisonOperatorsDifferPerComponent() + { + // No component is tied (see the float3 comment above). + var left = new int3(1, 5, 7); + var right = new int3(2, 3, 4); + var leftVals = new double[] { 1, 5, 7 }; + var rightVals = new double[] { 2, 3, 4 }; + + AssertElementwiseComparisonOperators( + left, right, leftVals, rightVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int3 vector-vector"); + + // Tied against itself (see the int2 tied-fixture comment above). + AssertElementwiseComparisonOperators( + left, left, leftVals, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int3 tied vector-vector"); + } + + // ---- int4 ------------------------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void Int4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new int4(3, -7, 4, 9); + var b = a; + b[index] += 5; + + AssertEqualsAndHashCodeContract(a, a, true, "int4 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"int4 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "int4 =="); + } + + [Fact] + public void Int4ComparisonOperatorsDifferPerComponent() + { + // No component is tied (see the float3 comment above). + var left = new int4(1, 5, 7, 2); + var right = new int4(2, 3, 4, 9); + var leftVals = new double[] { 1, 5, 7, 2 }; + var rightVals = new double[] { 2, 3, 4, 9 }; + + AssertElementwiseComparisonOperators( + left, right, leftVals, rightVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int4 vector-vector"); + + // Tied against itself (see the int2 tied-fixture comment above). + AssertElementwiseComparisonOperators( + left, left, leftVals, leftVals, + (x, y) => x < y, (x, y) => x > y, (x, y) => x <= y, (x, y) => x >= y, (v, i) => v[i], "int4 tied vector-vector"); + } + + // ---- bool2/3/4 (no ordering operators) --------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Bool2EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new bool2(true, false); + var b = a; + b[index] = !b[index]; + + AssertEqualsAndHashCodeContract(a, a, true, "bool2 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"bool2 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 2, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool2 =="); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Bool3EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new bool3(true, false, true); + var b = a; + b[index] = !b[index]; + + AssertEqualsAndHashCodeContract(a, a, true, "bool3 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"bool3 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 3, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool3 =="); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void Bool4EqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new bool4(true, false, true, false); + var b = a; + b[index] = !b[index]; + + AssertEqualsAndHashCodeContract(a, a, true, "bool4 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"bool4 differing at {index}"); + AssertElementwiseEqualityOperators( + a, b, 4, i => i != index, (x, y) => x == y, (x, y) => x != y, (v, i) => v[i], "bool4 =="); + } + + [Fact] + public void BoolVectorFalseAndTrueConstantsHaveEveryComponentSet() + { + // bool2.@false/@true and bool4.@false/bool3.@true have no other consumer in this suite + // (bool3.@false and bool4.@true are read by HlslSemanticsTests), so a wrong component in + // their field initializers would otherwise go unnoticed. + Assert.Equal(new bool2(false, false), bool2.@false); + Assert.Equal(new bool2(true, true), bool2.@true); + Assert.Equal(new bool3(false, false, false), bool3.@false); + Assert.Equal(new bool3(true, true, true), bool3.@true); + Assert.Equal(new bool4(false, false, false, false), bool4.@false); + Assert.Equal(new bool4(true, true, true, true), bool4.@true); + } + + // ---- quaternion (== and != return a plain bool, not a component vector) ----------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void QuaternionEqualsHashCodeAndOperatorsDetectSingleComponentDifference(int index) + { + var a = new quaternion(1.0f, 2.0f, 3.0f, 4.0f); + float4 asFloat4 = a; + asFloat4[index] += 5.0f; + quaternion b = asFloat4; + + AssertEqualsAndHashCodeContract(a, a, true, "quaternion self"); + AssertEqualsAndHashCodeContract(a, b, false, $"quaternion differing at {index}"); + + // quaternion's == / != are scalar bool, defined in terms of Equals: confirm they + // actually delegate rather than reimplementing (and weakening) the comparison. + var aCopy = new quaternion(a.x, a.y, a.z, a.w); + Assert.Equal(a.Equals(aCopy), a == aCopy); + Assert.Equal(!a.Equals(aCopy), a != aCopy); + Assert.Equal(a.Equals(b), a == b); + Assert.Equal(!a.Equals(b), a != b); + } + + // ---- float2x2 / float3x3 (Equals/GetHashCode only, row granularity) --------------------- + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Float2x2EqualsAndHashCodeDetectSingleRowDifference(int row) + { + var row0 = new float2(1.0f, 2.0f); + var row1 = new float2(3.0f, 4.0f); + var a = new float2x2(row0, row1); + + var differingRow0 = row == 0 ? row0 + new float2(10.0f, 0.0f) : row0; + var differingRow1 = row == 1 ? row1 + new float2(0.0f, 10.0f) : row1; + var b = new float2x2(differingRow0, differingRow1); + + AssertEqualsAndHashCodeContract(a, a, true, "float2x2 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"float2x2 differing at row {row}"); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Float3x3EqualsAndHashCodeDetectSingleRowDifference(int row) + { + var row0 = new float3(1.0f, 2.0f, 3.0f); + var row1 = new float3(4.0f, 5.0f, 6.0f); + var row2 = new float3(7.0f, 8.0f, 9.0f); + var a = new float3x3(row0, row1, row2); + + var differingRow0 = row == 0 ? row0 + new float3(10.0f, 0.0f, 0.0f) : row0; + var differingRow1 = row == 1 ? row1 + new float3(0.0f, 10.0f, 0.0f) : row1; + var differingRow2 = row == 2 ? row2 + new float3(0.0f, 0.0f, 10.0f) : row2; + var b = new float3x3(differingRow0, differingRow1, differingRow2); + + AssertEqualsAndHashCodeContract(a, a, true, "float3x3 self"); + AssertEqualsAndHashCodeContract(a, b, false, $"float3x3 differing at row {row}"); + } +} diff --git a/packages/cultmath/tests/CultMath.Tests/stryker-config.json b/packages/cultmath/tests/CultMath.Tests/stryker-config.json new file mode 100644 index 00000000..82c6d79c --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/stryker-config.json @@ -0,0 +1,14 @@ +{ + "stryker-config": { + "project": "CultMath.csproj", + "mutate": [ + "**/packages/cultmath/src/CultMath/**/*.cs", + "!**/packages/cultmath/src/CultMath/Swizzles.g.cs", + "!**/packages/cultmath/src/CultMath/*.meta" + ], + "mutation-level": "Standard", + "reporters": [ "progress", "html", "json" ], + "thresholds": { "high": 80, "low": 60, "break": 0 }, + "test-runner": "mtp" + } +}