Add tail manual fine-adjust (scroll) + per-project tail persistence

Relocate TailSetting into ReaSamplerSession; persist serializes it to
a new forever-stable "tail_setting" ext-state key (load-per-project,
save-on-save). Footer scroll-wheel adjusts Manual length in 250ms
steps; label now shows "Tail: Manual X.Xs". Pure step/label/JSON
round-trip covered by tests.
This commit is contained in:
2026-07-23 19:12:12 -04:00
parent 938c85a223
commit 37affc6a70
6 changed files with 365 additions and 38 deletions
+92 -2
View File
@@ -49,15 +49,53 @@ static void testManualClampCapsAtEightSeconds() {
CHECK(clampManualMs(-100.0) == 0.0);
}
// --- adjustManualMs: the scroll-wheel fine-adjust arithmetic ------------------
static void testAdjustUpAndDownBySteps() {
// Positive notches lengthen, negative shorten, in whole kManualStepMs increments.
CHECK(adjustManualMs(2000.0, 1, kManualStepMs) == 2250.0);
CHECK(adjustManualMs(2000.0, -1, kManualStepMs) == 1750.0);
CHECK(adjustManualMs(2000.0, 4, kManualStepMs) == 3000.0); // 4 * 250
CHECK(adjustManualMs(2000.0, 0, kManualStepMs) == 2000.0); // no notch, no move
}
static void testAdjustClampsAtUpperBound() {
// Scrolling up past the 8 s cap saturates AT the cap, never beyond.
CHECK(adjustManualMs(kMaxTailMs, 1, kManualStepMs) == kMaxTailMs);
CHECK(adjustManualMs(kMaxTailMs - 100.0, 10, kManualStepMs) == kMaxTailMs);
}
static void testAdjustClampsAtLowerBound() {
// Scrolling down past 0 floors at 0, never negative.
CHECK(adjustManualMs(0.0, -1, kManualStepMs) == 0.0);
CHECK(adjustManualMs(100.0, -10, kManualStepMs) == 0.0);
}
// --- tailToggleLabel: the exact strings the panel draws -----------------------
static void testLabelStringsPerMode() {
TailSetting off; off.mode = TailMode::None;
TailSetting autoM; autoM.mode = TailMode::Auto;
TailSetting man; man.mode = TailMode::Manual;
// Off/Auto carry NO length regardless of manualMs.
off.manualMs = 5000.0;
autoM.manualMs = 5000.0;
CHECK(tailToggleLabel(off) == "Tail: Off");
CHECK(tailToggleLabel(autoM) == "Tail: Auto");
CHECK(tailToggleLabel(man) == "Tail: Manual");
}
static void testManualLabelRendersLengthInSeconds() {
// Manual appends the length in seconds to one decimal — pin the format and the
// boundary values (0.0s, the 2 s default, the 8 s cap).
TailSetting man; man.mode = TailMode::Manual;
man.manualMs = 0.0;
CHECK(tailToggleLabel(man) == "Tail: Manual 0.0s");
man.manualMs = kDefaultManualTailMs; // 2000 ms
CHECK(tailToggleLabel(man) == "Tail: Manual 2.0s");
man.manualMs = kMaxTailMs; // 8000 ms
CHECK(tailToggleLabel(man) == "Tail: Manual 8.0s");
// An over-cap stored value renders at the CLAMPED length, never past the cap.
man.manualMs = kMaxTailMs + 3000.0;
CHECK(tailToggleLabel(man) == "Tail: Manual 8.0s");
}
static void testDefaultSettingIsOff() {
@@ -69,13 +107,65 @@ static void testDefaultSettingIsOff() {
CHECK(tailToggleLabel(s) == "Tail: Off");
}
// --- serialize/deserialize: per-project persistence round-trip ----------------
static bool settingsEqual(const TailSetting& a, const TailSetting& b) {
return a.mode == b.mode && a.manualMs == b.manualMs;
}
static void testRoundTripNoneDefault() {
TailSetting s; // None + 2 s default
auto back = deserializeTailSetting(serializeTailSetting(s));
CHECK(back.has_value());
CHECK(back && settingsEqual(*back, s));
}
static void testRoundTripManualArbitraryMs() {
// A non-round manual length must round-trip bit-for-bit (17-sig-digit emit).
TailSetting s; s.mode = TailMode::Manual; s.manualMs = 3141.592653589793;
auto back = deserializeTailSetting(serializeTailSetting(s));
CHECK(back.has_value());
CHECK(back && settingsEqual(*back, s));
}
static void testRoundTripAuto() {
TailSetting s; s.mode = TailMode::Auto; s.manualMs = 500.0;
auto back = deserializeTailSetting(serializeTailSetting(s));
CHECK(back.has_value());
CHECK(back && settingsEqual(*back, s));
}
static void testDeserializeEmptyIsDefault() {
// An absent/empty stored value (older project) -> nullopt, so the caller falls
// back to the default. This is the graceful-old-project path the brief requires.
CHECK(!deserializeTailSetting("").has_value());
}
static void testDeserializeMalformedIsDefault() {
// Garbage, a missing key, or an unknown mode enumerant -> nullopt (no crash).
CHECK(!deserializeTailSetting("not json at all").has_value());
CHECK(!deserializeTailSetting("{\"mode\":1}").has_value()); // manualMs missing
CHECK(!deserializeTailSetting("{\"manualMs\":2000}").has_value()); // mode missing
CHECK(!deserializeTailSetting("{\"mode\":9,\"manualMs\":2000}").has_value()); // bad enum
CHECK(!deserializeTailSetting("{\"mode\":x,\"manualMs\":2000}").has_value()); // non-numeric
}
int main() {
testCycleOrderIsNoneAutoManualNone();
testCycleThreeStepsReturnsToStart();
testManualClampInRangeIsUnchanged();
testManualClampCapsAtEightSeconds();
testAdjustUpAndDownBySteps();
testAdjustClampsAtUpperBound();
testAdjustClampsAtLowerBound();
testLabelStringsPerMode();
testManualLabelRendersLengthInSeconds();
testDefaultSettingIsOff();
testRoundTripNoneDefault();
testRoundTripManualArbitraryMs();
testRoundTripAuto();
testDeserializeEmptyIsDefault();
testDeserializeMalformedIsDefault();
if (g_fail == 0) std::printf("tail_control: all tests passed\n");
else std::printf("tail_control: %d CHECK(s) FAILED\n", g_fail);