Skip to content

About

TUnit-native math assertion library for .NET. Tolerance, sequences, statistics, linear algebra, number theory, 3D geometry. AOT-compatible, reflection-free.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

MathAssertions.TUnit

CI CodeQL OpenSSF Scorecard OpenSSF Best Practices codecov NuGet Downloads License: MIT .NET

TUnit-native math assertion library for .NET. Covers tolerance comparisons (scalar, System.Numerics compounds, spans, tensors), sequences, statistics, linear algebra, number theory, and 3D geometry. Source-generated entry points integrate with TUnit's Assert.That(...) pipeline. The tolerance comparisons handle NaN and infinity explicitly (see NaN and infinity semantics). AOT-compatible, reflection-free in the assertion path.

Scope: Test projects only. Not intended for production code.

Part of the DotNetAssertions family of assertion extensions for TUnit.


Status

The framework-agnostic core is feature-complete across seven topical clusters and the TUnit adapter exposes a near-full fluent surface over Assert.That(value).Method(...); the documented exception is ReadOnlyTensorSpan<T>, which is exposed only at the static MathTolerance level (see the carve-out note below the table). v0.2.0 adds rich per-component / per-cell delta rendering to every compound IsApproximatelyEqualTo failure message and ships HasAxisAngleApproximately for axis-angle-form quaternion rotation checks. Every fluent entry point is generated via TUnit's [GenerateAssertion] source generator and integrates directly into the existing Assert.That(...) pipeline. v0.3.0 adds the MathAssertions.Render namespace with PoseRenderer, a pure renderer that turns a position / orientation pose into deterministic, snapshot-friendly text for pinning via SnapshotAssertions.TUnit. v0.4.0 adds IsPoseApproximatelyEqualTo (and the Matrix4x4 overload IsRigidTransformApproximatelyEqualTo), comparing a pose's position and orientation in one call with separate tolerances and a combined failure diagnostic. v0.5.0 makes the vector parallel check scale-invariant and adds HasAngleBetweenApproximately for vectors and IsRotation for matrices.

Domain Coverage
Tolerance primitives Scalar / vector / quaternion / matrix / plane / complex / span / tensor IsApproximatelyEqual; ULP-distance equality; combined relative + absolute tolerance; finiteness, probability, percentage predicates; invertible-transformation roundtrip-identity
System.Numerics compounds Vector2/Vector3/Vector4 component-wise; Quaternion component-wise plus rotational equivalence (SO(3) double-cover); Matrix4x4 element-wise; Plane component-wise plus geometric equivalence; Complex; ReadOnlySpan<double>/<float>; generic ReadOnlyTensorSpan<T>
Sequences Monotonicity (strict and non-strict), sortedness, boundedness with NaN-aware element check, arithmetic / geometric progressions, single-step Cauchy convergence, generic length predicates
Statistics Welford's mean + variance, median, percentile (overflow-safe), standard deviation, sum, sigma-bound checks
Linear algebra Matrix4x4 invariants (symmetric, orthogonal, proper rotation, identity, determinant, trace, invertible); Vector3 pair properties (orthogonality, scale-invariant parallelism, angle between, linear independence)
Number theory long predicates: divisibility, primality (overflow-safe), coprimality, GCD/LCM (long.MinValue-aware, OverflowException-on-LCM-overflow; IsDivisibleBy(long.MinValue, -1) also throws OverflowException), powers of base, perfect square (overflow-safe successor check), modular congruence (canonical-residue form)
Geometry3D Eight primitive record struct types (Sphere, AxisAlignedBox, OrientedBox, Ray3D, LineSegment3D, Triangle3D, Capsule, Cylinder); property predicates (IsDegenerate, IsCollinear, AreCoplanar); containment (point/box/sphere/OBB/convex hull); closest-point distance (point->plane/segment/triangle, citing Ericson, Real-Time Collision Detection §§5.1.2, 5.1.5); intersection (sphere-sphere, AABB-AABB, ray-plane/sphere/triangle/AABB, citing Akenine-Möller, Haines, Hoffman, Real-Time Rendering 4th ed. §§22.6, 22.7, 22.8); pointcloud aggregates (boundedness, centroid, on-plane, on-sphere)

MathAssertions.TUnit ships ~85 fluent extensions across thirteen adapter classes; MathAssertions (the framework-agnostic core) ships the underlying static helpers. See the per-package READMEs and CHANGELOG for the exhaustive method listings.

ReadOnlyTensorSpan<T> is exposed only at the static MathTolerance level, not as a fluent assertion: TUnit's [GenerateAssertion] assertion-builder cannot capture ref-struct values across an await. Tensor-fluent integration is candidate work for a later release.


