tests/HumanoidRetargeter.Tests/Mapping/ProfileTests.cs

Unit tests for the HumanoidRetargeter mapping/profile detection system. Tests exercise ProfileDetector, Profile serialization, preset asset consistency and many synthetic and real skeleton fixtures to assert correct profile detection and role-to-bone mappings across many presets (mixamo, actorcore, UE, Rokoko, Fight Night, ValveBiped, Xsens, PerceptionNeuron, DAZ, Biped, Rigify, VRM, Auto-Rig Pro) and edge cases.

File Access
using HumanoidRetargeter.Core.Mapping;
using Xunit;

namespace HumanoidRetargeter.Tests.Mapping;

public class ProfileTests
{
    private static readonly BoneRole[] FingerProxMidDistBothSides = BuildFingerRoles();

    private static BoneRole[] BuildFingerRoles()
    {
        var fingers = new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" };
        var segments = new[] { "Prox", "Mid", "Dist" };
        var sides = new[] { "L", "R" };
        return (
            from finger in fingers
            from segment in segments
            from side in sides
            select Enum.Parse<BoneRole>(finger + segment + side)).ToArray();
    }

    // ---------------------------------------------------------------- detection: mixamo

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

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("mixamo", profile.Name);
        Assert.Equal("mixamo", result.ProfileName);
        Assert.Equal(MappingSource.Preset, result.Source);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(result.RoleToBone.Count >= 20, $"Only {result.RoleToBone.Count} roles mapped");

