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

namespace HumanoidRetargeter.Tests.Target;

public class AnimationWorkflowTests
{
    const string Graph = VmdlWriter.Kv3Header + "{ _class = \"CAnimationGraph\" m_previewModels = [\"stock.vmdl\"] m_boneMergeModels = [ { m_name = \"hat\" } ] nodes = [ { _class = \"CSequenceAnimNode\" m_sequenceName = \"Walk_N\" speed = 1.25 }, { m_sequenceName = \"Walk_S\" }, { m_sequenceName = \"WalkFast_N\" }, { m_sequenceName = \"Run_N_m\" } ] connections = [ 1, 2 ] }";

    [Fact]
    public void CopyPreservesGraphLogicAndUsesOnlyCustomPreview()
    {
        var original = (KvObject)Kv3.Parse(Graph).Root;
        var copy = (KvObject)Kv3.Parse(StockAnimationGraph.CopyForModel(Graph, "custom/body.vmdl")).Root;
        Assert.True(KvValue.DeepEquals(original["nodes"], copy["nodes"]));
        Assert.True(KvValue.DeepEquals(original["connections"], copy["connections"]));
        Assert.Empty(((KvArray)copy["m_boneMergeModels"]).Items);
        Assert.Equal("custom/body.vmdl", ((KvString)Assert.Single(((KvArray)copy["m_previewModels"]).Items)).Value);
    }

    [Fact]
    public void ReplacingAgainOnlyChangesSelectedSlotsReferences()
    {
        var slot = StockAnimationGraph.Slots.Single(s => s.Id == "walk_n");
        var first = StockAnimationGraph.Replace(Graph, slot, slot.ReplacementPrefix + "one", "custom.vmdl", out var count);
        Assert.Equal(1, count);
        var second = StockAnimationGraph.Replace(first, slot, slot.ReplacementPrefix + "two", "custom.vmdl", out count);
        Assert.Equal(1, count);
        Assert.DoesNotContain(slot.ReplacementPrefix + "one", second);
        Assert.Contains("Walk_S", second);
        Assert.Contains("WalkFast_N", second);
        Assert.Contains("Run_N_m", second);
        Assert.Contains("1.25", second);
        Assert.True(KvValue.DeepEquals(((KvObject)Kv3.Parse(Graph).Root)["connections"], ((KvObject)Kv3.Parse(second).Root)["connections"]));
    }

    [Fact]
    public void HumanMaleForwardRunAliasIsReplaced()
    {
        var slot = StockAnimationGraph.Slots.Single(s => s.Id == "run_n");
        var result = StockAnimationGraph.Replace(Graph, slot, slot.ReplacementPrefix + "test", "custom.vmdl", out var count);
        Assert.Equal(1, count);
        Assert.DoesNotContain("Run_N_m", result);
    }

    [Fact]
    public void MissingSlotFailsWithoutFallingBackToUnrelatedNodes()
        => Assert.Throws<InvalidOperationException>(() => StockAnimationGraph.Replace(Graph, StockAnimationGraph.Slots[0], "idle", "custom.vmdl", out _));

    [Theory]
    [InlineData("", "graphs/body.vanmgrph")]
    [InlineData("models/custom/", "models/custom/graphs/body.vanmgrph")]
    public void GraphLivesBesideItsModel(string folder, string expected)
        => Assert.Equal(expected, StockAnimationGraph.GraphPath(folder, "body"));

    [Fact]
    public void ActualGraphEscapedQuestionMarkRoundTrips()
    {
        var doc = Kv3.Parse(VmdlWriter.Kv3Header + "{ comment = \"why\\?\" }");
        Assert.Equal("why?", ((KvObject)doc.Root).GetString("comment"));
        Assert.True(KvValue.DeepEquals(doc.Root, Kv3.Parse(Kv3.Serialize(doc)).Root));
    }

    [Theory]
    [InlineData("Walk_N", "walk_n")]
    [InlineData("Walk_North-East_01", "walk_ne")]
    [InlineData("RunForwardLeft", "run_nw")]
    [InlineData("Crouch Walk backward right", "crouchwalk_se")]
    [InlineData("jog_SW", "run_sw")]
    [InlineData("walk south west", "walk_sw")]
    [InlineData("walk South", "walk_s")]
    [InlineData("Walk East", "walk_e")]
    [InlineData("Walk left", "walk_w")]
    [InlineData("KB_Cross-L_S", null)]
    [InlineData("Walk_N_S", null)]
    [InlineData("Walk_turn", null)]
    [InlineData("Walk", null)]
    public void NameSuggestionsRespectWordBoundariesAndAmbiguity(string name, string? expected)
        => Assert.Equal(expected, LocomotionSuggestion.Detect(name)?.SlotId);

