tests/CitizenAnimationSetup/CitizenAnimationSetupTests.cs
using System.Numerics;
using HumanoidRetargeter.Maths;
using HumanoidRetargeter.Skeleton;
using HumanoidRetargeter.Target;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Skeleton.Skeleton;

namespace HumanoidRetargeter.Tests.Target;

public class CitizenAnimationSetupTests
{
    private static SkeletonModel Rig(string child = "hand_L", string? parent = "pelvis", float offset = 1)
        => SkeletonModel.Create(new[]
        {
            new BoneDefinition("pelvis", null, XForm.Identity),
            new BoneDefinition(child, parent, new XForm(new Vector3(offset, 0, 0), Quaternion.Identity))
        });

    [Fact]
    public void CompatibleSkeletonPasses() => Assert.Null(CitizenAnimationSetup.CompatibilityError(Rig(), Rig()));

    [Fact]
    public void MissingBoneDisablesSetup() => Assert.Contains("Missing bone", CitizenAnimationSetup.CompatibilityError(Rig("other"), Rig()));

    [Fact]
    public void SameNamesDifferentHierarchyFail() => Assert.Contains("parent", CitizenAnimationSetup.CompatibilityError(Rig(parent: null), Rig()));

    [Fact]
    public void SameNamesDifferentProportionsFail() => Assert.Contains("bind pose", CitizenAnimationSetup.CompatibilityError(Rig(offset: 2), Rig()));

    [Fact]
    public void SmallCompilerRoundingPasses() => Assert.Null(CitizenAnimationSetup.CompatibilityError(Rig(offset: 1.001f), Rig()));

    [Fact]
    public void MissingIkFails() => Assert.Contains("root_IK", CitizenAnimationSetup.CompatibilityError(Rig(), Rig("root_IK")));

    [Fact]
    public void MissingFaceIsOptional() => Assert.Null(CitizenAnimationSetup.CompatibilityError(Rig(), Rig("face_lid_L")));

    private static string Custom(float scale = 0.3937f) => VmdlWriter.GenerateStandalone("", Array.Empty<AnimEntry>(), scale, "pelvis",
        meshFilePath: "custom/skin.fbx", materialRemaps: new Dictionary<string, string> { ["skin"] = "custom/skin.vmat" });

    private static string Shipped(bool human)
    {
        var prefix = human ? "human" : "citizen";
        return VmdlWriter.Kv3Header + $$"""
        { rootNode = {
            anim_graph_name = "models/{{prefix}}.vanmgrph"
            children = [
                { _class = "ModelModifierList" children = [ { _class = "ModelModifier_ScaleAndMirror" scale = 0.3937 } ] },
                { _class = "AnimationList" children = [
                    { _class = "Prefab" target_file = "{{prefix}}_animations.vmdl_prefab" },
                    { _class = "Prefab" target_file = "{{prefix}}_animations_menu.vmdl_prefab" } ] },
                { _class = "AnimConstraintList" children = [
                    { _class = "Prefab" target_file = "{{prefix}}_constraints.vmdl_prefab" },
                    {{(human ? "{ _class = \"Folder\" name = \"CopyPinky\" children = [ { _class = \"AnimConstraintOrient\" name = \"pinky\" weight = 1.0 } ] }" : "")}}
                ] },
                { _class = "IKData" children = [ { _class = "Prefab" target_file = "ik.vmdl_prefab" } ] },
                { _class = "WeightListList" children = [ { _class = "WeightList" name = "Blink" } ] },
                { _class = "RenderMeshList" children = [ { _class = "RenderMeshFile" filename = "stock.fbx" } ] },
                { _class = "MaterialGroupList" children = [ { _class = "MaterialGroup" name = "stock" } ] }
            ]
        } }
        """;
    }

    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void SetupIncludesAllAnimationPrefabsAndGraphWithoutReplacingSkin(bool human)
    {
        var result = CitizenAnimationSetup.Apply(Custom(), Shipped(human));
        var prefix = human ? "human" : "citizen";
        Assert.Contains($"models/{prefix}.vanmgrph", result);
        Assert.Contains($"{prefix}_animations.vmdl_prefab", result);
        Assert.Contains($"{prefix}_animations_menu.vmdl_prefab", result);
        Assert.Contains("ik.vmdl_prefab", result);
        Assert.Contains("Blink", result);
        Assert.Contains("custom/skin.fbx", result);
        Assert.Contains("custom/skin.vmat", result);
        Assert.DoesNotContain("stock.fbx", result);
        Assert.Equal(human, result.Contains("CopyPinky"));
        Assert.Equal(human, result.Contains("HumanoidRetargeter_CitizenConstraints"));
        Assert.Equal(result, CitizenAnimationSetup.Apply(result, Shipped(human)));
    }

    [Fact]
    public void AddedClipsCannotDisableStockCopyPinky()
    {
        var setup = CitizenAnimationSetup.Apply(Custom(), Shipped(true));
        var result = VmdlAugmenter.Augment(setup, new[] { new AnimEntry { Name = "custom_punch", SourceFilename = "punch.dmx" } }, out _,
            new AugmentOptions { NeutralizePinkyConstraints = true });
        Assert.Contains("weight = 1.0", result);
        Assert.Contains("custom_punch", result);
        Assert.Contains("human_animations.vmdl_prefab", result);
    }

    [Fact]
    public void WrongScaleFails() => Assert.Throws<InvalidOperationException>(() => CitizenAnimationSetup.Apply(Custom(1), Shipped(true)));

    [Fact]
    public void ExistingAnimationsAreNotOverwritten()
    {
        var custom = VmdlWriter.GenerateStandalone("", new[] { new AnimEntry { Name = "mine", SourceFilename = "mine.dmx" } }, .3937f, "pelvis", meshFilePath: "skin.fbx");
        Assert.Throws<InvalidOperationException>(() => CitizenAnimationSetup.Apply(custom, Shipped(true)));
    }

    [Fact]
    public void AnimationOnlyBaseModelRejected() => Assert.Throws<InvalidOperationException>(() =>
        CitizenAnimationSetup.Apply(VmdlWriter.GenerateStandalone("base.vmdl", Array.Empty<AnimEntry>(), .3937f, "pelvis"), Shipped(true)));
}