264 results

using Sandbox;

[TestClass]
public partial class LibraryTests
{
	[TestMethod]
	public void SceneTest()
	{
		var scene = new Scene();
		using ( scene.Push() )
		{
			var go = new GameObject();

			Assert.AreEqual( 1, scene.Directory.GameObjectCount );
		}
	}

}
global using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class TestInit
{
	public static Sandbox.TestAppSystem AppSystem;

	[AssemblyInitialize]
	public static void AssemblyInitialize( TestContext context )
	{
		AppSystem = new Sandbox.TestAppSystem();
		AppSystem.Init();
	}

	[AssemblyCleanup]
	public static void AssemblyCleanup()
	{
		AppSystem.Shutdown();
	}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class TestInit
{
	public static Sandbox.TestAppSystem AppSystem;

	[AssemblyInitialize]
	public static void AssemblyInitialize( TestContext context )
	{
		AppSystem = new Sandbox.TestAppSystem();
		AppSystem.Init();
	}

	[AssemblyCleanup]
	public static void AssemblyCleanup()
	{
		AppSystem.Shutdown();
	}
}
using Sandbox;

[TestClass]
public partial class LibraryTests
{
	[TestMethod]
	public void SceneTest()
	{
		var scene = new Scene();
		using ( scene.Push() )
		{
			var go = new GameObject();

			Assert.AreEqual( 1, scene.Directory.GameObjectCount );
		}
	}

}
using Hexagon.V2.Application;

namespace Hexagon.V2.Tests.Application;

[TestClass]
public sealed class CharacterNameSkeletonTests
{
	[TestMethod]
	public void WithinScriptLookAlikesCollapseOntoTheNameTheyImitate()
	{
		var alice = CharacterNameSkeleton.Of( "Alice" );
		// Digit-for-letter and the i/l/1 shape collision.
		Assert.AreEqual( alice, CharacterNameSkeleton.Of( "A1ice" ) );
		Assert.AreEqual( alice, CharacterNameSkeleton.Of( "Alice" ) );
		Assert.AreEqual( alice, CharacterNameSkeleton.Of( "AIice" ) );
		Assert.AreEqual( alice, CharacterNameSkeleton.Of( "4lice" ) );
		// Separators and punctuation carry no identity.
		Assert.AreEqual( alice, CharacterNameSkeleton.Of( "A l i c e" ) );
		Assert.AreEqual( alice, CharacterNameSkeleton.Of( "A-l-i-c-e" ) );
		// Accents are decoration, not identity.
		Assert.AreEqual( CharacterNameSkeleton.Of( "Jose" ), CharacterNameSkeleton.Of( "Jos\u00E9" ) );
		// The classic digraph pair.
		Assert.AreEqual( CharacterNameSkeleton.Of( "Bemard" ), CharacterNameSkeleton.Of( "Bernard" ) );
		Assert.AreEqual( CharacterNameSkeleton.Of( "Walker" ), CharacterNameSkeleton.Of( "VValker" ) );
	}

	[TestMethod]
	public void WholeNameWrittenInAnotherScriptCollapsesOntoTheLatinNameItImitates()
	{
		// The script profile permits these - they are single-script names. Only the skeleton
		// sees that they are drawn to read as "Alice" and "Petro".
		// Cyrillic es-o-o-er-ie-ghe, which draws "Cooper" without one Latin letter in it.
		Assert.AreEqual(
			CharacterNameSkeleton.Of( "Cooper" ),
			CharacterNameSkeleton.Of( "\u0441\u043E\u043E\u0440\u0435\u0433" ) );
		// Greek rho-omicron-rho-omicron-nu, which draws "Popov".
		Assert.AreEqual(
			CharacterNameSkeleton.Of( "Popov" ),
			CharacterNameSkeleton.Of( "\u03C1\u03BF\u03C1\u03BF\u03BD" ) );
	}

	[TestMethod]
	public void FoldingFollowsUnicodeRatherThanLocalIntuition()
	{
		// A documented residual, asserted so a table bump surfaces it rather than hiding it:
		// UTS #39 judges Cyrillic em confusable with a turned w, NOT with Latin m, so a Cyrillic
		// name using it does not collapse onto the Latin name it arguably resembles. The script
		// profile still blocks the mixed-script form, which is the reachable attack.
		Assert.AreNotEqual(
			CharacterNameSkeleton.Of( "Maxwell" ),
			CharacterNameSkeleton.Of( "\u043C\u0430\u0445\u051D\u0435\u0456\u0456" ) );

		// Latin i and l stay distinct - the standard folds the digit 1, capital I and the bar
		// onto l, but never merges lowercase i with it.
		Assert.AreEqual( CharacterNameSkeleton.Of( "Alice" ), CharacterNameSkeleton.Of( "A1ice" ) );
		Assert.AreNotEqual( CharacterNameSkeleton.Of( "Bili" ), CharacterNameSkeleton.Of( "Bill" ) );
	}

	[TestMethod]
	public void DistinctNamesKeepDistinctSkeletons()
	{
		Assert.AreNotEqual( CharacterNameSkeleton.Of( "Alice" ), CharacterNameSkeleton.Of( "Alicia" ) );
		Assert.AreNotEqual( CharacterNameSkeleton.Of( "Alice" ), CharacterNameSkeleton.Of( "Alison" ) );
		Assert.AreNotEqual( CharacterNameSkeleton.Of( "Barney" ), CharacterNameSkeleton.Of( "Gordon" ) );
		Assert.AreNotEqual( CharacterNameSkeleton.Of( "Judith Mossman" ), CharacterNameSkeleton.Of( "Eli Vance" ) );
		// A name in a script with no Latin look-alikes is left alone rather than flattened.
		Assert.AreNotEqual(
			CharacterNameSkeleton.Of( "\u0410\u043B\u0438\u0441\u0430" ),      // Cyrillic "Alisa"
			CharacterNameSkeleton.Of( "\u0411\u043E\u0440\u0438\u0441" ) );    // Cyrillic "Boris"
	}

	[TestMethod]
	public void NameWithNoIdentityBearingCharactersHasAnEmptySkeleton()
	{
		Assert.AreEqual( string.Empty, CharacterNameSkeleton.Of( "..." ) );
		Assert.AreEqual( string.Empty, CharacterNameSkeleton.Of( "---" ) );
		Assert.AreEqual( string.Empty, CharacterNameSkeleton.Of( string.Empty ) );
	}
}
using System.Text.Json;
using Hexagon.V2.Application;
using Hexagon.V2.Domain;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Hexagon.V2.Tests.Application;

[TestClass]
public sealed class CurrencyAndCharacterRulesTests
{
	[TestMethod]
	public void CurrencyRejectsUnderflowAndOverflow()
	{
		var character = CreateCharacter( 10 );
		Assert.IsTrue( CurrencyService.Debit( character, 11 ).Failed );
		Assert.IsTrue( CurrencyService.Credit( character with { Balance = long.MaxValue }, 1 ).Failed );
	}

	[TestMethod]
	public void SlotAllocatorUsesLowestGap()
	{
		var characters = new[] { CreateCharacter( 0 ) with { Slot = 0 }, CreateCharacter( 0 ) with { Slot = 2 } };
		Assert.AreEqual( 1, CharacterRules.FindLowestFreeSlot( characters ) );
	}

	[TestMethod]
	public void SlotAllocatorReturnsMinusOneWhenEverySlotIsOccupied()
	{
		var characters = Enumerable.Range( 0, CharacterRules.MaximumSlots )
			.Select( slot => CreateCharacter( 0 ) with { Slot = slot } )
			.ToArray();
		Assert.AreEqual( -1, CharacterRules.FindLowestFreeSlot( characters ) );

		var oneFree = characters.Where( character => character.Slot != CharacterRules.MaximumSlots - 1 ).ToArray();
		Assert.AreEqual( CharacterRules.MaximumSlots - 1, CharacterRules.FindLowestFreeSlot( oneFree ) );
	}

	[TestMethod]
	public void CreationRejectsUnregisteredFields()
	{
		var request = new CharacterCreationRequest
		{
			Name = "Valid Name",
			Description = "A sufficiently long character description.",
			Model = new DefinitionId( "test.model" ),
			Faction = new FactionId( "test.faction" ),
			Fields = new Dictionary<string, CreationValue> { ["money"] = CreationValue.Integer( long.MaxValue ) }
		};

		Assert.IsTrue( CharacterRules.ValidateCreationRequest( request, new HashSet<string>() ).Failed );
	}

	[TestMethod]
	public void IdentityTextRejectsControlAndFormatCharacters()
	{
		// A name reaches other players verbatim through recognition records and nameplates, so
		// spoofing payloads must not survive the creation boundary. These are written as escapes
		// on purpose: as literals they are invisible in an editor and lost by copy/paste.
		foreach ( var name in new[]
		{
			"Bob\nCombine Overwatch", // U+000A interior line break
			"Bob\u0007Smith",         // C0 control
			"Bob\u202Eelbat",         // right-to-left override
			"Bo\u200Bb Smith",        // zero-width space
			"Bob\u200DSmith",         // zero-width joiner
			"Bob\u2066Smith\u2069",   // bidirectional isolates
			"Bob\uFEFFSmith"          // zero-width no-break space
		} )
		{
			Assert.IsTrue(
				CharacterRules.NormalizeName( name ).Failed,
				$"Name '{name.Replace( "\n", "\\n" )}' should have been rejected." );
			Assert.IsTrue( CharacterRules.ValidateCreationRequest( Request( name: name ), NoFields ).Failed );
		}

		// Zero-width characters are not whitespace, so they survive Trim: a length check
		// alone can never catch a name built entirely from them.
		Assert.IsTrue( CharacterRules.NormalizeName( "\u200B\u200B\u200B\u200B" ).Failed );
	}

	[TestMethod]
	public void DescriptionKeepsLineBreaksButRejectsOtherControlCharacters()
	{
		var prose = "A tall citizen in a worn jacket.\nHe keeps his hands in his pockets.";
		Assert.AreEqual( prose, CharacterRules.NormalizeDescription( prose ).Value );
		Assert.IsTrue( CharacterRules.NormalizeDescription( "A citizen\u202Ewith a reversed tail." ).Failed );
		Assert.IsTrue( CharacterRules.NormalizeDescription( "A citizen with a\u0000 null byte." ).Failed );
	}

	[TestMethod]
	public void IdentityTextIsCanonicalizedSoValidationAndStorageAgree()
	{
		// Combining and precomposed acutes render identically, so they must not be
		// storable as two distinct names.
		var decomposed = CharacterRules.NormalizeName( "  Ame\u0301lie  " );
		Assert.IsTrue( decomposed.Succeeded );
		Assert.AreEqual( "Am\u00E9lie", decomposed.Value );
		Assert.AreEqual( decomposed.Value, CharacterRules.NormalizeName( "Am\u00E9lie" ).Value );

		// An unpaired surrogate is rejected rather than thrown out of Normalize.
		Assert.IsTrue( CharacterRules.NormalizeName( "Bob\uD800Smith" ).Failed );

		// Ordinary names still pass.
		Assert.IsTrue( CharacterRules.NormalizeName( "Valid Name" ).Succeeded );
		Assert.IsTrue( CharacterRules.NormalizeName( "Baßmann-O'Neill" ).Succeeded );
		Assert.IsTrue( CharacterRules.ValidateCreationRequest( Request(), NoFields ).Succeeded );
	}

	[TestMethod]
	public void NameRejectsMixedScriptImpersonation()
	{
		// Each of these renders as ordinary Latin text but smuggles one letter from another
		// script, which is exactly how a name is made to look like someone else's.
		Assert.IsTrue( CharacterRules.NormalizeName( "\u0410lice" ).Failed );  // Cyrillic A
		Assert.IsTrue( CharacterRules.NormalizeName( "Alic\u0435" ).Failed );  // Cyrillic e
		Assert.IsTrue( CharacterRules.NormalizeName( "\u03A1eter" ).Failed );  // Greek Rho
		Assert.IsTrue( CharacterRules.NormalizeName( "\u13AAlice" ).Failed );  // Cherokee
		Assert.IsTrue( CharacterRules.ValidateCreationRequest( Request( name: "\u0410lice" ), NoFields ).Failed );

		// A wholly Cyrillic or Greek name is a real name, not an impersonation.
		Assert.IsTrue( CharacterRules.NormalizeName( "Алиса" ).Succeeded );
		Assert.IsTrue( CharacterRules.NormalizeName( "Γεωργος" ).Succeeded );
	}

	[TestMethod]
	public void NameAllowsTheScriptCombinationsRealNamesNeed()
	{
		// Japanese mixes Han, Hiragana and Katakana by nature, and may carry Latin.
		Assert.IsTrue( CharacterRules.NormalizeName( "田中 ひろし" ).Succeeded );
		Assert.IsTrue( CharacterRules.NormalizeName( "タナカ Tanaka" ).Succeeded );
		// Korean mixes Hangul with Latin.
		Assert.IsTrue( CharacterRules.NormalizeName( "김철수 Kim" ).Succeeded );
		// Digits, spaces and punctuation are script-neutral and never constrain a name.
		Assert.IsTrue( CharacterRules.NormalizeName( "Dr. Judith Mossman II" ).Succeeded );
		// Greek alongside Latin is not a combination any real name needs.
		Assert.IsTrue( CharacterRules.NormalizeName( "Alice Γεω" ).Failed );
	}

	[TestMethod]
	public void NameFoldsCompatibilityLookAlikesOntoWhatTheyImitate()
	{
		// Fullwidth Latin is Latin script, so mixed-script detection cannot see it. NFKC folding
		// is what stops it being a second, visually identical spelling of an existing name.
		var fullwidth = CharacterRules.NormalizeName( "\uFF21lice" );
		Assert.IsTrue( fullwidth.Succeeded );
		Assert.AreEqual( "Alice", fullwidth.Value );

		// A description is prose, not identity: it keeps compatibility characters and may mix
		// scripts freely. Only its control- and format-character rules apply.
		Assert.IsTrue( CharacterRules.NormalizeDescription(
			"A citizen who mutters Алиса under their breath." ).Succeeded );
		Assert.IsTrue( CharacterRules.NormalizeDescription(
			"A citizen wearing a \uFF21-series jumpsuit, collar upturned." ).Succeeded );
	}

	[TestMethod]
	public void TwoUnenumeratedScriptsAreStillTwoScripts()
	{
		// Neither Coptic nor Runic is in the named script table. They must not share one "other"
		// bucket, or a name could mix two writing systems the profile never actually looked at.
		Assert.IsTrue( CharacterRules.NormalizeName( "\u2C81\u2C83\u2C85" ).Succeeded );  // Coptic alone
		Assert.IsTrue( CharacterRules.NormalizeName( "\u16A0\u16A2\u16A6" ).Succeeded );  // Runic alone
		Assert.IsTrue( CharacterRules.NormalizeName( "\u2C81\u2C83\u16A0" ).Failed );     // the two mixed
		Assert.IsTrue( CharacterRules.NormalizeName( "\u2C81\u2C83Ab" ).Failed );         // one with Latin
	}
	private static IReadOnlySet<string> NoFields => new HashSet<string>();

	private static CharacterCreationRequest Request( string name = "Valid Name" ) =>
		new()
		{
			Name = name,
			Description = "A sufficiently long character description.",
			Model = new DefinitionId( "test.model" ),
			Faction = new FactionId( "test.faction" ),
			Fields = new Dictionary<string, CreationValue>()
		};

	private static CharacterRecord CreateCharacter( long balance )
	{
		using var document = JsonDocument.Parse( "{}" );
		return new CharacterRecord
		{
			Id = CharacterId.New(),
			AccountId = new AccountId( 1 ),
			Slot = 0,
			Name = "Test",
			Description = "A sufficiently long description.",
			Model = new DefinitionId( "test.model" ),
			Faction = new FactionId( "test.faction" ),
			Balance = balance,
			CreatedAt = DateTimeOffset.UtcNow,
			LastPlayedAt = DateTimeOffset.UtcNow,
			SchemaState = new TypedPayload
			{
				TypeId = new PersistedTypeId( "test.character" ),
				TypeVersion = 1,
				Data = document.RootElement.Clone()
			}
		};
	}
}
#nullable enable

using Hexagon.V2.Client;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Networking;

namespace Hexagon.V2.Tests.Client;

[TestClass]
public sealed class ClientControllerTests
{
	[TestMethod]
	public async Task ControllerMapsEveryUiIntentToAnExplicitCommand()
	{
		var transport = new RecordingTransport();
		var controller = new HexClientController(transport);
		var characterId = CharacterId.New();
		var sourceId = InventoryId.New();
		var targetId = InventoryId.New();
		var itemId = ItemId.New();
		var actionId = new ActionId("use");
		var actionInstance = Guid.NewGuid();
		var actionArguments = new Dictionary<string, SnapshotValue>( StringComparer.Ordinal )
		{
			["amount"] = SnapshotValue.Integer( 3 )
		};
		var creation = new CharacterCreationInput(
			"Alyx", "Description", new DefinitionId("citizen_model"),
			new FactionId("citizen"), null);

		await controller.RequestCharactersAsync();
		await controller.CreateCharacterAsync(creation);
		await controller.LoadCharacterAsync(characterId);
		await controller.DeleteCharacterAsync(characterId);
		await controller.UnloadCharacterAsync();
		await controller.MoveItemAsync(sourceId, targetId, itemId, 2, 3);
		await controller.RunItemActionAsync(sourceId, itemId, actionId, actionArguments);
		actionArguments["amount"] = SnapshotValue.Integer( 99 );
		await controller.DropItemAsync(sourceId, itemId);
		await controller.PickUpItemAsync(itemId, targetId);
		await controller.SendChatAsync("ic", "Hello");
		await controller.CancelActionAsync(actionInstance);

		Assert.HasCount(11, transport.Commands);
		Assert.IsInstanceOfType<RequestCharacterListCommand>(transport.Commands[0]);
		Assert.AreSame(creation, ((CreateCharacterCommand)transport.Commands[1]).Input);
		Assert.AreEqual(characterId, ((LoadCharacterCommand)transport.Commands[2]).CharacterId);
		Assert.IsInstanceOfType<DeleteCharacterCommand>(transport.Commands[3]);
		Assert.IsInstanceOfType<UnloadCharacterCommand>(transport.Commands[4]);
		Assert.AreEqual((2, 3),
			(((MoveInventoryItemCommand)transport.Commands[5]).X,
				((MoveInventoryItemCommand)transport.Commands[5]).Y));
		Assert.AreEqual(actionId, ((RunItemActionCommand)transport.Commands[6]).ActionId);
		Assert.AreEqual( 3L, ((RunItemActionCommand)transport.Commands[6]).Arguments["amount"].IntegerValue );
		Assert.IsInstanceOfType<DropItemCommand>(transport.Commands[7]);
		Assert.IsInstanceOfType<PickUpItemCommand>(transport.Commands[8]);
		Assert.AreEqual("Hello", ((SendChatCommand)transport.Commands[9]).Text);
		Assert.AreEqual(actionInstance, ((CancelActionCommand)transport.Commands[10]).InstanceId);
	}

	[TestMethod]
	public async Task TransportFailureAndCancellationTokenArePropagated()
	{
		var expected = OperationResult.Failure(ErrorCode.Unauthorized, "Denied.");
		var transport = new RecordingTransport(expected);
		var controller = new HexClientController(transport);
		using var source = new CancellationTokenSource();

		var result = await controller.SendChatAsync("ic", "Hello", source.Token);

		Assert.AreEqual(ErrorCode.Unauthorized, result.Error!.Code);
		Assert.AreEqual(source.Token, transport.LastCancellationToken);
	}

	private sealed class RecordingTransport : IClientCommandTransport
	{
		private readonly OperationResult _result;

		public RecordingTransport(OperationResult? result = null) =>
			_result = result ?? OperationResult.Success();

		public List<ClientCommand> Commands { get; } = new();
		public CancellationToken LastCancellationToken { get; private set; }

		public ValueTask<OperationResult> SendAsync(
			ClientCommand command,
			CancellationToken cancellationToken = default)
		{
			Commands.Add(command);
			LastCancellationToken = cancellationToken;
			return ValueTask.FromResult(_result);
		}
	}
}
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;

using Microsoft.VisualStudio.TestTools.UnitTesting;

[assembly: Parallelize( Scope = ExecutionScope.MethodLevel )]
global using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class TestInit
{
	[AssemblyInitialize]
	public static void ClassInitialize( TestContext context )
	{
		Sandbox.Application.InitUnitTest();
	}
}
using System.Numerics;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Skeleton;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;

namespace HumanoidRetargeter.Tests.Target;

public class SmartPortAdditiveIkTests
{
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void RecoilDoesNotInventGoalTranslationFromDifferentArmBindPoses(bool authoredGoalMotion)
    {
        var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
        const string goal = "support_goal";
        var definitions = original.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : original[b.ParentIndex].Name, b.RestLocal)).ToList();
        definitions.Add(new(goal, "hand_R", XForm.ToLocal(original.RestWorld[original.IndexOf("hand_R")],
            original.RestWorld[original.IndexOf("hand_L")])));
        var source = SkeletonModel.Create(definitions);
        var target = SkeletonModel.Create(source.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : source[b.ParentIndex].Name,
            new XForm(b.RestLocal.Pos * .8f, b.RestLocal.Rot *
                (b.Name.StartsWith("arm_upper_") ? Quaternion.CreateFromAxisAngle(Vector3.UnitY, .6f) : Quaternion.Identity)))).ToArray());
        var rig = new SmartPortRig(source, target, new Dictionary<string, string> { [goal] = "hand_L" });
        var ordinary = new SmartPortRig(source, target);
        var pose = Enumerable.Repeat(XForm.Identity, source.Count).ToArray();
        pose[source.IndexOf("arm_upper_R")].Rot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, .2f);
        pose[source.IndexOf("arm_lower_L")].Rot = Quaternion.CreateFromAxisAngle(Vector3.UnitX, -.3f);
        pose[source.IndexOf(goal)] = new XForm(authoredGoalMotion ? new Vector3(.2f, -.1f, .3f) : Vector3.Zero,
            Quaternion.CreateFromAxisAngle(Vector3.UnitY, .1f));
        var actual = rig.Transfer(pose, true)[rig.Target.IndexOf(goal)];
        var expected = ordinary.Transfer(pose, true)[ordinary.Target.IndexOf(goal)];
        // Additive goals are local deltas on the already fitted grip, not absolute
        // effector positions computed against a reconstructed rest-pose animation.
        Assert.True(Vector3.Distance(expected.Pos, actual.Pos) < .0001f, $"Expected {expected.Pos}, got {actual.Pos}");
        Assert.True(MathQ.AngleBetween(expected.Rot, actual.Rot) < .001f);
        if (!authoredGoalMotion) Assert.True(actual.Pos.Length() < .0001f);
        else Assert.True(actual.Pos.Length() > .1f); // Preserve authored motion; do not freeze the goal.
    }
}
using System.Numerics;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Skeleton;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;

namespace HumanoidRetargeter.Tests.Target;

public class SmartPortGripReachTests
{
    [Fact]
    public void SharedGripFitsShorterArmsWithoutStretching()
    {
        var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
        var definitions = original.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : original[b.ParentIndex].Name, b.RestLocal)).ToList();
        foreach (var side in new[] { "L", "R" }) definitions.Add(new("grip_" + side, "hand_" + side, XForm.Identity));
        definitions.Add(new("support_goal", "hand_R", XForm.Identity));
        var source = SkeletonModel.Create(definitions);
        var target = SkeletonModel.Create(original.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : original[b.ParentIndex].Name,
            new XForm(b.RestLocal.Pos * (b.Name is "arm_lower_L" or "hand_L" ? .55f : 1), b.RestLocal.Rot))).ToArray());
        var pose = source.Bones.Select(b => b.RestLocal).ToArray();
        var left = source.IndexOf("arm_upper_L"); var right = source.IndexOf("arm_upper_R");
        var center = (source.RestWorld[left].Pos + source.RestWorld[right].Pos) * .5f;
        var up = Vector3.Normalize(source.RestWorld[source.IndexOf("head")].Pos - source.RestWorld[source.IndexOf("pelvis")].Pos);
        var lateral = Vector3.Normalize(source.RestWorld[right].Pos - source.RestWorld[left].Pos);
        var forward = Vector3.Normalize(Vector3.Cross(up, lateral));
        var reach = source[source.IndexOf("arm_lower_R")].RestLocal.Pos.Length() + source[source.IndexOf("hand_R")].RestLocal.Pos.Length();
        foreach (var side in new[] { "L", "R" })
        {
            var chain = new LimbChain { Upper = source.IndexOf("arm_upper_" + side), Lower = source.IndexOf("arm_lower_" + side), End = source.IndexOf("hand_" + side) };
            var goal = center + forward * reach * (side == "L" ? .75f : .5f) + lateral * (side == "L" ? -4 : 4) - up * reach * .1f;
            EffectorIk.ApplyGoals(new() { pose }, source, chain, new[] { goal }, lateral, soften: 0);
        }
        var input = new Pose(pose).ToWorld(source);
        pose[source.IndexOf("support_goal")] = XForm.ToLocal(input[source.IndexOf("hand_R")], input[source.IndexOf("hand_L")]);
        var targets = new Dictionary<string, string> { ["support_goal"] = "hand_L" };
        var rig = new SmartPortRig(source, target, targets, new[] { "grip_L", "grip_R" });
        var result = rig.Transfer(pose);
        var world = new Pose(result).ToWorld(rig.Target);
        var error = Vector3.Distance(world[rig.Target.IndexOf("hand_L")].Pos, world[rig.Target.IndexOf("support_goal")].Pos);
        Assert.True(error < .01f, $"Support hand missed its goal by {error}");
        foreach (var side in new[] { "L", "R" })
        foreach (var name in new[] { "arm_upper_", "arm_lower_", "hand_" })
        {
            var index = rig.Target.IndexOf(name + side);
            Assert.True(Vector3.Distance(rig.Target[index].RestLocal.Pos, result[index].Pos) < .0001f);
        }
        var unfitted = new SmartPortRig(source, target, targets);
        var before = new Pose(unfitted.Transfer(pose)).ToWorld(unfitted.Target);
        Assert.True(Vector3.Distance(before[unfitted.Target.IndexOf("hand_L")].Pos, before[unfitted.Target.IndexOf("support_goal")].Pos) > .1f);
        Assert.All(rig.Transfer(Enumerable.Repeat(XForm.Identity, source.Count).ToArray(), true), delta =>
        {
            Assert.True(delta.Pos.Length() < .0001f);
            Assert.True(MathQ.AngleBetween(delta.Rot, Quaternion.Identity) < .001f);
        });
    }
}
using System.Numerics;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Skeleton;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;

namespace HumanoidRetargeter.Tests.Target;

public class SmartPortHandSocketTests
{
    [Theory]
    [InlineData(0)]
    [InlineData(2)]
    public void CopiedSocketFitsTheTargetHandNotItsLegProportions(int helperDepth)
    {
        var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
        var names = new[] { "finger_index_0_R", "finger_middle_0_R", "finger_ring_0_R" };
        Vector3 Center(SkeletonModel sk, XForm[] world) => names.Select(n => world[sk.IndexOf(n)].Pos).Aggregate(Vector3.Zero, (a,b) => a+b) / names.Length;
        const string socket = "new_hand_socket";
        var bind = original.RestWorld.ToArray();
        var hand = original.IndexOf("hand_R");
        var definitions = original.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : original[b.ParentIndex].Name, b.RestLocal)).ToList();
        definitions.Add(new(socket, "hand_R", new XForm(bind[hand].Inverse().TransformPoint(Center(original, bind)), Quaternion.Identity)));
        var attachment = socket;
        for (var i = 0; i < helperDepth; i++)
        {
            var child = "nested_socket_" + i;
            definitions.Add(new(child, attachment, XForm.Identity));
            attachment = child;
        }
        var source = SkeletonModel.Create(definitions);
        var target = SkeletonModel.Create(original.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : original[b.ParentIndex].Name,
            new XForm(b.RestLocal.Pos * (b.Name.StartsWith("finger_") ? .5f : 1f), b.RestLocal.Rot))).ToArray());
        var rig = new SmartPortRig(source, target, attachmentBones: new[] { attachment, socket });
        var pose = source.Bones.Select(b => b.RestLocal).ToArray();
        var result = rig.Transfer(pose);
        var world = new Pose(result).ToWorld(rig.Target);
        var unfitted = new SmartPortRig(source, target);
        var before = new Pose(unfitted.Transfer(pose)).ToWorld(unfitted.Target);
        Assert.True(Vector3.Distance(Center(unfitted.Target, before), before[unfitted.Target.IndexOf(socket)].Pos) > .1f);
        Assert.True(Vector3.Distance(Center(rig.Target, world), world[rig.Target.IndexOf(socket)].Pos) < .001f,
            $"Expected {Center(rig.Target, world)}, got {world[rig.Target.IndexOf(socket)].Pos}");
        Assert.True(Vector3.Distance(Center(rig.Target, world), world[rig.Target.IndexOf(attachment)].Pos) < .001f);
        foreach (var bone in target.Bones)
            Assert.Equal(bone.RestLocal, rig.Target[rig.Target.IndexOf(bone.Name)].RestLocal);
        Assert.All(rig.Transfer(Enumerable.Repeat(XForm.Identity, source.Count).ToArray(), true), delta =>
        {
            Assert.True(delta.Pos.Length() < .0001f);
            Assert.True(MathQ.AngleBetween(delta.Rot, Quaternion.Identity) < .001f);
        });
    }

    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void ExistingFittedSocketIsNotReplacedByHandGeometry(bool nestedSource)
    {
        var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
        var definitions = original.Bones.Select(b => new BoneDefinition(b.Name,
            nestedSource && b.Name == "hold_R" ? "weapon_pivot" : b.ParentIndex < 0 ? null : original[b.ParentIndex].Name, b.RestLocal)).ToList();
        if (nestedSource) definitions.Add(new("weapon_pivot", "hand_R", XForm.Identity));
        var source = SkeletonModel.Create(definitions);
        var target = SkeletonModel.Create(original.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : original[b.ParentIndex].Name,
            new XForm(b.RestLocal.Pos * (b.Name.StartsWith("finger_") ? .5f : 1f)
                + (b.Name == "hold_R" ? new Vector3(.2f, .1f, .3f) : Vector3.Zero), b.RestLocal.Rot))).ToArray());
        var fitted = new SmartPortRig(source, target, attachmentBones: new[] { "hold_R" });
        var ordinary = new SmartPortRig(source, target);
        var pose = source.Bones.Select(b => b.RestLocal).ToArray();
        Assert.Equal(ordinary.Transfer(pose), fitted.Transfer(pose));
    }

    /// <summary>A target with a half-size hand whose hold_R sits outside the hand entirely.</summary>
    static (SkeletonModel Source, SkeletonModel Target) OffHandSocket()
    {
        var original = TargetRig.Load(TargetRigGenerator.Generate(File.ReadAllText(SkeletonTests.FixturePath("rig_human_male.json")))).Skeleton;
        var small = SkeletonModel.Create(original.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : original[b.ParentIndex].Name,
            new XForm(b.RestLocal.Pos * (b.Name.StartsWith("finger_") ? .5f : 1f), b.RestLocal.Rot))).ToArray());
        var hand = small.IndexOf("hand_R");
        var reach = small.Bones.Where(b => b.Name.StartsWith("finger_") && b.Name.EndsWith("_R"))
            .Max(b => Vector3.Distance(small.RestWorld[b.Index].Pos, small.RestWorld[hand].Pos));
        // Well past the fingertips: a weapon on it would hang in the air.
        var place = small.RestWorld[hand].Pos + Vector3.UnitZ * reach * 2.2f;
        var target = SkeletonModel.Create(small.Bones.Select(b => new BoneDefinition(b.Name, b.ParentIndex < 0 ? null : small[b.ParentIndex].Name,
            b.Name == "hold_R" ? new XForm(small.RestWorld[hand].Inverse().TransformPoint(place), b.RestLocal.Rot) : b.RestLocal)).ToArray());
        return (original, target);
    }

    static Vector3 Knuckles(SkeletonModel sk, XForm[] world) => new[] { "finger_index_0_R", "finger_middle_0_R", "finger_ring_0_R" }
        .Select(n => world[sk.IndexOf(n)].Pos).Aggregate(Vector3.Zero, (a, b) => a + b) / 3f;

    [Fact]
    public void ExistingSocketOutsideTheHandIsFittedToTheKnuckles()
    {
        var (source, target) = OffHandSocket();
        var rig = new SmartPortRig(source, target, attachmentBones: new[] { "hold_R" });
        var unfitted = new SmartPortRig(source, target);
        var pose = source.Bones.Select(b => b.RestLocal).ToArray();
        var world = new Pose(rig.Transfer(pose)).ToWorld(rig.Target);
        var before = new Pose(unfitted.Transfer(pose)).ToWorld(unfitted.Target);
        var socket = rig.Target.IndexOf("hold_R");
        var sourceGrip = source.RestWorld[source.IndexOf("hold_R")].Pos - Knuckles(source, source.RestWorld.ToArray());
        // Where the source's grip lands on this hand: the knuckle centre plus the source offset.
        var expected = Knuckles(rig.Target, world) + sourceGrip * rig.MotionScale;
        Assert.True(Vector3.Distance(before[socket].Pos, expected) > 1f, "the socket must start off the grip");
        Assert.True(Vector3.Distance(world[socket].Pos, expected) < .001f, $"Expected {expected}, got {world[socket].Pos}");
        // Rotation stays as animated; the bind and every other bone are untouched.
        Assert.True(MathQ.AngleBetween(world[socket].Rot, before[socket].Rot) < .001f);
        foreach (var bone in target.Bones)
            Assert.Equal(bone.RestLocal, rig.Target[rig.Target.IndexOf(bone.Name)].RestLocal);
        var result = rig.Transfer(pose);
        var plain = unfitted.Transfer(pose);
        for (var i = 0; i < result.Length; i++)
            if (i != socket) Assert.Equal(plain[i], result[i]);
        // Additive clips: a zero delta stays zero.
        Assert.All(rig.Transfer(Enumerable.Repeat(XForm.Identity, source.Count).ToArray(), true), delta =>
        {
            Assert.True(delta.Pos.Length() < .0001f);
            Assert.True(MathQ.AngleBetween(delta.Rot, Quaternion.Identity) < .001f);
        });
    }

    [Fact]
    public void FittedExistingSocketFollowsTheGripWhileTheHandAnimates()
    {
        var (source, target) = OffHandSocket();
        var rig = new SmartPortRig(source, target, attachmentBones: new[] { "hold_R" });
        // Bend the wrist: the grip moves with the hand, the socket must stay on it.
        var hand = source.IndexOf("hand_R");
        var pose = source.Bones.Select(b => b.RestLocal).ToArray();
        pose[hand] = new XForm(pose[hand].Pos, Quaternion.Normalize(pose[hand].Rot * Quaternion.CreateFromAxisAngle(Vector3.UnitZ, .6f)));
        var sourceWorld = new Pose(pose).ToWorld(source);
        var sourceGrip = sourceWorld[source.IndexOf("hold_R")].Pos - Knuckles(source, sourceWorld);
        var world = new Pose(rig.Transfer(pose)).ToWorld(rig.Target);
        var expected = Knuckles(rig.Target, world) + sourceGrip * rig.MotionScale;
        Assert.True(Vector3.Distance(world[rig.Target.IndexOf("hold_R")].Pos, expected) < .001f,
            $"Expected {expected}, got {world[rig.Target.IndexOf("hold_R")].Pos}");
    }
}
using System.Numerics;
using HumanoidRetargeter.Core.Dl;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Solve;
using Xunit;

namespace HumanoidRetargeter.Tests.Dl;

/// <summary>
/// Quality gate for the DL solver (Milestone 10): on the spike's fixture clip, the SAME
/// port's role trajectories must agree with the verified geometric solver's — the spike
/// measured a mean role cosine of 0.937 against the geometric dump; the gate sits below it
/// (mean ≥ 0.85 across body roles, feet/head ≥ 0.9 individually, hands are the known-weak
/// spot of the pretrained checkpoint).
/// </summary>
public class DlSolverTests
{
    private static readonly Lazy<byte[]> WeightBytes = new(() => File.ReadAllBytes(
        DlFixtures.RepoFile("Assets", "data", "humanoid_retargeter", "dl", "same_v1.weights")));

    private static readonly string[] BodyRoleBones =
    {
        "head", "hand_L", "hand_R", "ankle_L", "ankle_R",
        "ball_L", "ball_R", "arm_lower_L", "arm_lower_R",
        "leg_lower_L", "leg_lower_R",
    };

    private static readonly string[] StrictBones = { "head", "ankle_L", "ankle_R", "ball_L", "ball_R" };

    private static (Clip Dl, Clip Geo, SourceScene Scene) Solve()
    {
        var scene = DlFixtures.FixtureBvh();
        var map = ProfileDetector.Detect(scene.Skeleton) is { } detected
            ? detected.Result
            : throw new InvalidOperationException("fixture should detect as mixamo");
        var rig = DlFixtures.SboxRig.Value;

        var dl = new DlSolver(WeightBytes.Value).Solve(scene, map, rig, new SolveOptions());
        var geo = new GeometricSolver().Solve(scene, map, rig, new SolveOptions());
        return (dl, geo, scene);
    }

