tests/HumanoidRetargeter.Tests/Target/CustomTargetRigTests.cs

Unit tests for TargetRig and bone classification rules. Tests verify name-based BoneClass classification, building TargetRig from skeletons for various profiles (mixamo, actorcore, sbox, citizen), validate mapping round-trips and class agreement with a shipped Sbox classifier, and check error conditions and JSON parsing.

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

namespace HumanoidRetargeter.Tests.Target;

/// <summary>
/// Task 9.5.1: arbitrary humanoid TARGET rigs — <see cref="TargetRig.FromSkeleton"/> plus
/// the generic name-pattern <see cref="BoneClassRules"/> (design §1 "Custom targets").
/// </summary>
public class CustomTargetRigTests
{
    private static (SkeletonModel Skeleton, MappingResult Map) DetectAsTarget(SkeletonModel skeleton)
    {
        var detected = ProfileDetector.Detect(skeleton);
        Assert.NotNull(detected);
        return (skeleton, detected.Value.Result);
    }

    // ---------------------------------------------------------------- class rules (synthetic names)

    [Theory]
    // Twist bones across naming conventions.
    [InlineData("arm_upper_L_twist0", BoneClass.ConstraintDriven)] // s&box
    [InlineData("upperarm_twist_01_l", BoneClass.ConstraintDriven)] // UE
    [InlineData("thigh_twist_02_r", BoneClass.ConstraintDriven)] // UE
    [InlineData("CC_Base_L_UpperarmTwist01", BoneClass.ConstraintDriven)] // CC camelCase
    [InlineData("CC_Base_L_RibsTwist", BoneClass.ConstraintDriven)] // CC, no digits
    // Helper / corrective / roll bones.
    [InlineData("arm_elbow_helper_L", BoneClass.ConstraintDriven)]
    [InlineData("forearm_hlp_r", BoneClass.ConstraintDriven)]
    [InlineData("knee_corrective_L", BoneClass.ConstraintDriven)]
    [InlineData("CC_Base_L_ToeBaseShareBone", BoneClass.ConstraintDriven)]
    [InlineData("ForeArmRoll_L", BoneClass.ConstraintDriven)]
    [InlineData("upperarm_roll_01_l", BoneClass.ConstraintDriven)]
    // IK marker bones.
    [InlineData("root_IK", BoneClass.IkBaked)]
    [InlineData("hand_L_IK_target", BoneClass.IkBaked)]
    [InlineData("hand_R_IK_attach", BoneClass.IkBaked)]
    [InlineData("hand_L_to_R_ikrule", BoneClass.IkBaked)]
    [InlineData("aim_matrix_02a", BoneClass.IkBaked)]
    [InlineData("hold_L", BoneClass.IkBaked)]
    [InlineData("ik_foot_root", BoneClass.IkBaked)] // UE ^ik_ prefix
    [InlineData("ik_hand_gun", BoneClass.IkBaked)] // UE ^ik_ prefix
    // Curated s&box literals with no generalizable pattern.
    [InlineData("neck_clothing", BoneClass.ConstraintDriven)] // citizen clothing helper
    // Everything else defaults to Animated; near-miss names must not false-positive.
    [InlineData("pelvis", BoneClass.Animated)]
    [InlineData("finger_ring_2_L", BoneClass.Animated)] // "ring" is not "roll"
    [InlineData("LeftHand", BoneClass.Animated)]
    [InlineData("wrist_l", BoneClass.Animated)] // "wrist" is not "twist"
    [InlineData("holder", BoneClass.Animated)] // not "^hold_"
    public void Rules_Classify_FollowsNamePatterns(string name, BoneClass expected)
        => Assert.Equal(expected, new BoneClassRules().Classify(name));

    // ---------------------------------------------------------------- mixamo as target

