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)"); } } }