    /// <summary>One shared solve pair for this class and the deriver tests.</summary>
    internal static readonly Lazy<(Clip Dl, Clip Geo, SourceScene Scene)> Solved = new(Solve);

    [Fact]
    public void SolveIsFiniteAndFrameAligned()
    {
        var (dl, _, scene) = Solved.Value;
        var clip = scene.Clips[0];

        Assert.Equal(clip.FrameCount, dl.FrameCount);
        Assert.Equal(clip.Fps, dl.Fps);
        foreach (var frame in dl.Frames)
        {
            Assert.Equal(DlFixtures.SboxRig.Value.Skeleton.Count, frame.Length);
            foreach (var xf in frame)
            {
                Assert.True(float.IsFinite(xf.Pos.X) && float.IsFinite(xf.Pos.Y) && float.IsFinite(xf.Pos.Z));
                Assert.True(float.IsFinite(xf.Rot.X) && float.IsFinite(xf.Rot.Y)
                    && float.IsFinite(xf.Rot.Z) && float.IsFinite(xf.Rot.W));
            }
        }
    }

    [Fact]
    public void SolveIsDeterministic()
    {
        var (dl, _, scene) = Solved.Value;
        var map = ProfileDetector.Detect(scene.Skeleton)!.Value.Result;
        var again = new DlSolver(WeightBytes.Value).Solve(
            scene, map, DlFixtures.SboxRig.Value, new SolveOptions());

        for (var f = 0; f < dl.FrameCount; f++)
        {
            for (var b = 0; b < dl.Frames[f].Length; b++)
                Assert.Equal(dl.Frames[f][b], again.Frames[f][b]);
        }
    }

    [Fact]
    public void QualityGate_RoleTrajectoriesAgreeWithGeometricSolver()
    {
        var (dl, geo, _) = Solved.Value;
        var rig = DlFixtures.SboxRig.Value;
        var skeleton = rig.Skeleton;
        var frames = Math.Min(dl.FrameCount, geo.FrameCount);

        var pelvis = skeleton.IndexOf("pelvis");
        var head = skeleton.IndexOf("head");
        Assert.True(pelvis >= 0 && head >= 0);

        // World positions per frame for both solves.
        var dlPos = WorldPositions(skeleton, dl, frames);
        var geoPos = WorldPositions(skeleton, geo, frames);

        // Per-side scale proxy (mean pelvis→head distance) for scale-invariant comparison.
        var dlScale = MeanDistance(dlPos, pelvis, head, frames);
        var geoScale = MeanDistance(geoPos, pelvis, head, frames);
        Assert.True(dlScale > 1f && geoScale > 1f);

        var report = new System.Text.StringBuilder();
        var cosines = new Dictionary<string, float>();
        foreach (var bone in BodyRoleBones)
        {
            var index = skeleton.IndexOf(bone);
            Assert.True(index >= 0, $"rig has no bone '{bone}'");
            var sum = 0f;
            for (var f = 0; f < frames; f++)
            {
                var a = (dlPos[f][index] - dlPos[f][pelvis]) / dlScale;
                var b = (geoPos[f][index] - geoPos[f][pelvis]) / geoScale;
                var denom = a.Length() * b.Length() + 1e-8f;
                sum += Vector3.Dot(a, b) / denom;
            }
            var cos = sum / frames;
            cosines[bone] = cos;
            report.AppendLine($"{bone}: {cos:0.000}");
        }

        var mean = cosines.Values.Average();
        Assert.True(mean >= 0.85f,
            $"mean role-trajectory cosine {mean:0.000} < 0.85 (spike measured 0.937)\n{report}");
        foreach (var bone in StrictBones)
        {
            Assert.True(cosines[bone] >= 0.9f,
                $"{bone} trajectory cosine {cosines[bone]:0.000} < 0.90\n{report}");
        }
    }

    private static Vector3[][] WorldPositions(
        HumanoidRetargeter.Core.Skeleton.Skeleton skeleton, Clip clip, int frames)
    {
        var result = new Vector3[frames][];
        for (var f = 0; f < frames; f++)
        {
            var world = new Pose(clip.Frames[f]).ToWorld(skeleton);
            result[f] = new Vector3[skeleton.Count];
            for (var b = 0; b < skeleton.Count; b++)
                result[f][b] = world[b].Pos;
        }
        return result;
    }

    private static float MeanDistance(Vector3[][] positions, int a, int b, int frames)
    {
        var sum = 0f;
        for (var f = 0; f < frames; f++)
            sum += Vector3.Distance(positions[f][a], positions[f][b]);
        return sum / frames;
    }
}
using System.Globalization;
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;

namespace HumanoidRetargeter.Tests.Formats;

/// <summary>
/// The BVH importer's calibration rest-frame trim (user report: 47_01.bvh — "starts either
/// ends or starts at t-pose … when i move the preview slider to 0 then there's a t-pose").
/// Mocap conversions (CMU asf/amc exports among them) prepend a skeleton-calibration
/// segment: the rest pose itself, hard-cut — or blend-ramped over 2–3 frames
/// (makehuman-retarget exports) — into the real motion. Measured on the repro corpus:
/// calibration frames/ramps sit ≤ 24° (max joint rotation) from the identity-rotation bind
/// with a 25–177° discontinuity into the motion; every real clip edge sits ≥ 80° from the
/// bind with ≤ 8° edge continuity. The importer drops such a segment (≤ 4 frames) per clip
/// end, and ONLY when its frames are rest-like AND the clip beyond is not AND the segment's
/// exit discontinuity is large both absolutely and against the clip's median inter-frame
/// delta — a clip legitimately idling near rest must never lose its first frame.
/// </summary>
public class BvhCalibrationTrimTests
{
    private const float Rad2Deg = 180f / MathF.PI;

    // ---------------------------------------------------------------- synthetic fixtures

    /// <summary>
    /// Two-joint rig (30 native fps = the import grid, so native frames map 1:1 onto clip
    /// frames) whose Head rotation per native frame is caller-chosen. Offsets keep the rest
    /// height above the meters heuristic threshold.
    /// </summary>
    private static byte[] BvhWithHeadAngles(params float[] headZDegPerFrame)
    {
        var sb = new StringBuilder();
        sb.AppendLine("HIERARCHY");
        sb.AppendLine("ROOT Hips");
        sb.AppendLine("{");
        sb.AppendLine("    OFFSET 0 0 0");
        sb.AppendLine("    CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation");
        sb.AppendLine("    JOINT Head");
        sb.AppendLine("    {");
        sb.AppendLine("        OFFSET 0 70 0");
        sb.AppendLine("        CHANNELS 3 Zrotation Yrotation Xrotation");
        sb.AppendLine("        End Site");
        sb.AppendLine("        {");
        sb.AppendLine("            OFFSET 0 10 0");
        sb.AppendLine("        }");
        sb.AppendLine("    }");
        sb.AppendLine("}");
        sb.AppendLine("MOTION");
        sb.AppendLine($"Frames: {headZDegPerFrame.Length}");
        sb.AppendLine("Frame Time: 0.0333333");
        foreach (var z in headZDegPerFrame)
        {
            sb.AppendLine(string.Create(
                CultureInfo.InvariantCulture, $"0 90 0 0 0 0 {z} 0 0"));
        }
        return Encoding.ASCII.GetBytes(sb.ToString());
    }

    private static float HeadAngleDeg(SourceScene scene, int frame)
    {
        var head = scene.Skeleton.IndexOf("Head");
        return MathQ.AngleBetween(
            Quaternion.Identity, scene.Clips[0].Frames[frame][head].Rot) * Rad2Deg;
    }

    /// <summary>A held pose at 70° with ~1°/frame drift; interior deltas ≈ 1°.</summary>
    private static float[] HeldPose(int frames, float startDeg = 70f)
    {
        var values = new float[frames];
        for (var f = 0; f < frames; f++)
            values[f] = startDeg + f;
        return values;
    }

    // ---------------------------------------------------------------- trims

    [Fact]
    public void LeadingCalibrationRestFrame_IsTrimmed()
    {
        var angles = new float[10];
        HeldPose(9).CopyTo(angles, 1); // frame 0 stays 0 = the rest pose; 1..9 held at ~70°
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(9, clip.FrameCount); // 10 native − 1 calibration frame
        Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f); // clip now opens on the real pose
    }

    [Fact]
    public void TrailingCalibrationRestFrame_IsTrimmed()
    {
        var angles = new float[10];
        HeldPose(9).CopyTo(angles, 0); // frames 0..8 held at ~70°; frame 9 back to rest
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(9, clip.FrameCount);
        Assert.InRange(HeadAngleDeg(scene, clip.FrameCount - 1), 65f, 85f); // ends on the pose
    }

    [Fact]
    public void CalibrationFramesAtBothEnds_AreBothTrimmed()
    {
        var angles = new float[11];
        HeldPose(9).CopyTo(angles, 1); // frame 0 AND frame 10 at rest, 1..9 held
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(9, clip.FrameCount);
        Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f);
        Assert.InRange(HeadAngleDeg(scene, clip.FrameCount - 1), 65f, 85f);
    }

    /// <summary>A 2-frame rest→motion blend ramp (measured on a makehuman-retarget export:
    /// rest 10°, ramp 24°, then motion ≥ 49°) is a calibration segment too.</summary>
    [Fact]
    public void CalibrationBlendRamp_IsTrimmed()
    {
        var angles = new float[12];
        angles[1] = 12f; // frame 0 at rest, frame 1 mid-blend — both rest-like
        HeldPose(10).CopyTo(angles, 2); // frames 2..11 held at ~70°
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(10, clip.FrameCount); // both ramp frames trimmed
        Assert.InRange(HeadAngleDeg(scene, 0), 65f, 75f);
    }

    // ---------------------------------------------------------------- must-NOT-trim guards

    /// <summary>An idle that legitimately STARTS at the rest pose ramps continuously into
    /// motion — rest-similarity alone must never cost it its first frame.</summary>
    [Fact]
    public void IdleStartingAtRest_KeepsItsFirstFrame()
    {
        var angles = new float[20];
        for (var f = 0; f < angles.Length; f++)
            angles[f] = f * 3f; // 3°/frame ramp from exact rest
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(20, clip.FrameCount);
        Assert.InRange(HeadAngleDeg(scene, 0), 0f, 0.01f);
    }

    /// <summary>A clip resting throughout (e.g. a bind-pose export) has nothing to trim:
    /// the neighbor frame is rest-like too.</summary>
    [Fact]
    public void AllRestClip_IsNotTrimmed()
    {
        var scene = BvhImporter.Import(BvhWithHeadAngles(new float[8]));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(8, clip.FrameCount);
    }

    /// <summary>A hard cut between two NON-rest poses (action edit) is not a calibration
    /// frame — the edge must be rest-like for the trim to engage.</summary>
    [Fact]
    public void HardCutBetweenRealPoses_IsNotTrimmed()
    {
        var angles = HeldPose(10, startDeg: 120f);
        for (var f = 1; f < angles.Length; f++)
            angles[f] = 60f + f; // frame 0 at 120°, cut to ~60° held
        var scene = BvhImporter.Import(BvhWithHeadAngles(angles));

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(10, clip.FrameCount);
    }

    /// <summary>Two-frame clips are never trimmed (no interior to judge typical motion by).</summary>
    [Fact]
    public void TwoFrameClip_IsNotTrimmed()
    {
        var scene = BvhImporter.Import(BvhWithHeadAngles(0f, 70f));
        Assert.Equal(2, Assert.Single(scene.Clips).FrameCount);
    }

    // ---------------------------------------------------------------- real repro file

    /// <summary>47_01.bvh (CMU mocap, the user repro): native frame 0 is a calibration
    /// T-pose 21° from the bind with an 89.6° cut into frame 1 (measured); the imported
    /// clip must open on the real motion instead. Silently green when the local corpus
    /// file is absent.</summary>
    [Fact]
    public void Cmu4701_CalibrationTPose_DoesNotReachTheClip()
    {
        var path = TestUtil.RepoFile("dev", "corpus", "todo", "47_01.bvh");
        if (!File.Exists(path))
            return;

        var scene = BvhImporter.Import(File.ReadAllBytes(path));
        var clip = Assert.Single(scene.Clips);

        // 1320 native frames at 120 fps → 1319 after the trim → 330 on the 30 fps grid.
        Assert.Equal(330, clip.FrameCount);

        // Frame 0 is now a real pose, far from the identity-rotation bind …
        float MaxJointAngleDeg(int frame)
        {
            var max = 0f;
            foreach (var x in clip.Frames[frame])
                max = MathF.Max(max, MathQ.AngleBetween(Quaternion.Identity, x.Rot) * Rad2Deg);
            return max;
        }
        Assert.True(MaxJointAngleDeg(0) > 60f,
            $"clip still opens rest-like ({MaxJointAngleDeg(0):F1}° from bind) — calibration frame not trimmed");

        // … and continuous into its neighbor (the old frame 0 cut 89.6° deep).
        var maxDelta = 0f;
        for (var i = 0; i < scene.Skeleton.Count; i++)
        {
            maxDelta = MathF.Max(maxDelta, MathQ.AngleBetween(
                clip.Frames[0][i].Rot, clip.Frames[1][i].Rot) * Rad2Deg);
        }
        Assert.True(maxDelta < 30f, $"frame 0→1 still discontinuous ({maxDelta:F1}°)");
    }
}
using System.Numerics;
using System.Text.Json;
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;

namespace HumanoidRetargeter.Tests.Formats;

// =============================================================================================
// UNIT / SPACE RESOLUTION (determined empirically from research/extract_anim.py + the JSONs):
//
// The ground-truth JSONs (fixtures/anim_truth/*.json) were produced by Blender's FBX importer
// (automatic_bone_orientation=False) and mix TWO spaces:
//
//  1. `bones[]` rest data comes from `armature.data.bones[*].matrix_local`, which is in
//     ARMATURE space. Blender's FBX importer does not bake the axis/unit conversion into the
//     armature data — it stores it on the object (rotation_euler.x = +90 deg, scale = 0.01 *
//     UnitScaleFactor). Armature space therefore equals the FBX file's native space and units
//     (Y-up, centimeters for these fixtures, UnitScaleFactor = 1). Rest values compare to our
//     imported skeleton DIRECTLY, no conversion.
//
//  2. `frames[]` come from `obj.matrix_world @ pose_bone.matrix`, which is in BLENDER WORLD
//     space (Z-up, meters, the 0.01 object scale applied). To compare a truth world transform
//     against our FBX-space result: p_fbx = Rx(-90deg) * wp / object_scale,
//     q_fbx = Rx(-90deg) * wr (column-vector quaternion composition, parent on the left).
//
// Frame alignment: Blender places the first FBX key at action frame_start, and the truth was
// sampled on an fps grid from there; our importer samples from the earliest curve key on the
// same grid, so truth frame i == our clip frame i.
//
// Note: "Lead Jab.fbx" is NOT a Mixamo rig — it is a citizen-skeleton clip (84 s&box bones,
// pelvis/spine_0/...), which conveniently exercises a second rig family.
//
// TOLERANCES: the task gates were 0.5% of skeleton height / 1.0 deg (rest) and 0.7% / 1.0 deg
// (animation). Observed residuals are float-precision noise — max 0.0002 cm / 0.0004 deg over
// every bone of both rigs at all sampled frames — so the tolerances below are tightened to
// 0.25 cm / 0.25 deg absolute (~1000x above the observed residual, ~4x tighter than the gate).
// =============================================================================================
public class FbxImporterTests
{
    private const float KDegPerRad = 180f / MathF.PI;
    private const float PosTolCm = 0.25f;
    private const float RotTolDeg = 0.25f;

    private static byte[] Fixture(string name)
        => File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "fbx", name));

    private static SourceScene ImportFixture(string name)
        => FbxImporter.Import(Fixture(name), new FbxImportOptions());

    // ---- ground truth -----------------------------------------------------------------

    private sealed record TruthBone(
        string Name, string? Parent, Vector3 RestWorldPos, Quaternion RestWorldRot);

    private sealed record TruthFrameBone(Vector3 WorldPos, Quaternion WorldRot);

    private sealed class Truth
    {
        public required float SampleFps;
        public required int NSamples;
        public required float ObjectScale;
        public required List<TruthBone> Bones;
        public required List<TruthFrameBone[]> Frames;

        /// <summary>Rest skeleton height (max-min world Y, FBX Y-up) in native units (cm).</summary>
        public float Height()
        {
            float min = float.MaxValue, max = float.MinValue;
            foreach (var b in Bones)
            {
                min = MathF.Min(min, b.RestWorldPos.Y);
                max = MathF.Max(max, b.RestWorldPos.Y);
            }
            return max - min;
        }
    }

    private static Truth LoadTruth(string name)
    {
        var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "anim_truth", name);
        using var doc = JsonDocument.Parse(File.ReadAllBytes(path));
        var r = doc.RootElement;

        static Vector3 V3(JsonElement e) => new(
            e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle());
        static Quaternion Q(JsonElement e) => new(
            e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle(), e[3].GetSingle());

        var bones = new List<TruthBone>();
        foreach (var b in r.GetProperty("bones").EnumerateArray())
        {
            bones.Add(new TruthBone(
                b.GetProperty("name").GetString()!,
                b.GetProperty("parent").ValueKind == JsonValueKind.Null
                    ? null : b.GetProperty("parent").GetString(),
                V3(b.GetProperty("rest_world_pos")),
                Q(b.GetProperty("rest_world_rot_xyzw"))));
        }

        var frames = new List<TruthFrameBone[]>();
        foreach (var f in r.GetProperty("frames").EnumerateArray())
        {
            var fb = new TruthFrameBone[bones.Count];
            int i = 0;
            foreach (var e in f.EnumerateArray())
                fb[i++] = new TruthFrameBone(V3(e.GetProperty("wp")), Q(e.GetProperty("wr")));
            frames.Add(fb);
        }

        return new Truth
        {
            SampleFps = r.GetProperty("sample_fps").GetSingle(),
            NSamples = r.GetProperty("n_samples").GetInt32(),
            ObjectScale = r.GetProperty("object_scale")[0].GetSingle(),
            Bones = bones,
            Frames = frames,
        };
    }

    // Blender world -> FBX native space: undo object rotation (X +90 deg) and object scale.
    private static readonly Quaternion BlenderToFbx =
        Quaternion.CreateFromAxisAngle(Vector3.UnitX, -MathF.PI / 2f);

    private static Vector3 FramePosToFbx(Vector3 wp, float objectScale)
        => Vector3.Transform(wp, BlenderToFbx) / objectScale;

    private static Quaternion FrameRotToFbx(Quaternion wr)
        => BlenderToFbx * wr;

    // ---- comparison helper -------------------------------------------------------------

    /// <summary>
    /// Compares world transforms of all common bones; fails with a per-bone diff dump of the
    /// worst offenders. Returns the number of common bones compared.
    /// </summary>
    private static int AssertWorldsMatch(
        Skel skeleton,
        IReadOnlyList<XForm> worlds,
        IEnumerable<(string Name, Vector3 Pos, Quaternion Rot)> truthWorlds,
        float posTolCm,
        float rotTolDeg,
        string context)
    {
        var failures = new List<(string Bone, float PosErr, float RotErrDeg)>();
        int common = 0;
        float maxPos = 0f, maxRot = 0f;

        foreach (var (name, tPos, tRot) in truthWorlds)
        {
            int idx = skeleton.IndexOf(name);
            if (idx < 0)
                continue;
            common++;

            float posErr = Vector3.Distance(worlds[idx].Pos, tPos);
            float rotErr = MathQ.AngleBetween(worlds[idx].Rot, tRot) * KDegPerRad;
            maxPos = MathF.Max(maxPos, posErr);
            maxRot = MathF.Max(maxRot, rotErr);

            if (posErr > posTolCm || rotErr > rotTolDeg)
                failures.Add((name, posErr, rotErr));
        }

        if (failures.Count > 0)
        {
            var worst = failures
                .OrderByDescending(f => MathF.Max(f.PosErr / posTolCm, f.RotErrDeg / rotTolDeg))
                .Take(12)
                .Select(f => $"  {f.Bone}: posErr={f.PosErr:F3} cm (tol {posTolCm:F3}), rotErr={f.RotErrDeg:F3} deg (tol {rotTolDeg:F2})");
            Assert.Fail(
                $"{context}: {failures.Count}/{common} bones out of tolerance " +
                $"(max posErr={maxPos:F3} cm, max rotErr={maxRot:F3} deg):\n" +
                string.Join("\n", worst));
        }

        return common;
    }

    // ---- 1. Zombie Crawl: skeleton + clip basics ----------------------------------------

    [Fact]
    public void ZombieCrawl_SkeletonAndClipBasics()
    {
        var scene = ImportFixture("Zombie Crawl.fbx");

        // Spot list of 10 well-known Mixamo bones (this export uses the "mixamorig1:" namespace).
        string[] expected =
        {
            "mixamorig1:Hips", "mixamorig1:Spine", "mixamorig1:Spine1", "mixamorig1:Spine2",
            "mixamorig1:Neck", "mixamorig1:Head", "mixamorig1:LeftUpLeg",
            "mixamorig1:LeftHandIndex1", "mixamorig1:RightFoot", "mixamorig1:LeftShoulder",
        };
        foreach (var name in expected)
            Assert.True(scene.Skeleton.IndexOf(name) >= 0, $"missing bone {name}");

        Assert.Single(scene.Clips);
        var clip = scene.Clips[0];
        Assert.Equal(30f, clip.Fps);
        Assert.InRange(clip.FrameCount, 154, 156);

        Assert.Equal(1f, scene.UnitScaleCm, 3);
        Assert.Equal(1, scene.UpAxis);   // Y-up source, axes recorded but NOT converted
        Assert.Equal(1, scene.UpAxisSign);
    }

    // ---- 2/3. Zombie Crawl vs Blender ground truth --------------------------------------

    [Fact]
    public void ZombieCrawl_RestPose_MatchesBlender()
    {
        var scene = ImportFixture("Zombie Crawl.fbx");
        var truth = LoadTruth("zombie_crawl.json");

        int common = AssertWorldsMatch(
            scene.Skeleton,
            scene.Skeleton.RestWorld,
            truth.Bones.Select(b => (b.Name, b.RestWorldPos, b.RestWorldRot)),
            PosTolCm, RotTolDeg, "rest pose");

        Assert.True(common >= 60, $"only {common} common bones (truth has {truth.Bones.Count})");
    }

    [Fact]
    public void ZombieCrawl_Animation_MatchesBlender()
    {
        var scene = ImportFixture("Zombie Crawl.fbx");
        var truth = LoadTruth("zombie_crawl.json");
        var clip = scene.Clips[0];

        Assert.InRange(clip.FrameCount, truth.NSamples - 1, truth.NSamples + 1);

        foreach (int f in new[] { 0, 38, 77, 116, 154 })
        {
            var worlds = new Pose(clip.Frames[Math.Min(f, clip.FrameCount - 1)]).ToWorld(scene.Skeleton);
            var truthFrame = truth.Frames[f];
            int common = AssertWorldsMatch(
                scene.Skeleton,
                worlds,
                truth.Bones.Select((b, i) => (
                    b.Name,
                    FramePosToFbx(truthFrame[i].WorldPos, truth.ObjectScale),
                    FrameRotToFbx(truthFrame[i].WorldRot))),
                PosTolCm, RotTolDeg, $"frame {f}");
            Assert.True(common >= 60, $"frame {f}: only {common} common bones");
        }
    }

    // ---- 4. Lead Jab (citizen-skeleton clip) vs Blender ground truth ---------------------

    [Fact]
    public void LeadJab_RestPose_MatchesBlender()
    {
        var scene = ImportFixture("Lead Jab.fbx");
        var truth = LoadTruth("lead_jab.json");

        int common = AssertWorldsMatch(
            scene.Skeleton,
            scene.Skeleton.RestWorld,
            truth.Bones.Select(b => (b.Name, b.RestWorldPos, b.RestWorldRot)),
            PosTolCm, RotTolDeg, "rest pose");

        Assert.True(common >= 75, $"only {common} common bones (truth has {truth.Bones.Count})");
    }

    [Fact]
    public void LeadJab_Animation_MatchesBlender()
    {
        var scene = ImportFixture("Lead Jab.fbx");
        var truth = LoadTruth("lead_jab.json");
        Assert.NotEmpty(scene.Clips);
        var clip = scene.Clips[0];

        Assert.InRange(clip.FrameCount, truth.NSamples - 1, truth.NSamples + 1);

        foreach (int f in new[] { 0, 27, 54 })
        {
            var worlds = new Pose(clip.Frames[Math.Min(f, clip.FrameCount - 1)]).ToWorld(scene.Skeleton);
            var truthFrame = truth.Frames[f];
            int common = AssertWorldsMatch(
                scene.Skeleton,
                worlds,
                truth.Bones.Select((b, i) => (
                    b.Name,
                    FramePosToFbx(truthFrame[i].WorldPos, truth.ObjectScale),
                    FrameRotToFbx(truthFrame[i].WorldRot))),
                PosTolCm, RotTolDeg, $"frame {f}");
            Assert.True(common >= 75, $"frame {f}: only {common} common bones");
        }
    }

    // ---- 5. Edge fixtures: parse without throwing, non-empty skeleton --------------------

    [Theory]
    [InlineData("prod_fix7400.fbx")]
    [InlineData("mut_strip-prerotation.fbx")]
    [InlineData("gen_inherit.fbx")]
    public void EdgeFixture_ImportsWithNonEmptySkeleton(string name)
    {
        var scene = ImportFixture(name);
        Assert.True(scene.Skeleton.Count > 0, "skeleton is empty");
    }

    // ---- 6. ActorCore -------------------------------------------------------------------

    [Fact]
    public void ActorCore_CatwalkLoop_ImportsFiniteSkeletonAndClip()
    {
        var scene = ImportFixture("catwalk-loop-378982.fbx");

        Assert.True(scene.Skeleton.IndexOf("CC_Base_Hip") >= 0, "missing CC_Base_Hip");
        Assert.True(scene.Skeleton.IndexOf("CC_Base_L_Thigh") >= 0, "missing CC_Base_L_Thigh");

        Assert.NotEmpty(scene.Clips);
        Assert.True(scene.Clips[0].FrameCount > 1, "clip has <= 1 frame");

        static void AssertFinite(XForm x, string what)
        {
            Assert.True(
                float.IsFinite(x.Pos.X) && float.IsFinite(x.Pos.Y) && float.IsFinite(x.Pos.Z) &&
                float.IsFinite(x.Rot.X) && float.IsFinite(x.Rot.Y) && float.IsFinite(x.Rot.Z) &&
                float.IsFinite(x.Rot.W),
                $"non-finite transform in {what}: {x}");
        }

        for (int i = 0; i < scene.Skeleton.Count; i++)
        {
            AssertFinite(scene.Skeleton[i].RestLocal, $"rest local {scene.Skeleton[i].Name}");
            AssertFinite(scene.Skeleton.RestWorld[i], $"rest world {scene.Skeleton[i].Name}");
        }

        foreach (var clip in scene.Clips)
            for (int f = 0; f < clip.FrameCount; f++)
                foreach (var x in clip.Frames[f])
                    AssertFinite(x, $"clip {clip.Name} frame {f}");
    }

    // ---- 7. UE Mannequin: static translation channels override bind rest geometry --------
    //
    // The UE animation FBX files carry rest data (BindPose/Lcl defaults) whose foot->ball
    // local offset disagrees with the clip's STATIC ball translation channels by ~7.9 deg on
    // both feet, while every other bone pair agrees to 0.000 deg (dev/verification/RESULTS.md
    // root-cause analysis). The animation plays the static channel value, so the rest must use
    // it — otherwise the canonical foot->ball direction is constantly ~7.9 deg off in output.

    [Theory]
    [InlineData("foot_l", "ball_l")]
    [InlineData("foot_r", "ball_r")]
    public void UeMannequin_StaticTranslationChannel_OverridesBindRest(string foot, string ball)
    {
        var scene = ImportFixture("ThirdPersonWalk.FBX");
        var skel = scene.Skeleton;

        int ballIdx = skel.IndexOf(ball);
        Assert.True(ballIdx >= 0, $"missing bone {ball}");
        Assert.Equal(skel.IndexOf(foot), skel[ballIdx].ParentIndex);

        Assert.NotEmpty(scene.Clips);
        var frame0 = scene.Clips[0].Frames[0];

        // The ball's translation channels are static, so its sampled frame-0 local translation
        // IS the channel value: the rest local must match it in direction (foot->ball, both
        // vectors live in the foot frame; ~7.94 deg apart before the fix) and in value.
        var restPos = skel[ballIdx].RestLocal.Pos;
        var animPos = frame0[ballIdx].Pos;

        float angleDeg = MathF.Acos(Math.Clamp(
            Vector3.Dot(Vector3.Normalize(restPos), Vector3.Normalize(animPos)),
            -1f, 1f)) * KDegPerRad;
        Assert.True(angleDeg < 0.5f,
            $"rest foot->ball direction is {angleDeg:F3} deg away from the animation's static geometry");
        Assert.True(Vector3.Distance(restPos, animPos) < 0.01f,
            $"rest local translation {restPos} != static channel value {animPos}");
    }

    [Fact]
    public void UeMannequin_SingleStack_ProducesNoImportNotes()
    {
        // One animation stack: there is nothing to disagree with, so the multi-stack
        // static-translation detection must stay silent.
        var scene = ImportFixture("ThirdPersonWalk.FBX");
        Assert.Empty(scene.Notes);
    }

    [Theory]
    [InlineData("foot_l", "ball_l")]
    [InlineData("foot_r", "ball_r")]
    public void UeMannequin_StaticOverride_EqualsFrame0SampledLocalTranslation(string foot, string ball)
    {
        // The invariant the override formula must satisfy regardless of ancestor scale:
        // a bone whose translation channels are static plays exactly one local translation,
        // so the rest local must equal the frame-0 sampled local translation.
        var scene = ImportFixture("ThirdPersonWalk.FBX");
        var skel = scene.Skeleton;

        int ballIdx = skel.IndexOf(ball);
        Assert.True(ballIdx >= 0, $"missing bone {ball}");
        Assert.Equal(skel.IndexOf(foot), skel[ballIdx].ParentIndex);

        var restPos = skel[ballIdx].RestLocal.Pos;
        var frame0Pos = scene.Clips[0].Frames[0][ballIdx].Pos;
        Assert.True(Vector3.Distance(restPos, frame0Pos) < 0.005f,
            $"rest local {restPos} != frame-0 sampled local {frame0Pos}");
    }

    [Fact]
    public void UeMannequin_VaryingTranslationChannels_DoNotTouchRest()
    {
        var scene = ImportFixture("ThirdPersonWalk.FBX");
        var skel = scene.Skeleton;
        int pelvis = skel.IndexOf("pelvis");
        Assert.True(pelvis >= 0, "missing pelvis");

        // The pelvis carries the walk trajectory (VARYING translation channels): its rest must
        // stay bind-derived — pinned at the UE Mannequin bind height (Z-up, cm) — and must NOT
        // equal the mid-stride frame-0 trajectory sample.
        Assert.InRange(skel.RestWorld[pelvis].Pos.Z, 96.7f, 96.8f);
        var frame0 = scene.Clips[0].Frames[0];
        Assert.NotEqual(frame0[pelvis].Pos, skel[pelvis].RestLocal.Pos);
    }
}
using HumanoidRetargeter.Core.Formats;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;

namespace HumanoidRetargeter.Tests.Formats;

/// <summary>
/// Unity <c>.fbx.meta</c> sidecar support: the <see cref="UnityMeta.ParseClipAnimations"/>
/// line parser over the clipAnimations YAML subset, and the <see cref="UnityMeta.Slice"/>
/// native-frame → resampled-grid range mapping. The fixture
/// (fixtures/unity/synthetic_stance.fbx.meta) is SYNTHETIC, replicating the structure Unity
/// 2020+ writes (ModelImporter serializedVersion 20300, clipAnimations serializedVersion 16).
/// </summary>
public class UnityMetaTests
{
    private static string FixturePath(params string[] parts)
        => Path.Combine(new[] { AppContext.BaseDirectory, "fixtures" }.Concat(parts).ToArray());

    private static string FixtureText()
        => File.ReadAllText(FixturePath("unity", "synthetic_stance.fbx.meta"));

    // ---------------------------------------------------------------- parser

    [Fact]
    public void Parse_SyntheticUnityMeta_YieldsBothDefinitions()
    {
        var defs = UnityMeta.ParseClipAnimations(FixtureText());

        Assert.Equal(2, defs.Count);

        Assert.Equal("Idle", defs[0].Name);
        Assert.Equal("root|Animation", defs[0].TakeName);
        Assert.Equal(0f, defs[0].FirstFrame);
        Assert.Equal(20f, defs[0].LastFrame);
        Assert.True(defs[0].Loop); // loopTime: 1 (NOT the unrelated "loop: 0" field)

        // Quoted name unwrapped; fractional frames parsed.
        Assert.Equal("Idle Short", defs[1].Name);
        Assert.Equal("root|Animation", defs[1].TakeName);
        Assert.Equal(5.5f, defs[1].FirstFrame);
        Assert.Equal(6.5f, defs[1].LastFrame);
        Assert.False(defs[1].Loop); // loopTime: 0
    }

    [Fact]
    public void Parse_NestedEventListEntries_DoNotBleedIntoDefinitions()
    {
        // The first item carries an events list whose entries are themselves "- key: value"
        // lines at deeper indentation — they must neither start new definitions nor
        // overwrite the item's own fields.
        var defs = UnityMeta.ParseClipAnimations(FixtureText());
        Assert.Equal(2, defs.Count);
        Assert.Equal("Idle", defs[0].Name);
    }

    [Fact]
    public void Parse_InlineEmptyList_YieldsNoDefinitions()
        => Assert.Empty(UnityMeta.ParseClipAnimations(
            "ModelImporter:\n  animations:\n    clipAnimations: []\n    isReadable: 0\n"));

    [Fact]
    public void Parse_NoClipAnimationsKey_YieldsNoDefinitions()
        => Assert.Empty(UnityMeta.ParseClipAnimations(
            "fileFormatVersion: 2\nguid: abc\nTextureImporter:\n  mipmaps: 1\n"));

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("complete garbage \0 {{{{ ---")]
    [InlineData("clipAnimations:")]
    [InlineData("clipAnimations:\n- name: x")] // dash at indent 0 < key indent is malformed → tolerated
    public void Parse_DegenerateInput_NeverThrows(string? text)
    {
        var defs = UnityMeta.ParseClipAnimations(text);
        Assert.NotNull(defs);
    }

    [Fact]
    public void Parse_MissingFields_KeepDefaults()
    {
        var defs = UnityMeta.ParseClipAnimations(
            "  clipAnimations:\n  - name: OnlyName\n");

        var def = Assert.Single(defs);
        Assert.Equal("OnlyName", def.Name);
        Assert.Equal("", def.TakeName);
        Assert.Equal(0f, def.FirstFrame);
        Assert.True(float.IsPositiveInfinity(def.LastFrame)); // "to the end of the take"
        Assert.False(def.Loop);
    }

    [Fact]
    public void Parse_ListEndsAtSiblingKey()
    {
        var defs = UnityMeta.ParseClipAnimations(
            "  clipAnimations:\n  - name: A\n    lastFrame: 3\n  isReadable: 0\n  - name: NotAClip\n");
        var def = Assert.Single(defs);
        Assert.Equal("A", def.Name);
    }

    [Fact]
    public void Parse_NoiseOnlyItems_AreDropped()
    {
        var defs = UnityMeta.ParseClipAnimations(
            "  clipAnimations:\n  - serializedVersion: 16\n    wrapMode: 0\n  - name: Real\n    lastFrame: 2\n");
        var def = Assert.Single(defs);
        Assert.Equal("Real", def.Name);
    }

    // ---------------------------------------------------------------- slicing math

    /// <summary>Clip whose frame f carries Pos.X = f on its single bone — slice contents
    /// are then directly checkable.</summary>
    private static Clip IndexClip(int frameCount, float fps, float nativeFps)
    {
        var frames = new List<XForm[]>(frameCount);
        for (var f = 0; f < frameCount; f++)
            frames.Add(new[] { new XForm(new System.Numerics.Vector3(f, 0, 0), System.Numerics.Quaternion.Identity) });
        return new Clip("take", fps, looping: false, frames, nativeFps);
    }

    private static ExternalClipDef Def(float first, float last, bool loop = false, string name = "part")
        => new() { Name = name, FirstFrame = first, LastFrame = last, Loop = loop };

    [Fact]
    public void Slice_SameNativeAndSampleRate_UsesFrameIndicesDirectly()
    {
        var source = IndexClip(44, fps: 30f, nativeFps: 30f);

        var full = UnityMeta.Slice(source, Def(0, 43));
        Assert.Equal(44, full.FrameCount);

        var pose = UnityMeta.Slice(source, Def(0, 1));
        Assert.Equal(2, pose.FrameCount);
        Assert.Equal(0f, pose.Frames[0][0].Pos.X);
        Assert.Equal(1f, pose.Frames[1][0].Pos.X);
    }

    [Fact]
    public void Slice_RescalesNativeFramesOntoSampleGrid()
    {
        // 24 fps native take of 54 native intervals resampled at 30 fps:
        // round(54/24*30)+1 = 69 samples. Native frame n maps to sample round(n*30/24).
        var source = IndexClip(69, fps: 30f, nativeFps: 24f);

        var sliced = UnityMeta.Slice(source, Def(8, 43));
        Assert.Equal(10f, sliced.Frames[0][0].Pos.X);  // round(8 * 1.25) = 10
        Assert.Equal(54f, sliced.Frames[^1][0].Pos.X); // round(43 * 1.25) = 54 (53.75 rounds up)
        Assert.Equal(45, sliced.FrameCount);
    }

    [Fact]
    public void Slice_ClampsRangeIntoClip()
    {
        var source = IndexClip(44, fps: 30f, nativeFps: 30f);

        var past = UnityMeta.Slice(source, Def(100, 999));
        Assert.Equal(1, past.FrameCount);
        Assert.Equal(43f, past.Frames[0][0].Pos.X);

        var overrun = UnityMeta.Slice(source, Def(40, 999));
        Assert.Equal(4, overrun.FrameCount);
        Assert.Equal(43f, overrun.Frames[^1][0].Pos.X);
    }

    [Fact]
    public void Slice_SingleFrameRange_YieldsOneFrame()
    {
        var source = IndexClip(44, fps: 30f, nativeFps: 30f);
        var sliced = UnityMeta.Slice(source, Def(5, 5));
        Assert.Equal(1, sliced.FrameCount);
        Assert.Equal(5f, sliced.Frames[0][0].Pos.X);
    }

    [Fact]
    public void Slice_InvertedRange_CollapsesToFirstFrame()
    {
        var source = IndexClip(44, fps: 30f, nativeFps: 30f);
        var sliced = UnityMeta.Slice(source, Def(10, 2));
        Assert.Equal(1, sliced.FrameCount);
        Assert.Equal(10f, sliced.Frames[0][0].Pos.X);
    }

    [Fact]
    public void Slice_MissingLastFrame_RunsToTheEnd()
    {
        var source = IndexClip(44, fps: 30f, nativeFps: 30f);
        var sliced = UnityMeta.Slice(source, new ExternalClipDef { Name = "tail", FirstFrame = 30 });
        Assert.Equal(14, sliced.FrameCount);
        Assert.Equal(43f, sliced.Frames[^1][0].Pos.X);
    }

    [Fact]
    public void Slice_CarriesNameLoopAndRates()
    {
        var source = IndexClip(44, fps: 30f, nativeFps: 24f);
        var sliced = UnityMeta.Slice(source, Def(0, 5, loop: true, name: "walk cycle"));

        Assert.Equal("walk cycle", sliced.Name); // sanitization happens pipeline-side
        Assert.True(sliced.Looping);
        Assert.Equal(30f, sliced.Fps);
        Assert.Equal(24f, sliced.NativeFps);

        var named = UnityMeta.Slice(source, Def(0, 5), "explicit");
        Assert.Equal("explicit", named.Name);
    }
}
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.");
    }
}
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;