Table of contents


Why this package

Asserting on tolerance-based equality of System.Numerics compound types (Vector2/3/4, Quaternion, Matrix4x4, Plane, Complex) typically devolves into one of:

  • Manual per-component plumbing in every test:
    await Assert.That(Math.Abs(actual.X - expected.X)).IsLessThanOrEqualTo(tolerance);
    await Assert.That(Math.Abs(actual.Y - expected.Y)).IsLessThanOrEqualTo(tolerance);
    await Assert.That(Math.Abs(actual.Z - expected.Z)).IsLessThanOrEqualTo(tolerance);
  • A bespoke Vector3Assertions class re-implemented in every project, with subtle differences in NaN-handling and infinity-handling between codebases.

This library replaces both with a single fluent DSL that auto-imports alongside TUnit's own assertions. NaN, infinity, and tolerance-validation semantics match TUnit's IsCloseTo primitive so the mental model stays consistent.

Install

dotnet add package MathAssertions.TUnit

Requirements: TUnit 1.81.10 or later, .NET 10. MathAssertions (the framework-agnostic core) and TUnit's runtime + assertion deps come transitively. The package is AOT-compatible, trimmable, and uses no runtime reflection in the assertion path.

Package layout

This repo ships two NuGet packages:

Package Purpose Depends on
MathAssertions Framework-agnostic core: MathTolerance, Sequences, Statistics, LinearAlgebra, NumberTheory, and the Geometry3D namespace (~85 static methods plus eight primitive record struct types) BCL + System.Numerics.Tensors
MathAssertions.TUnit TUnit-specific fluent entry points generated via [GenerateAssertion]: ~85 Assert.That(value).Method(...) extensions covering the whole core surface MathAssertions + TUnit.Assertions + TUnit.Core

You install MathAssertions.TUnit; MathAssertions comes transitively. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not planned; the family targets TUnit.

Namespaces (and a GlobalUsings.cs recommendation)

The two packages place types in several namespaces with deliberately-different scopes:

Type / member Namespace Auto-imported?
Source-generated fluent entry points, e.g. IsApproximatelyEqualTo() on Vector3 TUnit.Assertions.Extensions Yes (TUnit auto-imports)
Adapter classes (ScalarAssertions, VectorAssertions, ...) that hold the entry-point source methods MathAssertions.TUnit No (you do not normally name them)
MathTolerance, Sequences, Statistics, LinearAlgebra, NumberTheory (the framework-agnostic helpers) MathAssertions No (needed at the call site only when invoking helpers directly, e.g. from a [GenerateAssertion] extension on a private type)
Sphere, AxisAlignedBox, Ray3D, Triangle3D and the other Geometry3D primitives, plus Containment, Distance, Intersection, Pointcloud, Properties MathAssertions.Geometry3D No (add using MathAssertions.Geometry3D; in files that construct the primitives)
PoseRenderer MathAssertions.Render No (add using MathAssertions.Render; in files that call it)

The fluent assertions auto-import via TUnit.Assertions.Extensions; no extra using directive is needed for them if your test project already uses TUnit. The value types and primitives need their own using (System.Numerics for Vector3 and friends, MathAssertions.Geometry3D for the geometry samples below). For test projects that consume MathTolerance directly (as in the extending to your own types cookbook entry below), put the namespace into a single GlobalUsings.cs so every test file sees it without ceremony:

// tests/MyApp.Tests/GlobalUsings.cs
global using MathAssertions;

Quick start

using System.Numerics;

[Test]
public async Task ComputedPositionIsApproximatelyAtTarget(CancellationToken ct)
{
    Vector3 target = new(0.300f, 0.150f, 0.450f);
    Vector3 actual = SolveTrajectory(input);

    await Assert.That(actual).IsApproximatelyEqualTo(target, tolerance: 1e-3);
}

That's the canonical use. The fluent extension auto-imports; using System.Numerics; is the only additional thing this sample needs beyond standard TUnit usings (the Geometry3D samples also need using MathAssertions.Geometry3D;).


Why component-wise rather than Euclidean distance

IsApproximatelyEqualTo(Vector3, Vector3, tolerance) compares each component independently:

    Math.Abs((double)a.X - (double)b.X) <= tolerance
 && Math.Abs((double)a.Y - (double)b.Y) <= tolerance
 && Math.Abs((double)a.Z - (double)b.Z) <= tolerance

