Effigy.Tests/BoneSelectionTests.cs
using System.Collections.Generic;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// Which bones a group drag is applied to.
///
/// WHY THIS IS WORTH A TEST FILE. The rule decides whether a bone gets transformed once or twice,
/// and being transformed twice does not look like a selection bug — it looks like one bone flying
/// further than the rest, which sends you to the gizmo. It lived as a one-line LINQ filter inside a
/// 2,100-line viewport where nothing could reach it, and it was wrong.
/// </summary>
public static class BoneSelectionTests
{
/// <summary>
/// pelvis → spine → chest → arm → hand, one straight chain, which is enough for every case
/// below: contiguous selections, selections that skip a generation, and roots.
/// </summary>
static readonly Dictionary<string, string> Chain = new()
{
["pelvis"] = null,
["spine"] = "pelvis",
["chest"] = "spine",
["arm"] = "chest",
["hand"] = "arm",
};
static string ParentOf( string name ) => Chain.TryGetValue( name, out var p ) ? p : null;
static List<string> Top( params string[] selected ) =>
BoneSelection.TopMost( selected, ParentOf ).OrderBy( n => n ).ToList();
public static void Run()
{
Report.Section( "bone selection: one bone is its own top" );
TestSingle();
Report.Section( "bone selection: a parent carries its children" );
TestContiguous();
Report.Section( "bone selection: a selection that skips a generation" );
TestSkippedGeneration();
Report.Section( "bone selection: unrelated bones are all tops" );
TestSiblings();
Report.Section( "bone selection: it does not hang on a malformed skeleton" );
TestCycle();
}
static void TestSingle()
{
Report.Check( "a lone bone is transformed directly",
Top( "chest" ).SequenceEqual( new[] { "chest" } ) );
Report.Check( "a root with nothing above it too",
Top( "pelvis" ).SequenceEqual( new[] { "pelvis" } ) );
Report.Check( "and an empty selection has no tops", Top().Count == 0 );
}
static void TestContiguous()
{
// The ordinary case, and the one the old immediate-parent check got right.
var tops = Top( "spine", "chest", "arm" );
Report.Check( "only the highest of a contiguous run is transformed",
tops.SequenceEqual( new[] { "spine" } ), string.Join( ", ", tops ) );
Report.Check( "the whole chain from the root collapses to the root",
Top( "pelvis", "spine", "chest", "arm", "hand" ).SequenceEqual( new[] { "pelvis" } ) );
}
/// <summary>
/// THE ONE THAT WAS BROKEN. spine and arm are selected; chest, between them, is not. The old
/// rule asked only whether arm's PARENT was selected — chest is not, so arm passed, and it was
/// transformed directly as well as being carried by spine. Twice the movement, on one bone.
/// </summary>
static void TestSkippedGeneration()
{
var tops = Top( "spine", "arm" );
Report.Check( "a bone with an unselected parent but a selected grandparent is NOT a top",
tops.SequenceEqual( new[] { "spine" } ), string.Join( ", ", tops ) );
Report.Check( "the same across two skipped generations",
Top( "pelvis", "hand" ).SequenceEqual( new[] { "pelvis" } ),
string.Join( ", ", Top( "pelvis", "hand" ) ) );
Report.Check( "and the ancestor test says so directly",
BoneSelection.HasSelectedAncestor( "arm", new HashSet<string> { "spine", "arm" }, ParentOf ) );
Report.Check( "while a bone above the selection has none",
!BoneSelection.HasSelectedAncestor( "spine", new HashSet<string> { "spine", "arm" }, ParentOf ) );
}
static void TestSiblings()
{
// Two chains that never meet: nothing carries anything, so both are transformed.
var forest = new Dictionary<string, string>
{
["left"] = null,
["right"] = null,
["left_hand"] = "left",
};
var tops = BoneSelection.TopMost( new[] { "left", "right" },
n => forest.TryGetValue( n, out var p ) ? p : null ).OrderBy( n => n ).ToList();
Report.Check( "two roots are both tops", tops.SequenceEqual( new[] { "left", "right" } ),
string.Join( ", ", tops ) );
var mixed = BoneSelection.TopMost( new[] { "left", "left_hand", "right" },
n => forest.TryGetValue( n, out var p ) ? p : null ).OrderBy( n => n ).ToList();
Report.Check( "and a child of one of them drops out, the other does not",
mixed.SequenceEqual( new[] { "left", "right" } ), string.Join( ", ", mixed ) );
}
/// <summary>A skeleton that points at itself is not something this should hang on — the walk is
/// bounded rather than trusting the data.</summary>
static void TestCycle()
{
var loop = new Dictionary<string, string> { ["a"] = "b", ["b"] = "a" };
var tops = BoneSelection.TopMost( new[] { "a" },
n => loop.TryGetValue( n, out var p ) ? p : null );
Report.Check( "a cycle returns rather than spinning", tops.Count <= 1 );
}
}