namespace HumanoidRetargeter.Tests.Skeleton;

public class ClipTests
{
    private static Clip WithFrames(int frameCount, float fps)
    {
        var frames = new List<XForm[]>(frameCount);
        for (var i = 0; i < frameCount; i++)
            frames.Add(new[] { XForm.Identity });
        return new Clip("clip", fps, looping: false, frames);
    }

    [Theory]
    [InlineData(0, 30f, 0f)]      // empty
    [InlineData(1, 30f, 0f)]      // a single sample spans no time
    [InlineData(2, 30f, 1f / 30f)]
    [InlineData(31, 30f, 1f)]     // 31 fence posts = 30 intervals = 1 s
    [InlineData(3, 30f, 2f / 30f)] // matches the DMX timeFrame duration (FrameCount-1)/Fps
    public void Duration_IsSpanBetweenFirstAndLastSample(int frameCount, float fps, float expected)
    {
        Assert.Equal(expected, WithFrames(frameCount, fps).Duration, 6);
    }

    [Fact]
    public void Duration_NonPositiveFpsRejectedAtConstruction()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new Clip("clip", 0f, false));
    }
}
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Solve;

public class GroundedLegDirectionTests
{
    [Theory]
    [InlineData(20f)]
    [InlineData(-20f)]
    public void GroundingDoesNotTiltPlantedLegsWithTargetTorso(float lean)
    {
        var bytes = Encoding.UTF8.GetBytes(WalkFixture.SyntheticWalkBvh());
        var source = BvhImporter.Import(bytes);
        var definitions = source.Skeleton.Bones.Select(b =>
        {
            var rest = b.RestLocal;
            if (b.Name == "mixamorig:Spine")
                rest.Rot = Quaternion.CreateFromAxisAngle(Vector3.UnitX, lean * MathF.PI / 180f);
            return new BoneDefinition(b.Name, b.ParentIndex < 0 ? null : source.Skeleton[b.ParentIndex].Name, rest);
        });
        var skeleton = Skel.Create(definitions.ToArray());
        var (map, _) = Retargeter.ResolveMapping(skeleton);
        var target = new RetargetTargetSpec { Rig = TargetRig.FromSkeleton(skeleton, map), VmdlScale = RetargetTargetSpec.SboxSourceScale };

        float PlantedLegAngle(bool cleanup)
        {
            var result = Retargeter.Convert(new RetargetRequest
            {
                SourceData = bytes, SourceFileName = "walk.bvh", FootPlantCleanup = cleanup,
            }, target);
            Assert.True(result.Success);
            var world = new Pose(result.Clips[0].SolvedFrames![7]).ToWorld(skeleton);
            var direction = world[map.RoleToBone[BoneRole.FootR]].Pos
                - world[map.RoleToBone[BoneRole.LowerLegR]].Pos;
            return MathQ.AngleBetween(direction, -Vector3.UnitY) * 180f / MathF.PI;
        }

        Assert.True(PlantedLegAngle(false) > 10f); // Repro: torso lean contaminates the stance.
        Assert.True(PlantedLegAngle(true) < 1f);
    }
}
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Target;
using Xunit;
using Xunit.Abstractions;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Target;

/// <summary>User repro (2026-07-04, local-only): DieExported.fbx picked as a custom FBX
/// target — skinned character, inch units (UnitScaleFactor 2.54), Y-up, textures folder
/// NEXT TO the source folder. Must import, be recognized (or at least accepted
/// best-effort), and convert cleanly onto its own skeleton.</summary>
public class UserDieFbxTargetTests
{
    private static readonly string FbxPath =
        TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");

    private readonly ITestOutputHelper _out;

    public UserDieFbxTargetTests(ITestOutputHelper output) => _out = output;

    [Fact]
    public void DieExported_ImportsAndResolvesAsTarget()
    {
        if (!File.Exists(FbxPath))
            return; // local-only

        var scene = FbxImporter.Import(File.ReadAllBytes(FbxPath));
        _out.WriteLine($"bones={scene.Skeleton.Count} unitScaleCm={scene.UnitScaleCm} upAxis={scene.UpAxis}");
        foreach (var bone in scene.Skeleton.Bones.Take(24))
            _out.WriteLine($"  {bone.Name}");

        var (map, report) = Retargeter.ResolveMapping(scene.Skeleton);
        _out.WriteLine($"profile={map.ProfileName} source={map.Source} conf={map.Confidence:0.00} "
            + $"needsUser={report.NeedsUserDecision} roles={map.RoleToBone.Count}");
        foreach (var (role, index) in map.RoleToBone.OrderBy(kv => kv.Key.ToString()))
            _out.WriteLine($"  {role} -> {scene.Skeleton[index].Name}");

        // The structural minimum TargetPickers accepts best-effort maps on.
        Assert.True(map.RoleToBone.ContainsKey(BoneRole.Hips), "no hips");
        Assert.True(map.RoleToBone.ContainsKey(BoneRole.UpperLegL), "no upper leg L");
        Assert.True(map.RoleToBone.ContainsKey(BoneRole.UpperLegR), "no upper leg R");

        // Converting a citizen clip onto it must solve.
        var rig = TargetRig.FromSkeleton(scene.Skeleton, map);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        {
            Rig = rig,
            VmdlScale = HumanoidRetargeter.Core.RetargetTargetSpec.SboxSourceScale,
            DefaultRootBone = scene.Skeleton[0].Name,
        };
        var bvh = TestUtil.RepoFile("dev", "corpus", "bvh", "cmu_01_01.bvh");
        if (!File.Exists(bvh))
            return;
        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[]
            {
                new HumanoidRetargeter.Core.RetargetRequest
                {
                    SourceData = File.ReadAllBytes(bvh),
                    SourceFileName = "cmu_01_01.bvh",
                },
            },
            target,
            new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
        foreach (var clip in batch.Clips.Where(c => !c.Success))
            _out.WriteLine($"FAIL {clip.ClipName}: {clip.Error}");
        Assert.True(batch.Clips.All(c => c.Success));
        Assert.Contains("cmu_01_01", batch.StandaloneVmdl);
    }

    /// <summary>User request: "apply the Neutral throw ball animation to that model, see
    /// if the animation plays correctly" (and "try the surprised.fbx animation as well,
    /// which is mixamo"). Solves each clip onto DieExported and requires real motion: the
    /// hands must travel, the character must stay upright (pelvis height near rest, no
    /// axis flip), and the pelvis must not sink.</summary>
    [Theory]
    [InlineData("todo/Neutral_throw_ball_001__A057.bvh", 0.5f)]
    [InlineData("mixamo/Surprised.fbx", 0.15f)] // startled recoil - hands travel less than a throw
    public void DieExported_UserClips_SolveWithRealMotion(string corpusRelative, float travelFactor)
    {
        var throwPath = TestUtil.RepoFile(
            ("dev/corpus/" + corpusRelative).Split('/'));
        if (!File.Exists(FbxPath) || !File.Exists(throwPath))
            return; // local-only

        var scene = FbxImporter.Import(File.ReadAllBytes(FbxPath));
        var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);
        var rig = TargetRig.FromSkeleton(scene.Skeleton, map);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        {
            Rig = rig,
            VmdlScale = HumanoidRetargeter.Core.RetargetTargetSpec.SboxSourceScale,
            DefaultRootBone = scene.Skeleton[0].Name,
        };

        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[]
            {
                new HumanoidRetargeter.Core.RetargetRequest
                {
                    SourceData = File.ReadAllBytes(throwPath),
                    SourceFileName = Path.GetFileName(throwPath),
                    FootPlantCleanup = true,
                },
            },
            target,
            new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });

        var clip = Assert.Single(batch.Clips);
        _out.WriteLine($"clip={clip.ClipName} success={clip.Success} err={clip.Error} "
            + $"frames={clip.SolvedFrames?.Count}");
        Assert.True(clip.Success, clip.Error);
        Assert.True(clip.SolvedFrames!.Count > 30, "suspiciously short solve");

        var skeleton = rig.Skeleton;
        var hips = rig.BoneForRole(BoneRole.Hips)!.Value;
        var handR = rig.BoneForRole(BoneRole.HandR)!.Value;

        var restPelvisY = skeleton.RestWorld[hips].Pos.Y;
        var minPelvis = float.MaxValue;
        var maxPelvis = float.MinValue;
        var handMin = new System.Numerics.Vector3(float.MaxValue);
        var handMax = new System.Numerics.Vector3(float.MinValue);
        foreach (var frame in clip.SolvedFrames)
        {
            var world = new HumanoidRetargeter.Core.Skeleton.Pose(frame).ToWorld(skeleton);
            minPelvis = MathF.Min(minPelvis, world[hips].Pos.Y);
            maxPelvis = MathF.Max(maxPelvis, world[hips].Pos.Y);
            handMin = System.Numerics.Vector3.Min(handMin, world[handR].Pos);
            handMax = System.Numerics.Vector3.Max(handMax, world[handR].Pos);
        }
        var handTravel = (handMax - handMin).Length();
        _out.WriteLine($"restPelvisY={restPelvisY:0.#} pelvisY=[{minPelvis:0.#}..{maxPelvis:0.#}] "
            + $"handTravel={handTravel:0.#}cm (rig units)");

        // Upright: pelvis stays within a sane band around its rest height (a lying/flipped
        // or buried character violates this immediately).
        Assert.InRange(minPelvis, restPelvisY * 0.5f, restPelvisY * 1.5f);
        Assert.InRange(maxPelvis, restPelvisY * 0.5f, restPelvisY * 1.5f);
        // The clip must move the hand substantially (relative to character size).
        Assert.True(handTravel > restPelvisY * travelFactor,
            $"hand barely moves ({handTravel:0.#} vs pelvis height {restPelvisY:0.#})");
    }
}

public class UserDieFbxHandDiagnostics
{
    private readonly Xunit.Abstractions.ITestOutputHelper _out;
    public UserDieFbxHandDiagnostics(Xunit.Abstractions.ITestOutputHelper o) => _out = o;

    [Xunit.Fact]
    public void Surprised_OnDieExported_HandTransferAudit()
    {
        var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");
        var clipPath = TestUtil.RepoFile("dev", "corpus", "mixamo", "Surprised.fbx");
        if (!File.Exists(fbx) || !File.Exists(clipPath)) return;

        var tgtScene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
        var (tgtMap, tgtReport) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(tgtScene.Skeleton);
        var rig = HumanoidRetargeter.Core.Target.TargetRig.FromSkeleton(tgtScene.Skeleton, tgtMap);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        {
            Rig = rig, VmdlScale = 0.3937f, DefaultRootBone = tgtScene.Skeleton[0].Name,
        };
        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[] { new HumanoidRetargeter.Core.RetargetRequest {
                SourceData = File.ReadAllBytes(clipPath), SourceFileName = "Surprised.fbx" } },
            target, new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
        var clip = batch.Clips.Single();
        Xunit.Assert.True(clip.Success, clip.Error);

        foreach (var note in clip.Mapping!.Notes.Where(n => n.Contains("and") || n.Contains("roll")))
            _out.WriteLine("NOTE: " + note);

        // Local-rotation swing per bone at the surprise peak vs source counterparts.
        var srcScene = HumanoidRetargeter.Core.Retargeter.ImportSource(File.ReadAllBytes(clipPath), "Surprised.fbx");
        var (srcMap, _) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(srcScene.Skeleton);
        var f = Math.Min(60, clip.SolvedFrames!.Count - 1);
        var srcF = Math.Min(60, srcScene.Clips[0].Frames.Count - 1);
        float Angle(System.Numerics.Quaternion a, System.Numerics.Quaternion b)
        {
            var d = MathF.Min(MathF.Abs(System.Numerics.Quaternion.Dot(
                System.Numerics.Quaternion.Normalize(a), System.Numerics.Quaternion.Normalize(b))), 1f);
            return 2f * MathF.Acos(d) * 180f / MathF.PI;
        }
        foreach (var role in new[] {
            HumanoidRetargeter.Core.Mapping.BoneRole.LowerArmR, HumanoidRetargeter.Core.Mapping.BoneRole.HandR,
            HumanoidRetargeter.Core.Mapping.BoneRole.IndexProxR, HumanoidRetargeter.Core.Mapping.BoneRole.MiddleProxR,
            HumanoidRetargeter.Core.Mapping.BoneRole.ThumbProxR,
            HumanoidRetargeter.Core.Mapping.BoneRole.HandL, HumanoidRetargeter.Core.Mapping.BoneRole.IndexProxL })
        {
            var t = rig.BoneForRole(role);
            srcMap.RoleToBone.TryGetValue(role, out var s);
            if (t is null) { _out.WriteLine($"{role}: unmapped on target"); continue; }
            var tgtSwing = Angle(clip.SolvedFrames[f][t.Value].Rot, rig.Skeleton[t.Value].RestLocal.Rot);
            var srcSwing = srcMap.RoleToBone.ContainsKey(role)
                ? Angle(srcScene.Clips[0].Frames[srcF][s].Rot, srcScene.Skeleton[s].RestLocal.Rot)
                : -1f;
            _out.WriteLine($"{role}: target swing {tgtSwing:0.#} deg  source swing {srcSwing:0.#} deg");
        }
    }
}

public class UserDieFbxFingerLengths
{
    private readonly Xunit.Abstractions.ITestOutputHelper _out;
    public UserDieFbxFingerLengths(Xunit.Abstractions.ITestOutputHelper o) => _out = o;

    [Xunit.Fact]
    public void Surprised_OnDieExported_FingerLengthAudit()
    {
        var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "die", "source", "DieExported.fbx");
        var clipPath = TestUtil.RepoFile("dev", "corpus", "mixamo", "Surprised.fbx");
        if (!File.Exists(fbx) || !File.Exists(clipPath)) return;

        var tgtScene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
        var (tgtMap, _) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(tgtScene.Skeleton);
        foreach (var b in tgtScene.Skeleton.Bones)
            if (b.Name.Contains("Finger") || b.Name.Contains("Hand") || b.Name.Contains("Forearm"))
                _out.WriteLine($"bone: {b.Name} mapped={tgtMap.RoleToBone.ContainsValue(b.Index)}");

        var rig = HumanoidRetargeter.Core.Target.TargetRig.FromSkeleton(tgtScene.Skeleton, tgtMap);
        var target = new HumanoidRetargeter.Core.RetargetTargetSpec
        { Rig = rig, VmdlScale = 0.3937f, DefaultRootBone = tgtScene.Skeleton[0].Name };
        var batch = HumanoidRetargeter.Core.Retargeter.ConvertBatch(
            new[] { new HumanoidRetargeter.Core.RetargetRequest {
                SourceData = File.ReadAllBytes(clipPath), SourceFileName = "Surprised.fbx" } },
            target, new HumanoidRetargeter.Core.BatchOptions { DmxFolderRelative = "animations" });
        var clip = batch.Clips.Single();
        var f = Math.Min(60, clip.SolvedFrames!.Count - 1);
        var world = new HumanoidRetargeter.Core.Skeleton.Pose(clip.SolvedFrames[f]).ToWorld(rig.Skeleton);
        var rest = rig.Skeleton.RestWorld;
        var hand = rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.HandR)!.Value;
        foreach (var role in new[] {
            HumanoidRetargeter.Core.Mapping.BoneRole.IndexDistR, HumanoidRetargeter.Core.Mapping.BoneRole.MiddleDistR,
            HumanoidRetargeter.Core.Mapping.BoneRole.ThumbDistR, HumanoidRetargeter.Core.Mapping.BoneRole.PinkyDistR })
        {
            if (rig.BoneForRole(role) is not { } tip) { _out.WriteLine($"{role}: unmapped"); continue; }
            var restD = (rest[tip].Pos - rest[hand].Pos).Length();
            var frameD = (world[tip].Pos - world[hand].Pos).Length();
            _out.WriteLine($"{role}: rest {restD:0.##}  frame {frameD:0.##}  ratio {frameD / restD:0.###}");
        }
    }
}

public class UserSimpsonRigDiagnostics
{
    private readonly Xunit.Abstractions.ITestOutputHelper _out;
    public UserSimpsonRigDiagnostics(Xunit.Abstractions.ITestOutputHelper o) => _out = o;

    [Xunit.Fact]
    public void Simpson_ImportAudit()
    {
        var fbx = TestUtil.RepoFile("dev", "corpus", "user_rigs", "simpson", "source", "C8V7NUC53TF8TTF3BXDMVGQIQ.fbx");
        if (!File.Exists(fbx)) return;
        var scene = HumanoidRetargeter.Core.Formats.Fbx.FbxImporter.Import(File.ReadAllBytes(fbx));
        _out.WriteLine($"bones={scene.Skeleton.Count} unitScaleCm={scene.UnitScaleCm} upAxis={scene.UpAxis} clips={scene.Clips.Count}");
        var (map, report) = HumanoidRetargeter.Core.Retargeter.ResolveMapping(scene.Skeleton);
        _out.WriteLine($"profile={map.ProfileName} conf={map.Confidence:0.00} roles={map.RoleToBone.Count} needsUser={report.NeedsUserDecision}");
        // Character extents + suspicious rest locals
        var rest = scene.Skeleton.RestWorld;
        float maxY = float.MinValue, minY = float.MaxValue;
        foreach (var w in rest) { maxY = MathF.Max(maxY, w.Pos.Y); minY = MathF.Min(minY, w.Pos.Y); }
        _out.WriteLine($"rest height span Y: {minY:0.#}..{maxY:0.#} cm");
        foreach (var role in new[] { HumanoidRetargeter.Core.Mapping.BoneRole.Hips, HumanoidRetargeter.Core.Mapping.BoneRole.Head,
            HumanoidRetargeter.Core.Mapping.BoneRole.HandR, HumanoidRetargeter.Core.Mapping.BoneRole.FootL })
        {
            if (map.RoleToBone.TryGetValue(role, out var i))
                _out.WriteLine($"{role} -> {scene.Skeleton[i].Name} world {rest[i].Pos}");
            else _out.WriteLine($"{role}: UNMAPPED");
        }
        foreach (var b in scene.Skeleton.Bones.Take(12))
            _out.WriteLine($"  [{b.Index}] {b.Name} parent={b.ParentIndex} localPos={b.RestLocal.Pos}");
    }
}
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Target;

/// <summary>
/// RenderMeshList embedding in standalone vmdls (user report 2026-07-04): a custom FBX
/// target has no compiled base model, so its generated vmdl used to compile into an EMPTY
/// model — 0 bones, 0 sequences — and playing any converted animation did nothing. With
/// <see cref="RetargetTargetSpec.MeshFilePath"/> set, the vmdl must embed the mesh source
/// so the compiled model carries the skeleton and skin the sequences play on.
/// </summary>
public class VmdlMeshEmbedTests
{
    private static readonly AnimEntry[] OneAnim =
    {
        new() { Name = "clip", SourceFilename = "animations/clip.dmx" },
    };

    [Fact]
    public void GenerateStandalone_WithMeshFile_EmbedsRenderMeshNode()
    {
        var vmdl = VmdlWriter.GenerateStandalone(
            "", OneAnim, 0.3937f, "pelvis",
            meshFilePath: "animations/retargeted/fox.fbx", meshImportScale: 100f);

        Assert.Contains("\"RenderMeshList\"", vmdl);
        Assert.Contains("\"RenderMeshFile\"", vmdl);
        Assert.Contains("animations/retargeted/fox.fbx", vmdl);
        Assert.Contains("import_scale = 100", vmdl);
        Assert.Contains("name = \"fox\"", vmdl);
        // The cm→inch modifier and the animation list still ride along.
        Assert.Contains("ModelModifier_ScaleAndMirror", vmdl);
        Assert.Contains("\"AnimationList\"", vmdl);
        // Round-trips through the kv3 parser (augmentation reads these files back).
        Kv3.Parse(vmdl);
    }

    [Fact]
    public void GenerateStandalone_WithoutMeshFile_OmitsRenderMeshNode()
    {
        var vmdl = VmdlWriter.GenerateStandalone(
            RetargetTargetSpec.SboxHumanMalePath, OneAnim, 0.3937f, "pelvis");

        Assert.DoesNotContain("RenderMeshList", vmdl);
        Assert.DoesNotContain("RenderMeshFile", vmdl);
    }

    /// <summary>The accumulate path augments an existing standalone vmdl IN PLACE, so a
    /// pipeline-owned output generated before mesh embedding existed (empty
    /// base_model_name, no mesh) would stay an empty unplayable model forever - user
    /// report: "convert onto the custom FBX, open the new vmdl, the animation doesn't
    /// play". EnsureMeshFile heals it.</summary>
    [Fact]
    public void EnsureMeshFile_HealsPreMeshEmbedVmdl_AndIsIdempotent()
    {
        // A pre-fix standalone output: no base model, no mesh, one accumulated animation.
        var old = VmdlWriter.GenerateStandalone("", OneAnim, 0.3937f, "pelvis");
        Assert.DoesNotContain("RenderMeshFile", old);

        var healed = VmdlAugmenter.EnsureMeshFile(old, "animations/retargeted/die.fbx", 2.54f);
        Assert.Contains("\"RenderMeshFile\"", healed);
        Assert.Contains("animations/retargeted/die.fbx", healed);
        Assert.Contains("import_scale = 2.54", healed);
        // The accumulated animation survives.
        Assert.Contains("\"clip\"", healed);
        Kv3.Parse(healed);

        // Same mesh again: no change at all.
        Assert.Equal(healed, VmdlAugmenter.EnsureMeshFile(healed, "animations/retargeted/die.fbx", 2.54f));

        // Switched target FBX: the pipeline-owned mesh node is replaced, not duplicated.
        var switched = VmdlAugmenter.EnsureMeshFile(healed, "animations/retargeted/fox.fbx", 100f);
        Assert.Contains("animations/retargeted/fox.fbx", switched);
        Assert.DoesNotContain("animations/retargeted/die.fbx", switched);
        Kv3.Parse(switched);
    }

    [Fact]
    public void EnsureMeshFile_EmptyMeshPath_IsNoOp()
    {
        var vmdl = VmdlWriter.GenerateStandalone("", OneAnim, 0.3937f, "pelvis");
        Assert.Same(vmdl, VmdlAugmenter.EnsureMeshFile(vmdl, "", 1f));
    }

    /// <summary>FBX materials are bare names the compiler cannot resolve as resource paths
    /// ("Trying to load an illegal resource name X.vmat") - generated vmdls must remap them
    /// to the real vmat files via MaterialGroupList (the shipped citizen mechanism).</summary>
    [Fact]
    public void GenerateStandalone_WithMaterialRemaps_EmitsMaterialGroup()
    {
        var remaps = new Dictionary<string, string>
        {
            ["mi_dante_head.vmat"] = "animations/retargeted/mi_dante_head.vmat",
        };
        var vmdl = VmdlWriter.GenerateStandalone(
            "", OneAnim, 0.3937f, "pelvis",
            meshFilePath: "animations/retargeted/die.fbx", meshImportScale: 2.54f,
            materialRemaps: remaps);

        Assert.Contains("\"MaterialGroupList\"", vmdl);
        Assert.Contains("\"DefaultMaterialGroup\"", vmdl);
        Assert.Contains("from = \"mi_dante_head.vmat\"", vmdl);
        Assert.Contains("to = \"animations/retargeted/mi_dante_head.vmat\"", vmdl);
        Kv3.Parse(vmdl);

        // And the healing path adds the same node to pre-remap outputs (idempotent).
        var old = VmdlWriter.GenerateStandalone("", OneAnim, 0.3937f, "pelvis");
        var healed = VmdlAugmenter.EnsureMeshFile(
            old, "animations/retargeted/die.fbx", 2.54f, remaps);
        Assert.Contains("\"MaterialGroupList\"", healed);
        Assert.Contains("from = \"mi_dante_head.vmat\"", healed);
        Assert.Equal(healed, VmdlAugmenter.EnsureMeshFile(
            healed, "animations/retargeted/die.fbx", 2.54f, remaps));
    }

    /// <summary>An output vmdl whose remap table predates a texture-matching improvement
    /// must LEARN new entries on the next conversion (observed: textured preview but
    /// untextured ModelDoc forever) - while existing entries stay untouched.</summary>
    [Fact]
    public void EnsureMeshFile_MergesNewMaterialRemaps()
    {
        var oldRemaps = new Dictionary<string, string> { ["a.vmat"] = "out/a.vmat" };
        var old = VmdlWriter.GenerateStandalone("", OneAnim, 0.3937f, "pelvis",
            meshFilePath: "out/m.fbx", meshImportScale: 1f, materialRemaps: oldRemaps);

        var merged = VmdlAugmenter.EnsureMeshFile(old, "out/m.fbx", 1f,
            new Dictionary<string, string>
            {
                ["a.vmat"] = "IGNORED/other.vmat",
                ["homer.vmat"] = "out/homer.vmat",
            });
        Assert.Contains("to = \"out/a.vmat\"", merged);       // existing entry untouched
        Assert.DoesNotContain("IGNORED", merged);
        Assert.Contains("from = \"homer.vmat\"", merged);     // new entry learned
        Assert.Contains("to = \"out/homer.vmat\"", merged);
        Kv3.Parse(merged);
        Assert.Equal(merged, VmdlAugmenter.EnsureMeshFile(merged, "out/m.fbx", 1f,
            new Dictionary<string, string> { ["homer.vmat"] = "out/homer.vmat" }));
    }
}
using System.Numerics;
using System.Text;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Solve;

public class PosedLocomotionTests
{
    [Theory]
    [InlineData(1, 500)]
    [InlineData(1, -500)]
    [InlineData(2, 500)]
    [InlineData(2, -500)]
    public void InPlaceCentersClipsWhoseStaticReferenceIsFarFromTheTake(int upAxis, float restTravel)
    {
        var target = Target(upAxis);
        var frames = Convert(Fixture(upAxis, restTravel: restTravel), target, RootMotionMode.InPlace);
        var hips = target.Rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.Hips)!.Value;
        var center = frames.Aggregate(Vector3.Zero, (sum, frame) => sum + new Pose(frame).ToWorld(target.Rig.Skeleton)[hips].Pos) / frames.Count;
        var delta = center - target.Rig.Skeleton.RestWorld[hips].Pos;
        if (upAxis == 1) delta.Y = 0; else delta.Z = 0;
        Assert.True(delta.Length() < .01f, $"In-place clip is displaced from the target bind by {delta}.");
    }

    [Theory]
    [InlineData(1, false, 0)]
    [InlineData(1, true, -90)]
    [InlineData(2, false, 90)]
    [InlineData(2, true, 90)]
    public void SerializedRootYawMatchesTargetImportConvention(int upAxis, bool embedsMesh, float degrees)
    {
        var original = Target(upAxis);
        var target = new RetargetTargetSpec
        {
            Rig = original.Rig, UpAxis = original.UpAxis, VmdlScale = 1,
            MeshFilePath = embedsMesh ? "test.fbx" : "",
        };
        var frame = target.Rig.Skeleton.Bones.Select(b => b.RestLocal).ToArray();
        var before = frame.ToArray();
        var serialized = Retargeter.TestHook_CompensateEmbeddedMeshRootYaw(new[] { frame }, target).Single();
        var yaw = Quaternion.CreateFromAxisAngle(upAxis == 1 ? Vector3.UnitY : Vector3.UnitZ, degrees * MathF.PI / 180);
        for (var i = 0; i < frame.Length; i++)
        {
            var root = target.Rig.Skeleton[i].ParentIndex < 0;
            var expectedPosition = root ? Vector3.Transform(before[i].Pos, yaw) : before[i].Pos;
            var expectedRotation = root ? Quaternion.Normalize(yaw * before[i].Rot) : before[i].Rot;
            Assert.True(Vector3.Distance(expectedPosition, serialized[i].Pos) < .0001f);
            Assert.True(MathF.Abs(Quaternion.Dot(expectedRotation, serialized[i].Rot)) > .99999f);
            Assert.Equal(before[i], frame[i]); // Serialization must not mutate solved poses.
        }
    }

    [Theory]
    [InlineData(1)]
    [InlineData(2)]
    public void LeaningSourceRestDoesNotTurnForwardTravelIntoVerticalMotion(int upAxis)
    {
        var target = Target(upAxis);
        var result = Convert(Fixture(upAxis, torsoLean: 70), target, RootMotionMode.InPlace);
        var hip = target.Rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.Hips)!.Value;
        var heights = result.Select(frame => Vertical(new Pose(frame).ToWorld(target.Rig.Skeleton)[hip].Pos, upAxis)).ToArray();
        Assert.True(heights.Max() - heights.Min() < .05f,
            $"Horizontal travel changed pelvis height by {heights.Max() - heights.Min()}.");
    }

    [Theory]
    [InlineData(1)]
    [InlineData(2)]
    public void RemovingRootTravelPreservesGroundedHeightsOnLeaningTarget(int upAxis)
    {
        var target = Target(upAxis, torsoLean: 20);
        var bytes = Fixture(upAxis);
        var moving = Convert(bytes, target, RootMotionMode.Off);
        var inPlace = Convert(bytes, target, RootMotionMode.InPlace);
        for (var f = 0; f < moving.Count; f++)
        {
            var before = new Pose(moving[f]).ToWorld(target.Rig.Skeleton);
            var after = new Pose(inPlace[f]).ToWorld(target.Rig.Skeleton);
            for (var b = 0; b < before.Length; b++)
                Assert.True(MathF.Abs(Vertical(before[b].Pos - after[b].Pos, upAxis)) < .005f,
                    $"Frame {f}, bone {b}: root-motion removal changed ground clearance.");
        }
    }

    [Fact]
    public void LiftedFootInExportedRestDoesNotPushPlantedFootBelowGround()
    {
        var target = Target(1);
        var result = Convert(Fixture(1, liftedRest: 35, travel: 0), target, RootMotionMode.Off);
        var toe = target.Rig.BoneForRole(HumanoidRetargeter.Core.Mapping.BoneRole.ToeR)!.Value;
        var floor = target.Rig.Skeleton.RestWorld[toe].Pos.Y;
        foreach (var frame in result)
            Assert.True(new Pose(frame).ToWorld(target.Rig.Skeleton)[toe].Pos.Y >= floor - .1f,
                "A mid-step rest was incorrectly used as the planted-foot height reference.");
    }

    static float Vertical(Vector3 value, int axis) => axis == 1 ? value.Y : value.Z;

    static RetargetTargetSpec Target(int upAxis, float torsoLean = 0)
    {
        var skeleton = FbxImporter.Import(Fixture(upAxis, torsoLean)).Skeleton;
        var (map, _) = Retargeter.ResolveMapping(skeleton);
        return new RetargetTargetSpec
        {
            Rig = TargetRig.FromSkeleton(skeleton, map), VmdlScale = 1,
            UpAxis = upAxis == 1 ? TargetUpAxis.YUpCm : TargetUpAxis.ZUpEngine,
        };
    }

    static List<HumanoidRetargeter.Core.Maths.XForm[]> Convert(byte[] bytes, RetargetTargetSpec target, RootMotionMode mode)
    {
        var result = Retargeter.Convert(new RetargetRequest
        {
            SourceData = bytes, SourceFileName = "posed-locomotion.fbx",
            RootMotion = mode, FootPlantCleanup = true,
        }, target);
        Assert.True(result.Success, string.Join("; ", result.Clips.Select(c => c.Error)));
        return result.Clips[0].SolvedFrames!;
    }

    // A tiny animation-only FBX: no proprietary pack files or optional corpus needed.
    // The static rig can be bent over or caught mid-step; the take moves horizontally.
    static byte[] Fixture(int upAxis, float torsoLean = 0, float liftedRest = 0, float travel = 100, float restTravel = 0)
    {
        var skeleton = BvhImporter.Import(Encoding.UTF8.GetBytes(PosedSkeletonFixture.SyntheticWalkBvh())).Skeleton;
        var objects = new StringBuilder();
        var connections = new StringBuilder();
        foreach (var bone in skeleton.Bones)
        {
            var p = bone.RestLocal.Pos;
            if (bone.ParentIndex < 0) { p.Y = 93; p.Z = restTravel; }
            if (upAxis == 2) p = new Vector3(p.X, -p.Z, p.Y);
            var angle = bone.Name == "mixamorig:Spine" ? torsoLean
                : bone.Name == "mixamorig:RightUpLeg" ? liftedRest : 0;
            objects.AppendLine(FormattableString.Invariant($$"""
                Model: {{bone.Index + 1}}, "Model::{{bone.Name}}", "LimbNode" {
                    Properties70: {
                        P: "Lcl Translation", "Lcl Translation", "", "A",{{p.X}},{{p.Y}},{{p.Z}}
                        P: "Lcl Rotation", "Lcl Rotation", "", "A",{{angle}},0,0
                    }
                }
                """));
            connections.AppendLine($"C: \"OO\",{bone.Index + 1},{bone.ParentIndex + 1}");
        }
        void Curve(int id, int bone, string property, string axis, float end)
        {
            objects.AppendLine(FormattableString.Invariant($$"""
                AnimationCurveNode: {{id}}, "AnimCurveNode::curve", "" { }
                AnimationCurve: {{id + 1}}, "AnimCurve::value", "" {
                    KeyTime: *2 { a: 0,46186158000 }
                    KeyValueFloat: *2 { a: 0,{{end}} }
                }
                """));
            connections.AppendLine($"C: \"OO\",{id},10001\nC: \"OP\",{id},{bone},\"{property}\"\nC: \"OP\",{id + 1},{id},\"d|{axis}\"");
        }
        Curve(10002, 1, "Lcl Translation", upAxis == 1 ? "Z" : "Y", upAxis == 1 ? travel : -travel);
        Curve(10004, skeleton.IndexOf("mixamorig:RightUpLeg") + 1, "Lcl Rotation", "X", 0);
        return Encoding.UTF8.GetBytes(FormattableString.Invariant($$"""
            GlobalSettings: { Properties70: {
                P: "UpAxis", "int", "Integer", "",{{upAxis}}
                P: "UpAxisSign", "int", "Integer", "",1
                P: "UnitScaleFactor", "double", "Number", "",1
            } }
            Objects: {
                {{objects}}
                AnimationStack: 10000, "AnimStack::walk", "" { }
                AnimationLayer: 10001, "AnimLayer::base", "" { }
            }
            Connections: {
                {{connections}}
                C: "OO",10001,10000
            }
            """));
    }
}
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;

