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