NOT Euclidean distance ((a - b).Length() <= tolerance). Reasons:

  1. Easier to reason about per-coordinate error. A test failing because Z drifted tells you exactly which coordinate misbehaved. A Euclidean-distance test failing tells you "something is off somewhere"; you have to instrument the assertion to find out which axis.
  2. No square-root cost. Pure component arithmetic, no Math.Sqrt. Matters more for matrix-element loops at v0.1.0 (16 elements per Matrix4x4 comparison) than for one Vector3, but the rule applies uniformly.
  3. Matches the most common test-failure mental model. "Is each component correct" maps onto how production code that produces these vectors typically computes them (per-axis from independent inputs).

If you want Euclidean-distance assertions, compose with TUnit's primitive IsCloseTo:

await Assert.That((double)Vector3.Distance(a, b)).IsCloseTo(0.0, tolerance);

Tolerance precision

Component values widen to double before the per-axis comparison so the caller's double tolerance is kept as is and each component difference is computed in double. Tight tolerances such as 1e-9 are therefore not lost to float rounding.

This precision-preserving cast is locked behavior. Tests pin it; v0.1.0 additive overloads (Vector2, Vector4, Quaternion, Matrix4x4, etc.) inherit it.


Entry points

~85 fluent entry points across thirteen adapter classes cover scalar, System.Numerics compounds, double[]/float[], sequences, statistics, linear algebra, integer number theory, and a 3D-geometry surface covering property predicates, containment, closest-point distance, ray intersection, and pointcloud aggregates. v0.2.0 adds HasAxisAngleApproximately on Quaternion and per-component delta rendering in every compound failure message. The exhaustive method listing lives in the package README; the examples below are representative.

Vector3 component-wise

Entry point Behavior
IsApproximatelyEqualTo(Vector3 expected, double tolerance) Asserts every component (X, Y, Z) of the value is within tolerance of the corresponding component of expected. Components widen to double for precision. NaN-aware, infinity-aware (see NaN and infinity semantics).
var a = new Vector3(1, 2, 3);
var b = new Vector3(1.0001f, 2.0001f, 3.0001f);

await Assert.That(a).IsApproximatelyEqualTo(b, tolerance: 1e-3);

Quaternion rotational equivalence

var rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 2);
var negated = new Quaternion(-rotation.X, -rotation.Y, -rotation.Z, -rotation.W);

// q and -q encode the same rotation; component-wise IsApproximatelyEqualTo would fail.
await Assert.That(rotation).IsRotationallyEquivalentTo(negated, tolerance: 1e-6);

Quaternion axis-angle form

// "the rotation under test is approximately 90 degrees around the Y axis"
var rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 2);

await Assert.That(rotation)
    .HasAxisAngleApproximately(Vector3.UnitY, expectedAngleDegrees: 90.0, tolerance: 1e-4);

HasAxisAngleApproximately handles the SO(3) double cover and the 180-degree boundary; both q and -q for the same rotation, as well as (axis, +180°) / (axis, -180°) / (-axis, ±180°), compare equivalent to the same expected axis-angle pair. Internally compares via the rotational-equivalence dot-product test, then renders the extracted axis / angle in the failure message for diagnosis.

Pose / rigid-transform approximate equality

A pose is a position and an orientation together. IsPoseApproximatelyEqualTo compares both halves in one call, with separate tolerances because they carry different units: position as a Euclidean distance, rotation as a geodesic angle in degrees.

(Vector3 position, Quaternion orientation) actual = ComputePose(input);

await Assert.That((actual.position, actual.orientation))
    .IsPoseApproximatelyEqualTo((expectedPosition, expectedOrientation),
        positionTolerance: 1e-3, rotationToleranceDegrees: 0.5);

// Matrix4x4 overload: a pose is a rigid transform.
await Assert.That(actualTransform)
    .IsRigidTransformApproximatelyEqualTo(expectedTransform,
        positionTolerance: 1e-3, rotationToleranceDegrees: 0.5);

The orientation comparison uses the SO(3) metric, so q and -q are the same rotation. On failure the combined message renders both poses and the measured position and rotation deltas, flagging which half exceeded its tolerance, so a position-only or orientation-only miss is read at a glance rather than from two independent assertions.

Migrating from a per-component quaternion tolerance

rotationToleranceDegrees is a geodesic angle in degrees, not a per-component delta. If you are replacing a two-call pattern that compared the orientation component-wise with IsApproximatelyEqualTo(expectedQuaternion, epsilon), do not reuse that component epsilon as rotationToleranceDegrees: the two measure different things. A 1e-6 component epsilon becomes an absurdly tight 1e-6 degrees, while a 0.5 component epsilon becomes a loose half-degree. Pick a fresh value in degrees that matches the angular precision the test actually needs (for example 1e-3 degrees for a near-exact round trip, 0.5 degrees for a solver result). As a rough intuition, for a unit quaternion a small rotation of θ radians moves each component by roughly θ/2, so a component tolerance ε corresponds to an angle near 2ε radians, or about 115·ε degrees.

