From 78a31ff2153d88de6ff35f5f5bf4974aea141666 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 24 Jul 2026 05:08:18 -0400 Subject: [PATCH] fix(view): lane-separate a content-bearing folder's own media when it's derived-visible in >1 mode planLaneMinting now takes the model + folder tree and splits a track visible in 2+ modes that carries own media (own-item span OR folder derived-visibility), fixing an item dropped on a cross-mode folder leaking into every mode. Show-both tracks are never force-split. --- src/view.cpp | 16 ++- src/view.h | 13 ++- src/view_mode_model.cpp | 74 +++++++++++--- src/view_mode_model.h | 72 ++++++++++---- tests/test_view_mode_model.cpp | 173 +++++++++++++++++++++++++++++++-- 5 files changed, 298 insertions(+), 50 deletions(-) diff --git a/src/view.cpp b/src/view.cpp index febaf06..fe7ca33 100644 --- a/src/view.cpp +++ b/src/view.cpp @@ -543,13 +543,19 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { std::vector> handleByGuid; - readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) + std::vector entries = readFolderEntries(proj, handleByGuid); + // The minting decision is now folder-tree / visibility aware: it needs the tree to + // detect a content-bearing folder derived-visible in >1 mode (which must lane-separate + // its own media even when that media is single-mode). Build it exactly as applyMode does. + const FolderTree tree = buildFolderTree(entries); - // Build the live per-track item picture and run the PURE decision. A single-mode - // track produces no split; a track that now holds >1 mode's content produces mints - // + assignments. Manual-lane items are reported exempt inside readLaneTracks. + // Build the live per-track item picture and run the PURE decision. A track visible in + // exactly one mode produces no split; a track visible in >1 mode while carrying its own + // media (own items span modes, OR a folder derived-visible across modes) produces mints + // + assignments. Manual-lane items are reported exempt inside readLaneTracks; show-both + // tracks are skipped inside the decision. const std::vector tracks = readLaneTracks(model, handleByGuid); - const LaneMintPlan plan = planLaneMinting(tracks); + const LaneMintPlan plan = planLaneMinting(model, tree, tracks); if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick // Wrap the structural mutation in ONE Undo block (unlike the invisible membership diff --git a/src/view.h b/src/view.h index 3c519db..ae5feb0 100644 --- a/src/view.h +++ b/src/view.h @@ -52,13 +52,16 @@ namespace reasampler { // registered mode. `proj` may be nullptr to mean REAPER's current project. bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj); -// Mints managed fixed lanes for any track in `proj` that now holds content of MORE -// THAN ONE mode, and assigns each item to its mode's managed lane (Phase D2 Wave 3). +// Mints managed fixed lanes for any track in `proj` that is VISIBLE IN MORE THAN ONE +// MODE while carrying its own media, and assigns each item to its mode's managed lane +// (Phase D2 Wave 3; visibility trigger added by the folder-media fix). // 1. Enumerates every track + its items; resolves each item's mode from the model's // membership (untagged ⇒ Arrange) and reads whether it currently sits on a MANUAL -// lane (exempt). -// 2. Runs the pure planLaneMinting decision. A track with content of only one mode -// is left whole-track-parked (D1) — NOT lane-split. +// lane (exempt). Builds the FolderTree (I_FOLDERDEPTH) so derived visibility counts. +// 2. Runs the pure planLaneMinting decision (model + tree aware). A track visible in +// exactly one mode is left whole-track-parked (D1) — NOT lane-split. A track visible +// in >1 mode while carrying own media splits: its own items span modes, OR it is a +// content-bearing folder derived-visible across modes. show-both tracks never split. // 3. For each track that must split: enables fixed-lane mode (I_FREEMODE=2), ensures // enough fixed lanes (I_NUMFIXEDLANES), stamps each managed lane's durable name // (P_LANENAME:n), records the lane MANAGED-for-its-mode in the model's ownership diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index 4b8dac8..7052f57 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -162,41 +162,85 @@ std::vector autoTagNewContent(const std::vector& newTrackG // lane minting decision // --------------------------------------------------------------------------- -LaneMintPlan planLaneMinting(const std::vector& tracks) { +LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, + const std::vector& tracks) { LaneMintPlan plan; + // Precompute, per track GUID, the count of modes it is VISIBLE in and the set of + // those mode ids — tree-aware, so a content-bearing folder's DERIVED visibility + // (visibleTracks marks a parent visible in every mode a descendant is visible in) + // is captured, not only the track's own item mode-span. This is the visibility + // trigger source (b): a folder derived-visible in >= 2 modes must lane-separate its + // own media even when that media is single-mode. Computed once for all tracks. + std::map> visibleModesOf; + for (const Mode& mode : model.modes().all()) { + const std::set vis = model.visibleTracks(tree, mode.id); + for (const std::string& guid : vis) + visibleModesOf[guid].insert(mode.id); + } + for (const LaneTrack& track : tracks) { if (track.trackGuid.empty()) continue; - // Collect the DISTINCT modes the track's managed-eligible items belong to, in + // SHOW-BOTH escape hatch: never force-split. A show-both track is visible in + // every mode ON PURPOSE and its content is meant to play across all of them, so + // neither the visibility trigger nor the own-item-span trigger confines it. Skip + // it entirely (no split/mint/assign) so its items stay cross-mode-visible. + if (model.membership().isShowBoth(track.trackGuid)) continue; + + // Collect the DISTINCT modes the track's managed-eligible OWN items belong to, in // deterministic (sorted) order so the mint list and lane count are stable across // runs (a set orders by mode id). Items on a manual lane are EXEMPT — never // counted toward the multi-mode test and never reassigned (the managed-only // invariant, upheld at the source of the decision). - std::set involvedModes; + std::set ownItemModes; for (const LaneItem& item : track.items) { if (item.guid.empty() || item.modeId.empty()) continue; if (item.onManualLane) continue; // exempt — user's hand-managed lane - involvedModes.insert(item.modeId); + ownItemModes.insert(item.modeId); } - // Single-mode (or empty) track: whole-track parking (D1) still separates the - // stances. NO split, NO mint, NO assignment — this is the load-bearing - // "don't lane-split single-mode tracks" rule. - if (involvedModes.size() < 2) continue; + // A track with NO managed-eligible own media never splits: there is nothing to + // confine (lane separation projects OWN items across modes). A folder derived- + // visible in many modes but carrying no own content stays whole-track visibility- + // only (D1 parent handling) — this guards the "carries its own media" clause. + if (ownItemModes.empty()) continue; - // Multi-mode track: transition to lane-split. One managed lane per involved - // mode (durable key = laneNameForMode(mode)), owned by that mode. + // The two visibility sources, OR'd: + // (a) own items span >= 2 modes (W3-A trigger), and + // (b) the track is derived-visible in >= 2 modes (the folder-media case). + // A track qualifies for a split if EITHER makes it multi-mode. + const auto visIt = visibleModesOf.find(track.trackGuid); + const std::size_t visibleModeCount = + visIt == visibleModesOf.end() ? 0 : visIt->second.size(); + const bool multiMode = ownItemModes.size() >= 2 || visibleModeCount >= 2; + + // Single-mode (visible in exactly one mode, own items single-mode): whole-track + // parking (D1) still separates the stances. NO split, NO mint, NO assignment — + // this is the load-bearing "don't lane-split single-mode tracks" rule. + if (!multiMode) continue; + + // Lanes to mint = the UNION of the modes the own items belong to and the modes the + // track is visible in. A folder whose own item is Design-only but which is derived- + // visible in Arrange too mints BOTH a Design lane (holding the item) and an Arrange + // lane (reserving Arrange's stance slot), matching "each mode owns a fixed lane." + std::set laneModes = ownItemModes; + if (visIt != visibleModesOf.end()) + laneModes.insert(visIt->second.begin(), visIt->second.end()); + + // Transition to lane-split: one managed lane per involved mode (durable key = + // laneNameForMode(mode)), owned by that mode. plan.splits.push_back(LaneMintPlan::TrackSplit{ - track.trackGuid, static_cast(involvedModes.size())}); - for (const std::string& mode : involvedModes) { + track.trackGuid, static_cast(laneModes.size())}); + for (const std::string& mode : laneModes) { plan.mints.push_back( LaneMint{track.trackGuid, laneNameForMode(mode), mode}); } - // Assign EVERY managed-eligible item onto its mode's lane — including the - // pre-existing single-mode items, so a track that just gained a second mode - // retroactively lanes all of its content, not only the newly-added item. + // Assign EVERY managed-eligible OWN item onto its tagged mode's lane — including + // the pre-existing single-mode items, so a folder carrying one own Design item + // while derived-visible in Arrange still lanes that item to the Design lane (it + // then hides+silences whenever Arrange is active — the exact failing-case fix). for (const LaneItem& item : track.items) { if (item.guid.empty() || item.modeId.empty()) continue; if (item.onManualLane) continue; // exempt — never reassigned diff --git a/src/view_mode_model.h b/src/view_mode_model.h index 01c2c78..deffc33 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -522,16 +522,36 @@ std::vector autoTagNewContent(const std::vector& newTrackG // -- Lane minting decision (Phase D2 / Wave 3) ------------------------------- // // D1 parks a whole track when it holds content of only ONE mode. The moment a track -// would carry content of MORE THAN ONE mode, whole-track parking can no longer keep -// the stances separate (the track is visible in every mode its content belongs to), -// so the projection drops to the ITEM level: the track becomes a fixed-lane track, -// each involved mode gets its own MANAGED lane, and each item is assigned to its -// mode's lane. A toggle then shows+plays only the active mode's lane. +// is VISIBLE IN MORE THAN ONE MODE while carrying its OWN media, whole-track parking +// can no longer keep the stances separate (the track shows in every mode it is visible +// in, so its items leak across all of them), so the projection drops to the ITEM level: +// the track becomes a fixed-lane track, each involved mode gets its own MANAGED lane, +// and each item is assigned to its mode's lane. A toggle then shows+plays only the +// active mode's lane. +// +// "Visible in more than one mode" has TWO sources, and both trigger a split: +// (1) the track's OWN managed-eligible items span >= 2 modes (a leaf carrying both +// an Arrange take and a Design take), OR +// (2) the track is a content-bearing FOLDER whose descendant leaves span modes, so +// it is DERIVED-VISIBLE in >= 2 modes (ViewModeModel::visibleTracks) even though +// its own single item is single-mode. This second source is why the decision is +// folder-tree / visibility aware — mirroring visibleTracks — rather than looking +// only at the track's own item mode-span. Without it, one MIDI item or capture +// dropped straight onto such a folder sits on the default lane and leaks into +// every mode the folder derives visibility in. +// +// SHOW-BOTH is the deliberate escape hatch: a show-both track is visible in every mode +// ON PURPOSE and its content is meant to play in all of them. It is NEVER force-split — +// neither the visibility trigger nor the own-item-span trigger confines its items to +// per-mode lanes. (Confining show-both content would contradict "stay audible across +// modes.") The decision skips show-both tracks entirely. // // This is the pure DECISION behind that transition — REAPER-free and unit-tested. -// The shell reads each track's items and their live mode+lane disposition, calls this, -// and applies the resulting REAPER writes (I_FREEMODE / I_NUMFIXEDLANES / P_LANENAME / -// I_FIXEDLANE) plus the ownership-index writes. The DECISION never lives in the shell. +// The shell reads each track's items and their live mode+lane disposition, builds the +// FolderTree (via the existing view_tree helper, exactly as the D1 shell does), calls +// this with the model + tree, and applies the resulting REAPER writes (I_FREEMODE / +// I_NUMFIXEDLANES / P_LANENAME / I_FIXEDLANE) plus the ownership-index writes. The +// DECISION never lives in the shell. // // THE MANAGED-LANES-ONLY INVARIANT is upheld here at the source: an item the shell // reports as already on a MANUAL lane is EXEMPT — it is never counted toward the @@ -605,17 +625,30 @@ struct LaneMintPlan { } }; -// The pure lane-minting decision. For each reported track: +// The pure lane-minting decision, folder-tree / visibility aware. `model` supplies the +// membership + show-both state; `tree` supplies the folder structure so a content-bearing +// folder's DERIVED visibility is accounted for (mirrors ViewModeModel::visibleTracks). +// For each reported track: +// * SHOW-BOTH tracks are skipped outright — never force-split (the escape hatch: their +// content is meant to stay audible in every mode). No split, mint, or assignment. // * Ignore items on manual lanes entirely (exempt — the managed-only invariant). -// * Collect the DISTINCT modes the remaining (managed-eligible) items belong to. -// * If that set has < 2 modes, the track stays whole-track-parked (D1) — NO split, -// NO mint, NO assignment. This is the single-mode-track rule. -// * If it has >= 2 modes, the track transitions to lane-split: emit one TrackSplit -// (laneCount == number of involved modes), one LaneMint per involved mode (durable -// key laneNameForMode(mode), owned by that mode), and one LaneAssign per managed- -// eligible item onto its mode's lane — INCLUDING the pre-existing items, so a -// single-mode track that just gained a second mode retroactively lanes ALL its -// items, not only the newly-added one. +// * A track splits iff it CARRIES OWN managed-eligible media AND is VISIBLE IN >= 2 +// MODES. Visibility spans two sources, either of which qualifies: +// (a) the track's own managed-eligible items span >= 2 modes (leaf carrying an +// Arrange take and a Design take), OR +// (b) the track is derived-visible in >= 2 modes per visibleTracks (a content- +// bearing folder whose descendant leaves span modes) — the missed case. +// * A track visible in exactly ONE mode (single-mode leaf, single-mode folder) stays +// whole-track-parked (D1) — NO split. This is the single-mode-track rule. +// * On a split: one TrackSplit (laneCount == number of lanes to mint), one LaneMint per +// involved mode, and one LaneAssign per managed-eligible OWN item onto ITS tagged +// mode's lane — INCLUDING pre-existing items, so a folder carrying one own Design item +// while derived-visible in Arrange too still lanes that item to the Design lane (it +// then hides+silences whenever Arrange is active). The lanes minted are the union of: +// the modes the track's own items belong to, PLUS the modes the track is visible in — +// so a folder whose own item is Design-only but which is derived-visible in Arrange +// mints BOTH a Design lane (holding the item) and an Arrange lane (empty, reserving +// the Arrange stance's slot), matching "each mode owns a fixed lane." // // Items with an empty GUID or empty modeId are skipped (defensive; a real item always // resolves to a mode). The function mutates nothing — it returns a plan the shell @@ -624,7 +657,8 @@ struct LaneMintPlan { // matches, so re-running the detection path does not thrash the project or the undo // history (the shell only opens an Undo block when the plan is non-empty AND some // write actually changes state — see the shell). -LaneMintPlan planLaneMinting(const std::vector& tracks); +LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, + const std::vector& tracks); // The next mode id in the registry's ordinal order, cycling past `currentModeId` // and wrapping to the first mode after the last (Arrange -> Design -> Arrange with diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index f994fb0..5e3f5f5 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1105,6 +1105,13 @@ static int splitLaneCount(const LaneMintPlan& p, const std::string& track) { return -1; // no split for this track } +// A plain LEAF track (not a folder) carrying its own items, with no tree derivation: +// an empty model + empty tree means visibleTracks contributes nothing, so the ONLY +// trigger is the track's own-item mode span — exactly the W3-A behavior. These helpers +// keep the W3-A leaf tests reading against a neutral model/tree. +static const ViewModeModel& bareModel() { static ViewModeModel m; return m; } +static const FolderTree& emptyTree() { static FolderTree t; return t; } + static void testLaneMintingSingleModeNoSplit() { // A track whose items all belong to ONE mode is NOT lane-split — D1 whole-track // parking still separates the stances. No split, no mint, no assignment. @@ -1114,12 +1121,12 @@ static void testLaneMintingSingleModeNoSplit() { LaneItem{"{i2}", kArrangeModeId, false}, }}, }; - const LaneMintPlan plan = planLaneMinting(tracks); + const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks); CHECK(plan.empty()); CHECK(splitLaneCount(plan, "{T}") == -1); // An empty track (no items) is likewise never split. - CHECK(planLaneMinting({LaneTrack{"{E}", {}}}).empty()); + CHECK(planLaneMinting(bareModel(), emptyTree(), {LaneTrack{"{E}", {}}}).empty()); } static void testLaneMintingMultiModeMintsAndAssignsAll() { @@ -1133,7 +1140,7 @@ static void testLaneMintingMultiModeMintsAndAssignsAll() { LaneItem{"{des1}", kDesignModeId, false}, // the newly-added 2nd-mode item }}, }; - const LaneMintPlan plan = planLaneMinting(tracks); + const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks); CHECK(!plan.empty()); // One split with two managed lanes (one per involved mode). @@ -1160,7 +1167,7 @@ static void testLaneMintingManualLaneExempt() { LaneItem{"{comp}", kDesignModeId, /*onManualLane=*/true}, // user's comp take }}, }; - const LaneMintPlan plan = planLaneMinting(tracks); + const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks); // Split for the two managed modes; the manual item never appears in assigns. CHECK(splitLaneCount(plan, "{T}") == 2); @@ -1179,7 +1186,8 @@ static void testLaneMintingManualLaneExempt() { LaneItem{"{d}", kDesignModeId, /*onManualLane=*/true}, // only 2nd mode, exempt }}, }; - CHECK(planLaneMinting(t2).empty()); // managed-eligible content is single-mode ⇒ no split + // managed-eligible content is single-mode ⇒ no split (leaf, no tree derivation). + CHECK(planLaneMinting(bareModel(), emptyTree(), t2).empty()); } static void testLaneMintingThreeModesAndOwnershipKeys() { @@ -1197,7 +1205,8 @@ static void testLaneMintingThreeModesAndOwnershipKeys() { LaneItem{"{m}", "mixdown", false}, }}, }; - const LaneMintPlan plan = planLaneMinting(tracks); + // Own items span three modes (leaf; empty tree ⇒ own-item-span is the sole trigger). + const LaneMintPlan plan = planLaneMinting(vm, FolderTree{}, tracks); CHECK(splitLaneCount(plan, "{T}") == 3); CHECK(plan.mints.size() == 3); @@ -1221,6 +1230,153 @@ static void testLaneMintingThreeModesAndOwnershipKeys() { if (back) CHECK(back->lanes().size() == 3); } +// -- Fix: content-bearing folder derived-visible in >1 mode splits its own media ---- +// +// The exact failing case. A folder {F} has descendant leaves in BOTH modes ({LD} Design, +// {LA} Arrange) and carries ONE OWN item ({own}) tagged Design. W3-A's own-item-span test +// alone would NOT split {F} (its own content is single-mode Design), so the item leaked +// into every mode the folder was derived-visible in. The visibility-aware decision now +// mints managed lanes on {F} and lanes {own} onto the Design lane — so it hides+silences +// whenever Arrange is active. This is the load-bearing fix; assert it hard. +static void testLaneMintingFolderDerivedVisibleSplitsOwnMedia() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); // a Design leaf under the folder + // {LA} left untagged ⇒ Arrange member; both stances thus live under {F}. + vm.membership().tag("{own}", kDesignModeId); // the folder's OWN dropped item (Design) + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + + // Sanity: the folder really is derived-visible in BOTH modes (the precondition the + // W3-A trigger ignored). If this ever stops holding, the fix's premise is gone. + CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{F}") == 1); + CHECK(vm.visibleTracks(tree, kDesignModeId).count("{F}") == 1); + + // The folder track {F} carries its own single Design item; its child leaves are the + // separate leaf tracks (not reported as items on {F}). + std::vector tracks{ + LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}}, + }; + + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + + // {F} MUST split even though its own item is single-mode: it is visible in 2 modes. + CHECK(!plan.empty()); + CHECK(splitLaneCount(plan, "{F}") == 2); // one lane per stance + CHECK(hasMint(plan, "{F}", kDesignModeId)); // Design lane (holds the item) + CHECK(hasMint(plan, "{F}", kArrangeModeId)); // Arrange lane (reserves the slot) + + // The own item is confined to its tagged (Design) lane — the exact hide-in-Arrange fix. + CHECK(plan.assigns.size() == 1); + CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId)); + for (const auto& a : plan.assigns) + CHECK(!(a.itemGuid == "{own}" && a.laneKey == laneNameForMode(kArrangeModeId))); +} + +// A folder carrying its OWN items that already span both modes → still split (the two +// triggers OR: own-item span AND derived visibility both point the same way here). Both +// own items separate to their tagged lanes. +static void testLaneMintingFolderOwnItemsSpanBothModes() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); + vm.membership().tag("{d}", kDesignModeId); + // {a} untagged ⇒ Arrange. + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); // untagged ⇒ Arrange + + std::vector tracks{ + LaneTrack{"{F}", { + LaneItem{"{a}", kArrangeModeId, false}, + LaneItem{"{d}", kDesignModeId, false}, + }}, + }; + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + CHECK(splitLaneCount(plan, "{F}") == 2); + CHECK(plan.assigns.size() == 2); + CHECK(hasAssign(plan, "{a}", "{F}", kArrangeModeId)); + CHECK(hasAssign(plan, "{d}", "{F}", kDesignModeId)); +} + +// SHOW-BOTH escape hatch: a show-both track carrying its own items is visible in every +// mode ON PURPOSE and must NOT be force-split — its content stays cross-mode-visible. +// Even with own items that would otherwise span modes, the decision skips it entirely. +static void testLaneMintingShowBothNotForceSplit() { + ViewModeModel vm; + vm.membership().setShowBoth("{SB}", true); + + // A show-both track whose OWN items even span two modes — the W3-A own-span trigger + // would fire, but show-both must override it (its items are meant to play everywhere). + std::vector tracks{ + LaneTrack{"{SB}", { + LaneItem{"{a}", kArrangeModeId, false}, + LaneItem{"{d}", kDesignModeId, false}, + }}, + }; + const LaneMintPlan plan = planLaneMinting(vm, FolderTree{}, tracks); + CHECK(plan.empty()); // NOT split — the escape hatch holds + CHECK(splitLaneCount(plan, "{SB}") == -1); + + // And a show-both FOLDER derived-visible in both modes carrying an own item: still not + // split. Visibility is the deliberate point of show-both. + ViewModeModel vm2; + vm2.membership().setShowBoth("{F}", true); + vm2.membership().tag("{LD}", kDesignModeId); + vm2.membership().tag("{own}", kDesignModeId); + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + std::vector t2{LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}}}; + CHECK(planLaneMinting(vm2, tree, t2).empty()); +} + +// A single-mode LEAF visible in exactly one mode is still never split — the D1 whole-track +// parking case. A leaf under a folder, tagged Design, whose sibling is also Design: the +// leaf is visible in one mode only, carries its own Design item, and must NOT lane-split. +static void testLaneMintingSingleModeLeafVisibleOnceNoSplit() { + ViewModeModel vm; + vm.membership().tag("{L}", kDesignModeId); + vm.membership().tag("{own}", kDesignModeId); + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", true}); + tree.nodes.push_back(FolderNode{"{L}", "{F}", false}); // the leaf under test + + // The leaf {L} is visible only in Design (its one tagged mode). + CHECK(vm.visibleTracks(tree, kDesignModeId).count("{L}") == 1); + CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{L}") == 0); + + std::vector tracks{ + LaneTrack{"{L}", {LaneItem{"{own}", kDesignModeId, false}}}, + }; + const LaneMintPlan plan = planLaneMinting(vm, tree, tracks); + CHECK(plan.empty()); // single-mode, visible once ⇒ D1 whole-track parking, no split +} + +// A content-EMPTY folder derived-visible in many modes carries NO own media, so there is +// nothing to lane-separate: it stays visibility-only (D1 parent handling), never split. +static void testLaneMintingEmptyFolderNotSplit() { + ViewModeModel vm; + vm.membership().tag("{LD}", kDesignModeId); + // {LA} untagged ⇒ Arrange; folder derived-visible in both modes but holds no own item. + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", true}); + tree.nodes.push_back(FolderNode{"{LD}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); + + CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{F}") == 1); + CHECK(vm.visibleTracks(tree, kDesignModeId).count("{F}") == 1); + + std::vector tracks{LaneTrack{"{F}", {}}}; // no own media + CHECK(planLaneMinting(vm, tree, tracks).empty()); +} + // -- D2.6 JSON round-trip with lane index + membership ----------------------- static void testLaneJsonRoundTrip() { @@ -1313,6 +1469,11 @@ int main() { testLaneMintingMultiModeMintsAndAssignsAll(); testLaneMintingManualLaneExempt(); testLaneMintingThreeModesAndOwnershipKeys(); + testLaneMintingFolderDerivedVisibleSplitsOwnMedia(); + testLaneMintingFolderOwnItemsSpanBothModes(); + testLaneMintingShowBothNotForceSplit(); + testLaneMintingSingleModeLeafVisibleOnceNoSplit(); + testLaneMintingEmptyFolderNotSplit(); testLaneJsonRoundTrip(); testLaneMalformedJson();