// A custom rig without stock attachments must receive calibrated copied sockets.
// Run only in an explicitly opted-in isolated project, never a user's active editor.
public static class SmartPortCopiedSocketEngineTest
{
    static bool started;
    [EditorEvent.Frame]
    public static void Tick()
    {
        var root = Environment.GetEnvironmentVariable("HR_SMART_PORT_COPIED_SOCKET_PROJECT");
        if (started || Project.Current is null || string.IsNullOrEmpty(root)
            || !string.Equals(Path.GetFullPath(Project.Current.GetRootPath()).TrimEnd('/', '\\'), Path.GetFullPath(root).TrimEnd('/', '\\'), StringComparison.OrdinalIgnoreCase)
            || !AssetSystem.All.Any()) return;
        started = true;
        _ = Run(root);
    }
    static async Task Run(string root)
    {
        var data = new Dictionary<string, object>();
        void Save() => File.WriteAllText(Path.Combine(root, "copied-socket-result.json"),
            JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
        try
        {
            data["started"] = DateTime.UtcNow; Save();
            var source = AssetSystem.FindByPath("models/citizen/citizen.vmdl") ?? throw new Exception("Missing Citizen source");
            var target = AssetSystem.FindByPath("humanoid_retargeter_smoke/customfbx/catgirl_2_preview_bind_9e13ad1f.vmdl") ?? throw new Exception("Missing Catgirl target fixture");
            var type = AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType("HumanoidRetargeter.EditorTools.SmartPortModels")).First(t => t != null);
            var name = "copied_socket_" + DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
            Action<string> progress = s => { data["stage"] = s; Save(); };
            var task = (Task)type.GetMethod("CreateAsync", BindingFlags.Static | BindingFlags.NonPublic)
                .Invoke(null, new object[] { source, target, "smart_port", name, progress, CancellationToken.None });
            await task;
            var result = task.GetType().GetProperty("Result").GetValue(task);
            data["compiled"] = result.GetType().GetProperty("Compiled").GetValue(result);
            data["errors"] = result.GetType().GetProperty("Errors").GetValue(result);
            data["model"] = "smart_port/" + name + ".vmdl"; Save();
            if (!(bool)data["compiled"]) throw new Exception("Compile failed");
            var session = SceneEditorSession.CreateDefault(); session.MakeActive();
            using (session.Scene.Push())
                foreach (var path in new[] { source.Path, (string)data["model"] })
                {
                    var go = session.Scene.CreateObject(); go.Name = path;
                    var actor = go.Components.Create<SkinnedModelRenderer>();
                    actor.Model = Model.Load(path); actor.UseAnimGraph = true;
                }
            EditorScene.Play(false, session); await Task.Delay(1500);
            if (!Game.IsPlaying || Game.ActiveScene is null || Game.ActiveScene.IsEditor) throw new Exception("Did not enter play mode");
            var actors = Game.ActiveScene.GetAllComponents<SkinnedModelRenderer>().ToArray();
            var observations = new List<object>();
            const int hold = 2; // Source graph's rifle hold type.
            foreach (var weight in new[] { 0f, 1f })
            foreach (var pitch in new[] { 0f, -25f, 25f })
            {
                for (var settle = 0; settle < 50; settle++)
                {
                    foreach (var actor in actors)
                    {
                        actor.Set("b_grounded", true); actor.Set("holdtype", hold); actor.Set("weapon_pose", 0);
                        actor.Set("aim_body", Rotation.From(pitch, 0, 0).Forward); actor.Set("aim_body_weight", weight);
                        actor.Set("aim_head", Rotation.From(pitch, 0, 0).Forward); actor.Set("aim_head_weight", 1f);
                    }
                    await Task.Delay(100);
                }
                var sourceActor = actors.Single(a => a.Model.Name == source.Path);
                var targetActor = actors.Single(a => a.Model.Name == (string)data["model"]);
                var sourceGrip = sourceActor.SceneModel.GetAttachment("hold_R", true).Value;
                var targetGrip = targetActor.SceneModel.GetAttachment("hold_R", true).Value;
                var a = sourceGrip.Rotation; var b = targetGrip.Rotation;
                var error = 2 * MathF.Acos(Math.Clamp(MathF.Abs(a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w), 0, 1)) * 180 / MathF.PI;
                if (!float.IsFinite(error) || error > 3) throw new Exception($"Rifle attachment diverged {error} degrees (aim weight {weight}, pitch {pitch})");
                foreach (var actor in actors)
                {
                    var att = actor.SceneModel.GetAttachment("hold_R", true) ?? throw new Exception("Missing grip");
                    observations.Add(new { model = actor.Model.Name, hold, weight, pitch, error,
                        rotation = att.Rotation.Angles().ToString(), forward = att.Rotation.Forward.ToString(), position = att.Position.ToString() });
                }
            }
            data["observations"] = observations; data["passed"] = true;
        }
        catch (Exception e) { data["error"] = e.ToString(); data["passed"] = false; }
        finally
        {
            data["completed"] = true; Save();
            if (Game.IsPlaying) EditorScene.Stop();
            await Task.Delay(1000); EditorUtility.Quit(true);
        }
    }
}
using HumanoidRetargeter.EditorTools;
using HumanoidRetargeter.EditorTools.Embedded.ValveResourceFormat;
using HumanoidRetargeter.EditorTools.Embedded.ValveResourceFormat.ResourceTypes;
using Xunit;

namespace SmartPort.Parser.Tests;

public class SmartPortHandSocketMetadataTests
{
    [CompiledFixtureFact]
    public void AttachmentFittingUsesCompiledParentBones()
    {
        var path = Path.Combine(Environment.GetEnvironmentVariable("HR_SMART_PORT_FIXTURE")!, "models/player/human/frank_mp.vmdl_c");
        var names = SmartPortAttachments.BoneNames(path);
        using var resource = new Resource();
        resource.Read(path);
        var model = Assert.IsType<Model>(resource.DataBlock);
        Assert.Contains("hold_R", names);
        Assert.Contains("hold_L", names);
        Assert.Equal(names.Length, names.Distinct().Count());
        Assert.All(names.Where(n => !string.IsNullOrEmpty(n)), n => Assert.Contains(model.Skeleton.Bones, b => b.Name == n));
    }
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class TestInit
{
	public static Sandbox.TestAppSystem AppSystem;

	[AssemblyInitialize]
	public static void AssemblyInitialize( TestContext context )
	{
		AppSystem = new Sandbox.TestAppSystem();
		AppSystem.Init();
	}

	[AssemblyCleanup]
	public static void AssemblyCleanup()
	{
		AppSystem.Shutdown();
	}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace WackyLib.Tests;

[TestClass]
public class TestInit
{
	private static Sandbox.TestAppSystem s_appSystem;
	
	[AssemblyInitialize]
	public static void AssemblyInitialize( TestContext context )
	{
		s_appSystem = new Sandbox.TestAppSystem();
		s_appSystem.Init();
	}
	
	[AssemblyCleanup]
	public static void AssemblyCleanup()
	{
		s_appSystem.Shutdown();
	}
}
using Sandbox;

[TestClass]
public class SyncToolYamlRendererTests
{
	[TestMethod]
	public void EmptyInputProducesEmptyOutput()
	{
		Assert.AreEqual( "", SyncToolYamlRenderer.RenderFromJson( null ) );
		Assert.AreEqual( "", SyncToolYamlRenderer.RenderFromJson( "" ) );
	}

	[TestMethod]
	public void EmptyObjectAndArrayRenderAsFlowStyle()
	{
		Assert.AreEqual( "{}\n", SyncToolYamlRenderer.RenderFromJson( "{}" ) );
		Assert.AreEqual( "[]\n", SyncToolYamlRenderer.RenderFromJson( "[]" ) );
	}

	[TestMethod]
	public void InvalidJsonReturnsInputUnchanged()
	{
		var notJson = "not: real: json: ::";
		Assert.AreEqual( notJson, SyncToolYamlRenderer.RenderFromJson( notJson ) );
	}

	[TestMethod]
	public void TopLevelKeysAreSortedAlphabetically()
	{
		var json = "{\"zeta\":1,\"alpha\":2,\"mu\":3}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		Assert.AreEqual( "alpha: 2\nmu: 3\nzeta: 1\n", yaml );
	}

	[TestMethod]
	public void NestedObjectsAreSortedRecursively()
	{
		var json = "{\"outer\":{\"zeta\":1,\"alpha\":2}}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		Assert.AreEqual( "outer:\n  alpha: 2\n  zeta: 1\n", yaml );
	}

	[TestMethod]
	public void DifferentKeyOrdersProduceIdenticalOutput()
	{
		var a = "{\"slug\":\"hello\",\"method\":\"POST\",\"enabled\":true}";
		var b = "{\"enabled\":true,\"method\":\"POST\",\"slug\":\"hello\"}";

		Assert.AreEqual(
			SyncToolYamlRenderer.RenderFromJson( a ),
			SyncToolYamlRenderer.RenderFromJson( b )
		);
	}

	[TestMethod]
	public void OutputUsesYamlSyntaxNotJsonSyntax()
	{
		var json = "{\"name\":\"hooked\",\"enabled\":true,\"max\":42}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		// Smoke check: the rendered text must look like YAML, not JSON.
		// This is the regression we're guarding: prior to the fix the diff
		// view rendered structured data as JSON.
		StringAssert.DoesNotMatch( yaml, new System.Text.RegularExpressions.Regex( @"^\s*\{" ) );
		StringAssert.Contains( yaml, "enabled: true" );
		StringAssert.Contains( yaml, "max: 42" );
		StringAssert.Contains( yaml, "name: \"hooked\"" );
	}

	[TestMethod]
	public void StringValuesAreQuotedAndEscapedSafely()
	{
		var json = "{\"text\":\"a:b\\nc\"}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		// Strings go through JSON quoting which is also valid YAML.
		// The colon/newline must not leak as YAML structure.
		Assert.AreEqual( "text: \"a:b\\nc\"\n", yaml );
	}

	[TestMethod]
	public void BooleanAndNullAreUnquoted()
	{
		var json = "{\"a\":true,\"b\":false,\"c\":null}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		Assert.AreEqual( "a: true\nb: false\nc: null\n", yaml );
	}

	[TestMethod]
	public void IntegerAndDoubleAreUnquoted()
	{
		var json = "{\"i\":7,\"d\":1.5}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		Assert.AreEqual( "d: 1.5\ni: 7\n", yaml );
	}

	[TestMethod]
	public void ArraysOfObjectsRenderAsBlockSequence()
	{
		var json = "{\"steps\":[{\"name\":\"first\",\"id\":1},{\"name\":\"second\",\"id\":2}]}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		var expected =
			"steps:\n" +
			"  -\n" +
			"    id: 1\n" +
			"    name: \"first\"\n" +
			"  -\n" +
			"    id: 2\n" +
			"    name: \"second\"\n";

		Assert.AreEqual( expected, yaml );
	}

	[TestMethod]
	public void ArraysOfScalarsRenderAsBlockSequence()
	{
		var json = "{\"tags\":[\"a\",\"b\",\"c\"]}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		Assert.AreEqual( "tags:\n  - \"a\"\n  - \"b\"\n  - \"c\"\n", yaml );
	}

	[TestMethod]
	public void TopLevelArrayRenders()
	{
		var json = "[1,2,3]";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		Assert.AreEqual( "- 1\n- 2\n- 3\n", yaml );
	}

	[TestMethod]
	public void KeysWithNonIdentifierCharactersAreQuoted()
	{
		var json = "{\"weird key\":1,\"x:y\":2}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		// Bare YAML keys must not contain spaces or colons, so the renderer
		// quotes them. Sort order is by raw key (Ordinal).
		StringAssert.Contains( yaml, "\"weird key\": 1" );
		StringAssert.Contains( yaml, "\"x:y\": 2" );
	}

	[TestMethod]
	public void EmptyNestedObjectAndArrayUseFlowStyle()
	{
		var json = "{\"obj\":{},\"arr\":[]}";
		var yaml = SyncToolYamlRenderer.RenderFromJson( json );

		Assert.AreEqual( "arr: []\nobj: {}\n", yaml );
	}
}
#nullable enable

using Hexagon.V2.Application;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Kernel.Events;
using Hexagon.V2.Persistence;

namespace Hexagon.V2.Tests.Application;

[TestClass]
public sealed class SceneAndAggregateMutationTests
{
	[TestMethod]
	public async Task SceneStateUsesRegisteredTypesAndPublishesOnlyCommittedReplacement()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var service = new SceneEntityStateService(
			environment.Repositories,
			environment.Schema,
			ApplicationServiceTestEnvironment.AllowPolicy<SceneEntityStateMutationContext>());
		var sceneEntityId = SceneEntityId.New();
		var actor = new AccountId(9101);
		var initial = ApplicationServiceTestEnvironment.StatePayload("closed");

		var ensured = await service.EnsureAsync(sceneEntityId, "door", initial);
		var replaced = await service.ReplaceStateAsync(
			actor, null, sceneEntityId, ApplicationServiceTestEnvironment.StatePayload("open"));
		var incompatible = await service.ReplaceStateAsync(
			actor, null, sceneEntityId, ApplicationServiceTestEnvironment.StatePayload("future", version: 2));
		environment.Provider.FailNextCommit();
		var failedCommit = await service.ReplaceStateAsync(
			actor, null, sceneEntityId, ApplicationServiceTestEnvironment.StatePayload("unpublished"));

		Assert.IsTrue(ensured.Succeeded, ensured.Error?.Message);
		Assert.IsTrue(replaced.Succeeded, replaced.Error?.Message);
		Assert.AreEqual(ErrorCode.PersistedTypeInvalid, incompatible.Error!.Code);
		Assert.AreEqual(ErrorCode.InternalError, failedCommit.Error!.Code);
		var stored = service.Find(sceneEntityId)!;
		Assert.AreEqual("open", stored.State.Data.GetProperty("name").GetString());
		Assert.AreEqual(ApplicationServiceTestEnvironment.StateTypeId, stored.State.TypeId.Value);
		Assert.AreEqual(1, stored.State.TypeVersion);
	}

	[TestMethod]
	public async Task TouchLastPlayedReturnsOnlyProviderIssuedDurableReceipt()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId( 9103 );
		var character = ApplicationServiceTestEnvironment.Character( account, 0 ) with
		{
			LastPlayedAt = DateTimeOffset.UnixEpoch
		};
		await environment.SeedAsync( unitOfWork =>
			unitOfWork.Create( environment.Repositories.Characters, DomainKeys.Character( character.Id ), character ) );
		var service = new AggregateMutationService(
			environment.Repositories,
			environment.Schema,
			ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
			ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>() );

		var timestamp = DateTimeOffset.UnixEpoch.AddMinutes( 1 );
		var touched = await service.TouchLastPlayedAsync( account, character.Id, timestamp );

		Assert.IsTrue( touched.Succeeded, touched.Error?.Message );
		Assert.AreEqual( CharacterMutationKind.TouchLastPlayed, touched.Value.Kind );
		Assert.AreEqual( character.LastPlayedAt, touched.Value.Before.LastPlayedAt );
		Assert.AreEqual( timestamp, touched.Value.After.LastPlayedAt );
		Assert.IsGreaterThan( 0L, touched.Value.Commit.Sequence );
		Assert.IsTrue( touched.Value.Commit.Documents.Any( document =>
			document.Address.Collection == DomainCollections.Characters &&
			document.Address.Key == DomainKeys.Character( character.Id ) ) );

		environment.Provider.FailNextCommit();
		var failed = await service.TouchLastPlayedAsync(
			account, character.Id, timestamp.AddMinutes( 1 ) );

		Assert.IsTrue( failed.Failed );
		Assert.AreEqual( ErrorCode.InternalError, failed.Error!.Code );
		Assert.AreEqual( timestamp, environment.Repositories.Characters.Find(
			DomainKeys.Character( character.Id ) )!.Value.LastPlayedAt );
	}

	[TestMethod]
	public async Task PreparedTouchStagesIntoCombinedCommitAndPublishesOnlyOnMatchingCompletion()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId( 9104 );
		var character = ApplicationServiceTestEnvironment.Character( account, 0 ) with
		{
			LastPlayedAt = DateTimeOffset.UnixEpoch
		};
		var item = ApplicationServiceTestEnvironment.Item();
		await environment.SeedAsync( unitOfWork =>
		{
			unitOfWork.Create(
				environment.Repositories.Characters, DomainKeys.Character( character.Id ), character );
			unitOfWork.Create( environment.Repositories.Items, DomainKeys.Item( item.Id ), item );
		} );
		var events = new RecordingCharacterChangedHandler();
		var service = new AggregateMutationService(
			environment.Repositories,
			environment.Schema,
			ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
			ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>(),
			new PostCommitEventBus<CharacterChangedEvent>( new[]
			{
				new EventHandlerRegistration<CharacterChangedEvent>( "record", events )
			} ) );

		var timestamp = DateTimeOffset.UnixEpoch.AddMinutes( 1 );
		var prepared = service.PrepareTouchLastPlayed( account, character.Id, timestamp );
		Assert.IsTrue( prepared.Succeeded, prepared.Error?.Message );
		var unitOfWork = environment.Repositories.Provider.BeginUnitOfWork();
		Assert.IsTrue( service.StageTouchLastPlayed( unitOfWork, prepared.Value ).Succeeded );
		var itemDocument = environment.Repositories.Items.Find( DomainKeys.Item( item.Id ) )!;
		var itemEditor = unitOfWork.Edit( environment.Repositories.Items, itemDocument )!;
		itemEditor.Replace( itemEditor.Value with { Revision = 1 } );
		unitOfWork.Save( itemEditor );
		var committed = await unitOfWork.CommitAsync();
		await unitOfWork.DisposeAsync();
		Assert.IsTrue( committed.Succeeded, committed.Error?.Message );
		Assert.HasCount( 2, committed.Value!.Documents );
		Assert.IsEmpty( events.Events );

		var premature = service.CompleteTouchLastPlayed(
			prepared.Value, new CommitReceipt( committed.Value.Sequence, Array.Empty<CommittedDocumentVersion>() ) );
		Assert.AreEqual( ErrorCode.InvariantViolation, premature.Error!.Code );
		Assert.IsEmpty( events.Events );

		var completed = service.CompleteTouchLastPlayed( prepared.Value, committed.Value );
		Assert.IsTrue( completed.Succeeded, completed.Error?.Message );
		Assert.AreSame( committed.Value, completed.Value.Commit );
		Assert.AreEqual( timestamp, completed.Value.After.LastPlayedAt );
		Assert.HasCount( 1, events.Events );
		Assert.AreEqual( committed.Value.Sequence, events.Events[0].CommitSequence );
	}

	[TestMethod]
	public async Task CompletionSucceedsFromTheReceiptAloneAfterAnInterleavedCommit()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId( 9106 );
		var character = ApplicationServiceTestEnvironment.Character( account, 0 ) with
		{
			LastPlayedAt = DateTimeOffset.UnixEpoch
		};
		await environment.SeedAsync( unitOfWork =>
			unitOfWork.Create(
				environment.Repositories.Characters, DomainKeys.Character( character.Id ), character ) );
		var events = new RecordingCharacterChangedHandler();
		var service = new AggregateMutationService(
			environment.Repositories,
			environment.Schema,
			ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
			ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>(),
			new PostCommitEventBus<CharacterChangedEvent>( new[]
			{
				new EventHandlerRegistration<CharacterChangedEvent>( "record", events )
			} ) );

		var timestamp = DateTimeOffset.UnixEpoch.AddMinutes( 1 );
		var prepared = service.PrepareTouchLastPlayed( account, character.Id, timestamp );
		Assert.IsTrue( prepared.Succeeded, prepared.Error?.Message );
		var unitOfWork = environment.Repositories.Provider.BeginUnitOfWork();
		Assert.IsTrue( service.StageTouchLastPlayed( unitOfWork, prepared.Value ).Succeeded );
		var committed = await unitOfWork.CommitAsync();
		await unitOfWork.DisposeAsync();
		Assert.IsTrue( committed.Succeeded, committed.Error?.Message );

		var current = environment.Repositories.Characters.Find( DomainKeys.Character( character.Id ) )!;
		var competingUnit = environment.Repositories.Provider.BeginUnitOfWork();
		var competingEditor = competingUnit.Edit( environment.Repositories.Characters, current )!;
		competingEditor.Replace( competingEditor.Value with
		{
			LastPlayedAt = DateTimeOffset.UnixEpoch.AddMinutes( 2 )
		} );
		competingUnit.Save( competingEditor );
		Assert.IsTrue( (await competingUnit.CommitAsync()).Succeeded );
		await competingUnit.DisposeAsync();

		var completed = service.CompleteTouchLastPlayed( prepared.Value, committed.Value! );

		Assert.IsTrue( completed.Succeeded,
			"A durably committed mutation must complete from its receipt even after an interleaved commit." );
		Assert.AreEqual( timestamp, completed.Value.After.LastPlayedAt );
		Assert.HasCount( 1, events.Events );
	}

	[TestMethod]
	public async Task PreparedTouchCommitFailureAndRevisionConflictEmitNoEvent()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId( 9105 );
		var character = ApplicationServiceTestEnvironment.Character( account, 0 ) with
		{
			LastPlayedAt = DateTimeOffset.UnixEpoch
		};
		await environment.SeedAsync( unitOfWork =>
			unitOfWork.Create(
				environment.Repositories.Characters, DomainKeys.Character( character.Id ), character ) );
		var events = new RecordingCharacterChangedHandler();
		var service = new AggregateMutationService(
			environment.Repositories,
			environment.Schema,
			ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
			ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>(),
			new PostCommitEventBus<CharacterChangedEvent>( new[]
			{
				new EventHandlerRegistration<CharacterChangedEvent>( "record", events )
			} ) );

		var failedPlan = service.PrepareTouchLastPlayed(
			account, character.Id, DateTimeOffset.UnixEpoch.AddMinutes( 1 ) );
		var failedUnit = environment.Repositories.Provider.BeginUnitOfWork();
		Assert.IsTrue( service.StageTouchLastPlayed( failedUnit, failedPlan.Value ).Succeeded );
		environment.Provider.FailNextCommit();
		var failedCommit = await failedUnit.CommitAsync();
		await failedUnit.DisposeAsync();
		Assert.IsFalse( failedCommit.Succeeded );
		Assert.IsEmpty( events.Events );
		Assert.AreEqual( DateTimeOffset.UnixEpoch, environment.Repositories.Characters.Find(
			DomainKeys.Character( character.Id ) )!.Value.LastPlayedAt );

		var conflictedPlan = service.PrepareTouchLastPlayed(
			account, character.Id, DateTimeOffset.UnixEpoch.AddMinutes( 2 ) );
		var staleUnit = environment.Repositories.Provider.BeginUnitOfWork();
		Assert.IsTrue( service.StageTouchLastPlayed( staleUnit, conflictedPlan.Value ).Succeeded );
		var current = environment.Repositories.Characters.Find( DomainKeys.Character( character.Id ) )!;
		var competingUnit = environment.Repositories.Provider.BeginUnitOfWork();
		var competingEditor = competingUnit.Edit( environment.Repositories.Characters, current )!;
		competingEditor.Replace( competingEditor.Value with
		{
			LastPlayedAt = DateTimeOffset.UnixEpoch.AddMinutes( 3 )
		} );
		competingUnit.Save( competingEditor );
		var competingCommit = await competingUnit.CommitAsync();
		await competingUnit.DisposeAsync();
		Assert.IsTrue( competingCommit.Succeeded, competingCommit.Error?.Message );
		var conflict = await staleUnit.CommitAsync();
		await staleUnit.DisposeAsync();
		Assert.IsFalse( conflict.Succeeded );
		Assert.AreEqual( PersistenceErrorCode.RevisionConflict, conflict.Error!.Code );
		Assert.IsEmpty( events.Events );

		// Completion validates from the receipt alone; a receipt that does not contain the
		// prepared revision is rejected without publishing.
		var wrongCompletion = service.CompleteTouchLastPlayed(
			conflictedPlan.Value,
			new CommitReceipt( competingCommit.Value!.Sequence, Array.Empty<CommittedDocumentVersion>() ) );
		Assert.AreEqual( ErrorCode.InvariantViolation, wrongCompletion.Error!.Code );
		Assert.IsEmpty( events.Events );
	}

	[TestMethod]
	public async Task AggregateMutationFailuresLeaveTimestampBanBalanceStateAndTraitsUntouched()
	{
		await using var environment = await ApplicationServiceTestEnvironment.CreateAsync();
		var account = new AccountId(9102);
		var character = ApplicationServiceTestEnvironment.Character(account, 0) with
		{
			Balance = 10,
			LastPlayedAt = DateTimeOffset.UnixEpoch.AddHours(2)
		};
		var item = ApplicationServiceTestEnvironment.Item() with
		{
			Traits = new Dictionary<string, TypedPayload>(StringComparer.Ordinal)
			{
				["state"] = ApplicationServiceTestEnvironment.StatePayload("old")
			}
		};
		await environment.SeedAsync(unitOfWork =>
		{
			unitOfWork.Create(environment.Repositories.Characters, DomainKeys.Character(character.Id), character);
			unitOfWork.Create(environment.Repositories.Items, DomainKeys.Item(item.Id), item);
		});
		var service = new AggregateMutationService(
			environment.Repositories,
			environment.Schema,
			ApplicationServiceTestEnvironment.AllowPolicy<CharacterMutationContext>(),
			ApplicationServiceTestEnvironment.AllowPolicy<ItemTraitMutationContext>());

		var oldTimestamp = await service.TouchLastPlayedAsync(
			account, character.Id, DateTimeOffset.UnixEpoch);
		var nonUtcBan = await service.SetBanAsync(
			account,
			character.Id,
			true,
			new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)));
		var insufficientFunds = await service.DebitAsync(account, character.Id, 11);
		var unknownState = await service.ReplaceSchemaStateAsync(
			account,
			character.Id,
			ApplicationServiceTestEnvironment.StatePayload(typeId: "unknown.state"));
		var unknownTrait = await service.ReplaceTraitAsync(
			account,
			character.Id,
			item.Id,
			"state",
			ApplicationServiceTestEnvironment.StatePayload(typeId: "unknown.trait"));

		environment.Provider.FailNextCommit();
		var failedCredit = await service.CreditAsync(account, character.Id, 1);
		environment.Provider.FailNextCommit();
		var failedTraitCommit = await service.ReplaceTraitAsync(
			account,
			character.Id,
			item.Id,
			"state",
			ApplicationServiceTestEnvironment.StatePayload("new"));

		Assert.AreEqual(ErrorCode.InvalidArgument, oldTimestamp.Error!.Code);
		Assert.AreEqual(ErrorCode.InvalidArgument, nonUtcBan.Error!.Code);
		Assert.AreEqual(ErrorCode.Conflict, insufficientFunds.Error!.Code);
		Assert.AreEqual(ErrorCode.PersistedTypeInvalid, unknownState.Error!.Code);
		Assert.AreEqual(ErrorCode.PersistedTypeInvalid, unknownTrait.Error!.Code);
		Assert.AreEqual(ErrorCode.InternalError, failedCredit.Error!.Code);
		Assert.AreEqual(ErrorCode.InternalError, failedTraitCommit.Error!.Code);

		var storedCharacter = environment.Repositories.Characters.Find(DomainKeys.Character(character.Id))!.Value;
		var storedItem = environment.Repositories.Items.Find(DomainKeys.Item(item.Id))!.Value;
		Assert.AreEqual(character.LastPlayedAt, storedCharacter.LastPlayedAt);
		Assert.IsFalse(storedCharacter.IsBanned);
		Assert.IsNull(storedCharacter.BanExpiresAt);
		Assert.AreEqual(10L, storedCharacter.Balance);
		Assert.AreEqual("citizen", storedCharacter.SchemaState.Data.GetProperty("name").GetString());
		Assert.AreEqual("old", storedItem.Traits["state"].Data.GetProperty("name").GetString());
	}

	private sealed class RecordingCharacterChangedHandler : IEventHandler<CharacterChangedEvent>
	{
		public List<CharacterChangedEvent> Events { get; } = new();
		public void Handle( CharacterChangedEvent @event ) => Events.Add( @event );
	}
}
using Hexagon.V2.Application;
using Hexagon.V2.Domain;

namespace Hexagon.V2.Tests.Application;

[TestClass]
public sealed class SceneIdentityValidatorTests
{
	[TestMethod]
	public void ProvenanceClassifier_TrustsOnlyCapturedNonNetworkComponents()
	{
		var authored = Guid.NewGuid();
		var laterRuntime = Guid.NewGuid();
		var classifier = new SceneIdentityProvenanceClassifier();

		Assert.AreEqual(
			SceneIdentityProvenance.RuntimeOrNetwork,
			classifier.Classify( authored, hasActiveNetworkRoot: false ) );

		classifier.CaptureAuthoredSnapshot( new[] { authored } );

		Assert.IsTrue( classifier.HasAuthoredSnapshot );
		Assert.AreEqual( 1, classifier.AuthoredComponentCount );
		Assert.AreEqual(
			SceneIdentityProvenance.EditorAuthored,
			classifier.Classify( authored, hasActiveNetworkRoot: false ) );
		Assert.AreEqual(
			SceneIdentityProvenance.RuntimeOrNetwork,
			classifier.Classify( authored, hasActiveNetworkRoot: true ) );
		Assert.AreEqual(
			SceneIdentityProvenance.RuntimeOrNetwork,
			classifier.Classify( laterRuntime, hasActiveNetworkRoot: false ) );
	}

	[TestMethod]
	public void ProvenanceClassifier_NewSceneSnapshotReplacesPriorAuthority()
	{
		var firstScene = Guid.NewGuid();
		var secondScene = Guid.NewGuid();
		var classifier = new SceneIdentityProvenanceClassifier();
		classifier.CaptureAuthoredSnapshot( new[] { firstScene } );

		classifier.CaptureAuthoredSnapshot( new[] { secondScene } );

		Assert.AreEqual(
			SceneIdentityProvenance.RuntimeOrNetwork,
			classifier.Classify( firstScene, hasActiveNetworkRoot: false ) );
		Assert.AreEqual(
			SceneIdentityProvenance.EditorAuthored,
			classifier.Classify( secondScene, hasActiveNetworkRoot: false ) );
	}

	[TestMethod]
	public void EditorRepair_FillsMissingAndRepairsOnlyLaterDuplicate()
	{
		var duplicate = new SceneEntityId( Guid.Parse( "11111111-1111-1111-1111-111111111111" ) );
		var generated = new Queue<SceneEntityId>( new[]
		{
			new SceneEntityId( Guid.Parse( "22222222-2222-2222-2222-222222222222" ) ),
			new SceneEntityId( Guid.Parse( "33333333-3333-3333-3333-333333333333" ) )
		} );
		var result = SceneIdentityValidator.RepairForEditor( new[]
		{
			new SceneIdentityCandidate( "a", duplicate ),
			new SceneIdentityCandidate( "b", duplicate ),
			new SceneIdentityCandidate( "c", null )
		}, () => generated.Dequeue() );

		Assert.IsFalse( result[0].Repaired );
		Assert.IsTrue( result[1].Repaired );
		Assert.IsTrue( result[2].Repaired );
		Assert.AreNotEqual( result[0].EffectiveId, result[1].EffectiveId );
	}

	[TestMethod]
	public void RuntimeValidation_DisablesEveryAmbiguousOrBlankEntity()
	{
		var duplicate = new SceneEntityId( Guid.Parse( "11111111-1111-1111-1111-111111111111" ) );
		var result = SceneIdentityValidator.ValidateRuntime( new[]
		{
			new SceneIdentityCandidate( "a", duplicate ),
			new SceneIdentityCandidate( "b", duplicate ),
			new SceneIdentityCandidate( "c", null )
		} );

		Assert.IsTrue( result.All( value => !value.Enabled && value.FatalDiagnostic is not null ) );
	}

	[TestMethod]
	public void PersistentIndexEnumeratesTenThousandCandidatesOnceAndProvidesConstantTimeLookups()
	{
		var ids = Enumerable.Range( 0, 10_000 ).Select( _ => SceneEntityId.New() ).ToArray();
		var enumerated = 0;
		IEnumerable<SceneIdentityCandidate> Candidates()
		{
			for ( var index = 0; index < ids.Length; index++ )
			{
				enumerated++;
				yield return new SceneIdentityCandidate( $"entity/{index}", ids[index] );
			}
		}

		var index = PersistentSceneIdentityIndex.Build( Candidates() );

		Assert.AreEqual( 10_000, enumerated );
		Assert.AreEqual( 10_000, index.Count );
		for ( var candidate = 0; candidate < ids.Length; candidate++ )
		{
			Assert.IsTrue( index.TryResolveId( ids[candidate], out var byId ) );
			Assert.IsTrue( index.TryResolvePath( $"entity/{candidate}", out var byPath ) );
			Assert.AreSame( byId, byPath );
		}
	}

	[TestMethod]
	public void RuntimeNetworkDuplicateCannotPoisonEditorAuthoredIdentity()
	{
		var duplicate = SceneEntityId.New();
		var initial = PersistentSceneIdentityIndex.Build( new[]
		{
			new SceneIdentityCandidate( "first", duplicate )
		} );
		Assert.IsTrue( initial.TryResolveId( duplicate, out _ ) );

		var rebuilt = PersistentSceneIdentityIndex.Build( new[]
		{
			new SceneIdentityCandidate( "first", duplicate ),
			new SceneIdentityCandidate(
				"first", duplicate, SceneIdentityProvenance.RuntimeOrNetwork )
		} );

		Assert.IsTrue( rebuilt.TryResolveId( duplicate, out var resolved ) );
		Assert.AreEqual( "first", resolved.StablePath );
		Assert.IsTrue( rebuilt.Resolutions[0].Enabled );
		Assert.IsFalse( rebuilt.Resolutions[1].Enabled );
		Assert.AreEqual( SceneIdentityProvenance.RuntimeOrNetwork, rebuilt.Resolutions[1].Provenance );
		Assert.IsNull( rebuilt.Resolutions[1].EffectiveId );
		StringAssert.Contains( rebuilt.Resolutions[1].FatalDiagnostic, "runtime/network root" );
	}

	[TestMethod]
	public void TwoEditorAuthoredDuplicatesStillFailClosed()
	{
		var duplicate = SceneEntityId.New();
		var rebuilt = PersistentSceneIdentityIndex.Build( new[]
		{
			new SceneIdentityCandidate( "first", duplicate ),
			new SceneIdentityCandidate( "second", duplicate )
		} );

		Assert.IsFalse( rebuilt.TryResolveId( duplicate, out _ ) );
		Assert.IsTrue( rebuilt.Resolutions.All( resolution => !resolution.Enabled ) );
	}
}
using System.IO;

namespace Hexagon.V2.Tests.Foundation;

[TestClass]
public sealed class SandboxCompatibilityTests
{
	private static readonly string[] ForbiddenSourceTokens =
	[
		".ConfigureAwait(",
		"Volatile.",
		"ReaderWriterLockSlim",
		"CryptographicOperations.FixedTimeEquals",
		".IsInterface",
		".IsByRef",
		".IsPointer",
		".IsInstanceOfType",
		"ExceptionDispatchInfo",
		"System.Reflection",
		"RuntimeHelpers.",
		"Activator.CreateInstance",
		".GetProperties(",
		".GetFields(",
		".MakeGenericType(",
		".GetGenericArguments(",
		"await using",
		"Task.WhenAll(",
		"TaskScheduler",
		"Environment.ProcessId"
	];

	private static readonly (string Name, string Pattern)[] ForbiddenRawIoSignatures =
	[
		("File static API", @"\bFile\s*\."),
		("Directory static API", @"\bDirectory\s*\."),
		("Path.GetFullPath", @"\bPath\s*\.\s*GetFullPath\s*\("),
		("FileStream", @"\bFileStream\b"),
		("FileInfo", @"\bFileInfo\b"),
		("DirectoryInfo", @"\bDirectoryInfo\b"),
		("FileMode", @"\bFileMode\b"),
		("FileAccess", @"\bFileAccess\b"),
		("FileShare", @"\bFileShare\b"),
		("FileOptions", @"\bFileOptions\b"),
		("SearchOption", @"\bSearchOption\b")
	];

