tests/HumanoidRetargeter.Tests/Target/TargetRigTests.cs

Unit tests for TargetRig and Sbox bone classification. Loads a fixture skeleton JSON, generates a target rig, checks classification, role mapping, deterministic generation, JSON matching committed asset, roundtrip load, counts of bone classes, and role lookup behavior.

File Access
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Skeleton;
using Xunit;

namespace HumanoidRetargeter.Tests.Target;

public class TargetRigTests
{
    private static string LoadRigFixture()
        => File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json"));

    private static TargetRig LoadGeneratedTargetRig()
        => TargetRig.Load(TargetRigGenerator.Generate(LoadRigFixture()));

    // ---------------------------------------------------------------- classifier

    [Theory]
    [InlineData("arm_upper_L_twist0", BoneClass.ConstraintDriven)]
    [InlineData("arm_upper_R_twist1", BoneClass.ConstraintDriven)]
    [InlineData("leg_lower_L_twist1", BoneClass.ConstraintDriven)]
    [InlineData("arm_elbow_helper_L", BoneClass.ConstraintDriven)]
    [InlineData("leg_knee_helper_R", BoneClass.ConstraintDriven)]
    [InlineData("neck_clothing", BoneClass.ConstraintDriven)]
    [InlineData("root_IK", BoneClass.IkBaked)]
    [InlineData("foot_L_IK_target", BoneClass.IkBaked)]
    [InlineData("hand_R_IK_target", BoneClass.IkBaked)]
    [InlineData("hand_L_IK_attach", BoneClass.IkBaked)]
    [InlineData("hand_L_to_R_ikrule", BoneClass.IkBaked)]
    [InlineData("hand_R_to_L_ikrule", BoneClass.IkBaked)]
    [InlineData("aim_matrix_01", BoneClass.IkBaked)]
    [InlineData("aim_matrix_02a", BoneClass.IkBaked)]
    [InlineData("hold_L", BoneClass.IkBaked)]
    [InlineData("hold_R", BoneClass.IkBaked)]
    [InlineData("pelvis", BoneClass.Animated)]
    [InlineData("spine_0", BoneClass.Animated)]
    [InlineData("hand_L", BoneClass.Animated)]
    [InlineData("finger_pinky_meta_R", BoneClass.Animated)]
    [InlineData("ball_L", BoneClass.Animated)]
    public void Classify_FollowsDesignRules(string name, BoneClass expected)
        => Assert.Equal(expected, SboxBoneClassifier.Classify(name));

    [Theory]
    [InlineData("pelvis", BoneRole.Hips)]
    [InlineData("spine_0", BoneRole.Spine0)]
    [InlineData("spine_2", BoneRole.Spine2)]
    [InlineData("neck_0", BoneRole.Neck)]
    [InlineData("head", BoneRole.Head)]
    [InlineData("clavicle_L", BoneRole.ClavicleL)]
    [InlineData("arm_upper_R", BoneRole.UpperArmR)]
    [InlineData("arm_lower_L", BoneRole.LowerArmL)]
    [InlineData("hand_R", BoneRole.HandR)]
    [InlineData("leg_upper_L", BoneRole.UpperLegL)]
    [InlineData("leg_lower_R", BoneRole.LowerLegR)]
    [InlineData("ankle_L", BoneRole.FootL)]
    [InlineData("ball_R", BoneRole.ToeR)]
    [InlineData("finger_thumb_0_L", BoneRole.ThumbProxL)]
    [InlineData("finger_thumb_1_L", BoneRole.ThumbMidL)]
    [InlineData("finger_thumb_2_R", BoneRole.ThumbDistR)]
    [InlineData("finger_index_meta_L", BoneRole.IndexMetaL)]
    [InlineData("finger_index_0_L", BoneRole.IndexProxL)]
    [InlineData("finger_middle_1_R", BoneRole.MiddleMidR)]
    [InlineData("finger_ring_2_L", BoneRole.RingDistL)]
    [InlineData("finger_pinky_meta_R", BoneRole.PinkyMetaR)]
    [InlineData("finger_pinky_0_R", BoneRole.PinkyProxR)]
    public void RoleFor_MapsSboxNames(string name, BoneRole expected)
        => Assert.Equal(expected, SboxBoneClassifier.RoleFor(name));

    [Theory]
    [InlineData("arm_upper_L_twist0")]
    [InlineData("root_IK")]
    [InlineData("hold_L")]
    [InlineData("aim_matrix_01")]
    public void RoleFor_NonAnimatedNames_ReturnsNull(string name)
        => Assert.Null(SboxBoneClassifier.RoleFor(name));

    // ---------------------------------------------------------------- generator