Projecting a non-System.Numerics pose

IsPoseApproximatelyEqualTo operates on System.Numerics (Vector3 position, Quaternion orientation). If a pose arrives in another shape, for example a protobuf message with separate x/y/z and x/y/z/w fields, project it to System.Numerics first and assert on that:

static (Vector3 Position, Quaternion Orientation) ToNumerics(ProtoPose p) =>
    (new Vector3((float)p.Position.X, (float)p.Position.Y, (float)p.Position.Z),
     new Quaternion((float)p.Orientation.X, (float)p.Orientation.Y, (float)p.Orientation.Z, (float)p.Orientation.W));

await Assert.That(ToNumerics(actual))
    .IsPoseApproximatelyEqualTo(ToNumerics(expected),
        positionTolerance: 1e-3, rotationToleranceDegrees: 0.5);

The cast to float loses precision, so distinct double positions can become the same Vector3: 16777216 and 16777217 both become 16777216f, and the assertion passes despite a position error of 1. When the double precision matters, compare the original double fields instead, for example per axis with the scalar IsApproximatelyEqualTo.

Statistics on a sample

double[] sample = [1.0, 2.0, 3.0, 4.0, 5.0];

await Assert.That(sample).HasMeanApproximately(3.0, tolerance: 1e-9);
await Assert.That(sample).HasStdDevApproximately(Math.Sqrt(2.5), tolerance: 1e-9);
await Assert.That(sample).HasPercentileApproximately(50.0, expected: 3.0, tolerance: 1e-9);

3D-geometry containment / intersection

using MathAssertions.Geometry3D;

var sphere = new Sphere(Vector3.Zero, 1f);
await Assert.That(sphere).ContainsPoint(new Vector3(0.3f, 0.3f, 0.3f));

var ray = new Ray3D(new Vector3(-3, 0, 0), Vector3.UnitX);
await Assert.That(ray).IntersectsSphere(sphere);

Failure diagnostics

A failed assertion is reported by TUnit as Expected {expectation} followed by but {message}. The compound IsApproximatelyEqualTo assertions have no fixed expectation text of their own, so TUnit uses "to satisfy" plus the method and its arguments as the expectation. The detail comes from the message, which this package builds so the failure can be diagnosed without extra Console.WriteLine calls.

For a compound IsApproximatelyEqualTo (Vector2/Vector3/Vector4, Quaternion, Plane, Complex) the message contains:

  • the expected value and the tolerance;
  • the actual value;
  • the per-component delta;
  • the components that exceeded the tolerance, each with its delta.

The delta and exceeded values use general (G) numeric formatting with CultureInfo.InvariantCulture, so the numbers carry full precision (including any float-to-double widening residue) rather than a rounded display form.

Other compound types have their own layouts:

  • Matrix4x4: the actual matrix and the [row,col] cells that exceeded the tolerance, each with its delta.
  • double[] and float[]: the actual length and either a length mismatch or the first mismatching index with actual, expected, and delta.
  • IsRotationallyEquivalentTo: the actual quaternion, the absolute dot product of the normalized quaternions against its threshold, and both normalized quaternions.
  • IsGeometricallyEquivalentTo: the actual plane and the deltas against both the expected plane and its sign-flipped form.
  • HasAxisAngleApproximately: the actual quaternion, the extracted axis and angle, and the angle delta.
  • IsPoseApproximatelyEqualTo and IsRigidTransformApproximatelyEqualTo: both poses, plus the position and rotation deltas, each flagged as within or over its tolerance.

The exact text format is not stable; see the Stability intent section.


Cookbook: common patterns

Pattern: assert a computed Vector3 against a target

[Test]
public async Task ComputedPositionIsApproximatelyAtTarget(CancellationToken ct)
{
    Vector3 target = new(0.300f, 0.150f, 0.450f);
    Vector3 actual = SolveTrajectory(input);

    await Assert.That(actual).IsApproximatelyEqualTo(target, tolerance: 1e-3);
}

Pattern: pin a pose as a snapshot

IsApproximatelyEqualTo checks one numeric value against a literal expected within tolerance. To pin a whole pose (position and orientation together) as a snapshot baseline, render it to deterministic text with MathAssertions.Render.PoseRenderer and pin the result. A regression that flips a quaternion sign or swaps two pose fields then surfaces as a snapshot diff, even when each component still passes its own tolerance check.