	[TestMethod]
	public void SandboxIndependentV2CodeAvoidsKnownWhitelistViolations()
	{
		var root = FindHexagonRoot();
		var sourceRoots = new[] { Path.Combine( root, "Code", "V2" ) }
			.Where( Directory.Exists )
			.ToArray();

		var violations = new List<string>();
		foreach ( var path in sourceRoots.SelectMany( sourceRoot =>
			Directory.GetFiles( sourceRoot, "*.cs", SearchOption.AllDirectories ) )
			.Where( path => !path.Contains(
				$"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}",
				StringComparison.OrdinalIgnoreCase ) ) )
		{
			var source = File.ReadAllText( path );
			if ( System.Text.RegularExpressions.Regex.IsMatch(
				source,
				@"^\s*(?:private|protected|internal|public)\s+(?:static\s+)?volatile\s+",
				System.Text.RegularExpressions.RegexOptions.Multiline ) )
				violations.Add(
					$"{Path.GetRelativePath( root, path )} declares a volatile field (lowers to forbidden IsVolatile)" );
			if ( System.Text.RegularExpressions.Regex.IsMatch(
				source,
				@"finally\s*\{[^{}]*\bawait\b",
				System.Text.RegularExpressions.RegexOptions.Singleline ) )
				violations.Add(
					$"{Path.GetRelativePath( root, path )} awaits inside a finally block (lowers to forbidden ExceptionDispatchInfo)" );
			if ( System.Text.RegularExpressions.Regex.IsMatch(
				source,
				@"catch(?:\s*\([^)]*\))?\s*\{[^{}]*\bawait\b[^{}]*\bthrow\s*;",
				System.Text.RegularExpressions.RegexOptions.Singleline ) )
				violations.Add(
					$"{Path.GetRelativePath( root, path )} awaits before a bare catch rethrow (lowers to forbidden ExceptionDispatchInfo)" );
			var lines = File.ReadAllLines( path );
			for ( var index = 0; index < lines.Length; index++ )
			{
				foreach ( var token in ForbiddenSourceTokens )
				{
					if ( lines[index].Contains( token, StringComparison.Ordinal ) )
						violations.Add( $"{Path.GetRelativePath( root, path )}:{index + 1} contains '{token}'" );
				}

				foreach ( var signature in FindForbiddenRawIoSignatures( lines[index] ) )
					violations.Add(
						$"{Path.GetRelativePath( root, path )}:{index + 1} uses forbidden raw I/O signature '{signature}'" );
			}
		}

		Assert.HasCount(
			0,
			violations,
			"Known s&box whitelist violations were found:" + Environment.NewLine + string.Join( Environment.NewLine, violations ) );
	}

	[TestMethod]
	public void RawIoSignatureGuardCoversOriginalSb1000SurfaceWithoutFalsePositives()
	{
		// Keep fixtures split so the production-source scanner can never match this test file if its roots expand.
		var separator = string.Empty;
		var forbiddenFixtures = new[]
		{
			"File" + separator + ".Exists( path )",
			"Path" + separator + ".GetFullPath( path )",
			"File" + separator + "Stream? lease",
			"File" + separator + "Info info",
			"Directory" + separator + "Info directory",
			"File" + separator + "Mode.OpenOrCreate",
			"File" + separator + "Access.ReadWrite",
			"File" + separator + "Share.None",
			"File" + separator + "Options.WriteThrough",
			"Search" + separator + "Option.AllDirectories",
			"Directory" + separator + " . EnumerateFiles( root )"
		};

		foreach ( var fixture in forbiddenFixtures )
			Assert.IsNotEmpty(
				FindForbiddenRawIoSignatures( fixture ),
				$"Expected raw I/O fixture to be rejected: {fixture}" );

		var allowedFixtures = new[]
		{
			"var path = issue.Path;",
			"var fileName = record.FileName;",
			"IReadOnlyList<string> directories",
			"storage.ExistsAsync( path, cancellationToken )"
		};

		foreach ( var fixture in allowedFixtures )
			Assert.IsEmpty(
				FindForbiddenRawIoSignatures( fixture ),
				$"Expected non-I/O fixture to remain allowed: {fixture}" );
	}

	private static string[] FindForbiddenRawIoSignatures( string sourceLine ) => ForbiddenRawIoSignatures
		.Where( signature => System.Text.RegularExpressions.Regex.IsMatch(
			sourceLine,
			signature.Pattern,
			System.Text.RegularExpressions.RegexOptions.CultureInvariant ) )
		.Select( signature => signature.Name )
		.ToArray();

	private static string FindHexagonRoot()
	{
		var directory = new DirectoryInfo( AppContext.BaseDirectory );
		while ( directory is not null && !File.Exists( Path.Combine( directory.FullName, "hexagon.sbproj" ) ) )
			directory = directory.Parent;

		Assert.IsNotNull( directory, "Could not locate the Hexagon repository root." );
		return directory.FullName;
	}
}
#nullable enable

using System;
using System.IO;
using System.Text.RegularExpressions;
using Hexagon.V2.Networking;
using Hexagon.V2.Tests.Foundation;

namespace Hexagon.V2.Tests.Networking;

/// <summary>
/// Sustained-run guards for the per-connection command budget, and a pin tying the numbers
/// <c>docs/security.md</c> publishes to the constants that enforce them.
/// <para>
/// The movement validator's failure was that a per-tick allowance was never checked as a rate, so prose
/// describing a bound and code granting one every tick could both read as true while disagreeing by
/// fifty times. Every other rate-limited surface therefore gets the same treatment: the published number
/// is read out of the document and asserted against the constant, and the constant is asserted against
/// what a client can actually extract over a run.
/// </para>
/// </summary>
[TestClass]
public sealed class CommandAdmissionSustainedTests
{
	private const long Frequency = 1000;          // timestamps in milliseconds
	private const int Hz = 50;
	private const int TickMilliseconds = 1000 / Hz;
	private const int Ticks = 10 * Hz;            // ten seconds
	private const double ElapsedSeconds = 10.0;

	[TestMethod]
	public void TheSecurityDocumentPublishesTheBudgetThatIsActuallyEnforced()
	{
		// docs/security.md: "a weighted per-connection token bucket before payload construction:
		// 16-unit burst, 8 units/second refill, and at most 16 active requests."
		// Reading the numbers out of the document makes the prose a claim this suite owns: editing
		// either side without the other fails here rather than shipping a document that describes a
		// system nobody built.
		var document = File.ReadAllText( Path.Combine( RepositoryRoot(), "docs", "security.md" ) );

		var match = Regex.Match( document,
			@"(?<burst>\d+)-unit burst,\s*(?<refill>\d+)\s*units?/second refill,\s*and at most\s*(?<active>\d+)\s*active requests" );
		Assert.IsTrue( match.Success,
			"docs/security.md no longer states the command budget in the form this guard reads. " +
			"Update the guard deliberately rather than letting the published numbers go unchecked." );

		Assert.AreEqual( CommandAdmissionController.BurstUnits, int.Parse( match.Groups["burst"].Value ),
			"Documented burst does not match the enforced constant." );
		Assert.AreEqual( CommandAdmissionController.RefillUnitsPerSecond, int.Parse( match.Groups["refill"].Value ),
			"Documented refill rate does not match the enforced constant." );
		Assert.AreEqual( CommandAdmissionController.MaximumActiveRequests, int.Parse( match.Groups["active"].Value ),
			"Documented active-request cap does not match the enforced constant." );
	}

	[TestMethod]
	public void SustainedAdmissionConvergesOnTheRefillRateNotTheBurst()
	{
		// The burst is a one-off; the refill rate is the ceiling. A bucket that re-granted its burst
		// the way the movement validator re-granted its skin would admit 800 units here, not 96.
		var controller = new CommandAdmissionController( Frequency );
		var admittedCount = 0;

		var run = SustainedEnvelope.Measure( Ticks, 1.0 / Hz, tick =>
		{
			var timestamp = (long)tick * TickMilliseconds;
			var requestId = new CommandRequestId( Guid.NewGuid() );
			var result = controller.TryBegin( requestId, cost: 1, timestamp );
			if ( !result.Accepted ) return SustainedEnvelope.StepOutcome.Rejected;
			controller.Finish( requestId );   // keep the active-request bound out of the way
			admittedCount++;
			return SustainedEnvelope.StepOutcome.Allowed( 1 );
		} );

		var bound = (CommandAdmissionController.RefillUnitsPerSecond * ElapsedSeconds)
			+ CommandAdmissionController.BurstUnits;

		Assert.IsLessThanOrEqualTo( bound, run.TotalWork, SustainedEnvelope.Describe(
			$"Sustained command admission over 10s ({run.WorkPerSecond:F1}/s)",
			run.TotalWork, bound, " units" ) );
		Assert.IsGreaterThan( 0, admittedCount,
			"The run admitted nothing, so the bound above proves nothing about the bucket." );
	}

	[TestMethod]
	public void RejectedAttemptsAreNotRefundedAcrossASustainedRun()
	{
		// docs/security.md: "Rejected, duplicate, and malformed attempts are not refunded." A refund on
		// rejection would make the bucket a no-op under exactly the load it exists to bound.
		var controller = new CommandAdmissionController( Frequency );
		var duplicate = new CommandRequestId( Guid.NewGuid() );

		// Drain the burst with duplicates, which are charged and then refused.
		for ( var i = 0; i < CommandAdmissionController.BurstUnits; i++ )
			controller.TryBegin( duplicate, cost: 1, 0 );

		Assert.IsLessThanOrEqualTo( 0.001, controller.AvailableUnits,
			$"Duplicates were refunded: {controller.AvailableUnits:F2} units remain after draining the burst." );
	}

	[TestMethod]
	public void ACostlierCommandDrainsTheBudgetProportionally()
	{
		// The bucket is weighted, so an expensive command must consume its weight rather than one slot.
		var controller = new CommandAdmissionController( Frequency );
		var admitted = 0;
		for ( var i = 0; i < 100; i++ )
		{
			var requestId = new CommandRequestId( Guid.NewGuid() );
			if ( !controller.TryBegin( requestId, CommandAdmissionController.BurstUnits, 0 ).Accepted ) break;
			controller.Finish( requestId );
			admitted++;
		}

		Assert.AreEqual( 1, admitted,
			"A full-burst-cost command should be admissible exactly once from a full bucket." );
	}

	private static string RepositoryRoot()
	{
		var directory = new DirectoryInfo( AppContext.BaseDirectory );
		while ( directory is not null && !File.Exists( Path.Combine( directory.FullName, "hexagon.sbproj" ) ) )
			directory = directory.Parent;
		Assert.IsNotNull( directory, "Could not locate the Hexagon repository root." );
		return directory!.FullName;
	}
}
#nullable enable

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Networking;

namespace Hexagon.V2.Tests.Networking;