    private static (SkeletonModel Skeleton, MappingResult Map) Rig()
    {
        var roles = new[] { BoneRole.Hips, BoneRole.UpperLegL, BoneRole.UpperLegR, BoneRole.UpperArmL, BoneRole.UpperArmR, BoneRole.FootL, BoneRole.FootR };
        var positions = new[] { new Vector3(0, 100, 0), new Vector3(10, 90, 0), new Vector3(-10, 90, 0), new Vector3(20, 150, 0), new Vector3(-20, 150, 0), new Vector3(10, 0, 0), new Vector3(-10, 0, 0) };
        var rig = SkeletonModel.Create(roles.Select((r, i) => new BoneDefinition(r.ToString(), null, new XForm(positions[i], Quaternion.Identity))).ToArray());
        var map = new MappingResult("test", MappingSource.Manual);
        for (var i = 0; i < roles.Length; i++) map.RoleToBone[roles[i]] = i;
        return (rig, map);
    }

    [Theory]
    [InlineData(0, 100, "N")]
    [InlineData(-100, 100, "NE")]
    [InlineData(-100, 0, "E")]
    [InlineData(-100, -100, "SE")]
    [InlineData(0, -100, "S")]
    [InlineData(100, -100, "SW")]
    [InlineData(100, 0, "W")]
    [InlineData(100, 100, "NW")]
    [InlineData(0, 0, null)]
    [InlineData(0, 1, null)]
    public void TravelSuggestionsAreCharacterRelative(float x, float z, string? direction)
    {
        var (rig, map) = Rig();
        var rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1.2f);
        var frames = Enumerable.Range(0, 31).Select(i => rig.RestWorld.Select(t => new XForm(Vector3.Transform(t.Pos + new Vector3(x, 0, z) * (i / 30f), rotation), rotation)).ToArray()).ToList();
        Assert.Equal(direction, LocomotionSuggestion.Detect("walk", rig, map, new Clip("walk", 30, true, frames))?.Direction);
    }

    [Fact]
    public void TurningMotionIsNotAssignedASingleDirection()
    {
        var (rig, map) = Rig();
        var frames = Enumerable.Range(0, 31).Select(i =>
        {
            var rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, i / 30f);
            return rig.RestWorld.Select(t => new XForm(Vector3.Transform(t.Pos, rotation) + new Vector3(0, 0, i * 4), rotation)).ToArray();
        }).ToList();
        Assert.Null(LocomotionSuggestion.Detect("walk", rig, map, new Clip("walk", 30, true, frames)));
    }

    [Theory]
    [InlineData(30, 1, false)]
    [InlineData(60, 1, false)]
    [InlineData(120, 1, false)]
    [InlineData(30, 0.3937f, true)]
    public void InPlaceSlidingFootHasOneEventPerLiftAtAnyRateOrAxis(int fps, float scale, bool zUp)
    {
        var rig = SkeletonModel.Create(new[] { new BoneDefinition("left", null, XForm.Identity), new BoneDefinition("right", null, XForm.Identity) });
        var frames = Enumerable.Range(0, fps * 2).Select(i =>
        {
            float t = i / (float)fps;
            var height = t >= 0.3f && t < 0.8f ? 12f : 0f;
            var position = new Vector3(-80 * t, height, 0) * scale;
            if (zUp) position = new Vector3(position.X, position.Z, position.Y);
            return new[] { new XForm(position, Quaternion.Identity), XForm.Identity };
        }).ToList();
        var events = FootstepEvents.Generate(frames, rig, new FootChain { Hip = 0, Knee = 0, Ankle = 0 }, new FootChain { Hip = 1, Knee = 1, Ankle = 1 }, zUp ? Vector3.UnitZ : Vector3.UnitY, fps,
            new FootPlantOptions { HeightThresholdCm = 4 * scale, SpeedThresholdCmPerSec = 8 * scale, MinPlantFrames = (int)MathF.Ceiling(fps * .1f) });
        var step = Assert.Single(events);
        Assert.Equal("0", step.Foot);
        Assert.InRange(step.Frame / (float)fps, .8f, .85f);
    }

    [Fact]
    public void GroundedSlidingAndHeightJitterDoNotProduceSteps()
    {
        var rig = SkeletonModel.Create(new[] { new BoneDefinition("foot", null, XForm.Identity) });
        var frames = Enumerable.Range(0, 90).Select(i => new[] { new XForm(new Vector3(i, i % 2 * .1f, 0), Quaternion.Identity) }).ToList();
        var foot = new FootChain { Hip = 0, Knee = 0, Ankle = 0 };
        Assert.Empty(FootstepEvents.Generate(frames, rig, foot, foot, Vector3.UnitY, 30));
    }
}