    [Fact]
    public void FromSkeleton_MixamoAsTarget_MappedRolesAnimated_HierarchyIntact()
    {
        var (skeleton, map) = DetectAsTarget(MappingFixtures.LoadZombieCrawl());
        Assert.Equal("mixamo", map.ProfileName);

        var rig = TargetRig.FromSkeleton(skeleton, map);

        // The rig wraps the given skeleton unchanged.
        Assert.Same(skeleton, rig.Skeleton);
        Assert.Equal(65, rig.Skeleton.Count);

        // Every mapped role lands on an Animated bone and round-trips through the lookups.
        foreach (var (role, boneIndex) in map.RoleToBone)
        {
            Assert.Equal(BoneClass.Animated, rig.ClassOf(boneIndex));
            Assert.Equal(role, rig.RoleOf(boneIndex));
            Assert.Equal(boneIndex, rig.BoneForRole(role));
        }

        var hips = rig.BoneForRole(BoneRole.Hips);
        Assert.NotNull(hips);
        Assert.Equal("mixamorig1:Hips", rig.Skeleton[hips.Value].Name);

        // Mixamo has no twist/helper/IK bones — nothing may be misclassified away
        // from Animated (the unmapped *_End leaf bones included).
        Assert.Equal(skeleton.Count, rig.BonesOfClass(BoneClass.Animated).Count());
    }

    // ---------------------------------------------------------------- actorcore as target

    [Fact]
    public void FromSkeleton_ActorCoreAsTarget_TwistAndShareBonesConstraintDriven()
    {
        var (skeleton, map) = DetectAsTarget(MappingFixtures.LoadActorCore());
        Assert.Equal("actorcore_cc", map.ProfileName);

        var rig = TargetRig.FromSkeleton(skeleton, map);

        foreach (var name in new[]
        {
            "CC_Base_L_UpperarmTwist01", "CC_Base_R_UpperarmTwist02",
            "CC_Base_L_ForearmTwist01", "CC_Base_L_ThighTwist01", "CC_Base_R_CalfTwist02",
            "CC_Base_L_RibsTwist", "CC_Base_NeckTwist02", // unmapped neck helper
            "CC_Base_L_ToeBaseShareBone", "CC_Base_L_KneeShareBone", "CC_Base_R_ElbowShareBone",
        })
        {
            var index = skeleton.IndexOf(name);
            Assert.True(index >= 0, $"Fixture bone '{name}' not found.");
            Assert.Equal(BoneClass.ConstraintDriven, rig.ClassOf(index));
            Assert.Null(rig.RoleOf(index));
        }

        // Every mapped role is Animated...
        foreach (var (role, boneIndex) in map.RoleToBone)
        {
            Assert.Equal(BoneClass.Animated, rig.ClassOf(boneIndex));
            Assert.Equal(boneIndex, rig.BoneForRole(role));
        }

        // ...including CC_Base_NeckTwist01, which matches the twist pattern by name but
        // IS the neck bone — the mapped role wins over the twist-like name.
        var neck = rig.BoneForRole(BoneRole.Neck);
        Assert.NotNull(neck);
        Assert.Equal("CC_Base_NeckTwist01", rig.Skeleton[neck.Value].Name);
        Assert.Equal(BoneClass.Animated, rig.ClassOf(neck.Value));
    }

    // ---------------------------------------------------------------- sbox round-trip sanity

    /// <summary>Builds a <see cref="TargetRig"/> from a rig JSON the way custom-target
    /// detection would (roles from the curated classifier) and diffs every bone class
    /// against <see cref="SboxBoneClassifier"/>.</summary>
    private static List<string> ClassMismatchesAgainstSboxClassifier(string rigJsonFixture)
    {
        var (skeleton, _) = RigJson.Load(
            File.ReadAllText(MappingFixtures.FixturePath(rigJsonFixture)));

        var map = new MappingResult("sbox", MappingSource.Preset);
        foreach (var bone in skeleton.Bones)
        {
            if (SboxBoneClassifier.RoleFor(bone.Name) is { } role)
                map.RoleToBone[role] = bone.Index;
        }

        var rig = TargetRig.FromSkeleton(skeleton, map);

        var mismatches = new List<string>();
        for (var i = 0; i < skeleton.Count; i++)
        {
            var expected = SboxBoneClassifier.Classify(skeleton[i].Name);
            var actual = rig.ClassOf(i);
            if (actual != expected)
                mismatches.Add($"{skeleton[i].Name}: rules={actual}, sbox={expected}");
        }
        return mismatches;
    }

