diff --git a/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef b/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef index 41e66be90..825a6f6e8 100644 --- a/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef +++ b/Assets/_Project/Tests/EditMode/ProjectM.Tests.EditMode.asmdef @@ -5,6 +5,8 @@ "ProjectM.Simulation", "ProjectM.Server", "ProjectM.Client", + "ProjectM.Authoring", + "Unity.Entities.Hybrid", "Unity.Entities", "Unity.Transforms", "Unity.Collections", diff --git a/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs b/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs new file mode 100644 index 000000000..b2611dd72 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using ProjectM.Simulation; + +namespace ProjectM.Tests +{ + /// + /// 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. + /// + public class TuningInvariantTests + { + /// 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. + static byte[] LiveKnobIndices() + { + var list = new List(); + 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(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)"); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs.meta b/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs.meta new file mode 100644 index 000000000..741893feb --- /dev/null +++ b/Assets/_Project/Tests/EditMode/TuningInvariantTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7086ea8eabfc7974c9c173c7a5e12429 \ No newline at end of file