    [Fact]
    public void GeneratedJson_MatchesCommittedAsset()
    {
        var generated = TargetRigGenerator.Generate(LoadRigFixture());

        var assetPath = FindRepoFile(Path.Combine("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json"));
        if (!File.Exists(assetPath))
        {
            // Regenerate-and-diff pattern: first run writes the committed artifact.
            Directory.CreateDirectory(Path.GetDirectoryName(assetPath)!);
            File.WriteAllText(assetPath, generated);
        }

        var committed = File.ReadAllText(assetPath);
        Assert.Equal(Normalize(committed), Normalize(generated));
    }

    [Fact]
    public void Generate_IsDeterministic()
    {
        var rigJson = LoadRigFixture();
        Assert.Equal(TargetRigGenerator.Generate(rigJson), TargetRigGenerator.Generate(rigJson));
    }

    // ---------------------------------------------------------------- TargetRig.Load

    [Fact]
    public void Load_RoundTripsGeneratedJson()
    {
        var rig = LoadGeneratedTargetRig();
        var (sourceSkeleton, _) = RigJson.Load(LoadRigFixture());

        Assert.Equal(sourceSkeleton.Count, rig.Skeleton.Count);
        for (var i = 0; i < sourceSkeleton.Count; i++)
        {
            Assert.Equal(sourceSkeleton[i].Name, rig.Skeleton[i].Name);
            Assert.Equal(sourceSkeleton[i].ParentIndex, rig.Skeleton[i].ParentIndex);
            TestUtil.AssertVectorEqual(sourceSkeleton[i].RestLocal.Pos, rig.Skeleton[i].RestLocal.Pos, 1e-3f);
            TestUtil.AssertQuaternionEqual(sourceSkeleton[i].RestLocal.Rot, rig.Skeleton[i].RestLocal.Rot, 1e-5f);
        }
    }

    [Fact]
    public void EveryBoneIsClassified_WithExpectedClassCounts()
    {
        var rig = LoadGeneratedTargetRig();

        Assert.Equal(94, rig.Skeleton.Count);
        Assert.Equal(60, rig.BonesOfClass(BoneClass.Animated).Count());
        Assert.Equal(20, rig.BonesOfClass(BoneClass.ConstraintDriven).Count());
        Assert.Equal(14, rig.BonesOfClass(BoneClass.IkBaked).Count());
    }

    [Fact]
    public void EveryAnimatedBoneHasARole()
    {
        var rig = LoadGeneratedTargetRig();
        foreach (var index in rig.BonesOfClass(BoneClass.Animated))
            Assert.True(rig.RoleOf(index).HasValue,
                $"Animated bone '{rig.Skeleton[index].Name}' has no role.");
    }

    [Fact]
    public void NoNonAnimatedBoneHasARole()
    {
        var rig = LoadGeneratedTargetRig();
        for (var i = 0; i < rig.Skeleton.Count; i++)
        {
            if (rig.ClassOf(i) == BoneClass.Animated)
                continue;
            Assert.False(rig.RoleOf(i).HasValue,
                $"{rig.ClassOf(i)} bone '{rig.Skeleton[i].Name}' must not have a role.");
        }
    }

    [Fact]
    public void RoleLookup_RoundTrips_AndAbsentRolesReturnNull()
    {
        var rig = LoadGeneratedTargetRig();

        var presentRoles = new HashSet<BoneRole>();
        for (var i = 0; i < rig.Skeleton.Count; i++)
        {
            var role = rig.RoleOf(i);
            if (role is null)
                continue;
            Assert.True(presentRoles.Add(role.Value), $"Role {role} assigned twice.");
            Assert.Equal(i, rig.BoneForRole(role.Value));
        }

        // The s&box rig has a 3-bone spine and no thumb metacarpals; those enum values exist
        // for source rigs only and must be absent here.
        var expectedAbsent = new[] { BoneRole.Spine3, BoneRole.Spine4, BoneRole.ThumbMetaL, BoneRole.ThumbMetaR };
        foreach (var role in expectedAbsent)
        {
            Assert.DoesNotContain(role, presentRoles);
            Assert.Null(rig.BoneForRole(role));
        }

        // Every other role is present exactly once.
        foreach (var role in Enum.GetValues<BoneRole>())
        {
            if (expectedAbsent.Contains(role))
                continue;
            Assert.Contains(role, presentRoles);
        }
    }

    [Fact]
    public void Load_ExposesRigName()
    {
        var rig = LoadGeneratedTargetRig();
        Assert.False(string.IsNullOrWhiteSpace(rig.Name));
    }

    // ---------------------------------------------------------------- helpers

    private static string Normalize(string text) => text.Replace("\r\n", "\n").TrimEnd('\n');

    /// <summary>Resolves a repo-relative path by walking up from the test output directory
    /// to the directory containing the .sbproj.</summary>
    private static string FindRepoFile(string relativePath)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(dir.FullName, relativePath);
            dir = dir.Parent;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found above test directory.");
    }
}