Soul's pass on 41440cb found BatchMath.cs:115
(AddRadialFalloffAcceleration2D(ReadOnlySpan<float2>, ...)) 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 <noreply@anthropic.com>
427 lines
17 KiB
C#
427 lines
17 KiB
C#
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<ArgumentException>(() =>
|
|
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<float>();
|
|
var py = Array.Empty<float>();
|
|
var ax = Array.Empty<float>();
|
|
var ay = Array.Empty<float>();
|
|
|
|
// 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<ArgumentOutOfRangeException>(() =>
|
|
BatchMath.IntegrateSemiImplicitEuler2D(0.0f, dynamicMask, px, py, vx, vy, ax, ay));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
|
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<ArgumentException>(() =>
|
|
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<ArgumentOutOfRangeException>(() =>
|
|
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<ArgumentException>(() =>
|
|
BatchMath.IntegrateSemiImplicitEuler2D(0.5f, dynamicMask, position, velocity, acceleration));
|
|
}
|
|
|
|
[Fact]
|
|
public void Float2RadialFalloffValidatesEqualSpanLengths()
|
|
{
|
|
var positions = new float2[3];
|
|
var acceleration = new float2[2];
|
|
|
|
Assert.Throws<ArgumentException>(() =>
|
|
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]);
|
|
}
|
|
}
|