using MathAssertions.Render;

[Test]
public async Task PoseMatchesBaseline(CancellationToken ct)
{
    (Vector3 position, Quaternion orientation) = ComputePose(input);

    var rendered = PoseRenderer.Render(position, orientation, tolerance: 1e-4);
    await Assert.That(rendered).MatchesSnapshot();
}

PoseRenderer lives in the framework-agnostic MathAssertions core and takes no dependency on a snapshot framework; MatchesSnapshot() is from the sibling SnapshotAssertions.TUnit package. The two-line composition is deliberate: the renderer never hard-depends on a snapshot framework. The quaternion is rendered verbatim (the sign is not canonicalized), so a q / -q flip is caught; the tolerance is recorded into the output, so a tolerance silently loosened during a refactor also shows up as a diff. The Render(position, tolerance) and Render(orientation, tolerance) overloads pin the position or orientation alone.

Only PoseRenderer lives in MathAssertions.Render. The assertions, the quaternion ones included, auto-import with Assert.That, so using MathAssertions.Render; is needed only in files that call PoseRenderer.

Pattern: assert a pose whose orientation comes from a protobuf message

The pose assertions take System.Numerics types (Vector3 and Quaternion). A pose delivered over the wire as a protobuf message usually carries its own position and orientation message types with double (or float) fields. Project the protobuf pose to System.Numerics first, then assert. Keeping the projection in one small mapping helper (here ToVector3() / ToQuaternion()) means the assertion reads the same whether the pose came from a solver or a deserialized message.

// MyProtoPose is your generated protobuf message; ToVector3() / ToQuaternion()
// are your own one-line projections from its fields onto System.Numerics types.
private static Vector3 ToVector3(MyProtoPosition p) => new((float)p.X, (float)p.Y, (float)p.Z);
private static Quaternion ToQuaternion(MyProtoOrientation q) => new((float)q.X, (float)q.Y, (float)q.Z, (float)q.W);

[Test]
public async Task DecodedPoseMatchesExpected(CancellationToken ct)
{
    MyProtoPose decoded = MyProtoPose.Parser.ParseFrom(payload);

    await Assert.That((ToVector3(decoded.Position), ToQuaternion(decoded.Orientation)))
        .IsPoseApproximatelyEqualTo(
            (expectedPosition, expectedOrientation),
            positionTolerance: 1e-3,
            rotationToleranceDegrees: 0.5);
}

A protobuf orientation is rarely a unit quaternion to the bit, and a default-initialized message leaves it as (0, 0, 0, 0). Both are handled: the orientation comparison normalizes before measuring the geodesic angle, and a non-normalizable (0, 0, 0, 0) falls back to a componentwise comparison, so a default-pose round-trip passes while a real mismatch still fails. The float casts lose precision for double fields; see Projecting a non-System.Numerics pose.

Pattern: extend the same DSL to your own domain types

The IsApproximatelyEqualTo DSL works on any consumer-defined type via a one-line [GenerateAssertion] extension that calls MathTolerance.IsApproximatelyEqual on each component you care about. Your domain types stay in your own code; the package never sees them.

using MathAssertions;
using TUnit.Assertions.Attributes;

// In your test project, anywhere with a [GenerateAssertion]-eligible static class:
file static class PositionAssertions
{
    [GenerateAssertion(
        ExpectationMessage = "to be approximately equal to {expected} within tolerance {tolerance}",
        InlineMethodBody = true)]
    public static bool IsApproximatelyEqualTo(this MyPosition value, MyPosition expected, double tolerance)
        => MathTolerance.IsApproximatelyEqual(value.AsVector3(), expected.AsVector3(), tolerance);
}

// Now in tests:
await Assert.That(actualPosition).IsApproximatelyEqualTo(expectedPosition, tolerance: 1e-6);

This is the design center of the package: consumers do not have to wait for upstream support of their types; they get a fluent DSL on whatever types they have, in five lines.

Pattern: assert each axis individually with Assert.Multiple

When per-axis diagnostics matter more than the package's failure-message format, fall back to the primitive:

using (Assert.Multiple())
{
    await Assert.That((double)actual.X).IsCloseTo(target.X, 1e-3);
    await Assert.That((double)actual.Y).IsCloseTo(target.Y, 1e-3);
    await Assert.That((double)actual.Z).IsCloseTo(target.Z, 1e-3);
}

Each axis reports as its own assertion. A single IsApproximatelyEqualTo call already names every component that exceeded the tolerance, with its delta (v0.2.0+, see Failure diagnostics); use the per-axis form when each axis should be a separate, individually named check.

