diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 8fbaadf..d8f8688 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -77,6 +77,7 @@ #define REAPERAPI_WANT_DockWindowActivate #define REAPERAPI_WANT_DockWindowRemove #define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_MarkProjectDirty // mark dirty when the tail toggle changes (saves with the project) #define REAPERAPI_WANT_GetMainHwnd #define REAPERAPI_WANT_PCM_Source_CreateFromFile #define REAPERAPI_WANT_PCM_Source_Destroy @@ -204,11 +205,13 @@ struct PanelState { // clear the selection rather than risk indices pointing past the new count. int selItemCount = 0; - // --- Tail-mode toggle (T1 exposure) --------------------------------------- - // The current tail setting the plain capture actions read (bankPanelTailSetting). - // Default None (exact bounds). In-memory only — resets on panel teardown; project - // persistence is a noted follow-on. Mutated ONLY by a click in the footer strip. - TailSetting tail; + // --- Tail-mode toggle ----------------------------------------------------- + // The authoritative tail setting now lives in ReaSamplerSession (session->tail()), + // NOT in panel state, so it travels inside the .rpp (persist serializes it on save, + // restores it on project load). The panel reads it for drawing and mutates it via + // the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the + // project dirty so the choice saves. bankPanelTailSetting is the read seam for the + // capture actions. Held here only through the session pointer above. // --- Audition preview (Wave B) -------------------------------------------- // @@ -492,9 +495,15 @@ RECT panelFooter(int w, int h) { return rc; } +// The session's live tail setting (default None / 2 s when no session). Single read +// point so draw, wheel-adjust, and the capture read seam all agree on the source. +TailSetting currentTail() { + return g_panel.session ? g_panel.session->tail() : TailSetting{}; +} + // Draws the tail-mode toggle into the footer strip: a filled band, a top divider, -// and the current mode's label ("Tail: Off / Auto / Manual") from the pure -// tail_control module. READ-ONLY: reads g_panel.tail; the click handler mutates it. +// and the current mode's label ("Tail: Off / Auto / Manual Xs") from the pure +// tail_control module. READ-ONLY: reads session->tail(); the input handlers mutate it. void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { const RECT f = panelFooter(w, h); if (f.top >= f.bottom) return; // no room — skip (short panel) @@ -505,7 +514,7 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { HDC dc = bmp->getDC(); if (!dc) return; - const std::string label = tailToggleLabel(g_panel.tail); + const std::string label = tailToggleLabel(currentTail()); RECT rc = f; rc.left += 8; // small left pad so the label is not flush against the edge SetTextColor(dc, kRgbFooterText); @@ -514,6 +523,26 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); } +// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. +// Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust) +// so both agree on the hit target. +bool pointInFooter(int x, int y) { + if (!g_panel.hwnd) return false; + RECT cr{}; + GetClientRect(g_panel.hwnd, &cr); + const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top); + return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom; +} + +// Marks the active project dirty so a tail-setting change saves with the project. +// A bare tail toggle dirtying the project is intended (it IS project state now). +// persist writes the setting into ext state on the next save; this only flags that a +// save is owed. NON-DESTRUCTIVE: touches nothing in the bank/arrange. +void markTailDirty() { + ReaProject* proj = EnumProjects(-1, nullptr, 0); + if (proj) MarkProjectDirty(proj); +} + // The cell rects for the panel's CURRENT client width and bank size, translated // DOWN by the header height so the grid sits below the mode switch. Both paint and // mouse hit-testing call this so they share identical geometry (no drift between @@ -902,20 +931,17 @@ void handleClick(int x, int y) { } // Tail-mode footer: a click anywhere in the bottom strip cycles the tail mode - // (None -> Auto -> Manual -> None) and repaints. Settings-only — it mutates the - // panel's in-memory tail setting the capture actions read, and NOTHING in the - // project/bank/arrange. Checked before the grid so a footer click never selects. - { - RECT cr{}; - GetClientRect(g_panel.hwnd, &cr); - const int w = cr.right - cr.left; - const int h = cr.bottom - cr.top; - const RECT f = panelFooter(w, h); - if (f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom) { - g_panel.tail.mode = cycleTailMode(g_panel.tail.mode); - invalidatePanel(); - return; // footer click consumed; do NOT fall through to grid selection - } + // (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail + // setting (which the capture actions read and persist saves with the project) and + // marks the project dirty so the choice travels inside the .rpp — it touches + // NOTHING in the bank/arrange. Checked before the grid so a footer click never + // selects. + if (g_panel.session && pointInFooter(x, y)) { + TailSetting& tail = g_panel.session->tail(); + tail.mode = cycleTailMode(tail.mode); + markTailDirty(); + invalidatePanel(); + return; // footer click consumed; do NOT fall through to grid selection } const std::vector rects = panelRects(); @@ -938,6 +964,34 @@ void handleClick(int x, int y) { invalidatePanel(); } +// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`. +// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is +// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens, +// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns +// false so the caller can let REAPER/the docker handle the wheel normally). On a real +// change it mutates the SESSION's tail setting, marks the project dirty (so it saves), +// and repaints the live length. Returns true iff the wheel was consumed. +bool handleWheel(int x, int y, int delta) { + if (!g_panel.session) return false; + if (!pointInFooter(x, y)) return false; + + TailSetting& tail = g_panel.session->tail(); + if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only + + // One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad + // that sends fractional deltas still steps predictably. Sign carries direction. + const int notches = delta / 120; + if (notches == 0) return false; // sub-notch movement — nothing to apply yet + + const double before = tail.manualMs; + tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs); + if (tail.manualMs == before) return true; // already at a bound — consumed, no change + + markTailDirty(); + invalidatePanel(); // label shows the new length live + return true; +} + // The column count for the panel's CURRENT client width (nav needs the same wrap // the layout uses). >= 1. int columnsNow() { @@ -1047,6 +1101,19 @@ WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { handleClick(x, y); return 0; } + case WM_MOUSEWHEEL: { + // Fine-adjust the Manual tail length when the wheel is over the footer. + // UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in + // lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so + // convert to client space before hit-testing the footer. The signed wheel + // delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120, + // matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer + // handler acts, so scrolling elsewhere in the dock still behaves normally. + POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; + ScreenToClient(hwnd, &pt); + const int delta = static_cast(HIWORD(wParam)); + return handleWheel(pt.x, pt.y, delta) ? 1 : 0; + } case WM_DESTROY: // REAPER closed the dock (user X'd it). Stop any audition (window-close // stop path — no preview may outlive the window) and reflect closed @@ -1156,12 +1223,11 @@ void bankPanelRefresh() { } TailSetting bankPanelTailSetting() { - // In-memory for the extension's lifetime (g_panel is static): the toggle's mode - // survives panel open/close and bank changes, and resets to the default None only - // on extension unload. Persistence across project reload is a noted follow-on. - // manualMs is clamped here so a caller always receives a within-cap length, even - // if a future fine-adjust UI stored an over-cap value. - TailSetting s = g_panel.tail; + // The authoritative setting lives in the session (session->tail()) so it travels + // inside the .rpp: it loads per project and saves with the project. This stays the + // read seam for the capture actions. manualMs is clamped here so a caller always + // receives a within-cap length regardless of what was stored/scrolled. + TailSetting s = currentTail(); s.manualMs = clampManualMs(s.manualMs); return s; } diff --git a/src/persist.cpp b/src/persist.cpp index e4c316e..c52922d 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -184,6 +184,13 @@ void ReaSamplerSession::saveToActiveProject() { SetProjExtState(static_cast(proj), kProjExtNamespace, kProjExtViewKey, viewJson.c_str()); + // Additive: the docked panel's tail setting rides alongside in its own key, so the + // tail choice travels inside the .rpp. Independent write — does not disturb the + // bank_index or view_state above. + const std::string tailJson = serializeTailSetting(tail_); + SetProjExtState(static_cast(proj), kProjExtNamespace, + kProjExtTailKey, tailJson.c_str()); + MarkProjectDirty(static_cast(proj)); } @@ -208,6 +215,23 @@ ViewModeModel loadViewModel(ReaProject* proj) { return std::move(*loaded); } +// Load the tail setting from a project's tail_setting key, or return the default. An +// absent/empty key (older / never-adjusted project) yields the default setting (None / +// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back +// to default, mirroring the bank's and view's malformed handling. +TailSetting loadTailSetting(ReaProject* proj) { + if (!proj) return TailSetting{}; + const std::string tailJson = + getProjExtStateString(proj, kProjExtNamespace, kProjExtTailKey); + if (tailJson.empty()) return TailSetting{}; // no stored setting -> default + std::optional loaded = deserializeTailSetting(tailJson); + if (!loaded) { + ShowConsoleMsg("ReaSampler: stored tail setting is malformed — ignoring.\n"); + return TailSetting{}; + } + return *loaded; +} + } // namespace void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { @@ -225,6 +249,11 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // only — no visibility/processing is applied here (that is D4). view_ = loadViewModel(static_cast(proj)); + // The tail setting is restored on EVERY load path too (peer-symmetry): switching + // to a project with no stored setting must fall back to the default, not inherit + // the previous project's choice (this REPLACES the old session-carry behavior). + tail_ = loadTailSetting(static_cast(proj)); + if (!proj) { bank_ = BankIndex{}; return; diff --git a/src/persist.h b/src/persist.h index f00b271..3ee8f75 100644 --- a/src/persist.h +++ b/src/persist.h @@ -20,6 +20,7 @@ #include #include "bank_model.h" +#include "tail_control.h" #include "view_mode_model.h" namespace reasampler { @@ -38,6 +39,13 @@ inline constexpr const char* kProjExtIndexKey = "bank_index"; // FOREVER-STABLE: changing it orphans every already-saved project's view state. inline constexpr const char* kProjExtViewKey = "view_state"; +// The ext-state key the docked panel's TailSetting JSON (mode + manualMs) is stored +// under, so the tail choice travels inside the .rpp and loads per project. Distinct +// from the index/view keys — one namespace, three keys. FOREVER-STABLE: changing it +// orphans every already-saved project's tail setting (which then falls back to the +// default — graceful, but the user's saved choice would be lost). +inline constexpr const char* kProjExtTailKey = "tail_setting"; + // The ext-state key holding a GUID we mint per project to establish CONTENT-BASED // project identity (REAPER exposes no stable per-project GUID). poll() uses it to // tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch @@ -80,6 +88,15 @@ public: ViewModeModel& view() { return view_; } const ViewModeModel& view() const { return view_; } + // The docked panel's tail setting (mode + manualMs), authoritative here — NOT in + // panel state — so it travels inside the .rpp: persist serializes it on save and + // replaces it on project load exactly as it treats the bank and view model. The + // panel reads/writes it through this seam (bank_panel holds the session), and the + // capture actions read it via bankPanelTailSetting. Default None / 2 s manual for + // an unsaved or pre-feature project (no stored key -> this default survives load). + TailSetting& tail() { return tail_; } + const TailSetting& tail() const { return tail_; } + // Serialize the current bank to the active project's ext state (namespace // "reasampler"). Non-destructive beyond writing our own ext-state key. Safe // to call when there is no active/saved project (it no-ops). @@ -110,6 +127,11 @@ private: // view_state (older project), so an absent key is graceful, not a crash. ViewModeModel view_; + // The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it + // to this default when a project has no stored tail_setting key (older / never- + // adjusted project), so an absent key is graceful. Peer to bank_/view_. + TailSetting tail_; + // The project identity last observed by poll(), used to detect load/Save-As. // The GUID is the PRIMARY signal (a different stored GUID = a different project // of record = Load, immune to pointer recycling). The pointer disambiguates the diff --git a/src/tail_control.cpp b/src/tail_control.cpp index 3fc2cbc..1d4063a 100644 --- a/src/tail_control.cpp +++ b/src/tail_control.cpp @@ -3,6 +3,10 @@ #include "tail_control.h" #include +#include +#include +#include +#include namespace reasampler { @@ -21,13 +25,108 @@ double clampManualMs(double manualMs) { return std::clamp(manualMs, 0.0, kMaxTailMs); } +double adjustManualMs(double current, int notches, double stepMs) { + // Clamp the stepped value so both scroll directions saturate at the bounds rather + // than running away (the same [0, kMaxTailMs] guard clampManualMs enforces). + return clampManualMs(current + notches * stepMs); +} + std::string tailToggleLabel(const TailSetting& setting) { switch (setting.mode) { - case TailMode::None: return "Tail: Off"; - case TailMode::Auto: return "Tail: Auto"; - case TailMode::Manual: return "Tail: Manual"; + case TailMode::None: return "Tail: Off"; + case TailMode::Auto: return "Tail: Auto"; + case TailMode::Manual: { + // Append the CLAMPED length in seconds to one decimal so the readout can + // never show an over-cap value even if manualMs was stored past the cap. + const double seconds = clampManualMs(setting.manualMs) / 1000.0; + char buf[32]; + std::snprintf(buf, sizeof(buf), "Tail: Manual %.1fs", seconds); + return std::string(buf); + } } return "Tail: Off"; // unreachable for a valid enum; fail to the safe default } +// --------------------------------------------------------------------------- +// JSON round-trip +// --------------------------------------------------------------------------- +// +// The setting is a flat object of one enum + one double, so a compact hand-rolled +// writer + a tolerant minimal reader is the simplest thing that works (mirroring +// bank_model's dependency-free JSON choice). manualMs is emitted with 17 significant +// digits (%.17g) — the shortest form that round-trips every IEEE-754 double exactly — +// so deserialize(serialize(x)) == x holds bit-for-bit. deserialize is deliberately +// forgiving: any parse failure returns nullopt so the caller falls back to a default, +// exactly as an absent ext-state key does. + +namespace { + +// The persisted integer for a mode. Stable forever (stored in the .rpp): never +// renumber these values or an already-saved project reads back the wrong mode. +int modeToInt(TailMode m) { + switch (m) { + case TailMode::None: return 0; + case TailMode::Auto: return 1; + case TailMode::Manual: return 2; + } + return 0; +} + +std::optional modeFromInt(int v) { + switch (v) { + case 0: return TailMode::None; + case 1: return TailMode::Auto; + case 2: return TailMode::Manual; + default: return std::nullopt; // unknown enumerant -> malformed -> default + } +} + +// Find the value token following `"key":` in `json`. Returns a pointer just past the +// colon (skipping whitespace) or nullptr if the key is absent. Minimal: the writer +// emits exactly one flat object with unique keys, so a substring search is sufficient +// and there is no nesting to confuse it. +const char* valueAfterKey(const std::string& json, const char* key) { + const std::string needle = std::string("\"") + key + "\""; + const std::size_t pos = json.find(needle); + if (pos == std::string::npos) return nullptr; + const char* p = json.c_str() + pos + needle.size(); + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; + if (*p != ':') return nullptr; + ++p; + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') ++p; + return p; +} + +} // namespace + +std::string serializeTailSetting(const TailSetting& setting) { + char buf[128]; + std::snprintf(buf, sizeof(buf), "{\"mode\":%d,\"manualMs\":%.17g}", + modeToInt(setting.mode), setting.manualMs); + return std::string(buf); +} + +std::optional deserializeTailSetting(const std::string& json) { + const char* modeTok = valueAfterKey(json, "mode"); + const char* msTok = valueAfterKey(json, "manualMs"); + if (!modeTok || !msTok) return std::nullopt; // absent key -> malformed -> default + + char* end = nullptr; + errno = 0; + const long modeVal = std::strtol(modeTok, &end, 10); + if (end == modeTok || errno != 0) return std::nullopt; + const std::optional mode = modeFromInt(static_cast(modeVal)); + if (!mode) return std::nullopt; + + end = nullptr; + errno = 0; + const double ms = std::strtod(msTok, &end); + if (end == msTok || errno != 0) return std::nullopt; + + TailSetting out; + out.mode = *mode; + out.manualMs = ms; + return out; +} + } // namespace reasampler diff --git a/src/tail_control.h b/src/tail_control.h index ba6cf2d..1e7ab93 100644 --- a/src/tail_control.h +++ b/src/tail_control.h @@ -9,6 +9,7 @@ // only (plus render_settings for the pure TailMode enum). Builds and unit-tests // without REAPER. +#include #include #include "render_settings.h" // TailMode (pure enum) — the three-state tail contract @@ -16,10 +17,15 @@ namespace reasampler { // The Manual-mode starting length. 2 s is a musically useful default tail (a bar of -// reverb throw at a moderate tempo) that is well under the 8 s cap. A fine-adjust UI -// (+/- click zones or scroll) is a noted follow-on; this pass ships a fixed default. +// reverb throw at a moderate tempo) that is well under the 8 s cap. Also the value a +// project with no stored tail setting (older / never-adjusted) falls back to on load. inline constexpr double kDefaultManualTailMs = 2000.0; +// The fine-adjust step per scroll-wheel notch in Manual mode. 250 ms is coarse enough +// that a few notches cover the useful range, fine enough to dial a length precisely. +// Daniel-set. The panel maps one wheel notch to +/- this many ms via adjustManualMs. +inline constexpr double kManualStepMs = 250.0; + // The panel's current tail setting: the mode plus the length used ONLY when the // mode is Manual. Held as in-memory panel/session state (bank_panel.cpp), default // None so a capture with no explicit choice stays exact-bounds / byte-identical to @@ -41,9 +47,24 @@ TailMode cycleTailMode(TailMode current); // tailMs into the CaptureRequest. Meaningful only for TailMode::Manual. double clampManualMs(double manualMs); -// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto", "Tail: Manual". -// (Manual omits the length here — the panel is unobtrusive; a length readout can be -// added with the fine-adjust follow-on.) Pure so the exact strings are test-pinned. +// Applies `notches` scroll-wheel steps of `stepMs` each to `current`, clamped to +// [0, kMaxTailMs]. Positive notches lengthen, negative shorten. Pure so the fine-adjust +// arithmetic (and its clamp at both bounds) is unit-tested; the panel wheel handler +// owns no arithmetic of its own. Meaningful only for TailMode::Manual. +double adjustManualMs(double current, int notches, double stepMs); + +// The toggle's label for a setting, e.g. "Tail: Off", "Tail: Auto". In Manual mode the +// clamped length is appended in seconds to one decimal, e.g. "Tail: Manual 2.0s" — +// Off/Auto carry no length. Pure so the exact strings (and the Manual format) are +// test-pinned, including the boundary lengths (0.0s, 8.0s). std::string tailToggleLabel(const TailSetting& setting); +// JSON round-trip of a TailSetting (mode + manualMs), for persist to store the tail +// setting per-project alongside the bank and view model. Kept pure/testable here — +// the natural home, mirroring bank_model's serialize/deserialize. serialize emits a +// compact object; deserialize returns std::nullopt on malformed input so the caller +// (persist) falls back to a default setting, exactly as an absent key does. +std::string serializeTailSetting(const TailSetting& setting); +std::optional deserializeTailSetting(const std::string& json); + } // namespace reasampler diff --git a/tests/test_tail_control.cpp b/tests/test_tail_control.cpp index 632e8c7..09d20b0 100644 --- a/tests/test_tail_control.cpp +++ b/tests/test_tail_control.cpp @@ -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);