tests/HumanoidRetargeter.Tests/TestUtil.cs

Unit test utilities for HumanoidRetargeter tests. Provides deterministic random generators for Vector3, unit vectors, quaternions and transforms, plus assertion helpers for vector and quaternion equality and a helper to locate a repo file by walking parent directories.

File Access
using System.Numerics;
using HumanoidRetargeter.Core.Maths;
using Xunit;

namespace HumanoidRetargeter.Tests;

/// <summary>Shared deterministic random generators and assertion helpers.</summary>
internal static class TestUtil
{
    /// <summary>Random vector with components in [-range, range].</summary>
    public static Vector3 RandomVector(Random rng, float range = 10f)
        => new(
            (float)(rng.NextDouble() * 2.0 - 1.0) * range,
            (float)(rng.NextDouble() * 2.0 - 1.0) * range,
            (float)(rng.NextDouble() * 2.0 - 1.0) * range);

    /// <summary>Random unit vector (rejection-sampled, deterministic for a seeded rng).</summary>
    public static Vector3 RandomUnitVector(Random rng)
    {
        while (true)
        {
            var v = RandomVector(rng, 1f);
            var lsq = v.LengthSquared();
            if (lsq > 0.01f && lsq <= 1f)
                return Vector3.Normalize(v);
        }
    }

    /// <summary>Random unit quaternion from a random axis and angle in (-pi, pi).</summary>
    public static Quaternion RandomQuaternion(Random rng)
    {
        var axis = RandomUnitVector(rng);
        var angle = (float)((rng.NextDouble() * 2.0 - 1.0) * Math.PI);
        return Quaternion.CreateFromAxisAngle(axis, angle);
    }

    /// <summary>Random rigid transform with positions in [-100, 100] (cm scale).</summary>
    public static XForm RandomXForm(Random rng)
        => new(RandomVector(rng, 100f), RandomQuaternion(rng));

    /// <summary>Asserts two vectors are equal within an absolute per-component tolerance.</summary>
    public static void AssertVectorEqual(Vector3 expected, Vector3 actual, float eps)
    {
        Assert.True(
            MathF.Abs(expected.X - actual.X) <= eps &&
            MathF.Abs(expected.Y - actual.Y) <= eps &&
            MathF.Abs(expected.Z - actual.Z) <= eps,
            $"Vectors differ beyond eps={eps}: expected {expected}, actual {actual}");
    }

    /// <summary>
    /// Asserts two quaternions represent the same rotation within a per-component tolerance,
    /// accounting for the q / -q double cover.
    /// </summary>
    public static void AssertQuaternionEqual(Quaternion expected, Quaternion actual, float eps)
    {
        var same = MaxComponentDelta(expected, actual);
        var flipped = MaxComponentDelta(expected, Quaternion.Negate(actual));
        Assert.True(
            MathF.Min(same, flipped) <= eps,
            $"Quaternions differ beyond eps={eps}: expected {expected}, actual {actual}");
    }

    private static float MaxComponentDelta(Quaternion a, Quaternion b)
        => MathF.Max(
            MathF.Max(MathF.Abs(a.X - b.X), MathF.Abs(a.Y - b.Y)),
            MathF.Max(MathF.Abs(a.Z - b.Z), MathF.Abs(a.W - b.W)));

    /// <summary>Resolves a repo-relative path by walking up to the .sbproj directory
    /// (shared by the local-corpus repro tests).</summary>
    public static string RepoFile(params string[] parts)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
            dir = dir.Parent;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found above test directory.");
    }
}