    [Fact]
    public void FromSkeleton_CitizenRig_AgreesWithSboxBoneClassifier()
    {
        // The 95-bone citizen rig adds neck_clothing (ConstraintDriven by curated literal);
        // the generic rules must classify the whole rig identically.
        var mismatches = ClassMismatchesAgainstSboxClassifier("rig_citizen.json");
        Assert.True(mismatches.Count == 0,
            $"Expected full agreement on the citizen rig; mismatches: {string.Join("; ", mismatches)}");
    }

    [Fact]
    public void FromSkeleton_SboxRig_AgreesWithSboxBoneClassifier()
    {
        var (skeleton, _) = RigJson.Load(
            File.ReadAllText(MappingFixtures.FixturePath("rig_human_male.json")));

        // The known mapping for the shipped rig (what detection would have produced).
        var map = new MappingResult("sbox_human_male", MappingSource.Preset);
        foreach (var bone in skeleton.Bones)
        {
            if (SboxBoneClassifier.RoleFor(bone.Name) is { } role)
                map.RoleToBone[role] = bone.Index;
        }

        var rig = TargetRig.FromSkeleton(skeleton, map);

        var mismatches = new List<string>();
        for (var i = 0; i < skeleton.Count; i++)
        {
            var expected = SboxBoneClassifier.Classify(skeleton[i].Name);
            var actual = rig.ClassOf(i);
            if (actual != expected)
                mismatches.Add($"{skeleton[i].Name}: rules={actual}, sbox={expected}");
        }

        // The generic patterns were designed from the sbox names; require >= 90% agreement
        // (and currently expect 100%).
        var agreement = (skeleton.Count - mismatches.Count) / (float)skeleton.Count;
        Assert.True(agreement >= 0.9f,
            $"Only {agreement:P1} of {skeleton.Count} bones agree with SboxBoneClassifier. "
            + $"Mismatches: {string.Join("; ", mismatches)}");
        Assert.True(mismatches.Count == 0,
            $"Expected full agreement on the sbox rig; mismatches: {string.Join("; ", mismatches)}");
    }

    // ---------------------------------------------------------------- validation

    [Fact]
    public void FromSkeleton_RejectsOutOfRangeAndDuplicateBoneAssignments()
    {
        var skeleton = MappingFixtures.LoadZombieCrawl();

        var outOfRange = new MappingResult("broken", MappingSource.Manual);
        outOfRange.RoleToBone[BoneRole.Hips] = skeleton.Count;
        Assert.Throws<ArgumentException>(() => TargetRig.FromSkeleton(skeleton, outOfRange));

        var duplicate = new MappingResult("broken", MappingSource.Manual);
        duplicate.RoleToBone[BoneRole.Hips] = 0;
        duplicate.RoleToBone[BoneRole.Spine0] = 0;
        Assert.Throws<ArgumentException>(() => TargetRig.FromSkeleton(skeleton, duplicate));
    }

    [Fact]
    public void SboxDefault_ParsesCommittedTargetRigJson()
    {
        var json = TargetRigGenerator.Generate(
            File.ReadAllText(MappingFixtures.FixturePath("rig_human_male.json")));
        var rig = TargetRig.SboxDefault(json);
        Assert.Equal("sbox_human_male", rig.Name);
        Assert.NotNull(rig.BoneForRole(BoneRole.Hips));
    }
}