[TestClass]
public sealed class CommandDispatchOrchestratorTests
{
	[TestMethod]
	public void UnauthenticatedCallerIsRejectedBeforeAnyChargeOrResolution()
	{
		var host = new FakeDispatchHost { Authenticated = false };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.IsEmpty( host.Calls, "Unauthenticated traffic must never reach the per-connection bucket." );
		Assert.HasCount( 1, host.Sent );
		Assert.AreEqual( "caller", host.Sent[0].Target );
		Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ChargeHappensBeforeResolutionAndCarriesTheDeclaredCost()
	{
		var host = new FakeDispatchHost();

		CommandDispatchOrchestrator.Dispatch( host, Header(), 3, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( "charge:3", host.Calls[0], "The caller is charged before its scope is inspected." );
		Assert.AreEqual( "resolve:initial", host.Calls[1] );
	}

	[TestMethod]
	public void RateLimitedAdmissionCarriesRetryAfterAndSkipsResolution()
	{
		var host = new FakeDispatchHost
		{
			Admission = CommandAdmissionResult.Reject(
				CommandAdmissionFailure.RateLimited, TimeSpan.FromSeconds( 2 ) )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 4, static () => new RequestCharacterListCommand() );

		CollectionAssert.AreEqual( new[] { "charge:4" }, host.Calls );
		Assert.HasCount( 1, host.Sent );
		Assert.AreEqual( ErrorCode.RateLimited, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void DuplicateRequestIdsConflict()
	{
		var host = new FakeDispatchHost
		{
			Admission = CommandAdmissionResult.Reject( CommandAdmissionFailure.Duplicate )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( ErrorCode.Conflict, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ResolutionFailureIsForwardedAndTheAcceptedChargeIsClosed()
	{
		var host = new FakeDispatchHost
		{
			Resolution = OperationResult<string>.Failure( ErrorCode.Unauthorized, "scope mismatch" )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		CollectionAssert.AreEqual(
			new[] { "charge:1", "resolve:initial", "finish-rejected" },
			host.Calls );
		Assert.AreEqual( "scope mismatch", host.Sent[0].Result.Error!.Message );
	}

	[TestMethod]
	public void EmptyRequestIdCompletesWithInvalidArgument()
	{
		var host = new FakeDispatchHost();
		// Wire deserialization can materialize a default header; the constructor itself
		// rejects empty request ids, so the guard is only reachable through default structs.
		var header = default( ClientCommandHeader );

		CommandDispatchOrchestrator.Dispatch( host, header, 1, static () => new RequestCharacterListCommand() );

		CollectionAssert.Contains( host.Calls, "complete" );
		Assert.HasCount( 1, host.Sent );
		Assert.AreEqual( "actor", host.Sent[0].Target );
		Assert.AreEqual( ErrorCode.InvalidArgument, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ThrowingCommandFactoryFailsClosedWithoutStartingAHostOperation()
	{
		var host = new FakeDispatchHost();

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1,
			static () => throw new FormatException( "bad wire payload" ) );

		Assert.HasCount( 1, host.MalformedPayloads );
		CollectionAssert.DoesNotContain( host.Calls, "host-operation" );
		Assert.AreEqual( ErrorCode.InvalidArgument, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void OversizedPayloadIsRejectedBeforeStableResolutionAndExecution()
	{
		var host = new FakeDispatchHost();

		CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
			static () => new SendChatCommand( "ic", new string( 'a', 1_000_000 ) ) );

		CollectionAssert.DoesNotContain( host.Calls, "resolve:stable" );
		CollectionAssert.DoesNotContain( host.Calls, "host-operation" );
		Assert.HasCount( 1, host.Sent );
		Assert.IsFalse( host.Sent[0].Result.Succeeded );
	}

	[TestMethod]
	public void StableCharacterCommandsAreReResolvedAfterPayloadValidation()
	{
		var host = new FakeDispatchHost();

		CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
			static () => new SendChatCommand( "ic", "hello" ) );

		CollectionAssert.AreEqual(
			new[]
			{
				"charge:2", "resolve:initial", "resolve:stable",
				"host-operation", "lease-check", "execute", "complete"
			},
			host.Calls );
		Assert.AreEqual( "stable-actor", host.Sent[0].Target,
			"Execution and completion must use the re-resolved stable actor." );
	}

	[TestMethod]
	public void StableResolutionFailureCompletesWithoutExecuting()
	{
		var host = new FakeDispatchHost
		{
			StableResolution = OperationResult<string>.Failure(
				ErrorCode.Unauthorized, "character changed" )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 2,
			static () => new SendChatCommand( "ic", "hello" ) );

		CollectionAssert.DoesNotContain( host.Calls, "execute" );
		Assert.AreEqual( "character changed", host.Sent[0].Result.Error!.Message );
	}

	[TestMethod]
	public void CharacterLifecycleCommandsSkipTheStableCharacterResolution()
	{
		foreach ( var factory in new Func<ClientCommand>[]
		{
			static () => new RequestCharacterListCommand(),
			static () => new LoadCharacterCommand( CharacterId.New() ),
			static () => new DeleteCharacterCommand( CharacterId.New() ),
			static () => new UnloadCharacterCommand()
		} )
		{
			var host = new FakeDispatchHost();
			CommandDispatchOrchestrator.Dispatch( host, Header(), 1, factory );
			CollectionAssert.DoesNotContain( host.Calls, "resolve:stable",
				$"{factory().GetType().Name} must dispatch without an active character." );
			Assert.AreEqual( "actor", host.Sent[0].Target );
		}
	}

	[TestMethod]
	public void HostOperationRefusalCompletesWithDrainingConflict()
	{
		var host = new FakeDispatchHost { AcceptHostOperation = false };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( ErrorCode.Conflict, host.Sent[0].Result.Error!.Code );
		StringAssert.Contains( host.StartedOperationName!, "command:" );
	}

	[TestMethod]
	public void StaleLeaseInsideTheHostOperationRejectsBeforeExecution()
	{
		var host = new FakeDispatchHost { LeaseCurrent = false };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		CollectionAssert.DoesNotContain( host.Calls, "execute" );
		Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void MissingHostApplicationCompletesWithInternalError()
	{
		var host = new FakeDispatchHost { ExecutionAvailable = false };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		CollectionAssert.DoesNotContain( host.Calls, "execute" );
		Assert.AreEqual( ErrorCode.InternalError, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ExecutionFaultBecomesInternalError()
	{
		var host = new FakeDispatchHost
		{
			Execution = static () => throw new InvalidOperationException( "handler exploded" )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.HasCount( 1, host.ExecutionFaults );
		Assert.AreEqual( ErrorCode.InternalError, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void ExecutionFaultUnderCancellationBecomesUnauthorized()
	{
		using var cancellation = new CancellationTokenSource();
		cancellation.Cancel();
		var host = new FakeDispatchHost
		{
			Cancellation = cancellation.Token,
			Execution = static () => throw new OperationCanceledException()
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( ErrorCode.Unauthorized, host.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void DisconnectedCompletionSendsNothing()
	{
		var host = new FakeDispatchHost { CompletionStatus = CommandCompletionStatus.Disconnected };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.IsEmpty( host.Sent );
	}

	[TestMethod]
	public void StaleCompletionKeepsASuccessfulExecutionResultButRejectsAFailedOne()
	{
		var success = new FakeDispatchHost { CompletionStatus = CommandCompletionStatus.Stale };
		CommandDispatchOrchestrator.Dispatch( success, Header(), 1, static () => new RequestCharacterListCommand() );
		Assert.IsTrue( success.Sent[0].Result.Succeeded,
			"A successful application result stays authoritative even under a stale lease." );

		var failure = new FakeDispatchHost
		{
			CompletionStatus = CommandCompletionStatus.Stale,
			Execution = static () => ValueTask.FromResult(
				OperationResult.Failure( ErrorCode.NotFound, "gone" ) )
		};
		CommandDispatchOrchestrator.Dispatch( failure, Header(), 1, static () => new RequestCharacterListCommand() );
		Assert.AreEqual( ErrorCode.Unauthorized, failure.Sent[0].Result.Error!.Code );
	}

	[TestMethod]
	public void SuccessfulExecutionForwardsTheApplicationResult()
	{
		var host = new FakeDispatchHost
		{
			Execution = static () => ValueTask.FromResult(
				OperationResult.Failure( ErrorCode.PolicyDenied, "application said no" ) )
		};

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.AreEqual( ErrorCode.PolicyDenied, host.Sent[0].Result.Error!.Code );
		Assert.AreEqual( "application said no", host.Sent[0].Result.Error!.Message );
	}

	[TestMethod]
	public void ThrowingCompletionHookIsObservedWithoutEscaping()
	{
		var host = new FakeDispatchHost { CompleteThrows = true };

		CommandDispatchOrchestrator.Dispatch( host, Header(), 1, static () => new RequestCharacterListCommand() );

		Assert.HasCount( 1, host.CompletionFaults );
		Assert.IsEmpty( host.Sent );
	}

	[TestMethod]
	public void CommandCostTableIsPinned()
	{
		var table = new Dictionary<string, int>( StringComparer.Ordinal )
		{
			[nameof( ClientCommandCosts.CharacterList )] = ClientCommandCosts.CharacterList,
			[nameof( ClientCommandCosts.CreateCharacter )] = ClientCommandCosts.CreateCharacter,
			[nameof( ClientCommandCosts.LoadCharacter )] = ClientCommandCosts.LoadCharacter,
			[nameof( ClientCommandCosts.DeleteCharacter )] = ClientCommandCosts.DeleteCharacter,
			[nameof( ClientCommandCosts.UnloadCharacter )] = ClientCommandCosts.UnloadCharacter,
			[nameof( ClientCommandCosts.MoveItem )] = ClientCommandCosts.MoveItem,
			[nameof( ClientCommandCosts.ItemAction )] = ClientCommandCosts.ItemAction,
			[nameof( ClientCommandCosts.DropItem )] = ClientCommandCosts.DropItem,
			[nameof( ClientCommandCosts.PickupItem )] = ClientCommandCosts.PickupItem,
			[nameof( ClientCommandCosts.Chat )] = ClientCommandCosts.Chat,
			[nameof( ClientCommandCosts.CancelAction )] = ClientCommandCosts.CancelAction,
			[nameof( ClientCommandCosts.BeginInteraction )] = ClientCommandCosts.BeginInteraction,
			[nameof( ClientCommandCosts.ContinueInteraction )] = ClientCommandCosts.ContinueInteraction,
			[nameof( ClientCommandCosts.CloseInteraction )] = ClientCommandCosts.CloseInteraction,
			[nameof( ClientCommandCosts.SchemaCommandFallback )] = ClientCommandCosts.SchemaCommandFallback
		};
		var expected = new Dictionary<string, int>( StringComparer.Ordinal )
		{
			["CharacterList"] = 1,
			["CreateCharacter"] = 4,
			["LoadCharacter"] = 4,
			["DeleteCharacter"] = 4,
			["UnloadCharacter"] = 1,
			["MoveItem"] = 4,
			["ItemAction"] = 4,
			["DropItem"] = 4,
			["PickupItem"] = 4,
			["Chat"] = 2,
			["CancelAction"] = 1,
			["BeginInteraction"] = 2,
			["ContinueInteraction"] = 1,
			["CloseInteraction"] = 1,
			["SchemaCommandFallback"] = 8
		};
		CollectionAssert.AreEquivalent( expected, table );
	}

	[TestMethod]
	public void OnlyCharacterLifecycleCommandsAreExemptFromTheStableCharacterRequirement()
	{
		Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter( new RequestCharacterListCommand() ) );
		Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter(
			new LoadCharacterCommand( CharacterId.New() ) ) );
		Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter(
			new DeleteCharacterCommand( CharacterId.New() ) ) );
		Assert.IsFalse( CommandDispatchOrchestrator.RequiresStableCharacter( new UnloadCharacterCommand() ) );
		Assert.IsTrue( CommandDispatchOrchestrator.RequiresStableCharacter( new SendChatCommand( "ic", "hi" ) ) );
		Assert.IsTrue( CommandDispatchOrchestrator.RequiresStableCharacter(
			new CloseInteractionCommand( InteractionSessionId.New() ) ) );
	}

	private static ClientSessionScope Scope() =>
		new( ClientSessionNonce.New(), ConnectionEpoch.New() );

	private static ClientCommandHeader Header() =>
		new( Scope(), new CommandRequestId( Guid.NewGuid() ) );

	private sealed class FakeDispatchHost : ICommandDispatchHost<string>
	{
		public List<string> Calls { get; } = new();
		public bool Authenticated { get; set; } = true;
		public bool ExecutionAvailable { get; set; } = true;
		public CommandAdmissionResult Admission { get; set; } = CommandAdmissionResult.Success();
		public OperationResult<string> Resolution { get; set; } = OperationResult<string>.Success( "actor" );
		public OperationResult<string> StableResolution { get; set; } = OperationResult<string>.Success( "stable-actor" );
		public bool LeaseCurrent { get; set; } = true;
		public bool AcceptHostOperation { get; set; } = true;
		public bool CompleteThrows { get; set; }
		public Func<ValueTask<OperationResult>>? Execution { get; set; }
		public CancellationToken Cancellation { get; set; }
		public CommandCompletionStatus CompletionStatus { get; set; } = CommandCompletionStatus.Current;
		public List<(string Target, OperationResult Result)> Sent { get; } = new();
		public List<Exception> MalformedPayloads { get; } = new();
		public List<Exception> ExecutionFaults { get; } = new();
		public List<Exception> CompletionFaults { get; } = new();
		public string? StartedOperationName { get; private set; }

		public bool CallerIsAuthenticated => Authenticated;
		public bool IsExecutionAvailable => ExecutionAvailable;

		public CommandAdmissionResult TryBeginCommand( CommandRequestId requestId, int cost )
		{
			Calls.Add( $"charge:{cost}" );
			return Admission;
		}

		public OperationResult<string> ResolveActor( ClientSessionScope scope, bool requireStableCharacter )
		{
			Calls.Add( requireStableCharacter ? "resolve:stable" : "resolve:initial" );
			return requireStableCharacter ? StableResolution : Resolution;
		}

		public void FinishRejectedCommand( CommandRequestId requestId ) => Calls.Add( "finish-rejected" );

		public bool IsCommandLeaseCurrent( string actor )
		{
			Calls.Add( "lease-check" );
			return LeaseCurrent;
		}

		public bool TryStartHostOperation( string name, Func<Task> operation )
		{
			Calls.Add( "host-operation" );
			StartedOperationName = name;
			if ( !AcceptHostOperation ) return false;
			operation().GetAwaiter().GetResult();
			return true;
		}

		public CancellationToken CommandCancellation( string actor ) => Cancellation;

		public ValueTask<OperationResult> ExecuteAsync(
			string actor,
			ClientCommand command,
			CancellationToken cancellationToken )
		{
			Calls.Add( "execute" );
			return Execution?.Invoke() ?? ValueTask.FromResult( OperationResult.Success() );
		}

		public CommandCompletionStatus CompleteCommand( string actor, CommandRequestId requestId )
		{
			Calls.Add( "complete" );
			if ( CompleteThrows ) throw new InvalidOperationException( "completion registry exploded" );
			return CompletionStatus;
		}

		public void SendResultToCaller( ClientSessionScope scope, CommandRequestId requestId, OperationResult result ) =>
			Sent.Add( ("caller", result) );

		public void SendResult( string actor, CommandRequestId requestId, OperationResult result ) =>
			Sent.Add( (actor, result) );

		public void OnMalformedPayload( Exception exception ) => MalformedPayloads.Add( exception );
		public void OnExecutionFault( CommandRequestId requestId, Exception exception ) => ExecutionFaults.Add( exception );
		public void OnCompletionFault( CommandRequestId requestId, Exception exception ) => CompletionFaults.Add( exception );
	}
}
#nullable enable

using System;
using Hexagon.V2.Runtime;
using static Hexagon.V2.Tests.Foundation.SustainedEnvelope.EngineDefaults;

namespace Hexagon.V2.Tests.Runtime;

/// <summary>
/// Guards for the windowed movement audit.
/// <para>
/// The host observes an INTERPOLATED proxy transform, not what the client reported, so its per-tick
/// deltas are artifacts of network buffering. The audit's soundness rests on one property —
/// <b>summing deltas over a window is invariant to how the travel is chunked</b> — and
/// <see cref="AnIdenticalPathIsMeasuredTheSameHoweverItIsChunked"/> is the test that pins it. Everything
/// else here is a bound stated over a window; nothing is stated per tick, because per tick the signal
/// carries no truth.
/// </para>
/// </summary>
[TestClass]
public sealed class HexMovementValidatorTests
{
	private static readonly HexMovementEnvelope Envelope = HexMovementEnvelope.Default;

	private static MovementSample At( float x, float y, float z ) => new( x, y, z );

	/// <summary>
	/// Walks a straight horizontal path of <paramref name="totalDistance"/> over
	/// <paramref name="seconds"/>, delivered in <paramref name="samples"/> chunks, and returns the
	/// decision of the window that closes.
	/// </summary>
	private static HexMovementValidator.Decision WalkHorizontally(
		float totalDistance, double seconds, int samples, bool frozen = false )
	{
		var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
		var last = default( HexMovementValidator.Decision );
		for ( var i = 1; i <= samples; i++ )
		{
			var t = seconds * i / samples;
			var x = totalDistance * i / samples;
			(last, audit) = HexMovementValidator.Observe(
				audit, At( (float)x, 0, 0 ), t, RunSpeed, JumpSpeed, frozen, Envelope );
		}
		return last;
	}

	[TestMethod]
	public void AnIdenticalPathIsMeasuredTheSameHoweverItIsChunked()
	{
		// THE property the whole design rests on. The interpolation buffer decides how travel is sliced
		// — smoothly, in bursts of three ticks, or irregularly — and the audit must be blind to that.
		// Per-tick checking is not: measured live, an honest sprint arrived as 19.2-unit bursts against
		// a ~15-unit per-tick budget and was refused twelve times.
		var smooth = WalkHorizontally( 300f, 1.0, samples: 60 );
		var bursty = WalkHorizontally( 300f, 1.0, samples: 20 );
		var coarse = WalkHorizontally( 300f, 1.0, samples: 5 );

		Assert.AreEqual( smooth.HorizontalPath, bursty.HorizontalPath, 0.01f,
			"Chunking changed the measured path; the audit is not sampling-invariant." );
		Assert.AreEqual( smooth.HorizontalPath, coarse.HorizontalPath, 0.01f,
			"Chunking changed the measured path; the audit is not sampling-invariant." );
	}

	[TestMethod]
	public void AnHonestSprintIsAcceptedHoweverItIsSampled()
	{
		// A player at exactly run speed for a full window, delivered every way the network might.
		foreach ( var samples in new[] { 60, 20, 12, 5 } )
		{
			var decision = WalkHorizontally( RunSpeed, 1.0, samples );
			Assert.IsTrue( decision.WindowClosed, $"Window did not close for {samples} samples." );
			Assert.IsFalse( decision.Corrected,
				$"Honest sprint flagged at {samples} samples/window: " +
				$"path {decision.HorizontalPath:F1} vs budget {decision.HorizontalBudget:F1}." );
		}
	}

	[TestMethod]
	public void ASpeedCheatIsFlagged()
	{
		// Twice run speed for a window: 640 units against a 496-unit budget.
		var decision = WalkHorizontally( RunSpeed * 2f, 1.0, samples: 60 );
		Assert.AreEqual( HexMovementValidator.Verdict.WindowExceeded, decision.Verdict,
			$"path {decision.HorizontalPath:F1} vs budget {decision.HorizontalBudget:F1}" );
	}

	[TestMethod]
	public void SustainedClimbBeyondTheGroundAngleIsFlagged()
	{
		// Rising far faster than the steepest standable surface would permit for the distance walked.
		var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
		var decision = default( HexMovementValidator.Decision );
		for ( var i = 1; i <= 60; i++ )
		{
			// 100 units of horizontal travel, 900 units of rise, in one window.
			(decision, audit) = HexMovementValidator.Observe(
				audit, At( 100f * i / 60f, 0, 900f * i / 60f ), i / 60.0,
				RunSpeed, JumpSpeed, false, Envelope );
		}
		Assert.AreEqual( HexMovementValidator.Verdict.WindowExceeded, decision.Verdict,
			$"rise {decision.NetRise:F1} vs budget {decision.RiseBudget:F1}" );
	}

	[TestMethod]
	public void AJumpAndLandIsNotAClimb()
	{
		// Net rise over a window is what "climbing" means. Jumping repeatedly nets nothing, and must
		// not accumulate into a violation the way summed positive rise would.
		var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
		var decision = default( HexMovementValidator.Decision );
		for ( var i = 1; i <= 60; i++ )
		{
			var t = i / 60.0;
			var phase = Math.Sin( t * Math.PI * 4 );           // two full jump arcs in the window
			var z = (float)(Math.Max( phase, 0 ) * 56.0);      // apex ~ one jump
			(decision, audit) = HexMovementValidator.Observe(
				audit, At( 200f * i / 60f, 0, z ), t, RunSpeed, JumpSpeed, false, Envelope );
		}
		Assert.IsFalse( decision.Corrected,
			$"Jumping while running was flagged: rise {decision.NetRise:F1} vs {decision.RiseBudget:F1}" );
	}

	[TestMethod]
	public void ATeleportIsRefusedImmediatelyWithoutWaitingForTheWindow()
	{
		var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
		var (decision, _) = HexMovementValidator.Observe(
			audit, At( 5000, 0, 0 ), 0.016, RunSpeed, JumpSpeed, false, Envelope );

		Assert.AreEqual( HexMovementValidator.Verdict.Teleport, decision.Verdict );
		Assert.IsFalse( decision.WindowClosed, "A teleport is caught on the sample, not at window end." );
	}

	[TestMethod]
	public void AnInterpolationSizedBurstIsNotMistakenForATeleport()
	{
		// The bursts that broke per-tick checking (~19 units) must be nowhere near the teleport guard.
		var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
		var (decision, _) = HexMovementValidator.Observe(
			audit, At( 19.2f, 0, 0 ), 0.016, RunSpeed, JumpSpeed, false, Envelope );
		Assert.AreEqual( HexMovementValidator.Verdict.Accepted, decision.Verdict );
	}

	[TestMethod]
	public void FrozenPlayersAreHeldToTheDriftAllowance()
	{
		var moved = WalkHorizontally( 200f, 1.0, samples: 60, frozen: true );
		Assert.AreEqual( HexMovementValidator.Verdict.WindowExceeded, moved.Verdict );

		var jitter = WalkHorizontally( Envelope.FrozenDriftAllowance * 0.5f, 1.0, samples: 60, frozen: true );
		Assert.IsFalse( jitter.Corrected, "Jitter within the frozen allowance must not be flagged." );
	}

	[TestMethod]
	public void CorrectsNonFinitePosition()
	{
		var audit = MovementAudit.OpenAt( At( 0, 0, 0 ), 0 );
		var (decision, _) = HexMovementValidator.Observe(
			audit, At( float.NaN, 0, 0 ), 0.016, RunSpeed, JumpSpeed, false, Envelope );
		Assert.AreEqual( HexMovementValidator.Verdict.Teleport, decision.Verdict );
	}

	[TestMethod]
	public void AnUnprimedAuditAdoptsTheFirstSampleWithoutFlagging()
	{
		var (decision, audit) = HexMovementValidator.Observe(
			MovementAudit.Unprimed, At( 500, 500, 500 ), 0, RunSpeed, JumpSpeed, false, Envelope );
		Assert.IsFalse( decision.Corrected );
		Assert.IsTrue( audit.Primed );
		Assert.AreEqual( 500f, audit.Anchor.X );
	}

	[TestMethod]
	public void AMalformedEnvelopeIsRejectedRatherThanAuditingNothing()
	{
		Assert.IsTrue( HexMovementEnvelope.Default.IsWellFormed( out _ ) );

		var permissive = HexMovementEnvelope.Default with { HorizontalTolerance = 50f };
		Assert.IsFalse( permissive.IsWellFormed( out var error ) );
		Assert.IsNotEmpty( error );

		var blind = HexMovementEnvelope.Default with { AuditWindowSeconds = 999f };
		Assert.IsFalse( blind.IsWellFormed( out _ ) );
	}
}
using Sandbox;

[TestClass]
public partial class LibraryTests
{
	[TestMethod]
	public void SceneTest()
	{
		var scene = new Scene();
		using ( scene.Push() )
		{
			var go = new GameObject();

			Assert.AreEqual( 1, scene.Directory.GameObjectCount );
		}
	}

}
using HumanoidRetargeter.Core.Target;
using Xunit;

namespace HumanoidRetargeter.Tests;

public class Kv3ModelDocNumberTests
{
    [Theory]
    [InlineData(1.0380962e-05)]
    [InlineData(-1.2e-12)]
    [InlineData(1.25e25)]
    [InlineData(double.Epsilon)]
    [InlineData(double.MaxValue)]
    public void ModelDocNumbersHaveNoExponentAndRoundTripExactly(double value)
    {
        var doc = Kv3.Parse(VmdlWriter.Kv3Header + "\n{ value = 0.0 }");
        ((KvObject)doc.Root)["value"] = new KvDouble(value);
        var text = Kv3.Serialize(doc);
        var number = text[(text.IndexOf("value = ", StringComparison.Ordinal) + 8)..].Trim().TrimEnd('}').Trim();
        Assert.DoesNotContain("E", number);
        Assert.DoesNotContain("e", number);
        Assert.Equal(value, ((KvDouble)((KvObject)Kv3.Parse(text).Root)["value"]).Value);
    }
}
using HumanoidRetargeter.Core.Target;
using Xunit;

namespace HumanoidRetargeter.Tests.Target;

/// <summary>Bone renaming that follows a graph into its subgraphs (the stock Citizen graph keeps its logic there).</summary>
public class SmartPortGraphsTests
{
    static string Graph(string cls, string body) => VmdlWriter.Kv3Header + "{ _class = \"" + cls + "\" " + body + " }";

    static readonly Dictionary<string, string> Renames = new()
    {
        ["pelvis"] = "ValveBiped_Bip01_Pelvis", ["hand_R"] = "ValveBiped_Bip01_R_Hand", ["hold_R"] = "hold_R",
    };

    static Dictionary<string, string> Files(params (string Path, string Text)[] files)
        => files.ToDictionary(f => f.Path, f => f.Text, StringComparer.OrdinalIgnoreCase);

    [Fact]
    public void SubgraphsNamingRenamedBonesAreCopiedAndReferenced()
    {
        var entry = Graph("CAnimationGraph", """
            m_nodes = [
              { m_subGraphFilename = "models/citizen/subgraphs/core.vsubgrph" },
              { m_subGraphFilename = "models\\citizen\\subgraphs\\face.vsubgrph" },
              { m_bone = "hand_R" }
            ]
            """);
        var files = Files(
            ("models/citizen/subgraphs/core.vsubgrph", Graph("CAnimationSubGraph", "m_hipBoneName = \"pelvis\" m_attachmentName = \"hold_R\"")),
            ("models/citizen/subgraphs/face.vsubgrph", Graph("CAnimationSubGraph", "m_sequenceName = \"Eyes_Blink\"")));
        var copy = SmartPortGraphs.Rebase(entry, Renames, p => files.GetValueOrDefault(p), "models/x_src/subgraphs");

        var core = Assert.Single(copy.Subgraphs);
        Assert.Equal("models/x_src/subgraphs/core.vsubgrph", core.Key);
        Assert.Contains("m_hipBoneName = \"ValveBiped_Bip01_Pelvis\"", core.Value);
        Assert.Contains("m_attachmentName = \"hold_R\"", core.Value);
        Assert.Contains("\"models/x_src/subgraphs/core.vsubgrph\"", copy.Graph);
        Assert.Contains("face.vsubgrph", copy.Graph); // unchanged: still the shipped one
        Assert.DoesNotContain("x_src/subgraphs/face", copy.Graph);
        Assert.Contains("\"ValveBiped_Bip01_R_Hand\"", copy.Graph);
        Assert.Empty(copy.Unread);
    }

    [Fact]
    public void AChangeDeepInsideCopiesEveryGraphAboveIt()
    {
        var entry = Graph("CAnimationGraph", "m_subGraphFilename = \"a.vsubgrph\"");
        var files = Files(
            ("a.vsubgrph", Graph("CAnimationSubGraph", "m_subGraphFilename = \"b.vsubgrph\"")),
            ("b.vsubgrph", Graph("CAnimationSubGraph", "m_hipBoneName = \"pelvis\" m_subGraphFilename = \"a.vsubgrph\"")));
        var copy = SmartPortGraphs.Rebase(entry, Renames, p => files.GetValueOrDefault(p), "out");
        Assert.Equal(new[] { "out/a.vsubgrph", "out/b.vsubgrph" }, copy.Subgraphs.Keys.OrderBy(k => k, StringComparer.Ordinal));
        Assert.Contains("\"out/b.vsubgrph\"", copy.Subgraphs["out/a.vsubgrph"]);
        Assert.Contains("\"out/a.vsubgrph\"", copy.Graph);
        Assert.Contains("\"a.vsubgrph\"", copy.Subgraphs["out/b.vsubgrph"]); // a reference back up the chain is not followed again
    }

    [Fact]
    public void UnreadableSubgraphsAreListedAndIdentityNamesChangeNothing()
    {
        var entry = Graph("CAnimationGraph", "m_subGraphFilename = \"missing.vsubgrph\" m_bone = \"hold_R\"");
        var copy = SmartPortGraphs.Rebase(entry, Renames, _ => null, "out");
        Assert.Empty(copy.Subgraphs);
        Assert.Equal(new[] { "missing.vsubgrph" }, copy.Unread);
        Assert.Contains("\"missing.vsubgrph\"", copy.Graph);
    }

    [Fact]
    public void SameNamedSubgraphsFromDifferentFoldersGetDistinctCopies()
    {
        var entry = Graph("CAnimationGraph", "m_nodes = [ { m_subGraphFilename = \"one/core.vsubgrph\" }, { m_subGraphFilename = \"two/core.vsubgrph\" } ]");
        var files = Files(
            ("one/core.vsubgrph", Graph("CAnimationSubGraph", "m_bone = \"pelvis\"")),
            ("two/core.vsubgrph", Graph("CAnimationSubGraph", "m_bone = \"hand_R\"")));
        var copy = SmartPortGraphs.Rebase(entry, Renames, p => files.GetValueOrDefault(p), "out");
        Assert.Equal(new[] { "out/core.vsubgrph", "out/core_2.vsubgrph" }, copy.Subgraphs.Keys.OrderBy(k => k, StringComparer.Ordinal));
    }
}
using System.Numerics;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Target;
using Xunit;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Target;

public class StockBindPoseTests
{
    private const string RigJson = """
    { "name": "stock", "bones": [
        { "name": "pelvis", "parent": null, "class": "Animated", "role": "Hips", "local_pos": [0, 100, 0], "local_rot_xyzw": [0, 0, 0, 1], "tail_world": [0, 110, 0] },
        { "name": "helper", "parent": "pelvis", "class": "ConstraintDriven", "local_pos": [1, 0, 0], "local_rot_xyzw": [0, 0, 0, 1] },
        { "name": "root_IK", "parent": null, "class": "IkBaked", "local_pos": [0, 0, 0], "local_rot_xyzw": [0, 0, 0, 1] }
    ] }
    """;

    [Fact]
    public void RebindingPreservesCuratedHelpersAndAdoptsCompiledRest()
    {
        var stock = TargetRig.Load(RigJson);
        var rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2);
        var definitions = stock.Skeleton.Bones.Select(b => new BoneDefinition(b.Name,
            b.ParentIndex < 0 ? null : stock.Skeleton[b.ParentIndex].Name,
            b.ParentIndex < 0 ? new XForm(Vector3.Transform(b.RestLocal.Pos, rotation), rotation) : b.RestLocal)).ToList();
        definitions.Add(new BoneDefinition("custom_extra", "pelvis", XForm.Identity));
        var bind = SkeletonModel.Create(definitions);
        var rig = stock.WithBindPose(bind);
        Assert.Same(bind, rig.Skeleton);
        Assert.True(rig.HelpersAreConstraintDriven);
        Assert.Equal(BoneClass.ConstraintDriven, rig.ClassOf(bind.IndexOf("helper")));
        Assert.Equal(BoneClass.IkBaked, rig.ClassOf(bind.IndexOf("root_IK")));
        Assert.Equal(BoneClass.Animated, rig.ClassOf(bind.IndexOf("custom_extra")));
        Assert.Equal(bind.IndexOf("pelvis"), rig.BoneForRole(BoneRole.Hips));
        Assert.True(Vector3.Distance(new Vector3(-110, 0, 0), rig.TailWorldOf(bind.IndexOf("pelvis"))!.Value) < .001f);
        Assert.Equal(new Vector3(0, 100, 0), stock.Skeleton.RestWorld[stock.Skeleton.IndexOf("pelvis")].Pos);
    }

    [Fact]
    public void MissingMappedBonesFailRatherThanDroppingMotion()
        => Assert.Throws<ArgumentException>(() => TargetRig.Load(RigJson).WithBindPose(
            SkeletonModel.Create(new[] { new BoneDefinition("unrelated", null, XForm.Identity) })));

    [Fact]
    public void PrefabMeshUsesTheSameDmxRootCorrectionAsAnExplicitMesh()
    {
        var rig = TargetRig.Load(RigJson);
        var frames = new[] { rig.Skeleton.Bones.Select(b => b.RestLocal).ToArray() };
        var prefab = new RetargetTargetSpec { Rig = rig, VmdlScale = .3937f, CompensateDmxRootYaw = true };
        var explicitMesh = new RetargetTargetSpec { Rig = rig, VmdlScale = .3937f, MeshFilePath = "body.fbx" };
        var expected = Retargeter.TestHook_CompensateEmbeddedMeshRootYaw(frames, explicitMesh);
        var actual = Retargeter.TestHook_CompensateEmbeddedMeshRootYaw(frames, prefab);
        Assert.Equal(expected[0], actual[0]);
        Assert.NotEqual(frames[0][0].Rot, actual[0][0].Rot);
        Assert.Equal(rig.Skeleton[0].RestLocal, frames[0][0]);
    }
}
using System.Numerics;
using System.Text;
using System.Text.Json;
using HumanoidRetargeter.Core.Formats.Bvh;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;

namespace HumanoidRetargeter.Tests.Formats;

// =============================================================================================
// SPACE / CONVENTION RESOLUTION (determined from Blender 5.1 io_anim_bvh/import_bvh.py and
// verified empirically against fixtures/anim_truth/freebvh.json, produced by
// research/extract_anim.py = import_anim.bvh(axis_forward='-Z', axis_up='Y', global_scale=1)):
//
//  1. AXES. Blender's BVH importer builds global_matrix = Rx(+90 deg) (file Y-up -> Blender
//     Z-up) and — unlike the FBX importer — BAKES it into the armature data
//     (`arm_ob.matrix_world = global_matrix; transform_apply(rotation=True)`, import_bvh.py).
//     The object ends up with identity transform (object_scale == 1). Therefore BOTH the
//     truth's rest data (`bones[].rest_world_*`, armature space) and its frame data
//     (`frames[][].wp/wr`, world space) are in Blender Z-up space, and converting EITHER back
//     to native BVH space is the same fixed rotation applied on the left:
//     p_bvh = Rx(-90 deg) * p_blender, q_bvh = Rx(-90 deg) * q_blender.
//     (Verified: file's Spine OFFSET (0, 9.9235, -1.2273) appears in the truth as rest world
//     (0, 1.2273, 9.9235) = Rx(+90 deg) of the native offset.)
//
//  2. BONE ORIENTATION. Blender bones are oriented along head->tail with the joint's world
//     position at the HEAD; BVH joints have identity rest rotation. The importer keys pose
//     rotations as basis = rest^-1 * R_bvh * rest, which makes the pose bone's world rotation
//     q_pose(t) = Q_joint(t) * q_rest (column-vector quaternion product, our a*b-applies-b-
//     first convention). The BVH joint world rotation is therefore recovered per bone as
//     Q_joint(t) = q_pose_bvh(t) * conj(q_rest_bvh); positions compare directly (heads).
//
//  3. END SITES. Blender turns End Sites into bone TAILS (no extra bones; the "<name>_end"
//     naming exists only in its non-armature OBJECT import path). Our importer synthesizes a
//     leaf bone "<parent>_end" per End Site, so: our joint count == truth bone count + number
//     of End Sites, and every extra bone name ends with "_end".
//
//  4. FRAME ALIGNMENT. Blender places BVH motion line 0 at action frame 1 (it prepends and
//     then skips an internal rest frame), and extract_anim.py samples from frame_start = 1 on
//     the same 30 fps grid our importer uses, so truth frame i == our clip frame i (the file's
//     30.000003 fps vs 30 fps grid mismatch is ~1e-7 s per frame — negligible over 40 frames;
//     Blender fcurves are keyed LINEAR so subframe evaluation is well-defined).
//
//  5. UNITS. global_scale=1, so truth values are in raw file units. freebvh is Mixamo-derived
//     (centimeters, rest height ~185) -> our meters-vs-cm heuristic keeps scale 1 and values
//     compare 1:1.
//
// TOLERANCES per the task gate: rest offsets ~exact (0.02 cm); animation world rotation
// within 1 deg and world position within 0.5% of skeleton height across 10 sampled frames.
// Observed residuals are float-precision noise (max 0.0002 cm / 0.0004 deg over all 40
// truth frames x 55 joints), so the gates have ~2500x margin. A deliberately flipped
// rotation-composition order fails 55/55 joints — the comparison is discriminating.
// =============================================================================================
public class BvhImporterTests
{
    private const float KDegPerRad = 180f / MathF.PI;

    private static byte[] Fixture(string name)
        => File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "bvh", name));

    private static SourceScene ImportFixture(string name, float fps = 30f)
        => BvhImporter.Import(Fixture(name), new BvhImportOptions { SampleFps = fps });

    // ---- ground truth -----------------------------------------------------------------

    private sealed record TruthBone(
        string Name, string? Parent, Vector3 RestWorldPos, Quaternion RestWorldRot);

    private sealed record TruthFrameBone(Vector3 WorldPos, Quaternion WorldRot);

    private sealed class Truth
    {
        public required int NSamples;
        public required List<TruthBone> Bones;
        public required List<TruthFrameBone[]> Frames;
    }

    private static Truth LoadTruth(string name)
    {
        var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "anim_truth", name);
        using var doc = JsonDocument.Parse(File.ReadAllBytes(path));
        var r = doc.RootElement;

        static Vector3 V3(JsonElement e) => new(
            e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle());
        static Quaternion Q(JsonElement e) => new(
            e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle(), e[3].GetSingle());

        var bones = new List<TruthBone>();
        foreach (var b in r.GetProperty("bones").EnumerateArray())
        {
            bones.Add(new TruthBone(
                b.GetProperty("name").GetString()!,
                b.GetProperty("parent").ValueKind == JsonValueKind.Null
                    ? null : b.GetProperty("parent").GetString(),
                V3(b.GetProperty("rest_world_pos")),
                Q(b.GetProperty("rest_world_rot_xyzw"))));
        }

        var frames = new List<TruthFrameBone[]>();
        foreach (var f in r.GetProperty("frames").EnumerateArray())
        {
            var fb = new TruthFrameBone[bones.Count];
            int i = 0;
            foreach (var e in f.EnumerateArray())
                fb[i++] = new TruthFrameBone(V3(e.GetProperty("wp")), Q(e.GetProperty("wr")));
            frames.Add(fb);
        }

        return new Truth
        {
            NSamples = r.GetProperty("n_samples").GetInt32(),
            Bones = bones,
            Frames = frames,
        };
    }

    // Blender Z-up world -> native BVH Y-up space (see header note 1).
    private static readonly Quaternion BlenderToBvh =
        Quaternion.CreateFromAxisAngle(Vector3.UnitX, -MathF.PI / 2f);

    private static Vector3 PosToBvh(Vector3 p) => Vector3.Transform(p, BlenderToBvh);

    private static Quaternion RotToBvh(Quaternion q) => BlenderToBvh * q;

    /// <summary>BVH joint world rotation from a truth pose rotation (see header note 2).</summary>
    private static Quaternion JointRot(Quaternion poseWorldRot, Quaternion restWorldRot)
        => RotToBvh(poseWorldRot) * Quaternion.Conjugate(RotToBvh(restWorldRot));

    private static float SkeletonHeight(Skel skeleton)
    {
        float min = float.MaxValue, max = float.MinValue;
        foreach (var w in skeleton.RestWorld)
        {
            min = MathF.Min(min, w.Pos.Y);
            max = MathF.Max(max, w.Pos.Y);
        }
        return max - min;
    }

    // ---- 1. freebvh vs Blender ground truth ---------------------------------------------

    [Fact]
    public void Freebvh_SkeletonTopology_MatchesBlender()
    {
        var scene = ImportFixture("bvhpython_test_freebvh.bvh");
        var truth = LoadTruth("freebvh.json");

        // Every Blender bone (= BVH joint) exists, with the same parent.
        var truthNames = new HashSet<string>();
        foreach (var b in truth.Bones)
        {
            truthNames.Add(b.Name);
            int idx = scene.Skeleton.IndexOf(b.Name);
            Assert.True(idx >= 0, $"missing joint {b.Name}");
            int parentIdx = scene.Skeleton[idx].ParentIndex;
            string? parent = parentIdx < 0 ? null : scene.Skeleton[parentIdx].Name;
            Assert.Equal(b.Parent, parent);
        }

        // Our extra bones are exactly the synthesized End Site leaves (15 in this file).
        var extras = scene.Skeleton.Bones
            .Where(b => !truthNames.Contains(b.Name))
            .Select(b => b.Name)
            .ToList();
        Assert.Equal(15, extras.Count);
        Assert.All(extras, n => Assert.EndsWith("_end", n, StringComparison.Ordinal));
        Assert.Equal(truth.Bones.Count + 15, scene.Skeleton.Count);
    }

    [Fact]
    public void Freebvh_RestPose_MatchesBlender()
    {
        var scene = ImportFixture("bvhpython_test_freebvh.bvh");
        var truth = LoadTruth("freebvh.json");

        Assert.Equal(1f, scene.UnitScaleCm); // centimeter-scale file, heuristic keeps x1
        Assert.Equal(1, scene.UpAxis);       // BVH convention: Y-up, recorded not converted

        foreach (var b in truth.Bones)
        {
            int idx = scene.Skeleton.IndexOf(b.Name);
            var ours = scene.Skeleton.RestWorld[idx];

            // Rest world position = accumulated OFFSETs (cm).
            TestUtil.AssertVectorEqual(PosToBvh(b.RestWorldPos), ours.Pos, 0.02f);

            // BVH rest rotations are identity by convention (Blender's nontrivial rest
            // rotations are bone head->tail orientations, cancelled in the frame tests).
            float identErr = MathQ.AngleBetween(Quaternion.Identity, ours.Rot) * KDegPerRad;
            Assert.True(identErr < 1e-3f, $"{b.Name}: rest rot not identity ({identErr} deg)");
        }
    }

    [Fact]
    public void Freebvh_Animation_MatchesBlender()
    {
        var scene = ImportFixture("bvhpython_test_freebvh.bvh");
        var truth = LoadTruth("freebvh.json");
        Assert.Single(scene.Clips);
        var clip = scene.Clips[0];

        float height = SkeletonHeight(scene.Skeleton);
        Assert.InRange(height, 100f, 400f); // sanity: a cm-scale humanoid
        float posTol = 0.005f * height;
        const float rotTolDeg = 1f;

        // 10 frames spread over the 40 extracted truth samples.
        int[] sampleFrames = { 0, 4, 8, 12, 16, 20, 24, 28, 32, 36 };
        foreach (int f in sampleFrames)
        {
            Assert.True(f < truth.NSamples && f < clip.FrameCount);
            var worlds = new Pose(clip.Frames[f]).ToWorld(scene.Skeleton);
            var truthFrame = truth.Frames[f];

            var failures = new List<string>();
            for (int i = 0; i < truth.Bones.Count; i++)
            {
                var tb = truth.Bones[i];
                int idx = scene.Skeleton.IndexOf(tb.Name);
                var expectedPos = PosToBvh(truthFrame[i].WorldPos);
                var expectedRot = JointRot(truthFrame[i].WorldRot, tb.RestWorldRot);

                float posErr = Vector3.Distance(worlds[idx].Pos, expectedPos);
                float rotErr = MathQ.AngleBetween(worlds[idx].Rot, expectedRot) * KDegPerRad;
                if (posErr > posTol || rotErr > rotTolDeg)
                    failures.Add($"  {tb.Name}: posErr={posErr:F3} cm (tol {posTol:F3}), rotErr={rotErr:F3} deg");
            }

            Assert.True(failures.Count == 0,
                $"frame {f}: {failures.Count}/{truth.Bones.Count} joints out of tolerance:\n" +
                string.Join("\n", failures.Take(12)));
        }
    }

    // ---- 2. cmu_01_01 basics --------------------------------------------------------------

    [Fact]
    public void Cmu0101_ImportBasics()
    {
        var scene = ImportFixture("cmu_01_01.bvh");

        // 31 joints + 7 End Sites.
        int endBones = scene.Skeleton.Bones.Count(b => b.Name.EndsWith("_end", StringComparison.Ordinal));
        Assert.Equal(7, endBones);
        Assert.Equal(31 + 7, scene.Skeleton.Count);
        Assert.True(scene.Skeleton.IndexOf("Hips") >= 0);

        Assert.Single(scene.Clips);
        var clip = scene.Clips[0];
        Assert.True(clip.Duration > 1f, $"duration {clip.Duration}s"); // 2752 frames at 120 fps ~ 22.9 s
        Assert.Equal(30f, clip.Fps);

        // No NaNs anywhere.
        static bool Finite(XForm x) =>
            float.IsFinite(x.Pos.X) && float.IsFinite(x.Pos.Y) && float.IsFinite(x.Pos.Z) &&
            float.IsFinite(x.Rot.X) && float.IsFinite(x.Rot.Y) && float.IsFinite(x.Rot.Z) &&
            float.IsFinite(x.Rot.W);
        for (int i = 0; i < scene.Skeleton.Count; i++)
            Assert.True(Finite(scene.Skeleton.RestWorld[i]), $"non-finite rest {scene.Skeleton[i].Name}");
        for (int f = 0; f < clip.FrameCount; f++)
            foreach (var x in clip.Frames[f])
                Assert.True(Finite(x), $"non-finite transform at frame {f}");

        // Root has 6 channels (3 position): its local translation is animated.
        int hips = scene.Skeleton.IndexOf("Hips");
        bool rootMoves = false;
        for (int f = 1; f < clip.FrameCount && !rootMoves; f++)
            rootMoves = Vector3.Distance(clip.Frames[f][hips].Pos, clip.Frames[0][hips].Pos) > 0.1f;
        Assert.True(rootMoves, "root translation never changes — position channels not applied");

        // A knee joint has only 3 (rotation) channels: local translation stays at the OFFSET.
        int knee = scene.Skeleton.IndexOf("LeftLeg");
        Assert.True(knee >= 0, "missing LeftLeg");
        var kneeRest = scene.Skeleton[knee].RestLocal.Pos;
        for (int f = 0; f < clip.FrameCount; f++)
            TestUtil.AssertVectorEqual(kneeRest, clip.Frames[f][knee].Pos, 1e-4f);
    }

    // ---- 3. resampling ---------------------------------------------------------------------

    // freebvh: Frames: 69, Frame Time: 0.0333333 -> duration = 68 * 0.0333333 s.
    // Expected frame count on the target grid = round(duration * fps) + 1.
    [Theory]
    [InlineData(30f, 69)]
    [InlineData(60f, 137)]
    [InlineData(15f, 35)]
    public void Freebvh_Resampling_FrameCountMatchesGrid(float fps, int expectedFrames)
    {
        var scene = ImportFixture("bvhpython_test_freebvh.bvh", fps);
        var clip = Assert.Single(scene.Clips);
        Assert.Equal(fps, clip.Fps);
        Assert.Equal(expectedFrames, clip.FrameCount);

        double duration = 68 * 0.0333333;
        Assert.Equal((int)Math.Round(duration * fps) + 1, clip.FrameCount);
    }

    // ---- 4. unit heuristic + channel-order unit check ---------------------------------------

    // Meter-scale skeleton (rest height 1.7 < 10) must be scaled x100 to centimeters,
    // including the root position channels. Frame 1 also pins down the rotation-channel
    // convention independently of Blender: channels "Zrotation Yrotation Xrotation" with
    // values (90, 0, 0) must yield world R = Rz(90 deg), so the Head joint at local offset
    // (0, 0.7, 0) m lands at hips + Rz90*(0, 70, 0) = hips + (-70, 0, 0) cm.
    private const string MeterBvh = @"HIERARCHY
ROOT Hips
{
    OFFSET 0 0.9 0
    CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation
    JOINT Head
    {
        OFFSET 0 0.7 0
        CHANNELS 3 Zrotation Yrotation Xrotation
        End Site
        {
            OFFSET 0 0.1 0
        }
    }
}
MOTION
Frames: 2
Frame Time: 0.0333333
0 0.9 0 0 0 0 0 0 0
0 1.0 0.2 90 0 0 0 0 0
";

    [Fact]
    public void MeterScaleFile_IsConvertedToCentimeters()
    {
        var scene = BvhImporter.Import(Encoding.ASCII.GetBytes(MeterBvh), new BvhImportOptions());

        Assert.Equal(100f, scene.UnitScaleCm);

        int hips = scene.Skeleton.IndexOf("Hips");
        int head = scene.Skeleton.IndexOf("Head");
        int end = scene.Skeleton.IndexOf("Head_end");
        Assert.True(hips >= 0 && head >= 0 && end >= 0);

        TestUtil.AssertVectorEqual(new Vector3(0f, 90f, 0f), scene.Skeleton.RestWorld[hips].Pos, 1e-3f);
        TestUtil.AssertVectorEqual(new Vector3(0f, 160f, 0f), scene.Skeleton.RestWorld[head].Pos, 1e-3f);
        TestUtil.AssertVectorEqual(new Vector3(0f, 10f, 0f), scene.Skeleton[end].RestLocal.Pos, 1e-3f);

        var clip = Assert.Single(scene.Clips);
        Assert.Equal(2, clip.FrameCount);

        var worlds = new Pose(clip.Frames[1]).ToWorld(scene.Skeleton);
        TestUtil.AssertVectorEqual(new Vector3(0f, 100f, 20f), worlds[hips].Pos, 0.01f);
        TestUtil.AssertVectorEqual(new Vector3(-70f, 100f, 20f), worlds[head].Pos, 0.01f);

        var rz90 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
        Assert.True(MathQ.AngleBetween(rz90, worlds[hips].Rot) * KDegPerRad < 0.01f,
            $"hips world rot {worlds[hips].Rot} != Rz(90)");
    }

    // ---- 5. malformed input ------------------------------------------------------------------

    [Fact]
    public void TruncatedFile_Throws()
    {
        var full = Fixture("bvhpython_test_freebvh.bvh");

        // Cut mid-MOTION (file is ~60% hierarchy, so 80% leaves a partial frame block).
        var midMotion = full.AsSpan(0, full.Length * 8 / 10).ToArray();
        Assert.Throws<FormatException>(() => BvhImporter.Import(midMotion, new BvhImportOptions()));

        // Cut mid-HIERARCHY.
        var midHierarchy = full.AsSpan(0, full.Length / 4).ToArray();
        Assert.Throws<FormatException>(() => BvhImporter.Import(midHierarchy, new BvhImportOptions()));
    }

    [Fact]
    public void NonBvhData_Throws()
    {
        Assert.Throws<FormatException>(() => BvhImporter.Import(
            Encoding.ASCII.GetBytes("this is not a bvh file"), new BvhImportOptions()));
        Assert.Throws<FormatException>(() => BvhImporter.Import(
            Array.Empty<byte>(), new BvhImportOptions()));
    }
}
using System.Numerics;
using HumanoidRetargeter.Core.Formats;
using HumanoidRetargeter.Core.Maths;
using Xunit;

namespace HumanoidRetargeter.Tests.Formats;

public class QuaternionContinuityTests
{
    private static Quaternion Rz(float radians)
        => Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians);

    private static void AssertAligned(IReadOnlyList<Quaternion> track)
    {
        for (var i = 1; i < track.Count; i++)
            Assert.True(Quaternion.Dot(track[i - 1], track[i]) >= 0f,
                $"negative dot between samples {i - 1} and {i}: {track[i - 1]} -> {track[i]}");
    }

    [Fact]
    public void Align_FlippedSample_NegatedBackOntoSameHemisphere()
    {
        var track = new[] { Quaternion.Identity, Quaternion.Negate(Rz(0.1f)) };
        QuaternionContinuity.Align(track);

        AssertAligned(track);
        // Semantically the same rotation: only the sign changed.
        Assert.Equal(Rz(0.1f).X, track[1].X, 6);
        Assert.Equal(Rz(0.1f).W, track[1].W, 6);
    }

    [Fact]
    public void Align_AlternatingFlips_PropagatesCumulatively()
    {
        // Flips must be judged against the ALIGNED predecessor, not the raw one: after the
        // second sample is negated, the third (raw-consistent with the second) must flip too.
        var track = new[]
        {
            Rz(0.0f),
            Quaternion.Negate(Rz(0.1f)),
            Quaternion.Negate(Rz(0.2f)),
            Rz(0.3f),
        };
        QuaternionContinuity.Align(track);
        AssertAligned(track);
    }

    [Fact]
    public void Align_AlreadyContinuous_Unchanged()
    {
        var track = new[] { Rz(0.0f), Rz(0.1f), Rz(0.2f) };
        var expected = (Quaternion[])track.Clone();
        QuaternionContinuity.Align(track);
        Assert.Equal(expected, track);
    }

    [Fact]
    public void AlignFrames_PerBoneTracks_AlignedIndependently()
    {
        var frames = new List<XForm[]>
        {
            new[]
            {
                new XForm(Vector3.Zero, Rz(0.0f)),
                new XForm(Vector3.Zero, Rz(1.0f)),
            },
            new[]
            {
                new XForm(Vector3.Zero, Quaternion.Negate(Rz(0.1f))), // bone 0 flips
                new XForm(Vector3.Zero, Rz(1.1f)),                    // bone 1 stays
            },
            new[]
            {
                new XForm(Vector3.Zero, Quaternion.Negate(Rz(0.2f))),
                new XForm(Vector3.Zero, Quaternion.Negate(Rz(1.2f))), // bone 1 flips
            },
        };

        QuaternionContinuity.AlignFrames(frames);

        for (var bone = 0; bone < 2; bone++)
            AssertAligned(frames.Select(f => f[bone].Rot).ToList());

        // Positions untouched.
        foreach (var frame in frames)
            foreach (var x in frame)
                Assert.Equal(Vector3.Zero, x.Pos);
    }

    [Fact]
    public void AlignFrames_SingleFrame_NoOp()
    {
        var frames = new List<XForm[]> { new[] { new XForm(Vector3.Zero, Rz(0.4f)) } };
        QuaternionContinuity.AlignFrames(frames);
        Assert.Equal(Rz(0.4f), frames[0][0].Rot);
    }
}
using HumanoidRetargeter.Core.Mapping;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Mapping;

/// <summary>
/// Local-only preset-detection checks against REAL rig files in <c>dev/corpus/</c> (not
/// committed; every test silently passes when the file is absent):
/// <list type="bullet">
/// <item><c>unknown_rigs/makehuman_cmu_03_03_dazNames.bvh</c> — MakeHuman CMU retarget with
/// Poser/DAZ classic names (hip/abdomen/chest, lShldr/lForeArm, lThigh/lShin, 2-segment
/// fingers, no toes). Previously fell through to the auto-mapper; must now hit the
/// <c>daz_poser</c> preset.</item>
/// <item><c>todo/Defenses.fbx</c> — Auto-Rig Pro export (PunchPerfect family): root.x hips,
/// *_stretch limbs, c_-prefixed fingers, leftover mixamorig:*4 finger-tip markers.
/// Previously auto-mapped at 0.94; must now hit the <c>auto_rig_pro</c> preset with a
/// full mapping.</item>
/// </list>
/// </summary>
public class RealRigPresetTests
{
    /// <summary>Resolves a repo-relative path by walking up to the .sbproj directory.</summary>
    private static string RepoFile(params string[] parts)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
            dir = dir.Parent!;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found.");
    }

    [Fact] // skipped (silently green) when the local corpus file is not present
    public void MakehumanDazNames_DetectedAsDazPoser_LimbsFullyMapped()
    {
        var path = RepoFile("dev", "corpus", "unknown_rigs", "makehuman_cmu_03_03_dazNames.bvh");
        if (!File.Exists(path))
            return;

        var scene = Retargeter.ImportSource(File.ReadAllBytes(path), Path.GetFileName(path));
        var (map, report) = Retargeter.ResolveMapping(scene.Skeleton);

        Assert.Equal(MappingSource.Preset, map.Source);
        Assert.Equal("daz_poser", map.ProfileName);
        Assert.True(map.Confidence >= 0.8f, $"Confidence {map.Confidence} < 0.8");
        Assert.False(report.NeedsUserDecision);

        string BoneOf(BoneRole role) => scene.Skeleton[map.RoleToBone[role]].Name;
        Assert.Equal("hip", BoneOf(BoneRole.Hips));
        Assert.Equal("abdomen", BoneOf(BoneRole.Spine0));
        Assert.Equal("chest", BoneOf(BoneRole.Spine1)); // no abdomen2 in this file
        Assert.Equal("neck", BoneOf(BoneRole.Neck));
        Assert.Equal("head", BoneOf(BoneRole.Head));
        Assert.Equal("lCollar", BoneOf(BoneRole.ClavicleL));
        Assert.Equal("lShldr", BoneOf(BoneRole.UpperArmL));
        Assert.Equal("rShldr", BoneOf(BoneRole.UpperArmR));
        Assert.Equal("lForeArm", BoneOf(BoneRole.LowerArmL));
        Assert.Equal("rForeArm", BoneOf(BoneRole.LowerArmR));
        Assert.Equal("lHand", BoneOf(BoneRole.HandL));
        Assert.Equal("rHand", BoneOf(BoneRole.HandR));
        Assert.Equal("lThigh", BoneOf(BoneRole.UpperLegL));
        Assert.Equal("rThigh", BoneOf(BoneRole.UpperLegR));
        Assert.Equal("lShin", BoneOf(BoneRole.LowerLegL));
        Assert.Equal("rShin", BoneOf(BoneRole.LowerLegR));
        Assert.Equal("lFoot", BoneOf(BoneRole.FootL));
        Assert.Equal("rFoot", BoneOf(BoneRole.FootR));
        // 2-segment fingers: Prox+Mid resolve, Dist roles stay unmapped, honestly.
        Assert.Equal("lThumb1", BoneOf(BoneRole.ThumbProxL));
        Assert.Equal("rMid2", BoneOf(BoneRole.MiddleMidR));
        Assert.False(map.RoleToBone.ContainsKey(BoneRole.ThumbDistL));

        // The lButtock/rButtock thigh helpers and eyes carry no role.
        foreach (var (role, boneIndex) in map.RoleToBone)
        {
            var name = scene.Skeleton[boneIndex].Name;
            Assert.DoesNotContain("Buttock", name);
            Assert.DoesNotContain("Eye", name);
        }
    }

    [Fact] // skipped (silently green) when the local corpus file is not present
    public void DefensesFbx_DetectedAsAutoRigPro_FullMappingBeatsAutoMap()
    {
        var path = RepoFile("dev", "corpus", "todo", "Defenses.fbx");
        if (!File.Exists(path))
            return;

        var scene = Retargeter.ImportSource(File.ReadAllBytes(path), Path.GetFileName(path));
        var (map, report) = Retargeter.ResolveMapping(scene.Skeleton);

        Assert.Equal(MappingSource.Preset, map.Source);
        Assert.Equal("auto_rig_pro", map.ProfileName);
        Assert.True(map.Confidence >= 0.9f, $"Confidence {map.Confidence} < 0.9");
        Assert.False(report.NeedsUserDecision);

        // Must beat the auto-mapper that used to handle this rig (0.94).
        var auto = AutoMapper.Map(scene.Skeleton);
        Assert.True(map.Confidence > auto.Confidence,
            $"Preset {map.Confidence} must beat auto-map {auto.Confidence}");

        string BoneOf(BoneRole role) => scene.Skeleton[map.RoleToBone[role]].Name;
        Assert.Equal("root.x", BoneOf(BoneRole.Hips));
        Assert.Equal("spine_01.x", BoneOf(BoneRole.Spine0));
        Assert.Equal("spine_02.x", BoneOf(BoneRole.Spine1));
        Assert.Equal("spine_03.x", BoneOf(BoneRole.Spine2));
        Assert.Equal("neck.x", BoneOf(BoneRole.Neck));
        Assert.Equal("head.x", BoneOf(BoneRole.Head));
        Assert.Equal("shoulder.l", BoneOf(BoneRole.ClavicleL));
        Assert.Equal("arm_stretch.l", BoneOf(BoneRole.UpperArmL));
        Assert.Equal("forearm_stretch.r", BoneOf(BoneRole.LowerArmR));
        Assert.Equal("hand.l", BoneOf(BoneRole.HandL));
        Assert.Equal("thigh_stretch.l", BoneOf(BoneRole.UpperLegL));
        Assert.Equal("leg_stretch.r", BoneOf(BoneRole.LowerLegR));
        Assert.Equal("foot.l", BoneOf(BoneRole.FootL));
        Assert.Equal("toes_01.l", BoneOf(BoneRole.ToeL));
        Assert.Equal("c_thumb1.l", BoneOf(BoneRole.ThumbProxL));
        Assert.Equal("c_middle2.r", BoneOf(BoneRole.MiddleMidR));
        Assert.Equal("c_pinky3.l", BoneOf(BoneRole.PinkyDistL));

        // All 30 finger phalanx roles resolve; the leftover mixamorig:*4 tip markers and
        // the ground bone "root" never carry a role.
        var fingers =
            from finger in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" }
            from segment in new[] { "Prox", "Mid", "Dist" }
            from side in new[] { "L", "R" }
            select Enum.Parse<BoneRole>(finger + segment + side);
        foreach (var role in fingers)
            Assert.True(map.RoleToBone.ContainsKey(role), $"Finger role {role} unmapped");
        foreach (var (role, boneIndex) in map.RoleToBone)
        {
            var name = scene.Skeleton[boneIndex].Name;
            Assert.False(name == "root" || name.StartsWith("mixamorig:"),
                $"{role} mapped to non-deform bone {name}");
        }
    }
}
using HumanoidRetargeter.Core.Mapping;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Mapping;

/// <summary>
/// Local-only mapping/detection checks against the REAL user-reported repro files in
/// <c>dev/corpus/todo/</c> (not committed; every test silently passes when a file is
/// absent):
/// <list type="bullet">
/// <item><c>Neutral_throw_ball_001__A057.bvh</c> — NVIDIA SOMA uniform-proportion skeleton
/// (github.com/NVIDIA/soma-retargeter assets/motions/bvh). Mixamo-identical upper body and
/// fingers but legs <c>LeftLeg→LeftShin</c> ("Leg" is the THIGH). Used to falsely detect
/// "90% mixamo" with both upper legs unmapped.</item>
/// <item><c>Armchair1.bvh</c> — classic MotionBuilder/Character-Studio BVH naming
/// (<c>Chest..Chest4</c>, <c>Collar→Shoulder→Elbow→Wrist</c>, <c>Hip→Knee→Ankle→Toe</c>).</item>
/// <item><c>47_01.bvh</c> — CMU mocap naming (<c>LHipJoint</c> helpers, <c>LowerBack</c>
/// spine start, otherwise mixamo-style limb names).</item>
/// </list>
/// </summary>
public class TodoCorpusRealFileTests
{
    /// <summary>Resolves a repo-relative path by walking up to the .sbproj directory.</summary>
    private static string RepoFile(params string[] parts)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
            dir = dir.Parent!;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found.");
    }

    private static string TodoFile(string name) => RepoFile("dev", "corpus", "todo", name);

    private static readonly BoneRole[] CoreLimbAndAxisRoles =
    {
        BoneRole.Hips, BoneRole.Spine0, BoneRole.Neck, BoneRole.Head,
        BoneRole.ClavicleL, BoneRole.ClavicleR,
        BoneRole.UpperArmL, BoneRole.UpperArmR, BoneRole.LowerArmL, BoneRole.LowerArmR,
        BoneRole.HandL, BoneRole.HandR,
        BoneRole.UpperLegL, BoneRole.UpperLegR, BoneRole.LowerLegL, BoneRole.LowerLegR,
        BoneRole.FootL, BoneRole.FootR,
    };

    // NOTE: the killed profile-support round (SOMA/classic-BVH presets for
    // Neutral_throw_ball_001__A057.bvh and Armchair1.bvh) lost its Code-side work; its
    // tests were removed with it. Todo.txt still tracks those repros.

    [Fact] // skipped (silently green) when the local corpus file is not present
    public void Cmu4701_LimbsFullyMappedBothSides()
    {
        var path = TodoFile("47_01.bvh");
        if (!File.Exists(path))
            return;

        var scene = Retargeter.ImportSource(File.ReadAllBytes(path), Path.GetFileName(path));
        var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);

        string? BoneOf(BoneRole role)
            => map.RoleToBone.TryGetValue(role, out var i) ? scene.Skeleton[i].Name : null;

        // Mapping path is unpinned (CMU naming is mixamo-adjacent: a preset or the
        // auto-mapper may resolve it) — what matters is the anatomy.
        Assert.Equal("Hips", BoneOf(BoneRole.Hips));
        Assert.Equal("LeftUpLeg", BoneOf(BoneRole.UpperLegL));
        Assert.Equal("RightUpLeg", BoneOf(BoneRole.UpperLegR));
        Assert.Equal("LeftLeg", BoneOf(BoneRole.LowerLegL));
        Assert.Equal("RightLeg", BoneOf(BoneRole.LowerLegR));
        Assert.Equal("LeftFoot", BoneOf(BoneRole.FootL));
        Assert.Equal("LeftToeBase", BoneOf(BoneRole.ToeL));
        Assert.Equal("LeftShoulder", BoneOf(BoneRole.ClavicleL));
        Assert.Equal("LeftArm", BoneOf(BoneRole.UpperArmL));
        Assert.Equal("RightForeArm", BoneOf(BoneRole.LowerArmR));
        Assert.Equal("LeftHand", BoneOf(BoneRole.HandL));
        Assert.NotNull(BoneOf(BoneRole.Head));
        Assert.NotNull(BoneOf(BoneRole.Neck));
        Assert.NotNull(BoneOf(BoneRole.Spine0));
        foreach (var role in CoreLimbAndAxisRoles)
            Assert.True(map.RoleToBone.ContainsKey(role), $"{role} unmapped");

        // The LHipJoint/RHipJoint helper joints carry no role.
        foreach (var (role, boneIndex) in map.RoleToBone)
            Assert.DoesNotContain("HipJoint", scene.Skeleton[boneIndex].Name);
    }
}
using System.Numerics;
using System.Text;
using System.Text.Json;
using HumanoidRetargeter.Core.Formats.Fbx;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Solve;
using HumanoidRetargeter.Core.Target;
using HumanoidRetargeter.Tests.Mapping;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;

namespace HumanoidRetargeter.Tests.Solve;

public class GeometricSolverTests
{
    internal const float RadToDeg = 180f / MathF.PI;

    // ---------------------------------------------------------------- fixtures (shared with FingerSolverTests)

    private static readonly Lazy<SourceScene> CitizenWalk =
        new(() => ImportFbx("Citizen@Walk_N.fbx"));

    private static readonly Lazy<SourceScene> ZombieCrawl =
        new(() => ImportFbx("Zombie Crawl.fbx"));

    private static readonly Lazy<SourceScene> ActorCoreCatwalk =
        new(() => ImportFbx("catwalk-loop-378982.fbx"));

    internal static SourceScene ImportFbx(string name)
        => FbxImporter.Import(
            File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "fbx", name)),
            new FbxImportOptions());

    internal static SourceScene Citizen() => CitizenWalk.Value;
    internal static SourceScene Zombie() => ZombieCrawl.Value;
    internal static SourceScene ActorCore() => ActorCoreCatwalk.Value;

    /// <summary>The shipped s&amp;box target rig (same generated definition as the committed
    /// Assets/humanoid_retargeter/target_rig_sbox.json).</summary>
    internal static TargetRig SboxRig()
        => TargetRig.Load(TargetRigGenerator.Generate(
            File.ReadAllText(MappingFixtures.FixturePath("rig_human_male.json"))));

    /// <summary>
    /// Builds a TargetRig over an arbitrary (imported) skeleton using the s&amp;box name
    /// classifier — the round-trip target: same skeleton, same rest, role/class annotations
    /// from <see cref="SboxBoneClassifier"/>. Animated bones the classifier does not know
    /// simply get no role (they stay at rest).
    /// </summary>
    internal static TargetRig RigOverSkeleton(Skel skeleton, string name)
    {
        using var buffer = new MemoryStream();
        using (var writer = new Utf8JsonWriter(buffer))
        {
            writer.WriteStartObject();
            writer.WriteString("name", name);
            writer.WriteStartArray("bones");
            foreach (var bone in skeleton.Bones)
            {
                writer.WriteStartObject();
                writer.WriteString("name", bone.Name);
                if (bone.ParentIndex < 0)
                    writer.WriteNull("parent");
                else
                    writer.WriteString("parent", skeleton[bone.ParentIndex].Name);

                var boneClass = SboxBoneClassifier.Classify(bone.Name);
                writer.WriteString("class", boneClass.ToString());
                if (boneClass == BoneClass.Animated && SboxBoneClassifier.RoleFor(bone.Name) is BoneRole role)
                    writer.WriteString("role", role.ToString());

                writer.WriteStartArray("local_pos");
                writer.WriteNumberValue(bone.RestLocal.Pos.X);
                writer.WriteNumberValue(bone.RestLocal.Pos.Y);
                writer.WriteNumberValue(bone.RestLocal.Pos.Z);
                writer.WriteEndArray();
                writer.WriteStartArray("local_rot_xyzw");
                writer.WriteNumberValue(bone.RestLocal.Rot.X);
                writer.WriteNumberValue(bone.RestLocal.Rot.Y);
                writer.WriteNumberValue(bone.RestLocal.Rot.Z);
                writer.WriteNumberValue(bone.RestLocal.Rot.W);
                writer.WriteEndArray();
                writer.WriteEndObject();
            }
            writer.WriteEndArray();
            writer.WriteEndObject();
        }
        return TargetRig.Load(Encoding.UTF8.GetString(buffer.ToArray()));
    }

    /// <summary>Identity source mapping for the round-trip: role → source bone index resolved
    /// by the rig's bone names against the source skeleton.</summary>
    internal static MappingResult RoleMapByName(Skel source, TargetRig rig)
    {
        var map = new MappingResult("identity", MappingSource.Manual) { Confidence = 1f };
        for (var i = 0; i < rig.Skeleton.Count; i++)
        {
            if (rig.RoleOf(i) is not BoneRole role)
                continue;
            var srcIndex = source.IndexOf(rig.Skeleton[i].Name);
            Assert.True(srcIndex >= 0, $"source skeleton lacks bone {rig.Skeleton[i].Name}");
            map.RoleToBone[role] = srcIndex;
        }
        return map;
    }

    internal static MappingResult DetectMap(SourceScene scene, string expectedProfile)
    {
        var detected = ProfileDetector.Detect(scene.Skeleton);
        Assert.NotNull(detected);
        Assert.Equal(expectedProfile, detected!.Value.Profile.Name);
        return detected.Value.Result;
    }

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

    internal static XForm[] Fk(Skel skeleton, XForm[] locals)
    {
        var world = new XForm[locals.Length];
        for (var i = 0; i < locals.Length; i++)
        {
            var parent = skeleton[i].ParentIndex;
            world[i] = parent < 0 ? locals[i] : XForm.Compose(world[parent], locals[i]);
        }
        return world;
    }

    internal static float DegBetween(Quaternion a, Quaternion b) => MathQ.AngleBetween(a, b) * RadToDeg;

    internal static float DegBetween(Vector3 a, Vector3 b) => MathQ.AngleBetween(a, b) * RadToDeg;

    /// <summary>Normalized rest + canonical frames + character-frame basis, exactly like the
    /// solver builds them.</summary>
    internal static (XForm[] NormRest, CanonicalFrames Canon, Quaternion ChrBasis) NormCanon(
        Skel skeleton, MappingResult map)
    {
        var (norm, _) = RestNormalizer.Normalize(skeleton, map);
        var canon = CanonicalFrames.Build(skeleton, map, norm.WorldRest);
        return (norm.WorldRest, canon, MathQ.BasisFromForwardUp(canon.CharacterForward, canon.CharacterUp));
    }

    /// <summary>All chains of consecutive anatomical roles (pinned independently of the
    /// production tables).</summary>
    internal static IEnumerable<BoneRole[]> ChainDefinitions()
    {
        yield return new[]
        {
            BoneRole.Hips, BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2, BoneRole.Spine3,
            BoneRole.Spine4, BoneRole.Neck, BoneRole.Head,
        };
        foreach (var s in new[] { "L", "R" })
        {
            yield return new[] { R("Clavicle", s), R("UpperArm", s), R("LowerArm", s), R("Hand", s) };
            yield return new[] { R("UpperLeg", s), R("LowerLeg", s), R("Foot", s), R("Toe", s) };
            foreach (var f in new[] { "Thumb", "Index", "Middle", "Ring", "Pinky" })
                yield return new[] { R(f + "Meta", s), R(f + "Prox", s), R(f + "Mid", s), R(f + "Dist", s) };
        }
    }

    internal static BoneRole R(string baseName, string side) => Enum.Parse<BoneRole>(baseName + side);

    /// <summary>Consecutive role pairs mapped on BOTH the source map and the target rig.</summary>
    internal static List<(BoneRole A, BoneRole B)> MappedChainPairs(MappingResult srcMap, TargetRig rig)
    {
        var pairs = new List<(BoneRole, BoneRole)>();
        foreach (var chain in ChainDefinitions())
        {
            var mapped = chain
                .Where(r => srcMap.RoleToBone.ContainsKey(r) && rig.BoneForRole(r) is not null)
                .ToArray();
            for (var i = 0; i + 1 < mapped.Length; i++)
                pairs.Add((mapped[i], mapped[i + 1]));
        }
        return pairs;
    }

    /// <summary>5 frame indices spread over a clip.</summary>
    internal static int[] SampleFrames(int frameCount)
        => new[] { 0, frameCount / 4, frameCount / 2, 3 * frameCount / 4, frameCount - 1 }
            .Distinct().ToArray();

    /// <summary>
    /// The cross-rig direction metric: worldspace anatomical direction (chain child head −
    /// bone head), each expressed in its own rig's character-frame coordinates; returns the
    /// per-pair angles in degrees.
    /// </summary>
    internal static IEnumerable<(BoneRole A, BoneRole B, float Deg)> DirectionErrors(
        Skel src, MappingResult srcMap, XForm[] srcWorld, Quaternion srcChr,
        TargetRig rig, XForm[] tgtWorld, Quaternion tgtChr)
    {
        var srcChrInv = Quaternion.Conjugate(srcChr);
        var tgtChrInv = Quaternion.Conjugate(tgtChr);
        foreach (var (a, b) in MappedChainPairs(srcMap, rig))
        {
            var ds = srcWorld[srcMap.RoleToBone[b]].Pos - srcWorld[srcMap.RoleToBone[a]].Pos;
            var dt = tgtWorld[rig.BoneForRole(b)!.Value].Pos - tgtWorld[rig.BoneForRole(a)!.Value].Pos;
            if (ds.LengthSquared() < 1e-8f || dt.LengthSquared() < 1e-8f)
                continue;
            var dsc = Vector3.Transform(Vector3.Normalize(ds), srcChrInv);
            var dtc = Vector3.Transform(Vector3.Normalize(dt), tgtChrInv);
            yield return (a, b, DegBetween(dsc, dtc));
        }
    }

    // ================================================================ A. citizen round-trip (identity gate)

    [Fact]
    public void CitizenRoundTrip_IsIdentity_ForAllMappedBones()
    {
        var scene = Citizen();
        var rig = RigOverSkeleton(scene.Skeleton, "citizen_roundtrip");
        var srcMap = RoleMapByName(scene.Skeleton, rig);
        var clip = scene.Clips[0];

        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        Assert.Equal(clip.FrameCount, solved.FrameCount);
        Assert.Equal(clip.Fps, solved.Fps);
        Assert.Equal(clip.Looping, solved.Looping);

        // (source bone, target bone, name) for every mapped role.
        var mapped = new List<(int Src, int Tgt, string Name)>();
        for (var i = 0; i < rig.Skeleton.Count; i++)
        {
            if (rig.RoleOf(i) is not BoneRole role)
                continue;
            mapped.Add((srcMap.RoleToBone[role], i, $"{rig.Skeleton[i].Name} ({role})"));
        }
        Assert.True(mapped.Count >= 50, $"only {mapped.Count} mapped bones in the round-trip");

        float maxRot = 0f, maxPos = 0f;
        var worst = "";
        for (var f = 0; f < clip.FrameCount; f++)
        {
            var srcWorld = Fk(scene.Skeleton, clip.Frames[f]);
            var outWorld = Fk(rig.Skeleton, solved.Frames[f]);
            foreach (var (s, t, name) in mapped)
            {
                var rotDeg = DegBetween(srcWorld[s].Rot, outWorld[t].Rot);
                var posCm = (srcWorld[s].Pos - outWorld[t].Pos).Length();
                if (rotDeg > maxRot)
                {
                    maxRot = rotDeg;
                    worst = $"{name} @ frame {f}";
                }
                maxPos = MathF.Max(maxPos, posCm);
            }
        }

        Assert.True(maxRot <= 0.5f, $"round-trip rotation error {maxRot:F3} deg (worst: {worst})");
        Assert.True(maxPos <= 0.2f, $"round-trip position error {maxPos:F3} cm");
    }

    [Fact]
    public void CitizenRoundTrip_PelvisPosition_IsIdentity()
    {
        var scene = Citizen();
        var rig = RigOverSkeleton(scene.Skeleton, "citizen_roundtrip");
        var srcMap = RoleMapByName(scene.Skeleton, rig);
        var clip = scene.Clips[0];

        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        var srcPelvis = srcMap.RoleToBone[BoneRole.Hips];
        var tgtPelvis = rig.BoneForRole(BoneRole.Hips)!.Value;
        var maxPos = 0f;
        for (var f = 0; f < clip.FrameCount; f++)
        {
            var srcWorld = Fk(scene.Skeleton, clip.Frames[f]);
            var outWorld = Fk(rig.Skeleton, solved.Frames[f]);
            maxPos = MathF.Max(maxPos, (srcWorld[srcPelvis].Pos - outWorld[tgtPelvis].Pos).Length());
        }
        Assert.True(maxPos <= 0.2f, $"pelvis round-trip error {maxPos:F3} cm");
    }

    /// <summary>
    /// Per-role canonical rest-direction divergence between the two rigs (angle between each
    /// rig's rest chain direction in its own character frame) — the constant offset that
    /// <see cref="RoleTransferMode.DeltaFromRest"/> roles intentionally keep instead of
    /// adopting the source's rest carriage.
    /// </summary>
    internal static float RestDivergenceDeg(
        BoneRole role, Skel src, MappingResult srcMap, TargetRig rig)
    {
        var (_, srcCanon, srcChr) = NormCanon(src, srcMap);
        var (_, tgtCanon, tgtChr) = NormCanon(rig.Skeleton, rig.ToMappingResult());
        var ds = Vector3.Transform(
            Vector3.UnitX, Quaternion.Conjugate(srcChr) * srcCanon.WorldFrameOf(role));
        var dt = Vector3.Transform(
            Vector3.UnitX, Quaternion.Conjugate(tgtChr) * tgtCanon.WorldFrameOf(role));
        return DegBetween(ds, dt);
    }

    /// <summary>
    /// Asserts the cross-rig direction contract on 5 sample frames: absolute-mode roles match
    /// the source direction within 3°, while delta-mode roles (clavicles, neck — they keep the
    /// target's own rest carriage by design) stay within their rest divergence + 5°.
    /// </summary>
    private static void AssertDirectionContract(SourceScene scene, MappingResult srcMap)
    {
        var rig = SboxRig();
        var clip = scene.Clips[0];

        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        var (_, _, srcChr) = NormCanon(scene.Skeleton, srcMap);
        var (_, _, tgtChr) = NormCanon(rig.Skeleton, rig.ToMappingResult());

        var maxStrict = 0f;
        var maxDeltaOver = float.MinValue; // worst (error − per-role bound); ≤ 0 passes
        string worstStrict = "", worstDelta = "";
        var bounds = new Dictionary<BoneRole, float>();
        foreach (var f in SampleFrames(clip.FrameCount))
        {
            var srcWorld = Fk(scene.Skeleton, clip.Frames[f]);
            var tgtWorld = Fk(rig.Skeleton, solved.Frames[f]);
            foreach (var (a, b, deg) in DirectionErrors(
                scene.Skeleton, srcMap, srcWorld, srcChr, rig, tgtWorld, tgtChr))
            {
                if (SolveOptions.DefaultTransferModes.ContainsKey(a))
                {
                    if (!bounds.TryGetValue(a, out var bound))
                        bounds[a] = bound = RestDivergenceDeg(a, scene.Skeleton, srcMap, rig) + 5f;
                    if (deg - bound > maxDeltaOver)
                    {
                        maxDeltaOver = deg - bound;
                        worstDelta = $"{a}->{b} @ frame {f}: {deg:F2} deg (bound {bound:F2})";
                    }
                }
                else if (deg > maxStrict)
                {
                    maxStrict = deg;
                    worstStrict = $"{a}->{b} @ frame {f}";
                }
            }
        }
        Assert.True(maxStrict <= 3.0f, $"direction error {maxStrict:F2} deg (worst: {worstStrict})");
        Assert.True(maxDeltaOver <= 0f,
            $"delta-mode direction sanity exceeded rest divergence + 5 deg: {worstDelta}");
    }

    // ================================================================ B. cross-rig direction gate (Mixamo)

    [Fact]
    public void ZombieCrawl_ToSbox_DirectionsMatchWithin3Degrees()
        => AssertDirectionContract(Zombie(), DetectMap(Zombie(), "mixamo"));

    [Fact]
    public void ZombieCrawl_ToSbox_PelvisTravelScalesWithHipRatio()
    {
        var scene = Zombie();
        var srcMap = DetectMap(scene, "mixamo");
        var rig = SboxRig();
        var clip = scene.Clips[0];

        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        var (_, srcCanon, srcChr) = NormCanon(scene.Skeleton, srcMap);
        var (_, tgtCanon, tgtChr) = NormCanon(rig.Skeleton, rig.ToMappingResult());

        var srcPelvis = srcMap.RoleToBone[BoneRole.Hips];
        var tgtPelvis = rig.BoneForRole(BoneRole.Hips)!.Value;

        Vector3 Horizontal(Vector3 worldTravel, Quaternion chr)
        {
            var v = Vector3.Transform(worldTravel, Quaternion.Conjugate(chr));
            return new Vector3(v.X, v.Y, 0f); // character basis: Z = up
        }

        var srcTravel = Horizontal(
            Fk(scene.Skeleton, clip.Frames[^1])[srcPelvis].Pos
            - Fk(scene.Skeleton, clip.Frames[0])[srcPelvis].Pos, srcChr);
        var tgtTravel = Horizontal(
            Fk(rig.Skeleton, solved.Frames[^1])[tgtPelvis].Pos
            - Fk(rig.Skeleton, solved.Frames[0])[tgtPelvis].Pos, tgtChr);

        Assert.True(srcTravel.Length() > 5f,
            $"fixture sanity: source pelvis should travel (got {srcTravel.Length():F1} cm)");

        var expected = tgtCanon.HipHeight / srcCanon.HipHeight;
        var ratio = tgtTravel.Length() / srcTravel.Length();
        Assert.InRange(ratio / expected, 0.85f, 1.15f);
    }

    // ================================================================ C. ActorCore cross-axis gate (Z-up source)

    [Fact]
    public void ActorCoreCatwalk_ToSbox_DirectionsMatchWithin3Degrees()
        => AssertDirectionContract(ActorCore(), DetectMap(ActorCore(), "actorcore_cc"));

    // ================================================================ D. per-role transfer modes

    [Fact]
    public void DefaultTransferModes_AreClavicleNeckDelta_AndHeadFeetCharacterDelta()
    {
        var modes = SolveOptions.DefaultTransferModes;
        Assert.Equal(RoleTransferMode.DeltaFromRest, modes[BoneRole.ClavicleL]);
        Assert.Equal(RoleTransferMode.DeltaFromRest, modes[BoneRole.ClavicleR]);
        Assert.Equal(RoleTransferMode.DeltaFromRest, modes[BoneRole.Neck]);
        Assert.Equal(RoleTransferMode.CharacterDeltaFromRest, modes[BoneRole.Head]);
        Assert.Equal(RoleTransferMode.CharacterDeltaFromRest, modes[BoneRole.FootL]);
        Assert.Equal(RoleTransferMode.CharacterDeltaFromRest, modes[BoneRole.FootR]);
        // Spine/limbs/toes stay absolute. The head keeps the target's neutral skull
        // attitude (rest neck→head lean is head-joint-placement anatomy, 0–27° across
        // neutral rigs); on a POSED-rest source the solver falls back to absolute gaze
        // matching instead — see TransferModeContractTests.
        Assert.Equal(6, modes.Count);
    }

    /// <summary>
    /// With the source posed at its own rest, a delta-mode role's source delta is identity, so
    /// the target bone must sit at its own normalized rest orientation (its natural carriage)
    /// even though the rigs' rest directions diverge — the defining property of
    /// <see cref="RoleTransferMode.DeltaFromRest"/>. An all-absolute override must instead
    /// adopt the source's rest direction (the legacy behavior).
    /// </summary>
    [Fact]
    public void SourceAtRest_DeltaRoles_KeepTargetRestCarriage()
    {
        var skeleton = Zombie().Skeleton;
        var srcMap = DetectMap(Zombie(), "mixamo");
        var rig = SboxRig();
        var scene = SceneFromWorldFrame(skeleton, skeleton.RestWorld.ToArray(), "rest_frame");

        // Fixture sanity: this source's clavicle line genuinely diverges from the target's.
        var clavDiv = RestDivergenceDeg(BoneRole.ClavicleL, skeleton, srcMap, rig);
        Assert.True(clavDiv > 5f, $"fixture sanity: clavicle rest divergence {clavDiv:F1} deg");

        var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());

        // Mixamo rests are unnormalized T-poses for the body (only arms could be swung, and
        // Mixamo is already a T-pose), so the source delta at this frame is identity for the
        // delta roles. RestNormalizer never touches clavicles or the neck regardless.
        var deltaSolved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
        var absSolved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions
        {
            TransferModes = new Dictionary<BoneRole, RoleTransferMode>(),
        });

        var deltaWorld = Fk(rig.Skeleton, deltaSolved.Frames[0]);
        var absWorld = Fk(rig.Skeleton, absSolved.Frames[0]);
        // Head included: its CharacterDeltaFromRest default shares the defining property
        // (source at rest → target keeps its own neutral skull attitude). Zombie's rest head
        // is neutral, so the posed-rest gaze fallback stays dormant here.
        foreach (var role in new[]
                 {
                     BoneRole.ClavicleL, BoneRole.ClavicleR, BoneRole.Neck, BoneRole.Head,
                 })
        {
            var bone = rig.BoneForRole(role)!.Value;
            var restRot = tgtNorm.WorldRest[bone].Rot;
            var deltaDeg = DegBetween(deltaWorld[bone].Rot, restRot);
            Assert.True(deltaDeg <= 0.5f,
                $"{role}: delta mode should keep the target rest orientation, off {deltaDeg:F2} deg");

            var absDeg = DegBetween(absWorld[bone].Rot, restRot);
            Assert.True(absDeg > 2f,
                $"{role}: all-absolute override should adopt the source rest carriage "
                + $"(only {absDeg:F2} deg from target rest — override not effective?)");
        }

        // Absolute roles are unaffected by the mode map: same output on both solves.
        foreach (var role in new[] { BoneRole.HandR, BoneRole.UpperArmL, BoneRole.Spine0 })
        {
            var bone = rig.BoneForRole(role)!.Value;
            Assert.True(DegBetween(deltaWorld[bone].Rot, absWorld[bone].Rot) <= 1e-3f,
                $"{role} should be identical across mode maps");
        }
    }

    /// <summary>
    /// A toe-less source's foot direction is a virtual character-forward extension; any
    /// direction-matching against it is meaningless (measured: constant ~41° heel-standing
    /// on the makehuman/daz rig under absolute matching). The solver must fall back to
    /// canonical delta for feet in that case: at source rest the target foot keeps its own
    /// rest orientation. The toed default (CharacterDeltaFromRest) shares that property —
    /// the contrast is the explicit all-absolute map, which adopts the source direction.
    /// </summary>
    [Fact]
    public void ToelessSource_FootFallsBackToDelta()
    {
        var skeleton = Zombie().Skeleton;
        var rig = SboxRig();
        var scene = SceneFromWorldFrame(skeleton, skeleton.RestWorld.ToArray(), "rest_frame");

        var toeless = DetectMap(Zombie(), "mixamo");
        toeless.RoleToBone.Remove(BoneRole.ToeL);
        toeless.RoleToBone.Remove(BoneRole.ToeR);

        var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());
        var solved = new GeometricSolver().Solve(scene, toeless, rig, new SolveOptions());
        var world = Fk(rig.Skeleton, solved.Frames[0]);
        foreach (var role in new[] { BoneRole.FootL, BoneRole.FootR })
        {
            var bone = rig.BoneForRole(role)!.Value;
            var deg = DegBetween(world[bone].Rot, tgtNorm.WorldRest[bone].Rot);
            Assert.True(deg <= 0.5f,
                $"{role}: toe-less source should keep the target foot rest carriage, off {deg:F2} deg");
        }

        // Control: under an explicit all-absolute map, the same rest frame drives the foot
        // to the SOURCE's direction, away from the target rest (rigs' foot pitches differ).
        var withToes = DetectMap(Zombie(), "mixamo");
        var absSolved = new GeometricSolver().Solve(scene, withToes, rig, new SolveOptions
        {
            TransferModes = new Dictionary<BoneRole, RoleTransferMode>(),
        });
        var absWorld = Fk(rig.Skeleton, absSolved.Frames[0]);
        var footBone = rig.BoneForRole(BoneRole.FootL)!.Value;
        Assert.True(DegBetween(absWorld[footBone].Rot, tgtNorm.WorldRest[footBone].Rot) > 1.5f,
            "control: explicit absolute mode should adopt the source foot direction");
    }

    /// <summary>
    /// The TransferModes contract (xmldoc on <see cref="SolveOptions.TransferModes"/>): a
    /// NON-NULL map replaces the defaults entirely AND disables every fallback heuristic —
    /// an explicit (or implicit-absent = absolute) foot entry must win over the virtual-foot
    /// delta fallback that null-mode solves apply on toe-less sources
    /// (<see cref="ToelessSource_FootFallsBackToDelta"/> pins the null behavior).
    /// </summary>
    [Fact]
    public void ExplicitTransferModes_DisableVirtualFootFallback()
    {
        var skeleton = Zombie().Skeleton;
        var rig = SboxRig();
        var scene = SceneFromWorldFrame(skeleton, skeleton.RestWorld.ToArray(), "rest_frame");

        var toeless = DetectMap(Zombie(), "mixamo");
        toeless.RoleToBone.Remove(BoneRole.ToeL);
        toeless.RoleToBone.Remove(BoneRole.ToeR);

        var (tgtNorm, _) = RestNormalizer.Normalize(rig.Skeleton, rig.ToMappingResult());

        // Explicit EMPTY map = all-absolute, no heuristics: the toe-less source's virtual
        // (character-forward) foot direction is imposed on the target — the foot must leave
        // its target rest carriage, unlike the null-mode fallback which keeps it there.
        var absSolved = new GeometricSolver().Solve(scene, toeless, rig, new SolveOptions
        {
            TransferModes = new Dictionary<BoneRole, RoleTransferMode>(),
        });
        var absWorld2 = Fk(rig.Skeleton, absSolved.Frames[0]);
        foreach (var role in new[] { BoneRole.FootL, BoneRole.FootR })
        {
            var bone = rig.BoneForRole(role)!.Value;
            var deg = DegBetween(absWorld2[bone].Rot, tgtNorm.WorldRest[bone].Rot);
            Assert.True(deg > 1.5f,
                $"{role}: explicit all-absolute map must be honored on a toe-less source "
                + $"(virtual-foot fallback must not override it) — only {deg:F2} deg from target rest");
        }

        // Explicit per-role DELTA entries are honored exactly too (same outcome as the
        // null-mode fallback, but by the caller's explicit choice).
        var deltaSolved = new GeometricSolver().Solve(scene, toeless, rig, new SolveOptions
        {
            TransferModes = new Dictionary<BoneRole, RoleTransferMode>
            {
                [BoneRole.FootL] = RoleTransferMode.DeltaFromRest,
                [BoneRole.FootR] = RoleTransferMode.DeltaFromRest,
            },
        });
        var deltaWorld = Fk(rig.Skeleton, deltaSolved.Frames[0]);
        foreach (var role in new[] { BoneRole.FootL, BoneRole.FootR })
        {
            var bone = rig.BoneForRole(role)!.Value;
            var deg = DegBetween(deltaWorld[bone].Rot, tgtNorm.WorldRest[bone].Rot);
            Assert.True(deg <= 0.5f,
                $"{role}: explicit DeltaFromRest entry should keep the target rest carriage "
                + $"at source rest, off {deg:F2} deg");
        }
    }

    /// <summary>Delta-mode roles still move 1:1 with the source: rotating the source neck by
    /// 20° about the character lateral axis must rotate the target neck by 20° too (the delta
    /// is replayed, only the rest carriage stays the target's own).</summary>
    [Fact]
    public void DeltaMode_ReplaysSourceRotationDelta()
    {
        var skeleton = Zombie().Skeleton;
        var srcMap = DetectMap(Zombie(), "mixamo");
        var rig = SboxRig();

        var (_, srcCanon, _) = NormCanon(skeleton, srcMap);
        var lateral = Vector3.Cross(srcCanon.CharacterUp, srcCanon.CharacterForward);
        var bent = skeleton.RestWorld.ToArray();
        var srcNeck = srcMap.RoleToBone[BoneRole.Neck];
        RotateWorldSubtree(skeleton, bent, srcNeck,
            Quaternion.CreateFromAxisAngle(lateral, 20f / RadToDeg), bent[srcNeck].Pos);

        var restScene = SceneFromWorldFrame(skeleton, skeleton.RestWorld.ToArray(), "rest");
        var bentScene = SceneFromWorldFrame(skeleton, bent, "neck_bend");

        var solver = new GeometricSolver();
        var restOut = Fk(rig.Skeleton, solver.Solve(restScene, srcMap, rig, new SolveOptions()).Frames[0]);
        var bentOut = Fk(rig.Skeleton, solver.Solve(bentScene, srcMap, rig, new SolveOptions()).Frames[0]);

        var tgtNeck = rig.BoneForRole(BoneRole.Neck)!.Value;
        var moved = DegBetween(restOut[tgtNeck].Rot, bentOut[tgtNeck].Rot);
        Assert.True(MathF.Abs(moved - 20f) <= 1.0f,
            $"target neck rotated {moved:F2} deg for a 20 deg source neck bend");
    }

    [Fact]
    public void ActorCoreCatwalk_ToSbox_HandsEndOnCorrectSides()
    {
        var scene = ActorCore();
        var srcMap = DetectMap(scene, "actorcore_cc");
        var rig = SboxRig();
        var clip = scene.Clips[0];

        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        var (_, srcCanon, _) = NormCanon(scene.Skeleton, srcMap);
        var (_, tgtCanon, _) = NormCanon(rig.Skeleton, rig.ToMappingResult());
        var srcLateral = Vector3.Cross(srcCanon.CharacterUp, srcCanon.CharacterForward);
        var tgtLateral = Vector3.Cross(tgtCanon.CharacterUp, tgtCanon.CharacterForward);

        var srcPelvis = srcMap.RoleToBone[BoneRole.Hips];
        var tgtPelvis = rig.BoneForRole(BoneRole.Hips)!.Value;
        foreach (var f in SampleFrames(clip.FrameCount))
        {
            var srcWorld = Fk(scene.Skeleton, clip.Frames[f]);
            var tgtWorld = Fk(rig.Skeleton, solved.Frames[f]);
            foreach (var (role, name) in new[] { (BoneRole.HandL, "left"), (BoneRole.HandR, "right") })
            {
                var srcSide = Vector3.Dot(
                    srcWorld[srcMap.RoleToBone[role]].Pos - srcWorld[srcPelvis].Pos, srcLateral);
                var tgtSide = Vector3.Dot(
                    tgtWorld[rig.BoneForRole(role)!.Value].Pos - tgtWorld[tgtPelvis].Pos, tgtLateral);
                Assert.True(srcSide * tgtSide > 0f,
                    $"{name} hand crossed sides at frame {f} (src {srcSide:F1}, tgt {tgtSide:F1})");
            }
        }
    }

    // ================================================================ spine interpolation (5 → 3 bones)

    [Fact]
    public void UeMannequinSpine_FiveToThree_InterpolatesBend()
    {
        var skeleton = MappingFixtures.BuildUeMannequin();
        var detected = ProfileDetector.Detect(skeleton);
        Assert.NotNull(detected);
        Assert.Equal("ue_mannequin", detected!.Value.Profile.Name);
        var srcMap = detected.Value.Result;

        // 5 source spine bones must actually be mapped for this to exercise interpolation.
        var srcSpines = new[]
        {
            BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2, BoneRole.Spine3, BoneRole.Spine4,
        }.Count(r => srcMap.RoleToBone.ContainsKey(r));
        Assert.Equal(5, srcSpines);

        // Build a 1-frame clip bending each spine bone 10 deg forward (about character lateral).
        var (_, canon, _) = NormCanon(skeleton, srcMap);
        var lateral = Vector3.Cross(canon.CharacterUp, canon.CharacterForward);
        var world = skeleton.RestWorld.ToArray();
        foreach (var role in new[]
        {
            BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2, BoneRole.Spine3, BoneRole.Spine4,
        })
        {
            var bone = srcMap.RoleToBone[role];
            RotateWorldSubtree(skeleton, world, bone,
                Quaternion.CreateFromAxisAngle(lateral, 10f / RadToDeg), world[bone].Pos);
        }
        var scene = SceneFromWorldFrame(skeleton, world, "spine_bend");

        var rig = SboxRig();
        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        // The target spine must bend by the same amount as the source: compare each rig's
        // chord (Spine0 head -> Neck head) bend angle between rest and the solved pose.
        // (The chord's absolute direction is proportion-sensitive — different spine station
        // spacing shifts it a few degrees even when every segment direction interpolates
        // correctly — so the invariant is the bend angle, not the chord itself.)
        var tgtWorld = Fk(rig.Skeleton, solved.Frames[0]);

        Vector3 Chord(IReadOnlyList<XForm> w, int from, int to) => w[to].Pos - w[from].Pos;
        var srcBend = DegBetween(
            Chord(skeleton.RestWorld, srcMap.RoleToBone[BoneRole.Spine0], srcMap.RoleToBone[BoneRole.Neck]),
            Chord(world, srcMap.RoleToBone[BoneRole.Spine0], srcMap.RoleToBone[BoneRole.Neck]));
        var tgtBend = DegBetween(
            Chord(rig.Skeleton.RestWorld, rig.BoneForRole(BoneRole.Spine0)!.Value, rig.BoneForRole(BoneRole.Neck)!.Value),
            Chord(tgtWorld, rig.BoneForRole(BoneRole.Spine0)!.Value, rig.BoneForRole(BoneRole.Neck)!.Value));
        Assert.True(srcBend > 25f, $"fixture sanity: source spine should bend (got {srcBend:F1} deg)");
        Assert.True(MathF.Abs(srcBend - tgtBend) <= 3.0f,
            $"spine bend {tgtBend:F2} deg vs source {srcBend:F2} deg");

        // And every target spine bone actually moved (no rest freeze on the interpolated path).
        foreach (var role in new[] { BoneRole.Spine0, BoneRole.Spine1, BoneRole.Spine2 })
        {
            var bone = rig.BoneForRole(role)!.Value;
            Assert.True(
                DegBetween(solved.Frames[0][bone].Rot, rig.Skeleton[bone].RestLocal.Rot) > 1f,
                $"{role} did not move");
        }
    }

    // ================================================================ E. determinism + rest preservation

    [Fact]
    public void Solve_IsDeterministic()
    {
        var scene = Zombie();
        var srcMap = DetectMap(scene, "mixamo");
        var rig = SboxRig();

        var a = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());
        var b = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        Assert.Equal(a.FrameCount, b.FrameCount);
        for (var f = 0; f < a.FrameCount; f++)
        {
            for (var i = 0; i < a.Frames[f].Length; i++)
                Assert.Equal(a.Frames[f][i], b.Frames[f][i]);
        }
    }

    [Fact]
    public void Solve_PreservesRest_ForUnmappedAndNonAnimatedBones()
    {
        var scene = Zombie();
        var srcMap = DetectMap(scene, "mixamo");
        var rig = SboxRig();

        var solved = new GeometricSolver().Solve(scene, srcMap, rig, new SolveOptions());

        var restBones = new List<int>();
        for (var i = 0; i < rig.Skeleton.Count; i++)
        {
            var role = rig.RoleOf(i);
            // ConstraintDriven/IkBaked, role-less animated bones, and animated bones whose
            // role the Mixamo source does not map (e.g. finger metacarpals) must stay at rest.
            if (rig.ClassOf(i) != BoneClass.Animated
                || role is null
                || !srcMap.RoleToBone.ContainsKey(role.Value))
            {
                restBones.Add(i);
            }
        }
        Assert.True(restBones.Count >= 30, $"only {restBones.Count} rest-pose bones — fixture changed?");

        foreach (var frame in solved.Frames)
        {
            foreach (var i in restBones)
                Assert.Equal(rig.Skeleton[i].RestLocal, frame[i]);
        }
    }

    // ---------------------------------------------------------------- frame-construction helpers (shared)

    /// <summary>Hierarchically rotates the world rest of <paramref name="root"/> and its
    /// descendants about a pivot (positions orbit, orientations premultiplied).</summary>
    internal static void RotateWorldSubtree(Skel skeleton, XForm[] world, int root, Quaternion q, Vector3 pivot)
    {
        var inSubtree = new bool[skeleton.Count];
        inSubtree[root] = true;
        for (var i = root; i < skeleton.Count; i++)
        {
            if (i != root)
            {
                var parent = skeleton[i].ParentIndex;
                if (parent < 0 || !inSubtree[parent])
                    continue;
                inSubtree[i] = true;
            }
            world[i] = new XForm(
                pivot + Vector3.Transform(world[i].Pos - pivot, q),
                MathQ.Normalize(q * world[i].Rot));
        }
    }

    /// <summary>Wraps a world-space pose as a 1-frame SourceScene clip (locals derived
    /// against the skeleton hierarchy).</summary>
    internal static SourceScene SceneFromWorldFrame(Skel skeleton, XForm[] world, string clipName)
    {
        var locals = new XForm[skeleton.Count];
        for (var i = 0; i < skeleton.Count; i++)
        {
            var parent = skeleton[i].ParentIndex;
            locals[i] = parent < 0 ? world[i] : XForm.ToLocal(world[parent], world[i]);
        }
        var clip = new Clip(clipName, 30f, false);
        clip.Frames.Add(locals);
        return new SourceScene(skeleton, new[] { clip }, 1f);
    }
}
using System.Numerics;
using HumanoidRetargeter.Core.Cleanup;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using HumanoidRetargeter.Core.Solve;
using HumanoidRetargeter.Core.Target;
using SkeletonModel = HumanoidRetargeter.Core.Skeleton.Skeleton;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Solve;

/// <summary>
/// Local-only planted-foot quality gates against the REAL user-reported repro files in
/// <c>dev/corpus/todo/</c> (not committed; every test silently passes when a file is
/// absent). User reports: 47_01.bvh "feet are a little bent upward", Defenses.fbx "feet
/// seem to be slightly bent inward, like part of the bone sticks out" — both were the
/// rest foot-direction divergence (23–44° vs the s&amp;box ankle) leaking through absolute
/// direction matching. The fix: feet transfer as
/// <see cref="RoleTransferMode.CharacterDeltaFromRest"/>, plus the
/// <see cref="FootGroundAlign"/> stance recalibration when the source's normalized rest is
/// not a plausible stance (Defenses: T-pose normalization swings its bent-leg rest feet
/// 14–26° off the rig's true stance).
/// </summary>
public class GroundedFeetReproTests
{
    private const float Rad2Deg = 180f / MathF.PI;

