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 <noreply@anthropic.com>
This commit is contained in:
parent
6a2b42f650
commit
e3d52406db
1 changed files with 337 additions and 0 deletions
337
packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs
Normal file
337
packages/cultmath/tests/CultMath.Tests/BatchMathTests.cs
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
|
||||
// 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 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 Float2RadialFalloffValidatesEqualSpanLengths()
|
||||
{
|
||||
var positions = new float2[3];
|
||||
var acceleration = new float2[2];
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
BatchMath.AddRadialFalloffAcceleration2D(positions, float2.zero, 1.0f, 1.0f, acceleration));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue