tests/SmartPort/Engine/SmartPortRetargetEngineTest.cs
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using Editor;
using Sandbox;

// Copy into the Editor folder of an isolated test project with this library installed.
// Never runs unless the exact project root is explicitly opted in.
public static class SmartPortRetargetEngineTest
{
    static bool started;
    static string output;
    static readonly Dictionary<string, object> results = new();

    [EditorEvent.Frame]
    public static void Tick()
    {
        var expected = Environment.GetEnvironmentVariable("HR_SMART_PORT_RETARGET_TEST_PROJECT");
        if (started || Project.Current is null || string.IsNullOrEmpty(expected)
            || !string.Equals(Path.GetFullPath(Project.Current.GetRootPath()).TrimEnd('/', '\\'),
                Path.GetFullPath(expected).TrimEnd('/', '\\'), StringComparison.OrdinalIgnoreCase)
            || !AssetSystem.All.Any()) return;
        started = true;
        output = Path.Combine(expected, "smart-port-retarget-result.json");
        _ = Run();
    }

    static void Save() => File.WriteAllText(output, JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }));

    static async Task Run()
    {
        try
        {
            results["started"] = DateTime.UtcNow; Save();
            var source = AssetSystem.FindByPath("models/player/human/frank_mp.vmdl") ?? throw new Exception("Missing compiled fixture");
            var target = AssetSystem.FindByPath("animations/retargeted/retargeted_bunny.vmdl") ?? throw new Exception("Missing target fixture");
            if (Model.Load(target.Path).IsError) throw new Exception("Target failed to load");
            var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("HumanoidRetargeter.Editor.SmartPortModels")).FirstOrDefault(t => t != null)
                ?? throw new Exception("SmartPortModels was not compiled into the editor library");
            var method = type.GetMethod("CreateAsync", BindingFlags.Static | BindingFlags.NonPublic);
            Action<string> progress = message => { results["stage"] = message; Save(); };
            var name = "test_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
            var task = (Task)method.Invoke(null, new object[] { source, target, "smart_port", name, progress, CancellationToken.None });
            await task;
            var write = task.GetType().GetProperty("Result").GetValue(task);
            var compiled = (bool)write.GetType().GetProperty("Compiled").GetValue(write);
            results["compiled"] = compiled;
            results["errors"] = write.GetType().GetProperty("Errors").GetValue(write); Save();
            if (!compiled) throw new Exception("Smart Port verification failed");
            var path = "smart_port/" + name + ".vmdl";
            var model = Model.Load(path);
            results["sequences"] = model.AnimationCount;
            results["graph"] = model.AnimGraph.Name; Save();
            var skin = Model.Load(target.Path);
            var skinBones = skin.Bones.AllBones.Where(b => b.Parent != null &&
                (b.Name.StartsWith("finger_") || b.Name.StartsWith("arm_lower_") || b.Name.StartsWith("hand_")
                || b.Name.StartsWith("leg_lower_") || b.Name.StartsWith("ankle_")) && !b.Name.Contains("twist")
                && !b.Name.Contains("IK") && !b.Name.Contains("ikrule")).ToArray();

            // Actual editor play mode: component animation updates are driven by the game,
            // not SceneModel.Update calls or the retargeter's preview widget.
            var session = SceneEditorSession.CreateDefault();
            session.MakeActive();
            using (session.Scene.Push())
            {
                var go = session.Scene.CreateObject(); go.Name = "Smart Port Character";
                var renderer = go.Components.Create<SkinnedModelRenderer>();
                renderer.Model = model; renderer.UseAnimGraph = true;
                var floor = session.Scene.CreateObject(); floor.Name = "Floor";
                var box = floor.Components.Create<BoxCollider>();
                box.Scale = new Vector3(4000, 4000, 4); box.Center = new Vector3(0, 0, -2);
            }
            EditorScene.Play(false, session);
            await Task.Delay(1500);
            if (!Game.IsPlaying || Game.ActiveScene is null || Game.ActiveScene.IsEditor) throw new Exception("Did not enter game play mode");
            results["inGame"] = true; Save();
            var actor = Game.ActiveScene.GetAllComponents<SkinnedModelRenderer>().Single(r => r.GameObject.Name == "Smart Port Character");
            var footsteps = 0;
            actor.OnFootstepEvent += _ => footsteps++;
            var movement = new List<object>();
            foreach (var (style, speed) in new[] { (0, 100f), (4, 120f), (5, 240f) })
            {
                actor.Set("b_grounded", true); actor.Set("move_style", style); actor.Set("weapon_pose", 0);
                actor.Set("move_x", speed); actor.Set("move_speed", speed); actor.Set("move_groundspeed", speed);
                actor.Set("wish_x", speed); actor.Set("wish_groundspeed", speed);
                await Task.Delay(1000);
                var boneNames = model.Bones.AllBones.Where(b => b.Name.Contains("ankle") || b.Name.Contains("leg_lower") || b.Name.Contains("hand")).Select(b => b.Name).ToArray();
                if (boneNames.Length < 4) throw new Exception("Insufficient limb bones for locomotion assertions");
                var initial = boneNames.Select(n => actor.SceneModel.GetBoneWorldTransform(n).Position - actor.WorldPosition).ToArray();
                float maxMotion = 0;
                float maxStretch = 0;
                for (var frame = 0; frame < 100; frame++)
                {
                    await Task.Delay(20);
                    actor.WorldPosition += Vector3.Forward * speed * .02f;
                    foreach (var bone in skinBones)
                    {
                        var bindLength = bone.LocalTransform.Position.Distance(bone.Parent.LocalTransform.Position);
                        var length = actor.SceneModel.GetBoneWorldTransform(bone.Name).Position.Distance(
                            actor.SceneModel.GetBoneWorldTransform(bone.Parent.Name).Position);
                        maxStretch = MathF.Max(maxStretch, MathF.Abs(length - bindLength));
                        if (!float.IsFinite(length) || MathF.Abs(length - bindLength) > .15f)
                            throw new Exception($"Stretched skin bone {bone.Name}: bind={bindLength}, animated={length}");
                    }
                    for (var b = 0; b < boneNames.Length; b++)
                    {
                        var pos = actor.SceneModel.GetBoneWorldTransform(boneNames[b]).Position - actor.WorldPosition;
                        var distance = pos.Distance(initial[b]);
                        if (!float.IsFinite(distance) || pos.Length > 200) throw new Exception($"Non-finite or stretched limb {boneNames[b]}: {pos} (initial {initial[b]}, style {style})");
                        maxMotion = MathF.Max(maxMotion, distance);
                    }
                }
                movement.Add(new { style, speed, maxMotion, maxStretch });
                if (maxMotion < 1) throw new Exception("Limbs did not animate during locomotion");
            }
            results["locomotion"] = movement;
            results["footsteps"] = footsteps;
            if (footsteps == 0) throw new Exception("No footstep events during walking/running");
            results["passed"] = true;
        }
        catch (Exception e) { results["passed"] = false; results["error"] = e.ToString(); }
        finally
        {
            results["completed"] = true; Save();
            if (Game.IsPlaying) EditorScene.Stop();
            await Task.Delay(1000);
            EditorUtility.Quit(true);
        }
    }
}