Tests: tuning invariants + Authoring reachable from the test asmdef (audit M10/M11/L18)

- New TuningInvariantTests (6 tests). Two gaps the audit named:
  * The six-site knob drift: adding a knob touches the struct field,
    Defaults(), the TuningKnob const and three switches, and nothing
    caught a knob wired into Apply but forgotten in ClampKnob. The clamp
    is the only thing stopping a 0 reaching the i-frame divide, which
    TuningConfig's own header records as "NaNs the kinematic body
    permanently". Now every LIVE knob round-trips Apply -> Get against
    ClampKnob.
    The knob set is read by reflection, not assumed to be 0..Count:
    CLAUDE.md reserves retired indices (20-23), so the space has holes
    on purpose. A second test pins that those stay reserved — re-using
    one would silently re-mean a value in saved feel profiles and in the
    DebugOp wire payload.
  * MeleeTiming had ZERO test references while producing the locked
    20/12/25 contact ticks, and MeleeComboTests pins fixture values that
    differ from the shipped defaults on nine of ten knobs. The new tests
    assert RELATIONSHIPS against Defaults() — finisher contact stays
    inside both CastFacingTicks and MeleeRecoverTicks — so a deliberate
    retune stays green and only a broken coupling fails. That coupling
    was previously enforced only by a [MenuItem] a human had to remember
    to run.
- Test asmdef now references ProjectM.Authoring + Unity.Entities.Hybrid,
  so bakers are reachable from tests at all (L18). Nothing referenced
  them before, which is why "no prefab carries ChargerAuthoring" could
  coexist with a green suite.

CORRECTION to the audit: "10 test files leak ECS Worlds" is REFUTED.
That finding came from counting `.Dispose()` tokens, which misses the
`using (world)` blocks these files actually use. Measured empirically:
World.All is 6 before the suite and 6 after — zero leaked.

304/304 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:24:50 -07:00
parent 37b211a7f8
commit b80aa1b24b
3 changed files with 114 additions and 0 deletions
@@ -0,0 +1,110 @@
using System.Collections.Generic;
using System.Reflection;
using NUnit.Framework;
using ProjectM.Simulation;
namespace ProjectM.Tests
{
/// <summary>
/// Invariants over the tuning surface, from the 2026-08-06 audit.
///
/// 1. THE SIX-SITE KNOB DRIFT. Adding a knob means touching six places (the struct field, Defaults(), the
/// TuningKnob byte const, and the ClampKnob / Apply / Get switches). Nothing in the build caught a knob
/// wired into Apply but forgotten in ClampKnob — which matters because the clamp is the only thing
/// stopping a 0 reaching the i-frame divide, documented in TuningConfig's own header as "NaNs the
/// kinematic body permanently".
///
/// 2. MELEE TESTS PINNED THE WRONG NUMBERS. MeleeComboTests pins fixture values that differ from the SHIPPED
/// defaults on nine of ten knobs, and MeleeTiming — which produces the locked 20/12/25 contact ticks — had
/// zero test references. These assert RELATIONSHIPS against Defaults(), so a deliberate retune stays green
/// and only a broken coupling fails.
/// </summary>
public class TuningInvariantTests
{
/// <summary>Knob indices that actually exist, read off the TuningKnob consts rather than assumed to be
/// 0..Count. CLAUDE.md's rule is that a RETIRED knob's byte value stays reserved and is never renumbered
/// (20-23 today), so the index space is deliberately full of holes.</summary>
static byte[] LiveKnobIndices()
{
var list = new List<byte>();
foreach (var f in typeof(TuningKnob).GetFields(BindingFlags.Public | BindingFlags.Static))
{
if (f.FieldType != typeof(byte) || f.Name == "Count") continue;
list.Add((byte)f.GetRawConstantValue());
}
return list.ToArray();
}
[Test]
public void EveryLiveKnob_RoundTrips_Through_Apply_And_Get()
{
var knobs = LiveKnobIndices();
Assert.Greater(knobs.Length, 20, "reflection should have found the whole live knob set");
foreach (byte knob in knobs)
{
var c = TuningConfig.Defaults();
const float probe = 7f; // inside every knob's clamp band
float expected = TuningConfig.ClampKnob(knob, probe);
TuningConfig.Apply(ref c, knob, probe);
float actual = TuningConfig.Get(in c, knob);
Assert.AreEqual(expected, actual, 1e-4f,
$"knob {knob} does not round-trip: Apply/Get disagree with ClampKnob. A knob added to one " +
"switch and forgotten in another silently reads 0 in the overlay or bypasses its safety floor.");
}
}
[Test]
public void RetiredKnobIndices_StayReserved()
{
var live = new HashSet<byte>(LiveKnobIndices());
foreach (byte retired in new byte[] { 20, 21, 22, 23 })
Assert.IsFalse(live.Contains(retired),
$"knob index {retired} is RETIRED and reserved (CLAUDE.md): re-using it would silently re-mean " +
"a value in saved feel profiles and in the DebugOp wire payload.");
}
[Test]
public void IFrameWindow_ClampsAboveZero()
{
Assert.Greater(TuningConfig.ClampKnob(TuningKnob.IFrameWindowTicks, 0f), 0f,
"IFrameWindowTicks must clamp above zero — a zero reaches a divide and NaNs the character body.");
}
[Test]
public void ShippedMeleeCadence_ContactLandsInsideTheCastAndRecoverWindows()
{
var d = TuningConfig.Defaults();
uint step2 = MeleeTiming.ContactTicks(2, d.MeleeContactTicks);
uint step3 = MeleeTiming.ContactTicks(3, d.MeleeContactTicks);
// MeleeTiming's header states this coupling as documented-but-unclamped; it is otherwise enforced only
// by a [MenuItem] audit tool a human has to remember to run. This is the mechanical version.
Assert.Less(step3, (uint)PlayerAimSystem.CastFacingTicks,
"finisher contact must land INSIDE the cast-facing window — at the 07-21 feel lock it sits one " +
"tick under, so raising contact without raising the window silently breaks aim-at-contact.");
Assert.Less(step3, (uint)d.MeleeRecoverTicks,
"finisher contact must land before recover ends, or the hit resolves after the swing is over.");
Assert.Less(step2, step3,
"step 2 is the quick contact (x0.625) and step 3 the finisher (x1.25) — never the other way round.");
}
[Test]
public void ContactTicks_ZeroKnob_MeansImmediate()
{
Assert.AreEqual(0u, MeleeTiming.ContactTicks(1, 0f));
Assert.AreEqual(0u, MeleeTiming.ContactTicks(3, 0f),
"0 is the IMMEDIATE sentinel at every step — the legacy same-tick resolve the mechanics tests use.");
}
[Test]
public void ContactTicks_ScalePerStep_FromTheOneKnob()
{
const float knob = 16f;
Assert.AreEqual(16u, MeleeTiming.ContactTicks(1, knob), "step 1 is the knob itself (x1.0)");
Assert.AreEqual(10u, MeleeTiming.ContactTicks(2, knob), "step 2 is the quick one (x0.625)");
Assert.AreEqual(20u, MeleeTiming.ContactTicks(3, knob), "step 3+ is the finisher (x1.25)");
}
}
}