    // ---------------------------------------------------------------- gates

    /// <summary>CMU-style rig whose rest IS a stance: the full pipeline must track the
    /// source's planted foot pitch (deviation from each rig's own rest anatomy) — the old
    /// constant ~23–25° "bent upward" leak is gone, and the stance recalibration must stay
    /// dormant (the planted offset is genuine articulation).</summary>
    [Fact] // skipped (silently green) when the local corpus file is not present
    public void Cmu4701_PlantedFootPitch_TracksSource_Within3Degrees()
    {
        if (Load("47_01.bvh") is not { } data)
            return;

        foreach (var side in Sides(data))
        {
            Assert.True(side.PlantedFrames >= 30, $"{side.Name}: too few planted frames");
            Assert.True(MathF.Abs(side.MeanPitchErrVsSource) <= 3f,
                $"{side.Name}: planted pitch error vs source {side.MeanPitchErrVsSource:F2} deg");
            Assert.True(side.MaxAbsPitchErrVsSource <= 5f,
                $"{side.Name}: planted pitch error max {side.MaxAbsPitchErrVsSource:F2} deg");
        }
    }

    /// <summary>ARP-style rig whose (normalized) rest is NOT a stance: the recalibration
    /// must level the planted soles against the ground — mean planted pitch deviation from
    /// the target's own flat-stance rest ≈ 0 (was +15…+17° "bent inward/upward" before, and
    /// would be −12…−25° toe-through-floor under a plain rest-relative replay).</summary>
    [Fact] // skipped (silently green) when the local corpus file is not present
    public void Defenses_PlantedFeet_LevelWithGround_Within3Degrees()
    {
        if (Load("Defenses.fbx") is not { } data)
            return;

        foreach (var side in Sides(data))
        {
            Assert.True(side.PlantedFrames >= 100, $"{side.Name}: too few planted frames");
            Assert.True(MathF.Abs(side.MeanTargetPitchDelta) <= 3f,
                $"{side.Name}: planted feet not level with ground — mean pitch delta "
                + $"{side.MeanTargetPitchDelta:F2} deg from the target's flat-stance rest");
        }
    }