        AssertRole(skeleton, result, BoneRole.Hips, "mixamorig1:Hips");
        AssertRole(skeleton, result, BoneRole.Spine0, "mixamorig1:Spine");
        AssertRole(skeleton, result, BoneRole.Spine1, "mixamorig1:Spine1");
        AssertRole(skeleton, result, BoneRole.Spine2, "mixamorig1:Spine2");
        AssertRole(skeleton, result, BoneRole.Neck, "mixamorig1:Neck");
        AssertRole(skeleton, result, BoneRole.Head, "mixamorig1:Head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "mixamorig1:LeftShoulder");
        AssertRole(skeleton, result, BoneRole.UpperArmL, "mixamorig1:LeftArm");
        AssertRole(skeleton, result, BoneRole.LowerArmL, "mixamorig1:LeftForeArm");
        AssertRole(skeleton, result, BoneRole.HandL, "mixamorig1:LeftHand");
        AssertRole(skeleton, result, BoneRole.IndexProxL, "mixamorig1:LeftHandIndex1");
        AssertRole(skeleton, result, BoneRole.MiddleDistR, "mixamorig1:RightHandMiddle3");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "mixamorig1:LeftUpLeg");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "mixamorig1:RightLeg");
        AssertRole(skeleton, result, BoneRole.FootL, "mixamorig1:LeftFoot");
        AssertRole(skeleton, result, BoneRole.ToeR, "mixamorig1:RightToeBase");

        // All 5 finger chains, both sides, all three phalanges.
        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
    }

    // ---------------------------------------------------------------- detection: actorcore

    [Fact]
    public void Detect_ActorCore_PicksActorCorePreset()
    {
        var skeleton = MappingFixtures.LoadActorCore();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("actorcore_cc", profile.Name);
        Assert.Equal(MappingSource.Preset, result.Source);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(result.RoleToBone.Count >= 20, $"Only {result.RoleToBone.Count} roles mapped");

        // Empirical hips resolution: CC_Base_Hip is the parent of both CC_Base_Pelvis (leg
        // branch) and CC_Base_Waist (spine branch) — it is the LCA of legs+spine and the
        // translating root, so it carries the Hips role; CC_Base_Pelvis stays unmapped.
        AssertRole(skeleton, result, BoneRole.Hips, "CC_Base_Hip");
        AssertRole(skeleton, result, BoneRole.Spine0, "CC_Base_Waist");
        AssertRole(skeleton, result, BoneRole.Spine1, "CC_Base_Spine01");
        AssertRole(skeleton, result, BoneRole.Spine2, "CC_Base_Spine02");
        AssertRole(skeleton, result, BoneRole.Neck, "CC_Base_NeckTwist01");
        AssertRole(skeleton, result, BoneRole.Head, "CC_Base_Head");
        AssertRole(skeleton, result, BoneRole.ClavicleR, "CC_Base_R_Clavicle");
        AssertRole(skeleton, result, BoneRole.UpperArmL, "CC_Base_L_Upperarm");
        AssertRole(skeleton, result, BoneRole.LowerArmR, "CC_Base_R_Forearm");
        AssertRole(skeleton, result, BoneRole.HandL, "CC_Base_L_Hand");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "CC_Base_L_Thumb1");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "CC_Base_R_Mid2");
        AssertRole(skeleton, result, BoneRole.PinkyDistL, "CC_Base_L_Pinky3");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "CC_Base_L_Thigh");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "CC_Base_R_Calf");
        AssertRole(skeleton, result, BoneRole.FootL, "CC_Base_L_Foot");
        AssertRole(skeleton, result, BoneRole.ToeL, "CC_Base_L_ToeBase");

        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");

        // ShareBone helpers and limb twist bones must never be mapped. The single allowed
        // "Twist" name is CC_Base_NeckTwist01, which IS the rig's neck bone.
        foreach (var (role, boneIndex) in result.RoleToBone)
        {
            var name = skeleton[boneIndex].Name;
            Assert.False(name.Contains("ShareBone"), $"{role} mapped to share bone {name}");
            if (name.Contains("Twist"))
                Assert.True(role == BoneRole.Neck && name == "CC_Base_NeckTwist01",
                    $"{role} mapped to twist bone {name}");
        }
    }

    // ---------------------------------------------------------------- detection: UE

    [Fact]
    public void Detect_SyntheticUeMannequin_PicksUePreset()
    {
        var skeleton = MappingFixtures.BuildUeMannequin();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("ue_mannequin", profile.Name);
        Assert.True(result.Confidence >= 0.8f, $"Confidence {result.Confidence} < 0.8");

        AssertRole(skeleton, result, BoneRole.Hips, "pelvis");
        AssertRole(skeleton, result, BoneRole.Spine0, "spine_01");
        AssertRole(skeleton, result, BoneRole.Spine4, "spine_05");
        AssertRole(skeleton, result, BoneRole.Neck, "neck_01");
        AssertRole(skeleton, result, BoneRole.Head, "head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "clavicle_l");
        AssertRole(skeleton, result, BoneRole.UpperArmR, "upperarm_r");
        AssertRole(skeleton, result, BoneRole.LowerArmL, "lowerarm_l");
        AssertRole(skeleton, result, BoneRole.HandR, "hand_r");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "thumb_01_l");
        AssertRole(skeleton, result, BoneRole.IndexMetaL, "index_metacarpal_l");
        AssertRole(skeleton, result, BoneRole.RingDistR, "ring_03_r");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "thigh_l");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "calf_r");
        AssertRole(skeleton, result, BoneRole.FootL, "foot_l");
        AssertRole(skeleton, result, BoneRole.ToeL, "ball_l");

        // *_twist_* bones are never mapped.
        foreach (var (role, boneIndex) in result.RoleToBone)
            Assert.DoesNotContain("_twist_", skeleton[boneIndex].Name);
    }

    // ---------------------------------------------------------------- detection: rokoko/bvh

    [Fact]
    public void Detect_SyntheticRokokoBvhNames_PicksRokokoPreset()
    {
        var bones = new List<(string, string?, System.Numerics.Vector3)>
        {
            ("Hips", null, new(0, 98, 0)),
            ("Spine", "Hips", new(0, 108, 0)),
            ("Spine1", "Spine", new(0, 120, 0)),
            ("Spine2", "Spine1", new(0, 132, 0)),
            ("Spine3", "Spine2", new(0, 142, 0)),
            ("Neck", "Spine3", new(0, 150, 0)),
            ("Head", "Neck", new(0, 158, 0)),
        };
        foreach (var (side, x) in new[] { ("Left", 1f), ("Right", -1f) })
        {
            bones.Add(($"{side}Shoulder", "Spine3", new(5 * x, 146, 0)));
            bones.Add(($"{side}Arm", $"{side}Shoulder", new(18 * x, 144, 0)));
            bones.Add(($"{side}ForeArm", $"{side}Arm", new(44 * x, 144, 0)));
            bones.Add(($"{side}Hand", $"{side}ForeArm", new(66 * x, 144, 0)));
            bones.Add(($"{side}UpLeg", "Hips", new(9 * x, 92, 0)));
            bones.Add(($"{side}Leg", $"{side}UpLeg", new(9 * x, 50, 0)));
            bones.Add(($"{side}Foot", $"{side}Leg", new(9 * x, 9, 0)));
            bones.Add(($"{side}Toe", $"{side}Foot", new(9 * x, 2, 12)));
        }
        var skeleton = MappingFixtures.FromWorldPositions(bones);

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("rokoko_bvh", profile.Name);
        AssertRole(skeleton, result, BoneRole.Hips, "Hips");
        AssertRole(skeleton, result, BoneRole.Spine0, "Spine");
        AssertRole(skeleton, result, BoneRole.Spine3, "Spine3");
        AssertRole(skeleton, result, BoneRole.UpperArmL, "LeftArm");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "RightLeg");
        AssertRole(skeleton, result, BoneRole.ToeL, "LeftToe");
    }

    // ---------------------------------------------------------------- scoring honesty

    [Fact]
    public void Score_GenericCoreNamesOnly_NoPresetReachesThreshold()
    {
        // Only the generic center names (Hips/Spine1/Spine2/Neck/Head) match any preset;
        // every limb is named outside all alias families. Rich center matches alone must
        // never carry a preset over the detection threshold.
        var bones = new List<(string, string?, System.Numerics.Vector3)>
        {
            ("Hips", null, new(0, 98, 0)),
            ("Spine1", "Hips", new(0, 110, 0)),
            ("Spine2", "Spine1", new(0, 124, 0)),
            ("Neck", "Spine2", new(0, 148, 0)),
            ("Head", "Neck", new(0, 156, 0)),
        };
        foreach (var (side, x) in new[] { ("A", 1f), ("B", -1f) })
        {
            bones.Add(($"limb_{side}_1", "Spine2", new(18 * x, 144, 0)));
            bones.Add(($"limb_{side}_2", $"limb_{side}_1", new(44 * x, 144, 0)));
            bones.Add(($"limb_{side}_3", $"limb_{side}_2", new(66 * x, 144, 0)));
            bones.Add(($"limb_{side}_4", "Hips", new(9 * x, 92, 0)));
            bones.Add(($"limb_{side}_5", $"limb_{side}_4", new(9 * x, 50, 0)));
            bones.Add(($"limb_{side}_6", $"limb_{side}_5", new(9 * x, 9, 0)));
        }
        var skeleton = MappingFixtures.FromWorldPositions(bones);

        foreach (var profile in ProfileLibrary.All)
        {
            var score = ProfileDetector.Score(profile, skeleton);
            Assert.True(score < ProfileDetector.DetectionThreshold,
                $"{profile.Name} scored {score} on a rig with no matching limb names");
        }
    }

    // ---------------------------------------------------------------- detection: negative

    [Fact]
    public void Detect_UnknownNames_ReturnsNull()
    {
        var skeleton = MappingFixtures.RenameAll(MappingFixtures.LoadZombieCrawl(), i => $"bone_{i:000}");
        Assert.Null(ProfileDetector.Detect(skeleton));
    }

    [Fact]
    public void Score_MixamoProfileOnZombieCrawl_BeatsOtherPresets()
    {
        var skeleton = MappingFixtures.LoadZombieCrawl();
        var mixamo = ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton);
        Assert.True(mixamo >= 0.9f);
        Assert.True(mixamo > ProfileDetector.Score(ProfileLibrary.ActorCoreCc, skeleton));
        Assert.True(mixamo > ProfileDetector.Score(ProfileLibrary.UeMannequin, skeleton));
        Assert.True(mixamo > ProfileDetector.Score(ProfileLibrary.RokokoBvh, skeleton));
    }

    // ---------------------------------------------------------------- json (de)serialization

    [Fact]
    public void Profile_JsonRoundTrips()
    {
        foreach (var profile in ProfileLibrary.All)
        {
            var json = profile.ToJson();
            var restored = Profile.FromJson(json);

            Assert.Equal(profile.Name, restored.Name);
            Assert.Equal(profile.NamespacePatterns, restored.NamespacePatterns);
            Assert.Equal(profile.Aliases.Count, restored.Aliases.Count);
            foreach (var (role, aliases) in profile.Aliases)
                Assert.Equal(aliases, restored.Aliases[role]);
        }
    }

    [Fact]
    public void Profile_UserPresetShape_V1RoundTrips()
    {
        // The exact document shape the editor's UserPresets.Save produces: name
        // "user_<sig8>", no namespace patterns, ONE alias per role - the literal source
        // bone name. This is the v1 user-preset contract; FromJson must keep reading it.
        var skeleton = MappingFixtures.LoadZombieCrawl();
        var aliases = new Dictionary<BoneRole, string[]>
        {
            [BoneRole.Hips] = new[] { "mixamorig1:Hips" },
            [BoneRole.Spine0] = new[] { "mixamorig1:Spine" },
            [BoneRole.Head] = new[] { "mixamorig1:Head" },
            [BoneRole.HandL] = new[] { "mixamorig1:LeftHand" },
            [BoneRole.PinkyDistR] = new[] { "mixamorig1:RightHandPinky3" },
        };
        var profile = new Profile("user_a1b2c3d4", Array.Empty<string>(), aliases);

        var json = profile.ToJson();
        Assert.Contains("\"v\": 1", json);
        var restored = Profile.FromJson(json);

        Assert.Equal(profile.Name, restored.Name);
        Assert.Empty(restored.NamespacePatterns);
        Assert.Equal(aliases.Count, restored.Aliases.Count);
        foreach (var (role, names) in aliases)
            Assert.Equal(names, restored.Aliases[role]);

        // And the restored preset actually resolves on the rig it was saved from.
        var applied = ProfileDetector.Apply(restored, skeleton);
        foreach (var (role, names) in aliases)
            Assert.Equal(names[0], skeleton[applied.RoleToBone[role]].Name);
    }

    [Fact]
    public void PresetAssetsFolder_HasNoOrphanProfiles()
    {
        // Every committed (non-user) profile JSON must correspond to a shipped preset -
        // an orphan file would silently diverge from ProfileLibrary.
        var dir = FindRepoFile(Path.Combine("Assets", "data", "humanoid_retargeter", "profiles"));
        Assert.True(Directory.Exists(dir), $"profiles folder missing: {dir}");

        var known = ProfileLibrary.All.Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
        var orphans = Directory.GetFiles(dir, "*.json", SearchOption.TopDirectoryOnly)
            .Select(Path.GetFileNameWithoutExtension)
            .Where(name => !known.Contains(name!))
            .ToList();
        Assert.True(orphans.Count == 0,
            $"Orphan profile JSONs without a ProfileLibrary preset: {string.Join(", ", orphans)}");
    }

    [Fact]
    public void Profile_FromJson_RejectsUnknownSchemaVersion()
    {
        var json = """{"v": 99, "name": "x", "namespace_patterns": [], "aliases": {}}""";
        Assert.Throws<ArgumentException>(() => Profile.FromJson(json));
    }

    [Fact]
    public void Profile_FromJson_RejectsUnknownRole()
    {
        var json = """{"v": 1, "name": "x", "namespace_patterns": [], "aliases": {"NotARole": ["a"]}}""";
        Assert.Throws<ArgumentException>(() => Profile.FromJson(json));
    }

    // ---------------------------------------------------------------- preset asset regeneration

    [Fact]
    public void PresetJsons_MatchCommittedAssets()
    {
        foreach (var profile in ProfileLibrary.All)
        {
            var generated = profile.ToJson();
            var assetPath = FindRepoFile(Path.Combine(
                "Assets", "data", "humanoid_retargeter", "profiles", profile.Name + ".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 PresetLibrary_HasExpectedProfiles()
    {
        Assert.Equal(
            new[]
            {
                "sbox", "mixamo", "actorcore_cc", "ue_mannequin", "xsens_mvn", "perception_neuron",
                "fight_night", "rokoko_bvh", "smpl_x", "smpl", "soma_bvh", "classic_bvh",
                "valvebiped", "biped", "daz_genesis", "daz_poser",
                "rigify", "vrm", "auto_rig_pro", "advanced_skeleton",
            },
            ProfileLibrary.All.Select(p => p.Name).ToArray());
    }

    // ---------------------------------------------------------------- detection: fight night

    /// <summary>
    /// The EA Fight Night boxer rig uses MotionBuilder/HumanIK joint names, so mixamo and
    /// perception_neuron both score respectably on it — the dedicated preset must win
    /// outright by covering the fourth spine bone, the toes AND declaring only the finger
    /// roles a gloved hand actually has.
    /// </summary>
    [Fact]
    public void Detect_SyntheticFightNight_PicksFightNightPreset()
    {
        var skeleton = MappingFixtures.BuildFightNight();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("fight_night", profile.Name);
        Assert.True(result.Confidence >= 0.99f, $"Confidence {result.Confidence} < 0.99");

        // It must beat the HumanIK-family presets that also match these names.
        var fightNight = ProfileDetector.Score(ProfileLibrary.FightNight, skeleton);
        Assert.True(fightNight > ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton),
            "fight_night must outscore mixamo on a boxer rig");
        Assert.True(fightNight > ProfileDetector.Score(ProfileLibrary.PerceptionNeuron, skeleton),
            "fight_night must outscore perception_neuron on a boxer rig");

        AssertRole(skeleton, result, BoneRole.Hips, "Hips");
        AssertRole(skeleton, result, BoneRole.Spine0, "Spine");
        AssertRole(skeleton, result, BoneRole.Spine3, "Spine3");
        Assert.False(result.RoleToBone.ContainsKey(BoneRole.Spine4), "Spine4 role double-mapped");
        // Neck→Neck1→Head: the second neck bone stays unmapped.
        AssertRole(skeleton, result, BoneRole.Neck, "Neck");
        AssertRole(skeleton, result, BoneRole.Head, "Head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "LeftShoulder");
        AssertRole(skeleton, result, BoneRole.UpperArmR, "RightArm");
        AssertRole(skeleton, result, BoneRole.LowerArmL, "LeftForeArm");
        AssertRole(skeleton, result, BoneRole.HandL, "LeftHand");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "LeftUpLeg");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "RightLeg");
        AssertRole(skeleton, result, BoneRole.FootL, "LeftFoot");
        AssertRole(skeleton, result, BoneRole.ToeR, "RightToeBase");

        // Gloved hand: two thumb segments, and the middle finger's InHand metacarpal maps
        // to the Meta role (NOT a phalanx — that is the SOMA finger bug).
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "LeftHandThumb1");
        AssertRole(skeleton, result, BoneRole.ThumbMidL, "LeftHandThumb2");
        AssertRole(skeleton, result, BoneRole.MiddleMetaL, "LeftInHandMiddle");
        AssertRole(skeleton, result, BoneRole.MiddleProxL, "LeftHandMiddle1");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "RightHandMiddle2");
    }

    /// <summary>
    /// Every decoy family on the boxer rig stays unmapped: the glove mesh shells (which
    /// carry finger NAMES), the root/trajectory bones above the hips, the second neck bone
    /// and the twist/muscle/jiggle simulation layer.
    /// </summary>
    [Fact]
    public void FightNight_GloveShellsRootAndSimulationBones_AreNeverMapped()
    {
        var skeleton = MappingFixtures.BuildFightNight();

        var result = ProfileDetector.Apply(ProfileLibrary.FightNight, skeleton);

        var mapped = result.RoleToBone.Values.Select(i => skeleton[i].Name).ToHashSet(StringComparer.Ordinal);
        foreach (var decoy in new[]
        {
            "LeftHandThumbGlove", "LeftHandIndexGlove", "LeftHandMiddleGlove",
            "LeftInHandRingGlove", "LeftHandPinkyGlove", "RightHandIndexGlove",
            "Reference", "AITrajectory", "Neck1", "Crotch",
            "LeftArmTwist", "LeftForeArmTwist", "LeftGloveTwist", "LeftUpLegTwist",
            "Muscle_Left_Bicep", "Offset_Muscle_Left_Delt", "Left_Elbow_Helper",
            "Left_Knee_Helper", "LeftFootEnd", "Spine2_Jiggle", "Gut_Center", "StomachFront",
        })
        {
            Assert.True(skeleton.IndexOf(decoy) >= 0, $"fixture missing decoy bone {decoy}");
            Assert.False(mapped.Contains(decoy), $"{decoy} must never carry a role");
        }

        // No index/ring/pinky roles exist on a gloved hand.
        foreach (var role in new[]
        {
            BoneRole.IndexProxL, BoneRole.IndexProxR, BoneRole.RingProxL,
            BoneRole.PinkyProxL, BoneRole.ThumbDistL, BoneRole.MiddleDistL,
        })
        {
            Assert.False(result.RoleToBone.ContainsKey(role), $"{role} must not be mapped");
        }
    }

    /// <summary>The boxer preset must not claim the rigs whose naming it shares.</summary>
    [Fact]
    public void FightNight_DoesNotOutscoreNativePresetsOnTheirOwnRigs()
    {
        var mixamo = MappingFixtures.LoadZombieCrawl();
        Assert.True(
            ProfileDetector.Score(ProfileLibrary.Mixamo, mixamo)
            > ProfileDetector.Score(ProfileLibrary.FightNight, mixamo),
            "fight_night must not outscore mixamo on a real Mixamo rig");
        Assert.Equal("mixamo", ProfileDetector.Detect(mixamo)!.Value.Profile.Name);

        var neuron = MappingFixtures.BuildPerceptionNeuron();
        Assert.True(
            ProfileDetector.Score(ProfileLibrary.PerceptionNeuron, neuron)
            > ProfileDetector.Score(ProfileLibrary.FightNight, neuron),
            "fight_night must not outscore perception_neuron on a Neuron rig");
        Assert.Equal("perception_neuron", ProfileDetector.Detect(neuron)!.Value.Profile.Name);
    }

    // ---------------------------------------------------------------- detection: valvebiped

    [Fact]
    public void Detect_SyntheticValveBiped_PicksValveBipedPreset()
    {
        var skeleton = MappingFixtures.BuildValveBiped();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("valvebiped", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(ProfileDetector.Score(ProfileLibrary.Biped, skeleton) < 0.8f,
            "the plain Biped preset must not claim a ValveBiped rig");
        Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
            "mixamo must not claim a ValveBiped rig");

        AssertRole(skeleton, result, BoneRole.Hips, "ValveBiped.Bip01_Pelvis");
        AssertRole(skeleton, result, BoneRole.Spine0, "ValveBiped.Bip01_Spine");
        AssertRole(skeleton, result, BoneRole.Spine2, "ValveBiped.Bip01_Spine2");
        // The stock chain skips Spine3: Spine4 is the chest and fills the Spine3 role.
        AssertRole(skeleton, result, BoneRole.Spine3, "ValveBiped.Bip01_Spine4");
        Assert.False(result.RoleToBone.ContainsKey(BoneRole.Spine4), "Spine4 role double-mapped");
        AssertRole(skeleton, result, BoneRole.Neck, "ValveBiped.Bip01_Neck1");
        AssertRole(skeleton, result, BoneRole.Head, "ValveBiped.Bip01_Head1");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "ValveBiped.Bip01_L_Clavicle");
        AssertRole(skeleton, result, BoneRole.UpperArmR, "ValveBiped.Bip01_R_UpperArm");
        AssertRole(skeleton, result, BoneRole.LowerArmL, "ValveBiped.Bip01_L_Forearm");
        AssertRole(skeleton, result, BoneRole.HandL, "ValveBiped.Bip01_L_Hand");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "ValveBiped.Bip01_L_Finger0");
        AssertRole(skeleton, result, BoneRole.ThumbDistL, "ValveBiped.Bip01_L_Finger02");
        AssertRole(skeleton, result, BoneRole.IndexProxL, "ValveBiped.Bip01_L_Finger1");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "ValveBiped.Bip01_R_Finger21");
        AssertRole(skeleton, result, BoneRole.PinkyDistR, "ValveBiped.Bip01_R_Finger42");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "ValveBiped.Bip01_L_Thigh");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "ValveBiped.Bip01_R_Calf");
        AssertRole(skeleton, result, BoneRole.FootL, "ValveBiped.Bip01_L_Foot");
        AssertRole(skeleton, result, BoneRole.ToeL, "ValveBiped.Bip01_L_Toe0");

        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");

        // Attachment/helper bones carry no role.
        foreach (var (role, boneIndex) in result.RoleToBone)
        {
            var name = skeleton[boneIndex].Name;
            Assert.False(name == "ValveBiped.forward" || name.Contains("Anim_Attachment"),
                $"{role} mapped to helper {name}");
        }
    }

    // ---------------------------------------------------------------- detection: xsens mvn

    [Fact]
    public void Detect_SyntheticXsensMvn_PicksXsensPreset()
    {
        var skeleton = MappingFixtures.BuildXsensMvn();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("xsens_mvn", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(ProfileDetector.Score(ProfileLibrary.RokokoBvh, skeleton) < 0.8f,
            "rokoko_bvh must not claim an MVN rig (no 'Hips' bone)");

        AssertRole(skeleton, result, BoneRole.Hips, "Pelvis");
        AssertRole(skeleton, result, BoneRole.Spine0, "L5");
        AssertRole(skeleton, result, BoneRole.Spine1, "L3");
        AssertRole(skeleton, result, BoneRole.Spine2, "T12");
        AssertRole(skeleton, result, BoneRole.Spine3, "T8");
        AssertRole(skeleton, result, BoneRole.Neck, "Neck");
        AssertRole(skeleton, result, BoneRole.Head, "Head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "LeftShoulder");
        AssertRole(skeleton, result, BoneRole.UpperArmR, "RightUpperArm");
        AssertRole(skeleton, result, BoneRole.LowerArmL, "LeftForeArm");
        AssertRole(skeleton, result, BoneRole.HandR, "RightHand");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "LeftUpperLeg");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "RightLowerLeg");
        AssertRole(skeleton, result, BoneRole.FootL, "LeftFoot");
        AssertRole(skeleton, result, BoneRole.ToeR, "RightToe");
    }

    // ---------------------------------------------------------------- detection: perception neuron

    [Fact]
    public void Detect_SyntheticPerceptionNeuron_PicksNeuronPreset()
    {
        var skeleton = MappingFixtures.BuildPerceptionNeuron();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("perception_neuron", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        // Mixamo matches most Neuron names but misses Spine3 and the (absent) toes; the
        // dedicated preset must outscore it.
        Assert.True(
            ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < result.Confidence,
            "perception_neuron must outscore mixamo on a Neuron rig");

        AssertRole(skeleton, result, BoneRole.Hips, "Hips");
        AssertRole(skeleton, result, BoneRole.Spine0, "Spine");
        AssertRole(skeleton, result, BoneRole.Spine3, "Spine3");
        AssertRole(skeleton, result, BoneRole.Neck, "Neck");
        AssertRole(skeleton, result, BoneRole.Head, "Head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "LeftShoulder");
        AssertRole(skeleton, result, BoneRole.UpperArmL, "LeftArm");
        AssertRole(skeleton, result, BoneRole.LowerArmR, "RightForeArm");
        AssertRole(skeleton, result, BoneRole.HandL, "LeftHand");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "LeftHandThumb1");
        AssertRole(skeleton, result, BoneRole.IndexProxL, "LeftHandIndex1");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "RightHandMiddle2");
        AssertRole(skeleton, result, BoneRole.PinkyDistR, "RightHandPinky3");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "LeftUpLeg");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "RightLeg");
        AssertRole(skeleton, result, BoneRole.FootL, "LeftFoot");

        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");

        // The InHand metacarpal helpers must never be mapped (mapping them as phalanges
        // shifts every curl one joint outward — the SOMA finger bug class).
        foreach (var (role, boneIndex) in result.RoleToBone)
            Assert.DoesNotContain("InHand", skeleton[boneIndex].Name);
    }

    // ---------------------------------------------------------------- detection: daz genesis

    [Fact]
    public void Detect_SyntheticDazGenesis_PicksDazGenesisPreset()
    {
        var skeleton = MappingFixtures.BuildDazGenesis();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("daz_genesis", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(ProfileDetector.Score(ProfileLibrary.DazPoser, skeleton) < 0.8f,
            "daz_poser must not claim a Genesis 3/8 rig (lShldr vs lShldrBend)");

        // hip is the LCA of the pelvis leg branch and the abdomen spine branch — it is
        // the hips; pelvis stays unmapped (ActorCore Hip/Pelvis policy).
        AssertRole(skeleton, result, BoneRole.Hips, "hip");
        AssertRole(skeleton, result, BoneRole.Spine0, "abdomenLower");
        AssertRole(skeleton, result, BoneRole.Spine1, "abdomenUpper");
        AssertRole(skeleton, result, BoneRole.Spine2, "chestLower");
        AssertRole(skeleton, result, BoneRole.Spine3, "chestUpper");
        AssertRole(skeleton, result, BoneRole.Neck, "neckLower");
        AssertRole(skeleton, result, BoneRole.Head, "head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "lCollar");
        AssertRole(skeleton, result, BoneRole.UpperArmL, "lShldrBend");
        AssertRole(skeleton, result, BoneRole.LowerArmR, "rForearmBend");
        AssertRole(skeleton, result, BoneRole.HandL, "lHand");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "lThumb1");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "rMid2");
        AssertRole(skeleton, result, BoneRole.PinkyDistL, "lPinky3");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "lThighBend");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "rShin");
        AssertRole(skeleton, result, BoneRole.FootL, "lFoot");
        AssertRole(skeleton, result, BoneRole.ToeL, "lToe");

        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");

        // Twist roll helpers, the pelvis intermediate, neckUpper and the metatarsals
        // carry no role.
        foreach (var (role, boneIndex) in result.RoleToBone)
        {
            var name = skeleton[boneIndex].Name;
            Assert.DoesNotContain("Twist", name);
            Assert.False(name is "pelvis" or "neckUpper", $"{role} mapped to {name}");
            Assert.DoesNotContain("Metatarsals", name);
        }
    }

    // ---------------------------------------------------------------- detection: 3ds max biped

    [Fact]
    public void Detect_SyntheticBiped_PicksBipedPreset()
    {
        var skeleton = MappingFixtures.BuildBiped();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("biped", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
            "mixamo must not claim a Biped rig");

        AssertRole(skeleton, result, BoneRole.Hips, "Bip01 Pelvis");
        AssertRole(skeleton, result, BoneRole.Spine0, "Bip01 Spine");
        AssertRole(skeleton, result, BoneRole.Spine3, "Bip01 Spine3");
        AssertRole(skeleton, result, BoneRole.Neck, "Bip01 Neck");
        AssertRole(skeleton, result, BoneRole.Head, "Bip01 Head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "Bip01 L Clavicle");
        AssertRole(skeleton, result, BoneRole.UpperArmR, "Bip01 R UpperArm");
        AssertRole(skeleton, result, BoneRole.LowerArmL, "Bip01 L Forearm");
        AssertRole(skeleton, result, BoneRole.HandL, "Bip01 L Hand");
        // Numbered fingers: Finger0 chain is the thumb, segment digits are phalanges.
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "Bip01 L Finger0");
        AssertRole(skeleton, result, BoneRole.ThumbDistL, "Bip01 L Finger02");
        AssertRole(skeleton, result, BoneRole.IndexProxL, "Bip01 L Finger1");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "Bip01 R Finger21");
        AssertRole(skeleton, result, BoneRole.PinkyDistR, "Bip01 R Finger42");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "Bip01 L Thigh");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "Bip01 R Calf");
        AssertRole(skeleton, result, BoneRole.FootL, "Bip01 L Foot");
        AssertRole(skeleton, result, BoneRole.ToeL, "Bip01 L Toe0");

        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");

        // The COM root, Footsteps and toe segments carry no role.
        foreach (var (role, boneIndex) in result.RoleToBone)
        {
            var name = skeleton[boneIndex].Name;
            Assert.False(name is "Bip01" or "Bip01 Footsteps", $"{role} mapped to {name}");
            Assert.False(name.EndsWith("Toe01") || name.EndsWith("Toe02"), $"{role} mapped to toe segment {name}");
        }
    }

    [Fact]
    public void Detect_BipedUnderscoreNames_StillDetected()
    {
        // Some exporters mangle the Biped spaces to underscores (Bip001_L_Thigh); the
        // namespace pattern and separator-insensitive normalization must absorb both.
        var original = MappingFixtures.BuildBiped();
        var skeleton = MappingFixtures.RenameAll(
            original, i => original[i].Name.Replace("Bip01 ", "Bip001_").Replace(' ', '_'));

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        Assert.Equal("biped", detected.Value.Profile.Name);
        Assert.True(detected.Value.Result.Confidence >= 0.9f);
        AssertRole(skeleton, detected.Value.Result, BoneRole.UpperLegL, "Bip001_L_Thigh");
    }

    // ---------------------------------------------------------------- detection: daz / poser

    [Fact]
    public void Detect_SyntheticDazPoser_PicksDazPreset()
    {
        var skeleton = MappingFixtures.BuildDazPoser();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("daz_poser", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
            "mixamo must not claim a DAZ/Poser rig");

        AssertRole(skeleton, result, BoneRole.Hips, "hip");
        AssertRole(skeleton, result, BoneRole.Spine0, "abdomen");
        AssertRole(skeleton, result, BoneRole.Spine1, "abdomen2");
        AssertRole(skeleton, result, BoneRole.Spine2, "chest");
        AssertRole(skeleton, result, BoneRole.Neck, "neck");
        AssertRole(skeleton, result, BoneRole.Head, "head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "lCollar");
        AssertRole(skeleton, result, BoneRole.UpperArmL, "lShldr");
        AssertRole(skeleton, result, BoneRole.LowerArmR, "rForeArm");
        AssertRole(skeleton, result, BoneRole.HandL, "lHand");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "lThumb1");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "rMid2");
        AssertRole(skeleton, result, BoneRole.PinkyDistL, "lPinky3");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "lThigh");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "rShin");
        AssertRole(skeleton, result, BoneRole.FootL, "lFoot");
        AssertRole(skeleton, result, BoneRole.ToeL, "lToe");

        // Buttock thigh-helpers and eyes carry no role.
        foreach (var (role, boneIndex) in result.RoleToBone)
        {
            var name = skeleton[boneIndex].Name;
            Assert.DoesNotContain("Buttock", name);
            Assert.DoesNotContain("Eye", name);
        }
    }

    // ---------------------------------------------------------------- detection: rigify

    [Theory]
    [InlineData(false)] // metarig names: spine, spine.001.., upper_arm.L
    [InlineData(true)]  // generated deform skeleton: DEF-spine, DEF-upper_arm.L (+ .001 twins)
    public void Detect_SyntheticRigify_PicksRigifyPreset(bool defVariant)
    {
        var skeleton = MappingFixtures.BuildRigify(defVariant);

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("rigify", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
            "mixamo must not claim a rigify rig");

        var d = defVariant ? "DEF-" : string.Empty;
        // rigify's "spine" IS the pelvis bone (rigify/metarigs/human.py); spine.004 is
        // the first neck bone, spine.006 the head. spine.005 stays unmapped.
        AssertRole(skeleton, result, BoneRole.Hips, $"{d}spine");
        AssertRole(skeleton, result, BoneRole.Spine0, $"{d}spine.001");
        AssertRole(skeleton, result, BoneRole.Spine2, $"{d}spine.003");
        AssertRole(skeleton, result, BoneRole.Neck, $"{d}spine.004");
        AssertRole(skeleton, result, BoneRole.Head, $"{d}spine.006");
        Assert.DoesNotContain(skeleton.Bones.First(b => b.Name == $"{d}spine.005").Index,
            result.RoleToBone.Values);
        AssertRole(skeleton, result, BoneRole.ClavicleL, $"{d}shoulder.L");
        AssertRole(skeleton, result, BoneRole.UpperArmL, $"{d}upper_arm.L");
        AssertRole(skeleton, result, BoneRole.LowerArmR, $"{d}forearm.R");
        AssertRole(skeleton, result, BoneRole.HandL, $"{d}hand.L");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, $"{d}thumb.01.L");
        AssertRole(skeleton, result, BoneRole.IndexProxL, $"{d}f_index.01.L");
        AssertRole(skeleton, result, BoneRole.RingDistR, $"{d}f_ring.03.R");
        AssertRole(skeleton, result, BoneRole.UpperLegL, $"{d}thigh.L");
        AssertRole(skeleton, result, BoneRole.LowerLegR, $"{d}shin.R");
        AssertRole(skeleton, result, BoneRole.FootL, $"{d}foot.L");
        AssertRole(skeleton, result, BoneRole.ToeL, $"{d}toe.L");

        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");

        // pelvis.L/R, palm, heel helpers and segmented DEF twins carry no role.
        foreach (var (role, boneIndex) in result.RoleToBone)
        {
            var name = skeleton[boneIndex].Name;
            Assert.False(name.StartsWith("pelvis.") || name.StartsWith("palm.") || name.StartsWith("heel."),
                $"{role} mapped to helper {name}");
            Assert.False(name.EndsWith(".001") && name.StartsWith("DEF-") && !name.Contains("spine"),
                $"{role} mapped to segmented deform twin {name}");
        }
    }

    // ---------------------------------------------------------------- detection: vrm / vroid

    [Fact]
    public void Detect_SyntheticVrm_PicksVrmPreset()
    {
        var skeleton = MappingFixtures.BuildVrm();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("vrm", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
            "mixamo must not claim a VRM rig");

        AssertRole(skeleton, result, BoneRole.Hips, "J_Bip_C_Hips");
        AssertRole(skeleton, result, BoneRole.Spine0, "J_Bip_C_Spine");
        AssertRole(skeleton, result, BoneRole.Spine1, "J_Bip_C_Chest");
        AssertRole(skeleton, result, BoneRole.Spine2, "J_Bip_C_UpperChest");
        AssertRole(skeleton, result, BoneRole.Neck, "J_Bip_C_Neck");
        AssertRole(skeleton, result, BoneRole.Head, "J_Bip_C_Head");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "J_Bip_L_Shoulder");
        AssertRole(skeleton, result, BoneRole.UpperArmL, "J_Bip_L_UpperArm");
        AssertRole(skeleton, result, BoneRole.LowerArmR, "J_Bip_R_LowerArm");
        AssertRole(skeleton, result, BoneRole.HandL, "J_Bip_L_Hand");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "J_Bip_L_Thumb1");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "J_Bip_R_Middle2");
        // VRM names the pinky "Little" (littleProximal.. humanoid bones).
        AssertRole(skeleton, result, BoneRole.PinkyDistL, "J_Bip_L_Little3");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "J_Bip_L_UpperLeg");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "J_Bip_R_LowerLeg");
        AssertRole(skeleton, result, BoneRole.FootL, "J_Bip_L_Foot");
        AssertRole(skeleton, result, BoneRole.ToeL, "J_Bip_L_ToeBase");

        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");

        // Root and the secondary physics/adjust bones carry no role.
        foreach (var (role, boneIndex) in result.RoleToBone)
        {
            var name = skeleton[boneIndex].Name;
            Assert.False(name == "Root" || name.StartsWith("J_Sec_") || name.StartsWith("J_Adj_"),
                $"{role} mapped to non-humanoid bone {name}");
        }
    }

    // ---------------------------------------------------------------- detection: auto-rig pro

    [Fact]
    public void Detect_SyntheticAutoRigPro_PicksAutoRigProPreset()
    {
        var skeleton = MappingFixtures.BuildAutoRigPro();

        var detected = ProfileDetector.Detect(skeleton);

        Assert.NotNull(detected);
        var (profile, result) = detected.Value;
        Assert.Equal("auto_rig_pro", profile.Name);
        Assert.True(result.Confidence >= 0.9f, $"Confidence {result.Confidence} < 0.9");
        Assert.True(ProfileDetector.Score(ProfileLibrary.Mixamo, skeleton) < 0.8f,
            "mixamo must not claim an Auto-Rig Pro rig");

        // root.x is the hips (the ground bone "root" carries no role).
        AssertRole(skeleton, result, BoneRole.Hips, "root.x");
        AssertRole(skeleton, result, BoneRole.Spine0, "spine_01.x");
        AssertRole(skeleton, result, BoneRole.Spine2, "spine_03.x");
        AssertRole(skeleton, result, BoneRole.Neck, "neck.x");
        AssertRole(skeleton, result, BoneRole.Head, "head.x");
        AssertRole(skeleton, result, BoneRole.ClavicleL, "shoulder.l");
        AssertRole(skeleton, result, BoneRole.UpperArmL, "arm_stretch.l");
        AssertRole(skeleton, result, BoneRole.LowerArmR, "forearm_stretch.r");
        AssertRole(skeleton, result, BoneRole.HandL, "hand.l");
        AssertRole(skeleton, result, BoneRole.ThumbProxL, "c_thumb1.l");
        AssertRole(skeleton, result, BoneRole.MiddleMidR, "c_middle2.r");
        AssertRole(skeleton, result, BoneRole.PinkyDistL, "c_pinky3.l");
        AssertRole(skeleton, result, BoneRole.UpperLegL, "thigh_stretch.l");
        AssertRole(skeleton, result, BoneRole.LowerLegR, "leg_stretch.r");
        AssertRole(skeleton, result, BoneRole.FootL, "foot.l");
        AssertRole(skeleton, result, BoneRole.ToeL, "toes_01.l");

        foreach (var role in FingerProxMidDistBothSides)
            Assert.True(result.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");

        // The ground bone and the leftover mixamorig finger-tip markers carry no role.
        foreach (var (role, boneIndex) in result.RoleToBone)
        {
            var name = skeleton[boneIndex].Name;
            Assert.False(name == "root" || name.StartsWith("mixamorig:"),
                $"{role} mapped to non-deform bone {name}");
        }
    }

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

    private static void AssertRole(
        HumanoidRetargeter.Core.Skeleton.Skeleton skeleton, MappingResult result, BoneRole role, string expectedBone)
    {
        Assert.True(result.RoleToBone.TryGetValue(role, out var index), $"Role {role} unmapped");
        Assert.Equal(expectedBone, skeleton[index].Name);
    }

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

    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.");
    }
}