Pattern: comparing quaternions

IsApproximatelyEqualTo is component-wise. Two unit quaternions q and -q represent the same physical rotation (the SO(3) double-cover) but fail component-wise comparison. If the production code may emit either sign of a unit quaternion (calibration outputs, slerp interpolation, normalization that picks a sign), use IsRotationallyEquivalentTo:

[Test]
public async Task RotationOutputMatchesExpected(CancellationToken ct)
{
    Quaternion expected = Quaternion.CreateFromAxisAngle(Vector3.UnitY, MathF.PI / 4);
    Quaternion actual = SolveOrientation(input);  // may emit ±expected

    // Component-wise: fails if SolveOrientation returned -expected
    // await Assert.That(actual).IsApproximatelyEqualTo(expected, tolerance: 1e-6);

    // Rotational: treats q and -q as the same rotation
    await Assert.That(actual).IsRotationallyEquivalentTo(expected, tolerance: 1e-6);
}

Use IsApproximatelyEqualTo when component identity matters (serialization roundtrips, exact storage formats). Use IsRotationallyEquivalentTo when geometric meaning matters (physical orientations, rotations applied to vectors). The implementation normalizes both inputs internally, so non-unit operands produce the correct rotational verdict.

Pattern: comparing planes

IsApproximatelyEqualTo is component-wise on the plane equation (n.X, n.Y, n.Z, d). The same geometric plane has two valid representations: (n, d) and (-n, -d). If the production code constructs planes via different paths (three-point, normal-and-distance, normal-and-point), the sign of the normal may differ between expected and actual without changing the plane. Use IsGeometricallyEquivalentTo:

[Test]
public async Task GroundPlaneMatchesExpected(CancellationToken ct)
{
    Plane expected = new(Vector3.UnitY, -1.0f);  // y = 1
    Plane actual = ComputeGroundPlane(input);    // could be (UnitY, -1) or (-UnitY, 1)

    await Assert.That(actual).IsGeometricallyEquivalentTo(expected, tolerance: 1e-6);
}

Use IsApproximatelyEqualTo when normal direction is observable in the consumer (winding-aware shading, half-space convention). Use IsGeometricallyEquivalentTo when only the plane's geometry is observable.

Verifying invertible transformations: HasRoundtripIdentity

When a transformation is invertible (f and its inverse g, where g(f(x)) == x within tolerance), MathTolerance.HasRoundtripIdentity(double input, Func<double,double> forward, Func<double,double> backward, double tolerance) checks the round-trip in one call. The primitive is double-only on purpose; consumers compose their own predicate for other types.

Three worked examples:

// 1. Sin and Asin compose back to the input within tolerance.
await Assert.That(MathTolerance.HasRoundtripIdentity(
    0.42, Math.Sin, Math.Asin, tolerance: 1e-12)).IsTrue();

// 2. Degree / radian conversion.
await Assert.That(MathTolerance.HasRoundtripIdentity(
    45.0,
    d => d * Math.PI / 180.0,
    r => r * 180.0 / Math.PI,
    tolerance: 1e-12)).IsTrue();

// 3. Encode / decode, with consumer-supplied delegates wrapping the under-test API.
//    Useful for serializer roundtrips when the encode / decode operate on a numeric
//    representation (e.g. epoch milliseconds <-> DateTimeOffset). Both halves of the
//    encode/decode round-trip are embedded inside the `forward` delegate; the `inverse`
//    delegate is the identity cast that re-widens the long result back to the double
//    the primitive operates on.
await Assert.That(MathTolerance.HasRoundtripIdentity(
    epochMs,
    forward: ms => DateTimeOffset.FromUnixTimeMilliseconds((long)ms).ToUnixTimeMilliseconds(),
    inverse: ms => (double)ms,  // identity widen; the encode/decode round-trip is in `forward`
    tolerance: 0.5)).IsTrue();

When the round-trip needs typed inputs (vector / quaternion / serializer pair), compose [GenerateAssertion] on your own type rather than chaining HasRoundtripIdentity: the primitive is intentionally double-only so the family does not lock in a particular cross-type roundtrip surface.

Pattern: geometric checks on vectors and rotations (v0.5.0+)

// The unsigned angle between two directions, in radians (0..pi):
await Assert.That(forward).HasAngleBetweenApproximately(target, expectedRadians: Math.PI / 2, tolerance: 1e-6);

