From 3e13c14f06312b43f9c5a738004ab87beac818e9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 20:49:05 +0200 Subject: [PATCH 01/11] Adopt Stryker.NET as CultLib's mutation-testing tool Pin dotnet-stryker 5.0.0 as a repo-local tool, matching Aetheria's Cut 11 adoption (F:\Projects\Aetheria dotnet-tools.json) and the current latest release on nuget.org. Co-Authored-By: Claude Opus 5 --- dotnet-tools.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 dotnet-tools.json 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 + } + } +} From e92413d6eb2911695ac96f37bb7589ae5ec1cd66 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 20:50:10 +0200 Subject: [PATCH 02/11] Add Stryker config for CultMath and ignore its output Scope mutation testing to CultMath.Tests only, matching Aetheria's stryker-config.json shape: Standard level, progress/html/json reporters, break threshold 0 (score is not a gate). Mutate only CultMath's own sources, excluding the generated Swizzles.g.cs. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 +++ .../tests/CultMath.Tests/stryker-config.json | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 packages/cultmath/tests/CultMath.Tests/stryker-config.json 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/packages/cultmath/tests/CultMath.Tests/stryker-config.json b/packages/cultmath/tests/CultMath.Tests/stryker-config.json new file mode 100644 index 00000000..3b4092e1 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/stryker-config.json @@ -0,0 +1,13 @@ +{ + "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 } + } +} From 6a2b42f650a0658221558f57c96a0f5d2650c17e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 21:24:05 +0200 Subject: [PATCH 03/11] Pin mtp test-runner for CultMath and document mutation testing CultMath.Tests is on xunit.v3. Stryker's default vstest runner cannot capture coverage against xunit.v3's test host and hangs retrying test sessions indefinitely (observed: 2096 mutants queued, zero completed after ~9 minutes of retries). Stryker's Microsoft.Testing.Platform runner (-t mtp, still preview) works correctly against xunit.v3 and completed the same 1351-mutant run in ~4.5 minutes. Pin it in config so a plain `dotnet stryker` works without a CLI flag. Add docs/mutation-testing.md: how to run it, the --since form for a single cut, the triage rules, a known coverage-capture caveat in the mtp runner (confirmed by hand-mutation against static readonly fields), and the packages still needing their own config. Co-Authored-By: Claude Opus 5 --- docs/mutation-testing.md | 86 +++++++++++++++++++ .../tests/CultMath.Tests/stryker-config.json | 3 +- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 docs/mutation-testing.md 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/packages/cultmath/tests/CultMath.Tests/stryker-config.json b/packages/cultmath/tests/CultMath.Tests/stryker-config.json index 3b4092e1..82c6d79c 100644 --- a/packages/cultmath/tests/CultMath.Tests/stryker-config.json +++ b/packages/cultmath/tests/CultMath.Tests/stryker-config.json @@ -8,6 +8,7 @@ ], "mutation-level": "Standard", "reporters": [ "progress", "html", "json" ], - "thresholds": { "high": 80, "low": 60, "break": 0 } + "thresholds": { "high": 80, "low": 60, "break": 0 }, + "test-runner": "mtp" } } From e3d52406db18ab910b3ca37d7328d9e8446d8f0e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 19:06:40 +0200 Subject: [PATCH 04/11] Test BatchMath's vector body, remainder, and guard branches Both existing BatchMath tests used 2-3 particles while LaneCount >= 4, so only the scalar tail ever ran, and both held y at 0 for every particle, so every y-axis mutation was invisible. Add fixtures sized LaneCount * 3 + 1 with distinct nonzero x/y per particle, pinned against a scalar reference computed independently in the test, plus explicit coverage of the early return, negative deltaTime, length-mismatch, and empty-span guards on all four BatchMath overloads. Co-Authored-By: Claude Opus 5.5 --- .../tests/CultMath.Tests/BatchMathTests.cs | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs diff --git a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs new file mode 100644 index 00000000..bd05d290 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs @@ -0,0 +1,337 @@ +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; + + 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; + } + + // 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 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 Float2RadialFalloffValidatesEqualSpanLengths() + { + var positions = new float2[3]; + var acceleration = new float2[2]; + + Assert.Throws(() => + BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 1.0f, 1.0f, acceleration)); + } +} From 71c42b125fa8207df1935fcd19a897528577d956 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 19:06:41 +0200 Subject: [PATCH 05/11] Test first_order_intercept's branches beyond the one happy path math.first_order_intercept_time had a single closing-target test. Add coverage for zero relative velocity, the a-approx-zero linear fallback, a target that outruns the projectile (negative determinant), a receding target with both quadratic roots non-positive, and a closing case with off-origin, off-axis shooter and target state. The closing cases assert that the projectile (inheriting the shooter's velocity) and the target actually coincide at the returned time; the no-solution branches pin the value the source's own contract specifies. Co-Authored-By: Claude Opus 5.5 --- .../FirstOrderInterceptTests.cs | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs diff --git a/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs b/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs new file mode 100644 index 00000000..17d74005 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs @@ -0,0 +1,152 @@ +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 float 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 crossing perpendicular to the line of sight fast enough, with a slow shot, + // that no real root of the intercept quadratic exists. + var time = math.first_order_intercept_time( + shotSpeed: 1.0f, + targetRelativePosition: new float3(10.0f, 0.0f, 0.0f), + targetRelativeVelocity: new float3(0.0f, 20.0f, 0.0f)); + + Assert.Equal(0.0f, 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); + } +} From ab98462c062e22a94d71bc060700851d97d172cb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 19:06:41 +0200 Subject: [PATCH 06/11] Test vector Equals/GetHashCode/comparison operators per component Hand-verified that weakening float3.Equals from && to || passes the whole suite. The same shape recurs with no shared base type across float2/3/4, double2/3, int2/3/4, bool2/3/4, quaternion, float2x2 and float3x3. Add data-driven tests, per component position, that a value differing at exactly one component is unequal under Equals/==/!=, that GetHashCode stays consistent with Equals, and that /<=/>= (including the vector-scalar and scalar-vector overloads on the float types) each report a different result per component. float2x2/float3x3 are covered at row granularity since they have no == operator; quaternion and bool2/3/4 have no ordering operators and are covered for equality only. Co-Authored-By: Claude Opus 5.5 --- .../CultMath.Tests/VectorEqualityTests.cs | 483 ++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs diff --git a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs new file mode 100644 index 00000000..8f898b22 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs @@ -0,0 +1,483 @@ +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"); + } + + // ---- 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() + { + var left = new float3(1.0f, 5.0f, 4.0f); + var right = new float3(2.0f, 3.0f, 4.0f); + var leftVals = new double[] { 1.0, 5.0, 4.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"); + } + + // ---- 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() + { + var left = new float4(1.0f, 5.0f, 4.0f, 8.0f); + var right = new float4(2.0f, 3.0f, 4.0f, 6.0f); + var leftVals = new double[] { 1.0, 5.0, 4.0, 8.0 }; + var rightVals = new double[] { 2.0, 3.0, 4.0, 6.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"); + } + + // ---- 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"); + } + + // ---- 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() + { + var left = new double3(1.0, 5.0, 4.0); + var right = new double3(2.0, 3.0, 4.0); + var leftVals = new double[] { 1.0, 5.0, 4.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"); + } + + // ---- 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"); + } + + // ---- 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() + { + var left = new int3(1, 5, 4); + var right = new int3(2, 3, 4); + var leftVals = new double[] { 1, 5, 4 }; + 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"); + } + + // ---- 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() + { + var left = new int4(1, 5, 4, 8); + var right = new int4(2, 3, 4, 6); + var leftVals = new double[] { 1, 5, 4, 8 }; + var rightVals = new double[] { 2, 3, 4, 6 }; + + 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"); + } + + // ---- 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 =="); + } + + // ---- 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}"); + } +} From 446859927a5ec198d0f42c3a03761856656c6f10 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 19:26:48 +0200 Subject: [PATCH 07/11] Fix degenerate fixtures that let real mutants survive their own tests Soul-style self-check against the actual mutation report (not just green tests) found several fixtures whose values coincidentally produced the same output on the buggy path as on the correct one: - BatchMath's scalar-tail fixtures put the one remainder particle/step either outside the falloff radius or mask-frozen, so the y-axis arithmetic a mutation would touch was never reached with an observable effect. Pin the remainder particle in range (radial falloff) and dynamic (Euler integration) so the scalar tail's own math is exercised, not just the vector body's. - The float2-overload of AddRadialFalloffAcceleration2D had no no-op (radius <= 0 / strength == 0) test at all. - first_order_intercept_time's negative-determinant test used a perpendicular relative velocity, making b = 2*dot(vel, pos) exactly zero; a mutant that drops the "no solution" branch entirely and falls through to the linear fallback coincidentally computes 0 too. Use a non-perpendicular fixture so the fallback's nonzero value is distinguishable from the correct zero. - No fixture ever reached the `t1 > 0` branch at all (a > 0, both quadratic roots real and positive), so the boundary/negate checks guarding it and the `t2 > 0 ? min(t1, t2) : t1` line were never covered. Add that case. - bool2.@false/@true, bool3.@true and bool4.@false have no consumer anywhere in the suite (bool3.@false and bool4.@true are read by HlslSemanticsTests); pin all six vector constants directly. Co-Authored-By: Claude Opus 5.5 --- .../tests/CultMath.Tests/BatchMathTests.cs | 28 +++++++++++ .../FirstOrderInterceptTests.cs | 36 ++++++++++++-- .../CultMath.Tests/VectorEqualityTests.cs | 49 +++++++++++++------ 3 files changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs index bd05d290..c5196154 100644 --- a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs @@ -43,6 +43,13 @@ public sealed class BatchMathTests // 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; @@ -180,6 +187,12 @@ public sealed class BatchMathTests 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(); @@ -334,4 +347,19 @@ public sealed class BatchMathTests 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 index 17d74005..91591645 100644 --- a/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs @@ -77,16 +77,46 @@ public sealed class FirstOrderInterceptTests [Fact] public void NegativeDeterminantReturnsZeroWhenTargetOutrunsTheProjectile() { - // Target crossing perpendicular to the line of sight fast enough, with a slow shot, - // that no real root of the intercept quadratic exists. + // 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(0.0f, 20.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() { diff --git a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs index 8f898b22..e23547ee 100644 --- a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs @@ -148,9 +148,11 @@ public sealed class VectorEqualityTests [Fact] public void Float3ComparisonOperatorsDifferPerComponent() { - var left = new float3(1.0f, 5.0f, 4.0f); + // 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, 4.0 }; + var leftVals = new double[] { 1.0, 5.0, 7.0 }; var rightVals = new double[] { 2.0, 3.0, 4.0 }; AssertElementwiseComparisonOperators( @@ -194,10 +196,12 @@ public sealed class VectorEqualityTests [Fact] public void Float4ComparisonOperatorsDifferPerComponent() { - var left = new float4(1.0f, 5.0f, 4.0f, 8.0f); - var right = new float4(2.0f, 3.0f, 4.0f, 6.0f); - var leftVals = new double[] { 1.0, 5.0, 4.0, 8.0 }; - var rightVals = new double[] { 2.0, 3.0, 4.0, 6.0 }; + // 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, @@ -263,9 +267,10 @@ public sealed class VectorEqualityTests [Fact] public void Double3ComparisonOperatorsDifferPerComponent() { - var left = new double3(1.0, 5.0, 4.0); + // 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, 4.0 }; + var leftVals = new double[] { 1.0, 5.0, 7.0 }; var rightVals = new double[] { 2.0, 3.0, 4.0 }; AssertElementwiseComparisonOperators( @@ -324,9 +329,10 @@ public sealed class VectorEqualityTests [Fact] public void Int3ComparisonOperatorsDifferPerComponent() { - var left = new int3(1, 5, 4); + // 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, 4 }; + var leftVals = new double[] { 1, 5, 7 }; var rightVals = new double[] { 2, 3, 4 }; AssertElementwiseComparisonOperators( @@ -356,10 +362,11 @@ public sealed class VectorEqualityTests [Fact] public void Int4ComparisonOperatorsDifferPerComponent() { - var left = new int4(1, 5, 4, 8); - var right = new int4(2, 3, 4, 6); - var leftVals = new double[] { 1, 5, 4, 8 }; - var rightVals = new double[] { 2, 3, 4, 6 }; + // 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, @@ -416,6 +423,20 @@ public sealed class VectorEqualityTests 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] From 6d8195811df35a698dc0ae19c1f6cd0f603577ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 19:38:30 +0200 Subject: [PATCH 08/11] Fix a tolerance-overload bug that made intercept precision asserts inert `Precision` was declared `const float`, so `Assert.Equal(a, b, Precision)` bound to xunit's absolute-tolerance overload (allowing any two values within 4.0 of each other) instead of the intended 4-decimal-place overload. Hand-mutating the `t2 > 0 ? min(t1, t2) : t1` ternary to always return `t1` (math.cs:287) exposed it: the assertion silently accepted a result almost 0.27 away from the expected value. Declaring it `const int` selects the correct overload; confirmed the same hand mutation now fails. Also add the float2-overload euler-integration length-validation test the mutation report showed missing (BatchMath.cs:208's ValidateEqualLengths call had no test at all). Co-Authored-By: Claude Opus 5.5 --- .../cultmath/tests/CultMath.Tests/BatchMathTests.cs | 12 ++++++++++++ .../tests/CultMath.Tests/FirstOrderInterceptTests.cs | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs index c5196154..3db3259c 100644 --- a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs @@ -338,6 +338,18 @@ public sealed class BatchMathTests 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() { diff --git a/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs b/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs index 91591645..a211d266 100644 --- a/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/FirstOrderInterceptTests.cs @@ -24,7 +24,7 @@ namespace CultMath.Tests; // determinant > 0 with both roots <= 0 => 0 (via max(t2, 0)). public sealed class FirstOrderInterceptTests { - private const float Precision = 4; + private const int Precision = 4; [Fact] public void ZeroRelativeVelocityReturnsZeroTime() From eeb67e2a2237493f2bab33da51f81d70a2a26887 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 19:44:58 +0200 Subject: [PATCH 09/11] Add tied-value comparisons to kill boundary-flip mutants for real The no-tie fixture in each *ComparisonOperatorsDifferPerComponent test correctly kills direction mutations (< swapped for >) but leaves every strict/non-strict boundary swap (< to <=, > to >=) surviving, since with no equal component the two behave identically. That default is appropriate for a float-threshold epsilon check, but these are general-purpose vector comparison operators (CultMath's own RectIntersectionTreatsTouchingEdgesAsContact test compares literal endpoints), and for the int types an exact tie is the routine case, not the rare one. Add a complementary "compared against itself" call per type (vector-vector for double2/double3/int2/int3/int4, plus vector-scalar and scalar-vector for float2/float3/float4) so every component ties and the boundary swaps are actually exercised. Co-Authored-By: Claude Opus 5.5 --- .../CultMath.Tests/VectorEqualityTests.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs index e23547ee..53fca558 100644 --- a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs @@ -119,6 +119,17 @@ public sealed class VectorEqualityTests 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"); } // ---- float3 ----------------------------------------------------------------------------- @@ -166,6 +177,11 @@ public sealed class VectorEqualityTests 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"); } // ---- float4 ----------------------------------------------------------------------------- @@ -214,6 +230,11 @@ public sealed class VectorEqualityTests 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"); } // ---- double2 ---------------------------------------------------------------------------- @@ -244,6 +265,11 @@ public sealed class VectorEqualityTests 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 ---------------------------------------------------------------------------- @@ -276,6 +302,11 @@ public sealed class VectorEqualityTests 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 ------------------------------------------------------------------------------- @@ -306,6 +337,12 @@ public sealed class VectorEqualityTests 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 ------------------------------------------------------------------------------- @@ -338,6 +375,11 @@ public sealed class VectorEqualityTests 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 ------------------------------------------------------------------------------- @@ -371,6 +413,11 @@ public sealed class VectorEqualityTests 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) --------------------------------------------------- From 41440cb538deaa7291c9c3b308e7f3152571b1fc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 19:49:23 +0200 Subject: [PATCH 10/11] Tie the scalar comparison overloads too, not just vector-vector The vector-vector self-tie added in the previous commit does not cover float2/float3/float4's vector-scalar and scalar-vector overloads: their existing no-tie fixture (a distinct vector against a single scalar) never had a component equal to that scalar, so their boundary-flip mutants (< to <=, > to >=) kept surviving. Add a uniform vector (every component the same value) against a scalar equal to that value, tying every component of both scalar overloads at once. Co-Authored-By: Claude Opus 5.5 --- .../CultMath.Tests/VectorEqualityTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs index 53fca558..9335784d 100644 --- a/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/VectorEqualityTests.cs @@ -130,6 +130,18 @@ public sealed class VectorEqualityTests 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 ----------------------------------------------------------------------------- @@ -182,6 +194,16 @@ public sealed class VectorEqualityTests 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 ----------------------------------------------------------------------------- @@ -235,6 +257,16 @@ public sealed class VectorEqualityTests 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 ---------------------------------------------------------------------------- From fd75c2acf95e0ed8285a0ac4d0943cb9a48cc4e2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 20:13:05 +0200 Subject: [PATCH 11/11] Test that the float2 radial falloff overload accumulates, not assigns Soul's pass on 41440cb found BatchMath.cs:115 (AddRadialFalloffAcceleration2D(ReadOnlySpan, ...)) accumulates onto the caller's acceleration buffer, but no test ever seeded that buffer with nonzero values: mutating += to = passed all 133 tests. The float-array overload already had this guard (RadialFalloffAccumulatesOntoExistingAcceleration); add the float2 equivalent, with a nonzero, per-element-distinct starting buffer, mixing in-range and out-of-range particles so both the accumulated and unchanged-seed cases are checked. Checked every other += site in BatchMath.cs for the same gap: the array-overload's scalar tail and vector body, and both IntegrateSemiImplicitEuler2D overloads, all already seed their accumulator buffers (vx/vy/px/py, velocity/position) with nonzero, per-element values in their existing tests. Only this one site was bare. Verified by hand-mutating BatchMath.cs:115 (+= to =): the new test fails (expected 6.78, actual 0.48); reverted the source byte-exactly after. Co-Authored-By: Claude Opus 5.5 --- .../tests/CultMath.Tests/BatchMathTests.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs index 3db3259c..62280c74 100644 --- a/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs @@ -299,6 +299,56 @@ public sealed class BatchMathTests 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() {