diff --git a/src/core/ui/CLAUDE.md b/src/core/ui/CLAUDE.md index 3603afd..f11d14a 100644 --- a/src/core/ui/CLAUDE.md +++ b/src/core/ui/CLAUDE.md @@ -95,7 +95,7 @@ L7 sub-pass, 2026-07-27): - `theme` — pure palette module: role→color mapping, REAPER-grey neutral ladder + the pastel accent system, the keyboard strip's spectral ramp, WCAG contrast-floor helpers + `compositeOver` (the effective color of a translucent fill, so alpha overlays are testable). Only the ramp's MID stop is its own constant; lo/hi are still aliases of `accent/primary`/`accent/tertiary`, so a categorical accent move CAN still reorder the ramp — `testSpectralRampLuminanceIsMonotonic` is the build-time catch, not the structure. - `component_geometry` — pure button/slider/list-row geometry + hover hit-test helpers. - `action_bar` — pure task-grouped action-bar layout/hit-test: clusters (Capture / Placement / Maintenance / Tagging / Switching). -- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement, plus `modeSegmentEnabled` — the mode segment's live/dead predicate under the playback gate (the transport bool is passed IN, so this stays REAPER-free). +- `footer_bar` — pure footer layout/hit-test: `[Arrange|Design]` mode-toggle geometry, Tail button, and Prune placement, plus `modeSegmentEnabled` — the mode segment's live/dead predicate under the playback gate AND under whether the shell resolved a routable command id for it (both bools passed IN, so this stays REAPER-free). - `overflow_menu` — pure overflow-menu-button geometry/reserve/hit-test for the top-toolbar More (⋯) button. - `mode_enable` — pure opposite-mode enablement predicate: given the active mode, computes per-button live/disabled state for the four Item/Track × Arrange/Design tag buttons. - `tooltip` — pure tooltip placement + prefix-strip: strips the `ReaSampler:` display prefix from the registered action phrase; width clamped to the client rect. diff --git a/src/core/ui/footer_bar.cpp b/src/core/ui/footer_bar.cpp index a7a4f11..e945ef7 100644 --- a/src/core/ui/footer_bar.cpp +++ b/src/core/ui/footer_bar.cpp @@ -64,8 +64,8 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) { return FooterHit::None; } -bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning) { - return isActiveSegment || !transportRunning; +bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable) { + return isActiveSegment || (!transportRunning && routable); } } // namespace reasampler::ui diff --git a/src/core/ui/footer_bar.h b/src/core/ui/footer_bar.h index a377530..9b2c562 100644 --- a/src/core/ui/footer_bar.h +++ b/src/core/ui/footer_bar.h @@ -65,7 +65,11 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout); // plays or records (shell/view::transportBlocksModeSwitch), so an unreachable segment must READ // dead before the click, not merely refuse on it. The ACTIVE segment stays live regardless: it // fires a reapply, which is never gated, and dimming the mode you are already in would read as -// "this mode is unavailable" rather than "you cannot leave it right now". -bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning); +// "this mode is unavailable" rather than "you cannot leave it right now". `routable` is whether +// the shell resolved a live command id for this segment (the model is N-mode, the UI ships two +// seeded ids — a third registered mode has no action to route through, so its segment must read +// dead rather than paint live and silently no-op on click). Passed IN so this predicate — and +// `core/ui` — stays REAPER-free, exactly as `transportRunning` is. +bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable); } // namespace reasampler::ui diff --git a/src/core/view/CLAUDE.md b/src/core/view/CLAUDE.md index 59d2de1..b8ecfbd 100644 --- a/src/core/view/CLAUDE.md +++ b/src/core/view/CLAUDE.md @@ -92,7 +92,7 @@ settled 2026-07-23): ## Modules - `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, the per-mode `SoloCache` it owns, JSON round-trip. -- `solo_cache` — the per-mode solo surface: `SoloCache` (mode id → GUID → raw `I_SOLO`), the soloed-subset filter, and `planSoloRestore`, whose two drop rules (dead GUID, track parked in the incoming mode) and their reasoning live in its header. +- `solo_cache` — the per-mode solo surface: `SoloCache` (mode id → GUID → raw `I_SOLO`), the soloed-subset filter, and `planSoloRestore`, whose two drop rules (dead GUID, not visible in the incoming mode) and their reasoning live in its header. - `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell. - `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch. - `guid_diff` — the pure, REAPER-free core of the D2 Wave-2 new-content detection: `newGuids(previous, current)` computes the GUIDs present in `current` but absent from `previous` (empty GUIDs ignored); `GuidBaseline` tracks the live GUID set across polls for one project, implementing the first-poll-after-open guard (the first `observe()` after construction/`reset()` records a baseline and reports nothing new, so pre-existing content is never mass-tagged) and re-arms via `reset()` on a detected project switch so detection never diffs across two unrelated projects. diff --git a/src/core/view/solo_cache.cpp b/src/core/view/solo_cache.cpp index cc68404..753b7a0 100644 --- a/src/core/view/solo_cache.cpp +++ b/src/core/view/solo_cache.cpp @@ -53,11 +53,11 @@ std::size_t SoloCache::reconcile(const std::set& liveGuids) { std::vector planSoloRestore(const std::map& cached, const std::set& liveGuids, - const std::set& parkedGuids) { + const std::set& visibleGuids) { std::vector ops; for (const auto& [guid, value] : cached) { if (liveGuids.count(guid) == 0) continue; - if (parkedGuids.count(guid) != 0) continue; + if (visibleGuids.count(guid) == 0) continue; ops.push_back(SoloOp{guid, value}); } return ops; diff --git a/src/core/view/solo_cache.h b/src/core/view/solo_cache.h index 4b7c884..4baa715 100644 --- a/src/core/view/solo_cache.h +++ b/src/core/view/solo_cache.h @@ -73,14 +73,18 @@ private: // The incoming mode's restore writes, in GUID order. Two kinds of entry are dropped // rather than written: // * a GUID absent from `liveGuids` — the track is gone (same prune rule as above); -// * a GUID parked in the incoming mode — a parked track is hidden and carries -// B_MAINSEND=0, so soloing it would silence the whole mix while contributing -// nothing audible, and the user would have no visible control to undo it. +// * a GUID absent from `visibleGuids` — not visible in the incoming mode, whether +// because the leaf is parked or because it is a folder parent that is itself +// derived-invisible there. Either way the track is hidden and (for a parked +// leaf) carries B_MAINSEND=0, so soloing it would silence the whole mix while +// contributing nothing audible, and the user would have no visible control to +// undo it. A show-both leaf is a member of every mode's visible set, so it is +// never dropped by this rule. // The caller consumes the whole mode entry regardless (clear-on-restore), so a // dropped entry does not linger as zombie state waiting on a track that may never -// come back unparked. +// become visible again. std::vector planSoloRestore(const std::map& cached, const std::set& liveGuids, - const std::set& parkedGuids); + const std::set& visibleGuids); } // namespace reasampler::view diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index a4f1455..a6f45ca 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -162,8 +162,10 @@ void persistViewState() { } // The footer segment already reads disabled while the transport runs, but an action can -// be fired with the panel closed — so the refusal also lands as a status line. Guarded on -// the transport because applyMode's other refusal is an unregistered mode id. +// be fired with the panel closed — so the refusal also goes through Help_Set (SDK +// documents only the signature, not its display surface; inferred to be visible +// feedback by name, not confirmed). Guarded on the transport because applyMode's +// other refusal is an unregistered mode id. void reportModeSwitchRefused() { if (transportBlocksModeSwitch(nullptr) && Help_Set) Help_Set("ReaSampler: stop the transport to switch view mode", true); diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 2299c33..8fbd912 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -44,6 +44,20 @@ TailSetting currentTail() { return g_panel.session ? g_panel.session->tail() : TailSetting{}; } +// The registered activate action behind a footer mode segment, or 0 if the mode has none. +// Routing the segment through the SAME action the Actions list fires is what gives a +// panel-initiated switch the persist + repaint it used to skip; a mode with no such +// action (the model is N-mode, the UI ships two) resolves to 0 here, which +// modeSegmentEnabled reads as unroutable so the segment paints dead rather than live- +// but-inert. Non-anonymous: panel_render.cpp resolves the same id to compute that bool. +int modeActivateCommandId(const std::string& modeId) { + ActionBarRow row{}; + if (modeId == kArrangeModeId) row.suffix = "VIEW_ACTIVATE_ARRANGE"; + else if (modeId == kDesignModeId) row.suffix = "VIEW_ACTIVATE_DESIGN"; + else return 0; + return resolveBarCommandId(row); +} + namespace { // Commits the current tail setting to ext state and marks the active project dirty so the @@ -76,18 +90,6 @@ bool handleToolbarClick(int x, int y, const ActionBarRect& bar, return true; } -// The registered activate action behind a footer mode segment. Routing the segment -// through the SAME action the Actions list fires is what gives a panel-initiated switch -// the persist + repaint it used to skip; a mode with no such action (the model is -// N-mode, the UI ships two) is unreachable from the footer rather than routed around it. -int modeActivateCommandId(const std::string& modeId) { - ActionBarRow row{}; - if (modeId == kArrangeModeId) row.suffix = "VIEW_ACTIVATE_ARRANGE"; - else if (modeId == kDesignModeId) row.suffix = "VIEW_ACTIVATE_DESIGN"; - else return 0; - return resolveBarCommandId(row); -} - // True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). Value verified in view.cpp; // reproduced locally so this file stays self-contained. constexpr int kFreeModeFixedLanes = 2; @@ -316,11 +318,10 @@ void handleClick(int x, int y) { if (seg < static_cast(modes.size())) { const std::string& id = modes[static_cast(seg)].id; const bool isActive = id == view.activeModeId(); - // Disabled while the transport runs: claimed but inert, the same shape a - // disabled toolbar row takes — a dead segment reads inert, never absent, - // and never falls through to the grid. - if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked)) { - const int cmd = modeActivateCommandId(id); + const int cmd = modeActivateCommandId(id); + // Disabled/dead rationale: core/ui/footer_bar.h. Claimed but inert, the + // same shape a disabled toolbar row takes — never falls through to the grid. + if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked, cmd != 0)) { if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0); } } diff --git a/src/shell/panel/panel_render.cpp b/src/shell/panel/panel_render.cpp index 608f786..cd6be8f 100644 --- a/src/shell/panel/panel_render.cpp +++ b/src/shell/panel/panel_render.cpp @@ -112,9 +112,9 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) { const SegmentRect& s = segs[static_cast(i)]; const Mode& mode = modes[static_cast(i)]; const bool active = mode.id == activeId; - // A switch is refused while the transport runs, so an unreachable segment - // draws dead rather than inviting a click that would silently do nothing. - const bool live = modeSegmentEnabled(active, g_panel.modeSwitchBlocked); + // Disabled/dead rationale: core/ui/footer_bar.h. + const bool live = modeSegmentEnabled(active, g_panel.modeSwitchBlocked, + modeActivateCommandId(mode.id) != 0); const InteractionState state = active ? InteractionState::Active : (live ? hoverState(g_panel.hovered, HoverKind::ModeSegment, i) diff --git a/src/shell/panel/panel_state.h b/src/shell/panel/panel_state.h index 5d5ea7c..a16eb6c 100644 --- a/src/shell/panel/panel_state.h +++ b/src/shell/panel/panel_state.h @@ -292,10 +292,10 @@ struct PanelState { unsigned int hoverSinceTick = 0; bool tooltipShown = false; - // Polled on the OnTimer tick (REAPER exposes no transport-change callback): a mode - // switch is refused while the transport runs, so the footer's [Arrange|Design] - // segments paint disabled. Cached rather than read per paint AND per click so the - // pixel the user saw and the click they made cannot disagree within a tick. + // Polled on the OnTimer tick (REAPER exposes no transport-change callback). Feeds + // modeSegmentEnabled (core/ui/footer_bar.h). Cached rather than read per paint AND + // per click so the pixel the user saw and the click they made cannot disagree + // within a tick. bool modeSwitchBlocked = false; BankPanelFullHeight fullHeight = BankPanelFullHeight::Split; @@ -485,6 +485,9 @@ void startAudition(int idx); // panel_input.cpp — click/wheel/key routing, accelerator, new-content detection, // and the session-tail read/mutate helpers. TailSetting currentTail(); +// The registered activate action behind a footer mode segment, or 0 if unroutable. +// Shared with panel_render.cpp so paint and click resolve the same id. +int modeActivateCommandId(const std::string& modeId); void handleClick(int x, int y); bool handleWheel(int x, int y, int delta); void registerAccel(); diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index c36c7e6..ac49705 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -83,7 +83,7 @@ applies the resulting lane state to live tracks. ## Modules - `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN` + per-FX offline), restores from snapshot. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** -- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`. +- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`. ## Gotchas @@ -94,3 +94,9 @@ applies the resulting lane state to live tracks. (fixed-lane mechanics, mode-aware capture placement, hidden-AND-silenced) are reflected in Invariants above; the membership/lane-ownership model concepts it also covers live in `core/view`'s Invariants. +- REAPER's own undo restores live `I_SOLO` but not the model's solo cache or + `activeModeId` — neither rolls back with a Ctrl-Z. An undo after a mode switch + leaves the two out of step, and the next switch banks the undo-restored solos + under whatever mode id is active at that point, not the one the user undid back + to. Pre-existing: `snapshots_` already carries this same model-vs-undo split; + the solo cache inherits it rather than introducing it. Not fixed here. diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index bb628e8..a81058f 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -60,15 +60,6 @@ constexpr int kFreeModeFixedLanes = 2; // deliberately excluded, nothing is moving there. constexpr int kTransportMoving = 1 | 4; -// GUIDs the plan parks in the mode being entered — the set a solo restore skips. -std::set parkedGuidsOf(const TogglePlan& plan) { - std::set parked; - for (const TrackPlan& tp : plan.park) { - if (!tp.flags.empty()) parked.insert(tp.flags.front().guid); - } - return parked; -} - // C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane // track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist // but displays as non-fixed-lane). @@ -394,7 +385,6 @@ bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, } // namespace bool transportBlocksModeSwitch(ReaProject* proj) { - if (!GetPlayStateEx) return false; // fail-open: never gate on a missing API pointer return (GetPlayStateEx(proj) & kTransportMoving) != 0; } @@ -439,11 +429,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject // ride the existing undo block — one mode toggle stays one Ctrl-Z. if (realSwitch) { model.soloCache().store(outgoingModeId, outgoingSolo); - std::vector clearOps; - clearOps.reserve(outgoingSolo.size()); - for (const auto& entry : outgoingSolo) - clearOps.push_back(view::SoloOp{entry.first, 0}); - applySoloOps(handleByGuid, clearOps); + clearTrackSolos(handleByGuid, outgoingSolo); } // PARK: snapshot before mutating, store into the model, then apply. @@ -502,11 +488,13 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject model.setActiveMode(targetModeId); - // Replay + consume, before the single relayout below picks the change up. + // Replay + consume, before the single relayout below picks the change up. Dropped + // against `visible` (computed above for parent visibility), not the park plan: a + // folder parent can be hidden without being parked, and a hidden track must not + // receive a replayed solo it carries no visible control to undo. if (realSwitch) { if (const std::map* cached = model.soloCache().query(targetModeId)) { - applySoloOps(handleByGuid, - view::planSoloRestore(*cached, liveGuids, parkedGuidsOf(plan))); + restoreTrackSolos(handleByGuid, *cached, liveGuids, visible); model.soloCache().clear(targetModeId); } } diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 8c6e7ab..94448ed 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -3,7 +3,8 @@ // ViewModeModel's pure planner, and applies the resulting flag / per-FX / // lane writes. The .cpp is the sole REAPER-facing TU here (CLAUDE.md contract: // only main.cpp defines the API pointers). See src/shell/view/CLAUDE.md for -// the enforced invariants (never touch master/mute/solo, snapshot-based restore). +// the enforced invariants (never touch master/mute, solo cached per mode and +// never lost, snapshot-based restore). #include @@ -17,7 +18,9 @@ namespace reasampler { // True while `proj`'s transport is playing or recording — the condition under which // applyMode refuses a real mode switch. Exposed so the panel can paint the footer's // [Arrange|Design] segments disabled BEFORE the click rather than only refusing on -// it. `proj` == nullptr means the current project. +// it. `proj` is passed through to GetPlayStateEx as-is; the SDK documents proj=0 +// meaning "current project" for GetTrack but is silent on GetPlayStateEx, so +// `nullptr` meaning the current project here is inferred by analogy, not confirmed. bool transportBlocksModeSwitch(ReaProject* proj); // Snapshots each about-to-park track's flags into `model`, caches/clears the diff --git a/src/shell/view/view_solo.cpp b/src/shell/view/view_solo.cpp index 70f19d5..20a925c 100644 --- a/src/shell/view/view_solo.cpp +++ b/src/shell/view/view_solo.cpp @@ -44,4 +44,18 @@ void applySoloOps(const TrackHandles& handles, const std::vector& } } +void clearTrackSolos(const TrackHandles& handles, const std::map& soloed) { + std::vector ops; + ops.reserve(soloed.size()); + for (const auto& entry : soloed) ops.push_back(view::SoloOp{entry.first, 0}); + applySoloOps(handles, ops); +} + +void restoreTrackSolos(const TrackHandles& handles, + const std::map& cached, + const std::set& liveGuids, + const std::set& visibleGuids) { + applySoloOps(handles, view::planSoloRestore(cached, liveGuids, visibleGuids)); +} + } // namespace reasampler diff --git a/src/shell/view/view_solo.h b/src/shell/view/view_solo.h index 7c0463f..7e4d5bb 100644 --- a/src/shell/view/view_solo.h +++ b/src/shell/view/view_solo.h @@ -3,6 +3,8 @@ // to cache, clear, or replay is core/view/solo_cache; this pair only moves I_SOLO // between the live tracks and that pure plan. +#include +#include #include #include #include @@ -25,4 +27,15 @@ std::vector readTrackSolos(const TrackHandles& handles); // absent from the enumeration is skipped (stale/deleted track). void applySoloOps(const TrackHandles& handles, const std::vector& ops); +// The outgoing-mode clear: zeroes I_SOLO for every GUID in `soloed` (the just-banked +// values applyMode is about to cache). +void clearTrackSolos(const TrackHandles& handles, const std::map& soloed); + +// The incoming-mode replay: applies `cached` filtered through planSoloRestore's drop +// rules (dead GUID, not visible in the incoming mode). +void restoreTrackSolos(const TrackHandles& handles, + const std::map& cached, + const std::set& liveGuids, + const std::set& visibleGuids); + } // namespace reasampler diff --git a/tests/test_footer_bar.cpp b/tests/test_footer_bar.cpp index e008e2a..88717dd 100644 --- a/tests/test_footer_bar.cpp +++ b/tests/test_footer_bar.cpp @@ -231,20 +231,32 @@ static void testResizeSweepNoOverlap() { // --- Mode-segment enablement (the playback gate's visible half) --------------- static void testModeSegmentsAreLiveWithTheTransportStopped() { - CHECK(modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false)); - CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false)); + CHECK(modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false, /*routable=*/true)); + CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/true)); } static void testInactiveSegmentGoesDeadWhileTheTransportRuns() { // The one that would fire a real switch — refused while playing/recording, so it // must read dead rather than invite a click that silently does nothing. - CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/true)); + CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/true, /*routable=*/true)); } static void testActiveSegmentStaysLiveWhileTheTransportRuns() { // Clicking the mode you are already in is a reapply, which is never gated; // dimming it would read as "this mode is unavailable". - CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/true)); + CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/true, /*routable=*/false)); +} + +static void testInactiveSegmentGoesDeadWhenUnroutable() { + // A third registered mode with no seeded command id (the model is N-mode, the UI + // ships two ids) must read dead, not live-but-silently-inert on click. + CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false, /*routable=*/false)); +} + +static void testActiveSegmentStaysLiveEvenWhenUnroutable() { + // The active segment always fires a reapply through its own already-resolved id in + // practice, but the predicate's active bypass does not consult routable either way. + CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false, /*routable=*/false)); } int main() { @@ -263,6 +275,8 @@ int main() { testModeSegmentsAreLiveWithTheTransportStopped(); testInactiveSegmentGoesDeadWhileTheTransportRuns(); testActiveSegmentStaysLiveWhileTheTransportRuns(); + testInactiveSegmentGoesDeadWhenUnroutable(); + testActiveSegmentStaysLiveEvenWhenUnroutable(); if (g_fail == 0) std::printf("footer_bar: all tests passed\n"); else std::printf("footer_bar: %d CHECK(s) FAILED\n", g_fail); diff --git a/tests/test_solo_cache.cpp b/tests/test_solo_cache.cpp index 37316b3..6567c63 100644 --- a/tests/test_solo_cache.cpp +++ b/tests/test_solo_cache.cpp @@ -2,8 +2,8 @@ // // Covers: the soloed-subset filter (zeros and empty GUIDs dropped, raw values kept), // the cache's store/query/clear lifecycle including the empty-set-removes rule, GUID -// pruning on reconcile, and the restore plan's two drop rules (dead GUID, parked -// track). +// pruning on reconcile, and the restore plan's two drop rules (dead GUID, not visible +// in the incoming mode — covering both a parked leaf and a derived-invisible parent). #include "../src/core/view/solo_cache.h" @@ -120,9 +120,9 @@ static void testReconcileWithEveryGuidLiveRemovesNothing() { // --- planSoloRestore --------------------------------------------------------- -static void testRestorePlanReplaysEveryLiveUnparkedEntryVerbatim() { +static void testRestorePlanReplaysEveryLiveVisibleEntryVerbatim() { const std::vector ops = - planSoloRestore({{"{A}", 1}, {"{B}", 2}}, {"{A}", "{B}"}, {}); + planSoloRestore({{"{A}", 1}, {"{B}", 2}}, {"{A}", "{B}"}, {"{A}", "{B}"}); CHECK(ops.size() == 2); CHECK(ops[0] == (SoloOp{"{A}", 1})); @@ -131,23 +131,34 @@ static void testRestorePlanReplaysEveryLiveUnparkedEntryVerbatim() { static void testRestorePlanSkipsAGuidThatNoLongerExists() { const std::vector ops = - planSoloRestore({{"{GONE}", 1}, {"{HERE}", 1}}, {"{HERE}"}, {}); + planSoloRestore({{"{GONE}", 1}, {"{HERE}", 1}}, {"{HERE}"}, {"{HERE}"}); CHECK(ops.size() == 1); CHECK(ops[0].guid == "{HERE}"); } -static void testRestorePlanSkipsATrackParkedInTheIncomingMode() { +static void testRestorePlanSkipsAParkedLeafNotVisibleInTheIncomingMode() { // Soloing a parked track would silence the mix while contributing nothing // audible, and the track is hidden, so the user could not undo it. const std::vector ops = planSoloRestore({{"{PARKED}", 1}, {"{VISIBLE}", 2}}, {"{PARKED}", "{VISIBLE}"}, - {"{PARKED}"}); + {"{VISIBLE}"}); CHECK(ops.size() == 1); CHECK(ops[0] == (SoloOp{"{VISIBLE}", 2})); } +static void testRestorePlanSkipsAFolderParentHiddenInTheIncomingMode() { + // The parent is never parked (parents are never parked — see planToggle), but a + // derived-invisible parent is hidden all the same: replaying its cached solo + // would silence the mix onto a track the user cannot see or unsolo. `visibleGuids` + // (not the park plan) is the drop set precisely so this case is caught too. + const std::vector ops = + planSoloRestore({{"{HIDDEN_PARENT}", 1}}, {"{HIDDEN_PARENT}"}, {}); + + CHECK(ops.empty()); +} + static void testRestorePlanOfAnEmptyCacheWritesNothing() { CHECK(planSoloRestore({}, {"{A}"}, {}).empty()); } @@ -166,9 +177,10 @@ int main() { testReconcileDropsDeadGuidsAcrossEveryMode(); testReconcileWithEveryGuidLiveRemovesNothing(); - testRestorePlanReplaysEveryLiveUnparkedEntryVerbatim(); + testRestorePlanReplaysEveryLiveVisibleEntryVerbatim(); testRestorePlanSkipsAGuidThatNoLongerExists(); - testRestorePlanSkipsATrackParkedInTheIncomingMode(); + testRestorePlanSkipsAParkedLeafNotVisibleInTheIncomingMode(); + testRestorePlanSkipsAFolderParentHiddenInTheIncomingMode(); testRestorePlanOfAnEmptyCacheWritesNothing(); if (g_fail == 0) std::printf("solo_cache: all tests passed\n");