// Parallel or antiparallel, whatever the lengths within float range: both lengths above the
// tolerance, and both lengths and their product below about 1.8e19. The lengths and the cross
// product are computed in float; beyond that range they overflow and the result can be wrong.
// The tolerance is the sine of the allowed deviation, so 1e-3 admits about a milliradian.
await Assert.That(axis).IsParallelTo(new Vector3(0, 0, 10), tolerance: 1e-3);

// A unit vector, and a unit quaternion:
await Assert.That(Vector3.Normalize(direction)).IsNormalized(tolerance: 1e-6);
await Assert.That(orientation).IsNormalized(tolerance: 1e-6);

// A proper rotation: two checks that must both pass, M * M^T = I and determinant +1, each within the
// tolerance. A reflection (determinant -1) fails under any tolerance below 2. An affine matrix (last
// column 0, 0, 0, 1) with a translation or a scale factor other than +1 or -1 fails unless both
// checks stay within the tolerance. CreateScale(-1, -1, 1) is a 180-degree rotation about Z and passes.
// The whole 4x4 matrix is checked, not the affine form: a 4D rotation that mixes W with X, Y or Z passes.
await Assert.That(Matrix4x4.CreateFromQuaternion(orientation)).IsRotation(tolerance: 1e-5);

For two quaternions that should describe the same rotation, see comparing quaternions: IsRotationallyEquivalentTo accepts both signs of the double cover.

Pattern: detecting an unpopulated zero quaternion

A zero-valued quaternion (all four components zero, length zero) is a valid in-memory state but not a valid rotation. Common cases: a protobuf default for an unpopulated Quaternion field, an in-construction calibration output that has not yet been written, an explicit "no rotation set" sentinel.

The BCL exposes Quaternion.Zero. The existing component-wise comparison handles the assertion without a specialized API:

[Test]
public async Task UnpopulatedRotationIsZeroSentinel(CancellationToken ct)
{
    Quaternion actual = ReadRotationFromMessage(message);

    await Assert.That(actual).IsApproximatelyEqualTo(Quaternion.Zero, tolerance: 1e-9);
}

This reads as "the quaternion is approximately the zero quaternion" and fails for any rotation including the identity rotation Quaternion.Identity (which has W = 1). No specialized IsZeroQuaternion extension is needed; the BCL constant plus the existing component-wise comparison cover the case.


Design notes

  • IsApproximatelyEqualTo, not IsCloseTo. TUnit core's IsCloseTo is for scalar double / float; reusing the name on compound types would imply the same scalar-distance semantics and cause bugs. IsApproximatelyEqualTo names the compound, component-wise, NaN/infinity-aware comparison. ScalarAssertions does provide a scalar IsApproximatelyEqualTo for double and float that follows the same NaN and infinity rules; TUnit's own IsCloseTo remains the idiom for scalar distance checks. The core MathTolerance.IsApproximatelyEqual(double, double, double) is available to [GenerateAssertion] authors.
  • Components widen to double; tolerance is not narrowed to float. A double tolerance is kept at full precision and the component difference is computed in double, so tight tolerances are not lost to float rounding. See Tolerance precision.

NaN and infinity semantics

Match TUnit's IsCloseTo primitive semantics:

Both Comparison Result
NaN NaN true (under any valid tolerance)
One NaN, other not (anything) false
Same-sign infinity (anything) true
Opposite-sign infinity (anything) false
Finite Math.Abs(a - b) <= tolerance as expected
Tolerance is NaN or negative (anything) ArgumentOutOfRangeException at call time

For the Vector3 overload, every component pair is evaluated against this table; the assertion passes iff all three pairs pass.

The generic ReadOnlyTensorSpan<T> overload follows the NaN rows but not the infinity rows: it compares infinities through T.Abs(a - b) > tolerance. Under a finite tolerance the results match the table; under a tolerance of positive infinity it returns true for opposite-sign infinities and for an infinity against a finite value.

This table describes the tolerance comparisons. Other checks do not define NaN handling: NaN input can pass the pose position comparison, the pointcloud on-plane and on-sphere checks, convex-hull containment, and the monotonic-sequence checks. Assert finiteness separately when it matters.

Stability intent (pre-1.0)

This is a 0.x release and the public API may evolve. Specifically:

  • Additive changes (new entry points, new tolerance overloads, additional System.Numerics types) ship in any patch without breaking ApiCompat. Entry points present in a prior version remain present, with compatible signatures, in every subsequent release that targets the same TFM.
  • Breaking changes to existing signatures bump the minor version (0.X.0) and are called out in the CHANGELOG. v0.2.0 evolved the source-method return types of the compound IsApproximatelyEqualTo family from bool to AssertionResult to enable rich per-component failure messages; the generated TUnit chain extensions (Assert.That(value).IsApproximatelyEqualTo(...)) are unaffected at the chain-syntax level.
  • PackageValidationBaselineVersion pins to the previous shipped version, so ApiCompat breakage is caught at pack time. Strict-mode baseline validation captures additive changes and intentional API evolution as accepted entries in CompatibilitySuppressions.xml.

