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:
@@ -5,6 +5,8 @@
|
||||
"ProjectM.Simulation",
|
||||
"ProjectM.Server",
|
||||
"ProjectM.Client",
|
||||
"ProjectM.Authoring",
|
||||
"Unity.Entities.Hybrid",
|
||||
"Unity.Entities",
|
||||
"Unity.Transforms",
|
||||
"Unity.Collections",
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7086ea8eabfc7974c9c173c7a5e12429
|
||||
Reference in New Issue
Block a user