    // ---------------------------------------------------------------- pipeline + metrics

    private sealed record SideStats(
        string Name, int PlantedFrames, float MeanTargetPitchDelta,
        float MeanPitchErrVsSource, float MaxAbsPitchErrVsSource);

    private sealed record ReproData(
        SourceScene Scene, MappingResult Map, TargetRig Rig, List<XForm[]> TargetFrames);

    private static ReproData? Load(string todoFileName)
    {
        var path = RepoFile("dev", "corpus", "todo", todoFileName);
        if (!File.Exists(path))
            return null;

        var bytes = File.ReadAllBytes(path);
        var scene = Retargeter.ImportSource(bytes, todoFileName);
        var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);
        var target = RetargetTargetSpec.SboxDefault(
            File.ReadAllText(RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json")));

        var result = Retargeter.Convert(new RetargetRequest
        {
            SourceData = bytes,
            SourceFileName = todoFileName,
            MappingOverride = map,
        }, target);
        var clip = result.Clips.First(c => c.Success);
        return new ReproData(scene, map, target.Rig, clip.SolvedFrames!);
    }

    private static IEnumerable<SideStats> Sides(ReproData data)
    {
        var (scene, map, rig, tgtFrames) = (data.Scene, data.Map, data.Rig, data.TargetFrames);
        var srcSkel = scene.Skeleton;
        var tgtSkel = rig.Skeleton;
        var tgtMap = rig.ToMappingResult();
        var (srcNorm, _) = RestNormalizer.Normalize(srcSkel, map);
        var (tgtNorm, _) = RestNormalizer.Normalize(tgtSkel, tgtMap);
        var srcUp = Vector3.Normalize(CanonicalFrames.Build(srcSkel, map, srcNorm.WorldRest).CharacterUp);
        var tgtUp = Vector3.Normalize(CanonicalFrames.Build(tgtSkel, tgtMap, tgtNorm.WorldRest).CharacterUp);

        FootChain Chain(Func<BoneRole, int?> bone, BoneRole u, BoneRole l, BoneRole f, BoneRole t) => new()
        {
            Hip = bone(u)!.Value,
            Knee = bone(l)!.Value,
            Ankle = bone(f)!.Value,
            Toe = bone(t),
        };
        int? SrcBone(BoneRole r) => map.RoleToBone.TryGetValue(r, out var b) ? b : null;

        var srcL = Chain(SrcBone, BoneRole.UpperLegL, BoneRole.LowerLegL, BoneRole.FootL, BoneRole.ToeL);
        var srcR = Chain(SrcBone, BoneRole.UpperLegR, BoneRole.LowerLegR, BoneRole.FootR, BoneRole.ToeR);
        var clip = scene.Clips[0];
        var (plantsL, plantsR) = FootPlant.DetectPlantIntervals(
            clip.Frames, srcSkel, srcL, srcR, srcUp, clip.Fps);

        foreach (var (name, footRole, toeRole, plants) in new[]
                 {
                     ("LEFT", BoneRole.FootL, BoneRole.ToeL, plantsL),
                     ("RIGHT", BoneRole.FootR, BoneRole.ToeR, plantsR),
                 })
        {
            var srcPitch = PitchDeltas(
                clip.Frames, srcSkel, map.RoleToBone[footRole], map.RoleToBone[toeRole],
                srcNorm.WorldRest, srcUp);
            var tgtPitch = PitchDeltas(
                tgtFrames, tgtSkel, rig.BoneForRole(footRole)!.Value, rig.BoneForRole(toeRole)!.Value,
                tgtNorm.WorldRest, tgtUp);

            var planted = new List<int>();
            foreach (var p in plants)
            {
                for (int f = Math.Max(p.Start, 0); f <= p.End && f < tgtFrames.Count; f++)
                    planted.Add(f);
            }

            if (planted.Count == 0)
            {
                yield return new SideStats(name, 0, 0f, 0f, 0f);
                continue;
            }

            var meanTgt = planted.Average(f => tgtPitch[f]);
            var meanErr = planted.Average(f => tgtPitch[f] - srcPitch[f]);
            var maxErr = planted.Max(f => MathF.Abs(tgtPitch[f] - srcPitch[f]));
            yield return new SideStats(name, planted.Count, meanTgt, meanErr, maxErr);
        }
    }

    /// <summary>Per-frame foot pitch (vs the ground plane) as a delta from the rig's own
    /// rest anatomy: rest foot→toe direction carried by the foot's world delta from the
    /// normalized rest.</summary>
    private static float[] PitchDeltas(
        List<XForm[]> frames, SkeletonModel skeleton, int foot, int toe,
        IReadOnlyList<XForm> normRest, Vector3 up)
    {
        var restDir = normRest[toe].Pos - normRest[foot].Pos;
        float Pitch(Vector3 d) =>
            MathF.Asin(Math.Clamp(Vector3.Dot(Vector3.Normalize(d), up), -1f, 1f)) * Rad2Deg;
        var restPitch = Pitch(restDir);
        var restRotInv = Quaternion.Conjugate(normRest[foot].Rot);

        var result = new float[frames.Count];
        for (int f = 0; f < frames.Count; f++)
        {
            var world = FootWorldRot(frames[f], skeleton, foot);
            var dir = Vector3.Transform(restDir, MathQ.Normalize(world * restRotInv));
            result[f] = Pitch(dir) - restPitch;
        }
        return result;
    }

    private static Quaternion FootWorldRot(XForm[] locals, SkeletonModel skeleton, int bone)
    {
        var rot = locals[bone].Rot;
        for (var b = skeleton[bone].ParentIndex; b >= 0; b = skeleton[b].ParentIndex)
            rot = locals[b].Rot * rot;
        return MathQ.Normalize(rot);
    }

    private static string RepoFile(params string[] parts)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
            dir = dir.Parent;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found above test directory.");
    }
}
using HumanoidRetargeter.Core.Formats;
using HumanoidRetargeter.Core.Mapping;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests;

/// <summary>
/// Local-only end-to-end check against a REAL Unity animation pack file
/// (PunchPerfect "Stance.fbx" + its .fbx.meta sidecar: one 44-frame 30 fps take
/// "root|Animation" carrying two clipAnimations — 'Stance' frames 0–43 and
/// 'Stance No Move' frames 0–1, both loopTime 1; Auto-Rig-Pro-style bone names like
/// thigh_stretch.l / spine_01.x / root.x). The licensed file is NOT committed: every test
/// silently passes when it is absent.
/// </summary>
public class UnityMetaRealFileTests
{
    private const string RealFbx =
        @"P:\10 3 2025\Pointless Group\PunchPerfect Boxing Animations & Tools\Core\Animations\Stance.fbx";

    private static bool Available => File.Exists(RealFbx) && File.Exists(RealFbx + ".meta");

    /// <summary>Resolves a repo-relative path by walking up to the .sbproj directory.</summary>
    private static string RepoFile(params string[] parts)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
            dir = dir.Parent!;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found.");
    }

    private static readonly Lazy<RetargetTargetSpec> SboxTarget = new(()
        => RetargetTargetSpec.SboxDefault(
            File.ReadAllText(RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json"))));

    [Fact] // skipped (silently green) when the local pack is not present
    public void RealStanceMeta_ParsesBothClipDefinitions()
    {
        if (!Available)
            return;

        var defs = UnityMeta.ParseClipAnimations(File.ReadAllText(RealFbx + ".meta"));

        Assert.Equal(2, defs.Count);
        Assert.Equal("Stance", defs[0].Name);
        Assert.Equal("Stance No Move", defs[1].Name);
        Assert.All(defs, d => Assert.Equal("root|Animation", d.TakeName));
        Assert.Equal(0f, defs[0].FirstFrame);
        Assert.Equal(43f, defs[0].LastFrame);
        Assert.Equal(0f, defs[1].FirstFrame);
        Assert.Equal(1f, defs[1].LastFrame);
        Assert.All(defs, d => Assert.True(d.Loop)); // loopTime: 1 on both
    }

    [Fact] // skipped (silently green) when the local pack is not present
    public void RealStanceFbx_AutoMapsTheAutoRigProSkeleton()
    {
        if (!Available)
            return;

        var scene = Retargeter.ImportSource(File.ReadAllBytes(RealFbx), "Stance.fbx");
        var (map, _) = Retargeter.ResolveMapping(scene.Skeleton);

        string? BoneOf(BoneRole role)
            => map.RoleToBone.TryGetValue(role, out var i) ? scene.Skeleton[i].Name : null;

        Assert.Equal("root.x", BoneOf(BoneRole.Hips));
        Assert.Equal("thigh_stretch.l", BoneOf(BoneRole.UpperLegL));
        Assert.Equal("leg_stretch.r", BoneOf(BoneRole.LowerLegR));
        Assert.Equal("foot.l", BoneOf(BoneRole.FootL));
        Assert.Equal("toes_01.l", BoneOf(BoneRole.ToeL));
        Assert.Equal("spine_01.x", BoneOf(BoneRole.Spine0));
        Assert.Equal("spine_03.x", BoneOf(BoneRole.Spine2));
        Assert.Equal("neck.x", BoneOf(BoneRole.Neck));
        Assert.Equal("head.x", BoneOf(BoneRole.Head));
        Assert.Equal("arm_stretch.l", BoneOf(BoneRole.UpperArmL));
        Assert.Equal("forearm_stretch.r", BoneOf(BoneRole.LowerArmR));
        Assert.Equal("hand.l", BoneOf(BoneRole.HandL));
        Assert.True(map.Confidence >= 0.9f, $"confidence {map.Confidence}");
    }

    [Fact] // skipped (silently green) when the local pack is not present
    public void RealStanceFbx_MetaDefinitions_ConvertToSboxTarget()
    {
        if (!Available)
            return;

        var defs = UnityMeta.ParseClipAnimations(File.ReadAllText(RealFbx + ".meta"));
        Assert.Equal(2, defs.Count);

        var result = Retargeter.Convert(new RetargetRequest
        {
            SourceData = File.ReadAllBytes(RealFbx),
            SourceFileName = "Stance.fbx",
            ClipDefinitions = defs,
        }, SboxTarget.Value);

        Assert.Equal(2, result.Clips.Count);
        Assert.All(result.Clips, c => Assert.True(c.Success, c.Error));
        Assert.Equal("Stance", result.Clips[0].ClipName);
        Assert.Equal("Stance_No_Move", result.Clips[1].ClipName); // sanitized like take names

        // The take is 44 frames (0..43) at native 30 fps == the 30 fps sample grid:
        // 'Stance' spans the whole timeline, 'Stance No Move' is the 2-frame pose range.
        Assert.Equal(44, result.Clips[0].SolvedFrames!.Count);
        Assert.Equal(2, result.Clips[1].SolvedFrames!.Count);
        Assert.True(result.Clips[0].SolvedFrames!.Count > 10);
        Assert.All(result.Clips, c => Assert.True(c.Looping)); // loopTime on both

        // Plausibility: every solved transform finite, pelvis at a sane standing height.
        var pelvis = SboxTarget.Value.Rig.BoneForRole(BoneRole.Hips)!.Value;
        foreach (var clip in result.Clips)
        {
            foreach (var frame in clip.SolvedFrames!)
            {
                foreach (var xf in frame)
                {
                    Assert.True(float.IsFinite(xf.Pos.X) && float.IsFinite(xf.Pos.Y) && float.IsFinite(xf.Pos.Z));
                    Assert.True(float.IsFinite(xf.Rot.X) && float.IsFinite(xf.Rot.Y)
                        && float.IsFinite(xf.Rot.Z) && float.IsFinite(xf.Rot.W));
                }
            }
            // s&box source space is Y-up cm; a standing pelvis sits roughly 70–120 cm up.
            var hipsY = clip.SolvedFrames![0][pelvis].Pos.Y;
            Assert.InRange(hipsY, 50f, 150f);
            Assert.NotNull(clip.DmxContent);
            Assert.NotEmpty(clip.DmxContent!);
        }
    }
}
using System.Text;
using HumanoidRetargeter.Core.Mapping;
using HumanoidRetargeter.Core.Target;
using Xunit;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests;

/// <summary>
/// .vrm input tests: a VRM is a glTF 2.0 GLB container plus a VRM extension whose
/// <c>humanoid.humanBones</c> block is an EXPLICIT, authored bone map. The importer must
/// surface it as <see cref="HumanoidRetargeter.Core.Skeleton.SourceScene.AuthoredMapping"/>
/// (<see cref="MappingSource.Authored"/>, confidence 1.0) and the mapping cascade must
/// prefer it over presets/auto detection. Both extension layouts are covered: VRM 0.x
/// (<c>extensions.VRM.humanoid.humanBones</c> as an array of <c>{bone, node}</c>) and
/// VRM 1.0 (<c>extensions.VRMC_vrm.humanoid.humanBones</c> as a name-keyed object).
/// </summary>
public class VrmInputTests
{
    private static string RepoFile(params string[] parts)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
            dir = dir.Parent!;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found.");
    }

    // ---------------------------------------------------------------- synthetic .vrm

    /// <summary>
    /// Tiny humanoid: deliberately OPAQUE node names (j00…j20) so no preset and no
    /// name-based auto-map could resolve it — proving the roles come from the VRM extension.
    /// Meter-scale T-pose (glTF units), Y-up. Index = position in this array.
    /// </summary>
    private static readonly (string Vrm, int Parent, float X, float Y, float Z)[] Rig =
    {
        ("hips", -1, 0f, 0.95f, 0f),
        ("spine", 0, 0f, 0.10f, 0f),
        ("chest", 1, 0f, 0.12f, 0f),
        ("neck", 2, 0f, 0.14f, 0f),
        ("head", 3, 0f, 0.06f, 0f),
        ("leftShoulder", 2, 0.06f, 0.08f, 0f),
        ("leftUpperArm", 5, 0.12f, 0f, 0f),
        ("leftLowerArm", 6, 0.26f, 0f, 0f),
        ("leftHand", 7, 0.25f, 0f, 0f),
        ("rightShoulder", 2, -0.06f, 0.08f, 0f),
        ("rightUpperArm", 9, -0.12f, 0f, 0f),
        ("rightLowerArm", 10, -0.26f, 0f, 0f),
        ("rightHand", 11, -0.25f, 0f, 0f),
        ("leftUpperLeg", 0, 0.09f, -0.05f, 0f),
        ("leftLowerLeg", 13, 0f, -0.40f, 0f),
        ("leftFoot", 14, 0f, -0.40f, 0f),
        ("leftToes", 15, 0f, -0.08f, 0.12f),
        ("rightUpperLeg", 0, -0.09f, -0.05f, 0f),
        ("rightLowerLeg", 17, 0f, -0.40f, 0f),
        ("rightFoot", 18, 0f, -0.40f, 0f),
        ("rightToes", 19, 0f, -0.08f, 0.12f),
    };

    private static string NodeName(int index) => $"j{index:00}";

    /// <summary>Builds the glTF JSON: nodes + skin + a 1 s hips-sway animation (data: URI
    /// buffers) + the requested VRM extension block.</summary>
    private static string VrmJson(int vrmVersion)
    {
        var sb = new StringBuilder();
        sb.Append("{ \"asset\": { \"version\": \"2.0\" }, \"nodes\": [");
        for (var i = 0; i < Rig.Length; i++)
        {
            var children = Enumerable.Range(0, Rig.Length)
                .Where(c => Rig[c].Parent == i).ToList();
            sb.Append(i > 0 ? ", {" : " {");
            sb.Append($" \"name\": \"{NodeName(i)}\"");
            sb.Append(string.Create(System.Globalization.CultureInfo.InvariantCulture,
                $", \"translation\": [{Rig[i].X}, {Rig[i].Y}, {Rig[i].Z}]"));
            if (children.Count > 0)
                sb.Append($", \"children\": [{string.Join(", ", children)}]");
            sb.Append(" }");
        }
        sb.Append(" ], \"skins\": [ { \"joints\": [");
        sb.Append(string.Join(", ", Enumerable.Range(0, Rig.Length)));
        sb.Append("] } ]");

        // One rotation channel on the hips (node 0): ±10° about Y over 1 s.
        var sin5 = MathF.Sin(MathF.PI / 36f);
        var cos5 = MathF.Cos(MathF.PI / 36f);
        var times = Base64Floats(0f, 1f);
        var rotations = Base64Floats(0f, -sin5, 0f, cos5, 0f, sin5, 0f, cos5);
        sb.Append(", \"animations\": [ { \"name\": \"sway\", \"channels\": [ " +
            "{ \"sampler\": 0, \"target\": { \"node\": 0, \"path\": \"rotation\" } } ], " +
            "\"samplers\": [ { \"input\": 0, \"output\": 1, \"interpolation\": \"LINEAR\" } ] } ]");
        sb.Append(", \"buffers\": [" +
            $" {{ \"byteLength\": 8, \"uri\": \"data:application/octet-stream;base64,{times}\" }}," +
            $" {{ \"byteLength\": 32, \"uri\": \"data:application/octet-stream;base64,{rotations}\" }} ]");
        sb.Append(", \"bufferViews\": [" +
            " { \"buffer\": 0, \"byteOffset\": 0, \"byteLength\": 8 }," +
            " { \"buffer\": 1, \"byteOffset\": 0, \"byteLength\": 32 } ]");
        sb.Append(", \"accessors\": [" +
            " { \"bufferView\": 0, \"componentType\": 5126, \"count\": 2, \"type\": \"SCALAR\" }," +
            " { \"bufferView\": 1, \"componentType\": 5126, \"count\": 2, \"type\": \"VEC4\" } ]");

        // ---- the VRM extension (authored humanoid bone map) ----
        if (vrmVersion == 0)
        {
            var entries = string.Join(", ", Rig.Select(
                (bone, i) => $"{{ \"bone\": \"{bone.Vrm}\", \"node\": {i} }}"));
            sb.Append($", \"extensions\": {{ \"VRM\": {{ \"exporterVersion\": \"test\", " +
                $"\"humanoid\": {{ \"humanBones\": [ {entries} ] }} }} }}");
        }
        else
        {
            var entries = string.Join(", ", Rig.Select(
                (bone, i) => $"\"{bone.Vrm}\": {{ \"node\": {i} }}"));
            sb.Append($", \"extensions\": {{ \"VRMC_vrm\": {{ \"specVersion\": \"1.0\", " +
                $"\"humanoid\": {{ \"humanBones\": {{ {entries} }} }} }} }}");
        }

        sb.Append(" }");
        return sb.ToString();
    }

    private static string Base64Floats(params float[] values)
    {
        var bytes = new byte[values.Length * 4];
        Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
        return Convert.ToBase64String(bytes);
    }

    /// <summary>Wraps JSON into a GLB container (header + space-padded JSON chunk) — the
    /// physical layout of real .vrm files.</summary>
    private static byte[] Glb(string json)
    {
        var jsonBytes = Encoding.UTF8.GetBytes(json);
        var pad = (4 - jsonBytes.Length % 4) % 4;
        var chunkLength = jsonBytes.Length + pad;

        using var ms = new MemoryStream();
        using var bw = new BinaryWriter(ms);
        bw.Write(0x46546C67u);                  // 'glTF'
        bw.Write(2u);                           // container version
        bw.Write((uint)(12 + 8 + chunkLength)); // total length
        bw.Write((uint)chunkLength);
        bw.Write(0x4E4F534Au);                  // 'JSON'
        bw.Write(jsonBytes);
        for (var i = 0; i < pad; i++)
            bw.Write((byte)0x20);               // spaces, per spec
        bw.Flush();
        return ms.ToArray();
    }

    private static byte[] TinyVrm(int vrmVersion) => Glb(VrmJson(vrmVersion));

    // ---------------------------------------------------------------- import + mapping

    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    public void ImportSource_Vrm_SurfacesAuthoredMapping(int vrmVersion)
    {
        var scene = Retargeter.ImportSource(TinyVrm(vrmVersion), "avatar.vrm");

        Assert.NotNull(scene.AuthoredMapping);
        var map = scene.AuthoredMapping!;
        Assert.Equal(MappingSource.Authored, map.Source);
        Assert.Equal(1f, map.Confidence);
        Assert.Equal("vrm", map.ProfileName);
        Assert.Equal(Rig.Length, map.RoleToBone.Count); // all 21 humanoid bones resolved

        // Every VRM bone landed on the node it names (verified through the skeleton's
        // OPAQUE bone names — nothing here is name-detectable).
        var expectedRoles = new Dictionary<string, BoneRole>
        {
            ["hips"] = BoneRole.Hips,
            ["spine"] = BoneRole.Spine0,
            ["chest"] = BoneRole.Spine1,
            ["neck"] = BoneRole.Neck,
            ["head"] = BoneRole.Head,
            ["leftShoulder"] = BoneRole.ClavicleL,
            ["leftUpperArm"] = BoneRole.UpperArmL,
            ["leftLowerArm"] = BoneRole.LowerArmL,
            ["leftHand"] = BoneRole.HandL,
            ["rightUpperLeg"] = BoneRole.UpperLegR,
            ["rightLowerLeg"] = BoneRole.LowerLegR,
            ["rightFoot"] = BoneRole.FootR,
            ["rightToes"] = BoneRole.ToeR,
        };
        foreach (var (vrmName, role) in expectedRoles)
        {
            var nodeIndex = Array.FindIndex(Rig, b => b.Vrm == vrmName);
            var bone = Assert.Contains(role, (IDictionary<BoneRole, int>)map.RoleToBone);
            Assert.Equal(NodeName(nodeIndex), scene.Skeleton[bone].Name);
        }

        Assert.Contains(map.Notes, n => n.Contains(vrmVersion == 1 ? "VRM 1.0" : "VRM 0.x"));
    }

    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    public void Inspect_Vrm_ReportsAuthoredProfileAtFullConfidence(int vrmVersion)
    {
        var inspected = Retargeter.Inspect(TinyVrm(vrmVersion), "avatar.vrm");

        Assert.Equal("vrm", inspected.Mapping.ProfileName);
        Assert.Equal(MappingSource.Authored, inspected.Mapping.Source);
        Assert.Equal(1f, inspected.Mapping.Confidence);
        Assert.False(inspected.Mapping.NeedsUserDecision);
        Assert.Equal(Rig.Length, inspected.Mapping.MappedRoleCount);
        Assert.Equal(1, inspected.TakeCount); // the sway animation
    }

    [Fact]
    public void ResolveMapping_AuthoredBeatsUserPreset_ButOverrideBeatsAuthored()
    {
        var scene = Retargeter.ImportSource(TinyVrm(0), "avatar.vrm");
        var authored = scene.AuthoredMapping!;

        // Authored wins over a user preset — the lookup is never even consulted.
        var lookupCalls = 0;
        var (map, report) = Retargeter.ResolveMapping(scene.Skeleton, null,
            _ => { lookupCalls++; return new MappingResult("user", MappingSource.UserPreset); },
            authored);
        Assert.Same(authored, map);
        Assert.Equal(MappingSource.Authored, report.Source);
        Assert.Equal(0, lookupCalls);

        // An explicit override (deliberate user decision) still beats the file's own map.
        var manual = new MappingResult("manual", MappingSource.Manual) { Confidence = 1f };
        manual.RoleToBone[BoneRole.Hips] = 0;
        var (overridden, overriddenReport) = Retargeter.ResolveMapping(
            scene.Skeleton, manual, null, authored);
        Assert.Same(manual, overridden);
        Assert.Equal(MappingSource.Manual, overriddenReport.Source);
    }

    [Fact]
    public void PlainGlb_WithoutVrmExtension_IsUnchanged()
    {
        // The existing mixamo-named GLB fixture: no VRM extension → no authored mapping,
        // and the cascade still lands on the shipped preset like before.
        var bytes = File.ReadAllBytes(Path.Combine(
            AppContext.BaseDirectory, "fixtures", "gltf", "freebvh.glb"));
        var scene = Retargeter.ImportSource(bytes, "freebvh.glb");
        Assert.Null(scene.AuthoredMapping);

        var inspected = Retargeter.Inspect(bytes, "freebvh.glb");
        Assert.Equal("mixamo", inspected.Mapping.ProfileName);
        Assert.Equal(MappingSource.Preset, inspected.Mapping.Source);
    }

    // ---------------------------------------------------------------- end-to-end facade

    [Fact]
    public void Convert_Vrm_EndToEnd_UsesAuthoredMapping()
    {
        // Convert the .vrm's animation onto a target built from its own authored rig — the
        // full pipeline (import → authored mapping → solve → cleanup → DMX → vmdl) must
        // succeed even though NO name-based detection could ever map the j00…j20 skeleton.
        var scene = Retargeter.ImportSource(TinyVrm(0), "avatar.vrm");
        var spec = new RetargetTargetSpec
        {
            Rig = TargetRig.FromSkeleton(scene.Skeleton, scene.AuthoredMapping!),
            VmdlScale = 1.0f,
            BaseModelPath = "",
            DefaultRootBone = scene.Skeleton[scene.AuthoredMapping!.RoleToBone[BoneRole.Hips]].Name,
        };

        var result = Retargeter.Convert(new RetargetRequest
        {
            SourceData = TinyVrm(0),
            SourceFileName = "avatar.vrm",
        }, spec);

        Assert.True(result.Success, string.Join("; ", result.Errors));
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);
        Assert.Equal("sway", clip.ClipName);
        Assert.False(string.IsNullOrEmpty(clip.DmxContent));

        Assert.NotNull(clip.Mapping);
        Assert.Equal("vrm", clip.Mapping!.ProfileName);
        Assert.Equal(MappingSource.Authored, clip.Mapping.Source);
        Assert.Equal(1f, clip.Mapping.Confidence);
        Assert.False(clip.Mapping.NeedsUserDecision);
    }
}
using System.Reflection;
using System.Text;
using HumanoidRetargeter.EditorTools;
using HumanoidRetargeter.EditorTools.Embedded.ValveResourceFormat;
using HumanoidRetargeter.EditorTools.Embedded.ValveResourceFormat.IO;
using Xunit;

namespace SmartPort.Parser.Tests;

public class OptionalMaterialTests
{
    const string Outfit = "models/human_clothes/default_outfit/default_outfit_male_lower.vmat";

    sealed class FailingLoader(Exception error) : IFileLoader
    {
        public Resource LoadFile(string file) => throw error;
        public Resource LoadFileCompiled(string file) => LoadFile(file);
    }

    [Fact]
    public void MissingOutfitMetadataUsesVertexBufferSemantics()
    {
        var loader = new FailingLoader(new FileNotFoundException("Missing material", Outfit));
        Assert.Empty(ModelExtract.ReadMaterialInputSignature(loader, Outfit).Elements);
    }

    [Fact]
    public void CorruptMetadataAndCancellationAreNotHidden()
    {
        Assert.Throws<InvalidDataException>(() => ModelExtract.ReadMaterialInputSignature(
            new FailingLoader(new InvalidDataException("Corrupt material")), Outfit));
        Assert.Throws<OperationCanceledException>(() => ModelExtract.ReadMaterialInputSignature(
            new FailingLoader(new OperationCanceledException()), Outfit));
    }

    [Theory]
    [InlineData("required.vmesh")]
    [InlineData("required.vphys")]
    [InlineData("required.vanim")]
    [InlineData("required.vagrp")]
    [InlineData("required.vmdl")]
    public void RequiredDependenciesStillFailWithTheirFilename(string path)
    {
        var type = typeof(CompiledAssetRecovery).GetNestedType("Loader", BindingFlags.NonPublic)!;
        using var loader = (IDisposable)Activator.CreateInstance(type,
            BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null,
            new object[] { new Dictionary<string, string>(), CancellationToken.None }, null)!;
        var error = Assert.Throws<FileNotFoundException>(() => ((IFileLoader)loader).LoadFileCompiled(path));
        Assert.Equal(path, error.FileName);
        Assert.Contains(path, error.Message);
    }

    [CompiledFixtureFact]
    public void MissingMaterialFilesDoNotBlockRecoveryOrEraseMaterialAssignments()
    {
        var fixture = Environment.GetEnvironmentVariable("HR_SMART_PORT_FIXTURE")!;
        var paths = Directory.GetFiles(fixture, "*_c", SearchOption.AllDirectories)
            .Where(p => !p.EndsWith(".vmat_c", StringComparison.OrdinalIgnoreCase))
            .ToDictionary(p => Path.GetRelativePath(fixture, p)[..^2].Replace('\\', '/'), p => p, StringComparer.OrdinalIgnoreCase);
        const string model = "models/player/human/frank_mp.vmdl";
        var output = Path.Combine(Path.GetTempPath(), "hr-smart-port-no-materials-" + Guid.NewGuid().ToString("N"));
        var text = CompiledAssetRecovery.Recover(paths[model], model, output, "recovered", paths, default);
        using var resource = new Resource();
        resource.Read(paths[model]);
        Assert.NotNull(resource.ExternalReferences);
        var materials = resource.ExternalReferences.ResourceRefInfoList.Where(r => r.Name.EndsWith(".vmat")).Select(r => r.Name).ToArray();
        Assert.NotEmpty(materials);
        var files = Directory.GetFiles(output, "*.dmx", SearchOption.AllDirectories);
        Assert.Equal(398, files.Length); // Seven meshes and all 391 animation sources.
        var meshText = string.Join("\n", files.Where(f => Path.GetFileName(f).StartsWith("frank_mp_")).Select(f => Encoding.UTF8.GetString(File.ReadAllBytes(f))));
        foreach (var material in materials) Assert.Contains(material, text + meshText);
    }
}
using Sandbox;

[TestClass]
public partial class LibraryTests
{
	[TestMethod]
	public void SceneTest()
	{
		var scene = new Scene();
		using ( scene.Push() )
		{
			var go = new GameObject();

			Assert.AreEqual( 1, scene.Directory.GameObjectCount );
		}
	}

}
global using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Sandbox.States.Test;

[TestClass]
public class TestInit
{
	[AssemblyInitialize]
	public static void ClassInitialize( TestContext context )
	{
		Sandbox.Application.InitUnitTest();
	}
}
global using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class TestInit
{
	public static Sandbox.TestAppSystem AppSystem;

	[AssemblyInitialize]
	public static void AssemblyInitialize( TestContext context )
	{
		AppSystem = new Sandbox.TestAppSystem();
		AppSystem.Init();
	}

	[AssemblyCleanup]
	public static void AssemblyCleanup()
	{
		AppSystem.Shutdown();
	}
}
#nullable enable

using System;
using System.Linq;
using Hexagon.V2.Infrastructure;
using Hexagon.V2.Persistence;

namespace Hexagon.V2.Tests.Infrastructure;

[TestClass]
public sealed class PrefixedPersistenceStorageTests
{
	[TestMethod]
	public async Task EveryOperationIsMappedBelowTheIsolatedPrefix()
	{
		var physical = new InMemoryPersistenceStorage();
		var isolated = new PrefixedPersistenceStorage( physical, "verification/run-42" );

		Assert.IsTrue( await isolated.TryWriteImmutableAsync( "hexagon/v2/schema/format.json", new byte[] { 1, 2 } ) );
		Assert.IsTrue( await physical.ExistsAsync( "verification/run-42/hexagon/v2/schema/format.json" ) );
		Assert.IsFalse( await physical.ExistsAsync( "hexagon/v2/schema/format.json" ) );

		var listed = await isolated.ListAsync( "hexagon/v2/schema" );
		Assert.HasCount( 1, listed );
		Assert.AreEqual( "hexagon/v2/schema/format.json", listed.Single() );
	}

	[TestMethod]
	public void PrefixRejectsParentAndCurrentDirectorySegments()
	{
		var storage = new InMemoryPersistenceStorage();
		Assert.ThrowsExactly<ArgumentException>( () => new PrefixedPersistenceStorage( storage, "../escape" ) );
		Assert.ThrowsExactly<ArgumentException>( () => new PrefixedPersistenceStorage( storage, "safe/./escape" ) );
	}
}