The 1.0 milestone signals API stability; see Limitations and future work for what's still being designed.

Limitations and future work

The catalog covers System.Numerics compounds (vectors, quaternions, matrices, planes, complex), span/tensor and array overloads, statistics, linear-algebra invariants, number theory, and Geometry3D primitives with containment, intersection, and point-distance. Everything below is demand-driven; nothing is committed.

  • Depth: more Geometry3D (OrientedBox SAT, triangle-triangle, Hausdorff, RANSAC), statistics (distributions, correlation), mesh validity, BigInteger number theory.
  • ReadOnlyTensorSpan<T> fluent adapter: the static MathTolerance overload exists; the fluent form is blocked on TUnit capturing ref-struct values across await.
  • Sibling adapters (MathAssertions.MathNet.TUnit, MathAssertions.GeometRi.TUnit, and similar): separate packages, built on request.

Out of scope: statistical inference / hypothesis testing, symbolic math, 2D-Euclidean primitives in core (2D would arrive via an adapter), and production-code use.

Family compatibility

The nine assertion-family packages: LogAssertions.TUnit, TimeAssertions.TUnit, SnapshotAssertions.TUnit, MathAssertions.TUnit, JsonAssertions.TUnit, SseAssertions.TUnit, GrpcAssertions.TUnit, TracingAssertions.TUnit, and MetricsAssertions.TUnit: release independently and target the same .NET TFM at any moment (LTS-anchored, multi-target during STS support windows; see the TFM policy in CONVENTIONS.md for the rotation schedule). Mix versions freely. Each package ships under SemVer with EnablePackageValidation strict-mode ApiCompat against its previous baseline, so binary breaks within a version line are caught at pack time.

For per-package release notes:

Pair with

  • LogAssertions.TUnit: fluent log assertions over Microsoft.Extensions.Logging.Testing.FakeLogCollector.
  • SnapshotAssertions.TUnit: text-snapshot assertions for API-surface tests and similar deterministic-string scenarios. Coexists with Verify; covers the 80% case without coverage friction.
  • TimeAssertions.TUnit: assertion-level timing budgets via .And.WithinTimeBudget(...). Compose with any IsApproximatelyEqualTo chain for combined value+timing assertions.
  • JsonAssertions.TUnit: fluent JSON assertions over System.Text.Json, HTTP response bodies (including RFC 7807 ProblemDetails), and source-generated JsonSerializerContext registration.
  • SseAssertions.TUnit: Server-Sent Events (SSE) wire-format assertions: event-count, field shape (event:, data:, id:, retry:), and stream content validation.
  • GrpcAssertions.TUnit: fluent gRPC outcome assertions (ThrowsGrpcException with StatusCode shorthands and detail refinements) plus the GrpcCallBuilder test-double helper.
  • TracingAssertions.TUnit: fluent OpenTelemetry distributed-tracing (Activity / span) assertions: operation name, tags, status, and parent/child and same-trace relationships, captured via a raw ActivityListener with no OpenTelemetry SDK dependency.
  • MetricsAssertions.TUnit: fluent assertions over System.Diagnostics.Metrics instruments (counters, histograms, gauges), built on MetricCollector.

Contributing

Issues and pull requests welcome. Before opening a PR:

  • Run dotnet build and dotnet test locally; the CI pipeline enforces the same quality bar (zero warnings as errors, 90% line / 90% branch coverage minimum).
  • Match the existing code style (.editorconfig is authoritative; dotnet format covers formatting).
  • For new assertions, include a test for both the happy path and a representative failure case so the failure-message rendering is verified.

For larger ideas (new entry points, breaking changes, cross-cutting refactors), open a Discussion first to align on direction before investing implementation time.

See CONTRIBUTING.md for the full PR review checklist and API design principles, and CONVENTIONS.md for the family-wide code conventions shared across LogAssertions.TUnit, SnapshotAssertions.TUnit, TimeAssertions.TUnit, JsonAssertions.TUnit, SseAssertions.TUnit, and this repo.

License

MIT. Copyright (c) 2026 John Verheij.

About

TUnit-native math assertion library for .NET. Tolerance, sequences, statistics, linear algebra, number theory, 3D geometry. AOT-compatible, reflection-free.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages