MathAssertions.TUnit
0.4.0
Prefix Reserved
See the version list below for details.
dotnet add package MathAssertions.TUnit --version 0.4.0
NuGet\Install-Package MathAssertions.TUnit -Version 0.4.0
<PackageReference Include="MathAssertions.TUnit" Version="0.4.0" />
<PackageVersion Include="MathAssertions.TUnit" Version="0.4.0" />
<PackageReference Include="MathAssertions.TUnit" />
paket add MathAssertions.TUnit --version 0.4.0
#r "nuget: MathAssertions.TUnit, 0.4.0"
#:package MathAssertions.TUnit@0.4.0
#addin nuget:?package=MathAssertions.TUnit&version=0.4.0
#tool nuget:?package=MathAssertions.TUnit&version=0.4.0
MathAssertions.TUnit
Scope: Test projects only. Not intended for production code.
TUnit-native math assertion library for .NET. Covers tolerance comparisons, sequences, statistics, linear algebra, number theory, and 3D geometry. NaN-aware, infinity-aware, AOT-compatible, no runtime reflection in the assertion path.
Full documentation, "Why component-wise rather than Euclidean", cookbook, design notes, and roadmap: github.com/JohnVerheij/MathAssertions.TUnit
Status
The mathematical-assertion surface is exposed as fluent extensions over TUnit's Assert.That(value).Method(...). ~85 fluent entry points across twelve adapter classes covering scalar tolerance comparisons, the System.Numerics compound types (vector / quaternion / matrix / plane / complex), double[] / float[] element-wise comparison with null-array guards, sequence properties, descriptive statistics, linear-algebra invariants, integer number theory, and a complete 3D-geometry primitive surface (containment / closest-point distance / intersection / pointcloud aggregates). v0.2.0 adds rich per-component / per-cell delta rendering to every compound IsApproximatelyEqualTo failure message plus HasAxisAngleApproximately on Quaternion. v0.3.0 adds the MathAssertions.Render namespace with PoseRenderer for turning a position / orientation pose into deterministic, snapshot-friendly text.
Install
dotnet add package MathAssertions.TUnit
MathAssertions (the framework-agnostic core) comes transitively. Requirements: TUnit 1.48.6 or later, .NET 10.
The source-generated entry point (IsApproximatelyEqualTo on Vector3) auto-imports via TUnit.Assertions.Extensions. The only additional using you typically need is System.Numerics for the value type itself. If you call MathTolerance.IsApproximatelyEqual directly from a [GenerateAssertion] extension on a private domain type, add MathAssertions to your 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);
}
Entry points
The fluent surface, organized by adapter class:
| Class | Coverage |
|---|---|
ScalarAssertions |
IsApproximatelyEqualTo, IsCloseInUlpsTo, IsRelativelyAndAbsolutelyCloseTo, IsNonNegativeFinite, IsProbability, IsPercentage, HasRoundtripIdentity for double/float. |
VectorAssertions |
IsApproximatelyEqualTo for Vector2/Vector3/Vector4; Vector3.HasMagnitudeApproximately, IsNormalized. |
QuaternionAssertions |
IsApproximatelyEqualTo, IsRotationallyEquivalentTo (SO(3) double-cover), HasAxisAngleApproximately (axis-angle form; v0.2.0+), IsIdentity, IsNormalized. |
PoseAssertions |
(Vector3, Quaternion).IsPoseApproximatelyEqualTo and Matrix4x4.IsRigidTransformApproximatelyEqualTo: pose / rigid-transform equality with separate position (Euclidean distance) and rotation (geodesic degrees) tolerances, plus a combined failure diagnostic (v0.4.0+). |
MatrixAssertions |
Matrix4x4.IsApproximatelyEqualTo plus the full invariant surface: IsSymmetric, IsOrthogonal, IsIdentity, HasDeterminantApproximately, HasTraceApproximately, IsInvertible. |
PlaneAssertions |
IsApproximatelyEqualTo, IsGeometricallyEquivalentTo (sign-flip equivalence). |
ComplexAssertions |
IsApproximatelyEqualTo. |
ArrayAssertions |
double[] / float[] element-wise IsApproximatelyEqualTo with ArgumentNullException on null arrays. |
SequencesAssertions |
Monotonicity, IsBounded, IsArithmeticProgression, IsGeometricProgression, ConvergesTo, IsCauchyConvergent, generic HasLength, HasMinLength over T[]. |
StatisticsAssertions |
HasMean/Variance/StdDev/Sum/Median/PercentileApproximately, IsWithinSigmasOfMean, AreAllWithinSigmasOfMean. |
LinearAlgebraAssertions |
Vector3.IsOrthogonalTo, IsParallelTo; Vector3[].AreLinearlyIndependent. |
NumberTheoryAssertions |
long-integer predicates: IsDivisibleBy, IsPrime, IsCoprimeWith, IsPowerOf, IsPerfectSquare, IsCongruentTo. |
Geometry3DAssertions |
Triangle / point-set property predicates, containment (point/box/sphere/OBB/convex hull), predicate-style HasDistanceFromPlane/Segment/Triangle, ray-plane/sphere/triangle/AABB intersection, pointcloud aggregates. |
IsFinite() for double/float is provided by TUnit's built-in DoubleAssertionExtensions/SingleAssertionExtensions; this package does not duplicate it.
Cookbook
Pattern: comparing quaternions
IsApproximatelyEqualTo is component-wise. Two unit quaternions q and -q represent the same physical rotation 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).
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, the framework-agnostic MathTolerance.HasRoundtripIdentity(double input, Func<double,double> forward, Func<double,double> backward, double tolerance) checks that backward(forward(input)) lands back at input within tolerance. The primitive is double-only on purpose; consumers wrap their own typed inputs.
// Sin / Asin compose back to the input.
await Assert.That(MathTolerance.HasRoundtripIdentity(
0.42, Math.Sin, Math.Asin, tolerance: 1e-12)).IsTrue();
// 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();
For 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: 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).
NaN and infinity semantics
The fluent extensions match the underlying MathTolerance semantics, which match TUnit's IsCloseTo primitive:
| Both | Comparison | Result |
|---|---|---|
| NaN | NaN | true (under any 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 compound types (Vector2/Vector3/Vector4, Quaternion, Matrix4x4, Plane, Complex), every component pair is evaluated against this table; the assertion passes iff every pair passes.
Extending to your own types
Add tolerance assertions for your own domain types via a one-line [GenerateAssertion] extension calling MathTolerance.IsApproximatelyEqual on each component. The package never sees your private types.
using MathAssertions;
using TUnit.Assertions.Attributes;
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);
}
Failure diagnostics
On a failed assertion, the exception message renders the actual Vector3 against the expected and the supplied tolerance:
Expected:
to be approximately equal to <1, 2, 99> component-wise within tolerance 0.001
Actual:
<1, 2, 3>
Full failure-diagnostics examples, design notes, stability intent, and roadmap on GitHub.
Family
Part of an assertion family for TUnit:
License
MIT. Copyright (c) 2026 John Verheij.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- MathAssertions (>= 0.4.0)
- System.Numerics.Tensors (>= 10.0.8)
- TUnit.Assertions (>= 1.48.6)
- TUnit.Core (>= 1.48.6)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
View the rendered release notes: https://github.com/JohnVerheij/MathAssertions.TUnit/releases/tag/v0.4.0
Feature release. Adds the pose assertion the package's `PoseRenderer` was waiting for: a pose is a position and an orientation together, so `IsPoseApproximatelyEqualTo` compares both halves in one call with separate position (length) and rotation (degrees) tolerances and one combined diagnostic that names which half missed. Also folds in the accumulated CI hardening, the Renovate migration, and the CONVENTIONS v0.7 sync that had collected on the unreleased line.
### Added
- **`Assert.That((position, orientation)).IsPoseApproximatelyEqualTo((expectedPosition, expectedOrientation), positionTolerance, rotationToleranceDegrees)`** (TUnit adapter) compares a `(Vector3, Quaternion)` pose in one call. The two halves use separate tolerances because they carry different units: a Euclidean position distance and a geodesic rotation angle in degrees. On failure the combined message renders both poses and the measured position and rotation deltas, flagging which half exceeded its tolerance. The orientation comparison uses the SO(3) metric, so a quaternion and its negation are the same rotation. Source-generated via `[GenerateAssertion]`.
- **`Assert.That(matrix).IsRigidTransformApproximatelyEqualTo(expectedMatrix, positionTolerance, rotationToleranceDegrees)`** (TUnit adapter) is the `Matrix4x4` overload (a pose is a rigid transform). Translation is read from `Matrix4x4.Translation` and rotation via `Quaternion.CreateFromRotationMatrix`; the overload assumes a rigid transform (orthonormal rotation, unit scale).
- **`MathTolerance.IsPoseApproximatelyEqual(...)`, `RotationAngleDegrees(Quaternion, Quaternion)`, and `PositionDistance(Vector3, Vector3)`** (framework-agnostic core) back the assertions: the geodesic rotation angle in degrees, the double-precision Euclidean position distance, and the combined pose predicate.
### Changed
- Removed `paths-ignore` from `.github/workflows/ci.yml` so the `Build, test & pack` required check always reports a status. Without the fix, docs-only PRs stuck in `Expected - Waiting for status to be reported` and could not satisfy branch protection.
- Dropped drift-prone own-version anchors from the root README and packed adapter README: `## Status: v0.3.0 (pose renderer)` is now `## Status`. Historical "added in vX.Y" markers and the `### Shipped at v0.X.Y` historical sections are unchanged. The CHANGELOG remains the single source of truth for what shipped when.
- Migrated CI dependency automation from Dependabot to Renovate (`.github/renovate.json`), matching `SseAssertions.TUnit` and `TimeAssertions.TUnit`. Daily schedule (before 4am Europe/Amsterdam), `customManagers` keep TUnit version literals in the root README, package README, smoketest csproj, and bug-report Issue Form in lockstep with the central `Directory.Packages.props` pin. `platformAutomerge` replaces the separate `dependabot-auto-merge.yml` workflow. Dependency dashboard issue enabled. Explicit semantic commit scopes: `deps(nuget)`, `ci(github-actions)`, `ci(dotnet-sdk)`. Auto-merge covers `digest`, `pin`, `pinDigest`, and `lockFileMaintenance` updateTypes alongside `minor` and `patch`. The three TUnit packages (`TUnit`, `TUnit.Assertions`, `TUnit.Core`) are grouped into a single PR per release.
- **TUnit dependency bumped `1.44.0` -> `1.44.39`** (and the external-consumer smoke-test pin). 1.44.39 carries the `[GenerateAssertion]` source-generator fix for value-type optional parameters; no behavioural change for this package, taken for family lockstep.
- Updated `CONVENTIONS.md` to v0.7 (cumulative from v0.5).
- Added `JsonAssertions.TUnit` (the fifth family package, JSON path / value / shape assertions) and `SseAssertions.TUnit` (the sixth family package, Server-Sent Events wire-format assertions) to the `CONVENTIONS.md` family roster.
- Added a per-package strict-scope policy section to `CONVENTIONS.md` with explicit scope statements for all six packages.
- Added a core+adapter packaging rule section to `CONVENTIONS.md`: five of six family packages ship core+adapter; `JsonAssertions.TUnit` is the sole single-package member.
- Synchronised `CONVENTIONS.md` across all six family repos (the file is copied identically).
- Expanded the `README.md` Family roster to six packages, adding `JsonAssertions.TUnit` and `SseAssertions.TUnit` to the "Family compatibility" section, the "Pair with" section, and the "shared across" line in Contributing.
- Added GitHub Actions workflow security scanning. `.github/workflows/zizmor.yml` runs `zizmor` (blocking, with findings shown as inline annotations) on every workflow change; `.github/workflows/codeql.yml` now analyzes the `actions` language alongside `csharp`; `.github/workflows/scorecard.yml` (OpenSSF Scorecard) and `.github/workflows/dependency-review.yml` (fails a PR that adds a high-severity-vulnerable dependency) are new. Added the Renovate `helpers:pinGitHubActionDigestsToSemver` preset so any newly-introduced action is auto-pinned to a commit SHA. CI-only; no effect on shipped packages.
### Security
- Hardened GitHub Actions token handling: set `persist-credentials: false` on every `actions/checkout` so the repository token is not written into `.git/config`; moved the inline coverage-report expression in `ci.yml` into an `env:` variable to remove a template-injection vector; and scoped workflow write permissions (`security-events` on `codeql`; `contents`/`id-token`/`packages`/`attestations` on `release`) to the job level with a read-only workflow-level default. CI-only; no released package is affected.