diff --git a/src/view_mode_model.cpp b/src/view_mode_model.cpp index faf3c8e..868d01d 100644 --- a/src/view_mode_model.cpp +++ b/src/view_mode_model.cpp @@ -89,6 +89,63 @@ std::set MembershipIndex::modesOf(const std::string& guid) const { return m ? m->modeIds : std::set{}; } +// --------------------------------------------------------------------------- +// LaneOwnershipIndex +// --------------------------------------------------------------------------- + +bool LaneOwnershipIndex::setManaged(const std::string& trackGuid, const std::string& laneKey, + const std::string& modeId) { + if (trackGuid.empty() || laneKey.empty() || modeId.empty()) return false; + entries_[LaneRef{trackGuid, laneKey}] = LaneOwnership{modeId}; + return true; +} + +bool LaneOwnershipIndex::setManual(const std::string& trackGuid, const std::string& laneKey) { + if (trackGuid.empty() || laneKey.empty()) return false; + entries_[LaneRef{trackGuid, laneKey}] = LaneOwnership{std::nullopt}; + return true; +} + +bool LaneOwnershipIndex::remove(const std::string& trackGuid, const std::string& laneKey) { + return entries_.erase(LaneRef{trackGuid, laneKey}) > 0; +} + +const LaneOwnership* LaneOwnershipIndex::query(const std::string& trackGuid, + const std::string& laneKey) const { + auto it = entries_.find(LaneRef{trackGuid, laneKey}); + return it == entries_.end() ? nullptr : &it->second; +} + +int laneModeState(const std::string& managedMode, const std::string& activeMode) { + // The active mode's lane plays exclusively; every other managed lane is silenced + // and hidden (C_LANEPLAYS = 0). Exclusive membership: only one stance's lane at a + // time. Show-both, which keeps a lane audible across modes, is a per-lane opt-out + // the shell layers on; the default per-mode decision here is exclusive. + return managedMode == activeMode ? kLanePlaysExclusive : kLaneSilent; +} + +// --------------------------------------------------------------------------- +// auto-tag decision +// --------------------------------------------------------------------------- + +std::vector autoTagNewContent(const std::vector& newTrackGuids, + const std::vector& newItems, + const std::string& activeMode) { + std::vector tags; + if (activeMode.empty()) return tags; // nothing to tag into + + for (const auto& guid : newTrackGuids) { + if (guid.empty()) continue; + tags.push_back(AutoTag{guid, activeMode}); + } + for (const auto& item : newItems) { + if (item.guid.empty()) continue; + if (item.onManualLane) continue; // manual-lane content is off-limits to auto-tag + tags.push_back(AutoTag{item.guid, activeMode}); + } + return tags; +} + // --------------------------------------------------------------------------- // planner helpers // --------------------------------------------------------------------------- @@ -255,11 +312,35 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& } } + // D2 item-level projection: emit a C_LANEPLAYS op for every MANAGED lane. The + // active mode's lane plays exclusively; every other managed lane is silenced+hidden + // (laneModeState). MANUAL lanes are skipped entirely — the load-bearing invariant: + // a toggle never drives a lane the tool did not mint (the fixed-lane analog of + // "never touch mute/solo"). Lane ownership is not a tree property, so this walks the + // ownership index directly, not the FolderTree; a project with no fixed lanes leaves + // plan.lanes empty and the plan is byte-identical to a D1 plan. + for (const auto& [ref, ownership] : lanes_.all()) { + if (!ownership.isManaged()) continue; // manual lanes are off-limits + const int lanePlays = laneModeState(*ownership.managedMode, targetMode); + plan.lanes.push_back(LanePlayOp{ref.trackGuid, ref.laneKey, lanePlays}); + } + return plan; } +std::set ViewModeModel::lanesTouchedByToggle() const { + // Managed-only: exactly the lanes a toggle is permitted to drive. A manual lane — + // absent OR recorded manual in the ownership index — is never returned, so the shell + // can never write C_LANEPLAYS to a lane the user hand-manages. + std::set touched; + for (const auto& [ref, ownership] : lanes_.all()) { + if (ownership.isManaged()) touched.insert(ref); + } + return touched; +} + bool ViewModeModel::operator==(const ViewModeModel& o) const { - return modes_ == o.modes_ && membership_ == o.membership_ && + return modes_ == o.modes_ && membership_ == o.membership_ && lanes_ == o.lanes_ && activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_; } @@ -403,6 +484,25 @@ std::string ViewModeModel::serialize() const { } } out += ']'; + + // lanes: array of { trackGuid, laneKey, managed(bool), mode(str, managed only) }. + // A manual lane omits "mode"; managed carries the owning mode id. Emitting an + // explicit "managed" bool keeps a manual lane distinguishable from a managed lane + // whose mode string is (illegally) empty — the parser rejects the latter. + root.keyBegin("lanes"); + out += '['; + { + bool first = true; + for (const auto& [ref, ownership] : lanes_.all()) { + if (!first) out += ','; first = false; + ObjWriter e(out); + e.keyStr("trackGuid", ref.trackGuid); + e.keyStr("laneKey", ref.laneKey); + e.keyRaw("managed", ownership.isManaged() ? "true" : "false"); + if (ownership.isManaged()) e.keyStr("mode", *ownership.managedMode); + } + } + out += ']'; } // root closes here (see bank_model note on NRVO + deferred close) return out; } @@ -447,6 +547,7 @@ private: bool parseModes(ModeRegistry& reg); bool parseMembership(MembershipIndex& idx); bool parseSnapshots(std::map& snaps); + bool parseLanes(LaneOwnershipIndex& idx); bool parseIntArray(std::vector& out); }; @@ -701,6 +802,41 @@ bool Parser::parseSnapshots(std::map& snaps) { return consume(']'); } +bool Parser::parseLanes(LaneOwnershipIndex& idx) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; + do { + if (!consume('{')) return false; + std::string trackGuid, laneKey, mode; + bool haveTrack = false, haveLane = false, managed = false, haveManaged = false; + do { + std::string k; + if (!parseKey(k)) return false; + if (k == "trackGuid") { if (!parseString(trackGuid)) return false; haveTrack = true; } + else if (k == "laneKey") { if (!parseString(laneKey)) return false; haveLane = true; } + else if (k == "managed") { if (!parseBool(managed)) return false; haveManaged = true; } + else if (k == "mode") { if (!parseString(mode)) return false; } + else if (!skipValue()) return false; + } while (consume(',')); + if (!consume('}')) return false; + // Both keys mandatory and non-empty (they form the lane's identity). A managed + // lane must carry a non-empty mode; a manual lane must not claim one. Enforcing + // this on parse keeps a round-tripped index byte-for-byte identical to the + // serialized one and rejects a malformed managed-without-mode entry. + if (!haveTrack || !haveLane || !haveManaged) return false; + if (trackGuid.empty() || laneKey.empty()) return false; + if (managed) { + if (mode.empty()) return false; + if (!idx.setManaged(trackGuid, laneKey, mode)) return false; + } else { + if (!mode.empty()) return false; // manual lane must not carry a mode + if (!idx.setManual(trackGuid, laneKey)) return false; + } + } while (consume(',')); + return consume(']'); +} + bool Parser::parseModel(ViewModeModel& out) { if (!consume('{')) return false; skipWs(); @@ -711,6 +847,7 @@ bool Parser::parseModel(ViewModeModel& out) { std::string activeMode; bool haveActive = false; MembershipIndex membership; + LaneOwnershipIndex lanes; std::map snaps; do { @@ -728,6 +865,8 @@ bool Parser::parseModel(ViewModeModel& out) { if (!parseMembership(membership)) return false; } else if (key == "snapshots") { if (!parseSnapshots(snaps)) return false; + } else if (key == "lanes") { + if (!parseLanes(lanes)) return false; } else { // Unknown keys and the "version" field are skipped here. // "version" is serialized as a forward-compat placeholder — there is no @@ -743,6 +882,7 @@ bool Parser::parseModel(ViewModeModel& out) { if (haveModes) out.modes() = reg; out.membership() = membership; + out.lanes() = lanes; for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap); if (haveActive) { if (!out.setActiveMode(activeMode)) return false; // active mode must exist diff --git a/src/view_mode_model.h b/src/view_mode_model.h index b42ed92..9a843cf 100644 --- a/src/view_mode_model.h +++ b/src/view_mode_model.h @@ -100,6 +100,109 @@ struct Membership { } }; +// -- Lane ownership (Phase D2 / two-canvas item-level projection) ------------- +// +// D2 extends the track-level projection to the ITEM level via REAPER fixed lanes +// (I_FREEMODE=2). On a track shared by two stances, each mode owns a fixed lane; a +// toggle shows/plays only the active mode's lane. This is the item-visibility analog +// of D1's track parking, and it carries the same load-bearing guarantee: +// +// THE TOOL DRIVES ONLY WHAT IT MINTED. A fixed-lane track is also REAPER's native +// comping surface — a user may keep their OWN manual lanes (comp takes, alternate +// reads). Mode operations touch ONLY managed lanes; manual lanes are never shown, +// hidden, silenced, or re-laned, and their C_LANEPLAYS stays exactly as set. This +// is the fixed-lane analog of "never touch B_MUTE/I_SOLO" and "never touch master". +// +// LANE IDENTITY IS AN OPAQUE, STABLE KEY SUPPLIED BY THE SHELL (boundary). The pure +// index keys a lane by (track GUID + a lane key string). The lane key is an OPAQUE +// identifier the shell provides; this model does NOT assume lane ordinals are stable +// and bakes in NO I_FIXEDLANE renumber/reorder assumptions. Whether the shell derives +// the key from a raw I_FIXEDLANE ordinal or a more durable identity — and how it keeps +// the index from going stale across lane reorder/renumber/deletion — is a Wave-2 SHELL +// design point (CONTEXT.md §Lane-identity fragility). The pure model's only contract: +// the same lane key denotes the same lane across calls. + +// One lane's ownership: managed by a specific mode, or manual (user-minted, outside +// the mode system). `managedMode` present ⇒ managed by that mode id; absent ⇒ manual. +struct LaneOwnership { + std::optional managedMode; // set ⇒ managed by this mode; unset ⇒ manual + + bool isManaged() const { return managedMode.has_value(); } + bool isManual() const { return !managedMode.has_value(); } + + bool operator==(const LaneOwnership& o) const { return managedMode == o.managedMode; } +}; + +// A lane's composite key: (track GUID, opaque lane key). Ordered so it can key a map. +struct LaneRef { + std::string trackGuid; + std::string laneKey; // opaque, shell-supplied; NOT assumed to be a stable ordinal + + bool operator<(const LaneRef& o) const { + if (trackGuid != o.trackGuid) return trackGuid < o.trackGuid; + return laneKey < o.laneKey; + } + bool operator==(const LaneRef& o) const { + return trackGuid == o.trackGuid && laneKey == o.laneKey; + } +}; + +// (track GUID, lane key) -> ownership. Managed lanes name their owning mode; manual +// lanes are user-minted and off-limits to every mode operation. GUID-keyed and +// portable, it rides in the "reasampler" view_state alongside the membership index. +// A lane ABSENT from the index has no recorded ownership — the model treats an absent +// lane as manual by default (the tool never minted it), so the managed-only guarantee +// holds even before the index is populated. +class LaneOwnershipIndex { +public: + // Records lane (trackGuid, laneKey) as MANAGED by `modeId`, replacing any prior + // ownership. Returns false if any argument is empty. + bool setManaged(const std::string& trackGuid, const std::string& laneKey, + const std::string& modeId); + + // Records lane (trackGuid, laneKey) as MANUAL (user-minted), replacing any prior + // ownership. Returns false if trackGuid or laneKey is empty. + bool setManual(const std::string& trackGuid, const std::string& laneKey); + + // Removes the lane from the index entirely (⇒ treated as manual-by-default again). + // Returns true if it was present. + bool remove(const std::string& trackGuid, const std::string& laneKey); + + // The ownership for a lane, or nullptr if the lane has no recorded entry (⇒ manual + // by default). Invalidated by any mutating call. + const LaneOwnership* query(const std::string& trackGuid, const std::string& laneKey) const; + + // True if the lane is recorded MANAGED (by any mode). A lane absent from the index + // is NOT managed (manual by default) — this is the load-bearing predicate the + // toggle planner and the "which lanes may this toggle touch" query gate on. + bool isManaged(const std::string& trackGuid, const std::string& laneKey) const { + const LaneOwnership* o = query(trackGuid, laneKey); + return o && o->isManaged(); + } + + const std::map& all() const { return entries_; } + + std::size_t size() const { return entries_.size(); } + bool empty() const { return entries_.empty(); } + + bool operator==(const LaneOwnershipIndex& o) const { return entries_ == o.entries_; } + +private: + std::map entries_; // (guid, laneKey) -> ownership +}; + +// The play/show state a managed lane takes for a given active mode, matching REAPER's +// item/track-side C_LANEPLAYS values (SDK: 0=lane silent+hidden, 1=lane plays +// exclusively). A managed lane owned by the ACTIVE mode plays (1); every other managed +// lane is silenced+hidden (0) — consistent with exclusive membership and D1's "a mode +// flip is a real change, not cosmetic." Exposed as a free function for direct testing. +// managedMode == activeMode ⇒ 1 (plays exclusively) +// otherwise ⇒ 0 (does not play; hidden + silent) +// The caller must only pass MANAGED lanes here; manual lanes never reach this decision. +inline constexpr int kLanePlaysExclusive = 1; // C_LANEPLAYS: plays exclusively +inline constexpr int kLaneSilent = 0; // C_LANEPLAYS: does not play (hidden+silent) +int laneModeState(const std::string& managedMode, const std::string& activeMode); + // GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange. // Keyed by track GUID string, never index (reorder-safe). class MembershipIndex { @@ -221,6 +324,23 @@ struct FxOfflineOp { } }; +// One managed-lane play/show write the shell must apply. The shell translates this +// into the REAPER lane setters (track-side C_LANEPLAYS:N and, per item, I_FIXEDLANE / +// C_LANEPLAYS; B_FIXEDLANE_HIDDEN follows from the play state). `lanePlays` is a +// C_LANEPLAYS value: kLanePlaysExclusive when the active mode owns the lane, +// kLaneSilent otherwise. The pure model emits these for MANAGED lanes ONLY — never a +// manual lane (the fixed-lane analog of "never touch mute/solo"), enforced in +// planToggle and mirrored by lanesTouchedByToggle. +struct LanePlayOp { + std::string trackGuid; + std::string laneKey; // opaque, shell-supplied + int lanePlays = kLaneSilent; + + bool operator==(const LanePlayOp& o) const { + return trackGuid == o.trackGuid && laneKey == o.laneKey && lanePlays == o.lanePlays; + } +}; + // The complete set of operations to park one inactive leaf, or restore one leaf. // Park uses fixed zeros (parking contract); restore uses a snapshot's values. // fxOffline is emitted per known FX slot: on park, from the snapshot's slot count @@ -239,6 +359,12 @@ struct TrackPlan { struct TogglePlan { std::vector park; // inactive leaves -> parked (fixed zeros) std::vector restore; // active leaves returning -> snapshot values + + // D2 item-level projection: per managed lane, the C_LANEPLAYS state for the target + // mode (active mode's lane plays; every other managed lane silenced+hidden). MANAGED + // lanes ONLY — a manual lane never appears here. Empty when no managed lanes exist, + // so a D1-only project (no fixed lanes) produces an identical plan to before. + std::vector lanes; }; // -- The view mode model ----------------------------------------------------- @@ -255,6 +381,8 @@ public: const ModeRegistry& modes() const { return modes_; } MembershipIndex& membership() { return membership_; } const MembershipIndex& membership() const { return membership_; } + LaneOwnershipIndex& lanes() { return lanes_; } + const LaneOwnershipIndex& lanes() const { return lanes_; } const std::string& activeModeId() const { return activeModeId_; } // Sets the active mode. Returns false (no change) if the id is not registered. @@ -317,6 +445,16 @@ public: // access to REAPER FX counts at plan time. TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const; + // The managed-only "which lanes may this toggle touch" query: the set of lane refs + // a toggle is permitted to drive — MANAGED lanes ONLY, from the ownership index. + // Manual lanes are NEVER in the result, regardless of target mode. This is the pure, + // testable decision behind the load-bearing invariant; the shell reads live lane + // state and applies C_LANEPLAYS only to lanes this query returns. Independent of the + // folder tree (lane ownership is not a tree property) — the target mode does not + // filter the SET (every managed lane is touchable), only the play VALUE each takes + // (see planToggle / laneModeState). + std::set lanesTouchedByToggle() const; + bool operator==(const ViewModeModel& o) const; std::string serialize() const; @@ -328,6 +466,7 @@ public: private: ModeRegistry modes_; MembershipIndex membership_; + LaneOwnershipIndex lanes_; // (guid, laneKey) -> ownership std::string activeModeId_; // always a registered id std::map snapshots_; // guid -> pre-park snapshot }; @@ -341,6 +480,45 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount); // restore-contract invariant directly. TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap); +// -- Auto-tag decision (Phase D2) -------------------------------------------- +// +// New content — both new tracks and new items — is tagged to whatever mode is active +// when it is created; pre-existing content defaults to Arrange. The DECISION is pure: +// the Wave-2 shell detects new GUIDs by diffing project state on the panel timer and +// asks this function what to tag. Pre-existing content (a GUID the shell does not +// report as new) never reaches here and stays at its index state (Arrange by default). +// +// Manual-lane exemption: an item that landed in a MANUAL lane is off-limits to auto-tag +// — auto-tag governs normal timeline content, not hand-managed lanes. The shell marks +// such an item `onManualLane = true` (it knows the item's lane and consults the +// ownership index); the decision then emits NO tag for it. New tracks and new items on +// managed/no lane follow the active-mode rule. + +// One new item the shell detected this poll. Its lane disposition decides exemption. +struct NewItem { + std::string guid; + bool onManualLane = false; // true ⇒ EXEMPT from auto-tag (hand-managed lane) +}; + +// One membership write the auto-tag decision produced: tag `guid` into `modeId`. The +// shell applies it to the MembershipIndex (a new track/item joins the active mode). +struct AutoTag { + std::string guid; + std::string modeId; + + bool operator==(const AutoTag& o) const { return guid == o.guid && modeId == o.modeId; } +}; + +// The pure auto-tag decision: given the new track GUIDs and new items detected this +// poll plus the active mode, produce the membership writes. Every new track is tagged +// to `activeMode`; every new item is tagged to `activeMode` UNLESS it landed on a +// manual lane (exempt). An empty `activeMode` yields no tags (nothing to tag into). +// Empty GUIDs are skipped. The result is a plan the shell applies; this function +// mutates nothing. +std::vector autoTagNewContent(const std::vector& newTrackGuids, + const std::vector& newItems, + const std::string& activeMode); + // 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 // the two seed modes; the same cycle scales to N modes with no call-site change). diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index e98164f..53b0de9 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -835,6 +835,273 @@ static void testNestedToggleSnapshotSurvivesRepark() { } } +// =========================================================================== +// D2 two-canvas lane extension tests +// =========================================================================== + +// Does the plan emit a lane op for (trackGuid, laneKey)? Returns its lanePlays, or a +// sentinel if absent. +static int lanePlaysFor(const TogglePlan& plan, const std::string& trackGuid, + const std::string& laneKey) { + for (const auto& op : plan.lanes) + if (op.trackGuid == trackGuid && op.laneKey == laneKey) return op.lanePlays; + return -999; // sentinel: no op for this lane +} + +static bool laneTouched(const std::set& s, const std::string& g, const std::string& k) { + return s.count(LaneRef{g, k}) > 0; +} + +// -- D2.1 Lane↔mode mapping + C_LANEPLAYS values ----------------------------- +// +// A managed lane owned by the ACTIVE mode plays exclusively (1); every inactive-mode +// managed lane is silenced+hidden (C_LANEPLAYS = 0). Covers the direct laneModeState +// decision and the planToggle op values. + +static void testLaneModeStateAndPlayValues() { + // Direct decision: active mode's lane plays exclusively; others silent. + CHECK(laneModeState(kArrangeModeId, kArrangeModeId) == kLanePlaysExclusive); + CHECK(laneModeState(kDesignModeId, kArrangeModeId) == kLaneSilent); + CHECK(laneModeState(kDesignModeId, kDesignModeId) == kLanePlaysExclusive); + CHECK(laneModeState(kArrangeModeId, kDesignModeId) == kLaneSilent); + CHECK(kLanePlaysExclusive == 1 && kLaneSilent == 0); // SDK C_LANEPLAYS values + + // Via planToggle: a shared track {T} with an Arrange lane and a Design lane. + ViewModeModel vm; + CHECK(vm.lanes().setManaged("{T}", "laneA", kArrangeModeId)); + CHECK(vm.lanes().setManaged("{T}", "laneD", kDesignModeId)); + FolderTree tree; + tree.nodes.push_back(FolderNode{"{T}", "", false}); + + // Toggle to Design: the Design lane plays (1); the Arrange lane is silenced (0). + auto design = vm.planToggle(tree, kDesignModeId); + CHECK(lanePlaysFor(design, "{T}", "laneD") == kLanePlaysExclusive); + CHECK(lanePlaysFor(design, "{T}", "laneA") == kLaneSilent); + + // Toggle to Arrange: mirror image. + auto arrange = vm.planToggle(tree, kArrangeModeId); + CHECK(lanePlaysFor(arrange, "{T}", "laneA") == kLanePlaysExclusive); + CHECK(lanePlaysFor(arrange, "{T}", "laneD") == kLaneSilent); + + // A D1-only project (no fixed lanes) emits no lane ops — plan unchanged from before. + ViewModeModel plain; + FolderTree t2; t2.nodes.push_back(FolderNode{"{L}", "", false}); + CHECK(plain.planToggle(t2, kDesignModeId).lanes.empty()); +} + +// -- D2.2 Lane-ownership index: managed vs manual, add/query/remove ----------- + +static void testLaneOwnershipIndex() { + LaneOwnershipIndex idx; + CHECK(idx.empty()); + + // A lane ABSENT from the index is manual-by-default (never minted by the tool). + CHECK(idx.query("{T}", "l0") == nullptr); + CHECK(!idx.isManaged("{T}", "l0")); + + // Managed lane names its owning mode. + CHECK(idx.setManaged("{T}", "l0", kDesignModeId)); + const LaneOwnership* o = idx.query("{T}", "l0"); + CHECK(o != nullptr); + if (o) { + CHECK(o->isManaged() && !o->isManual()); + CHECK(o->managedMode && *o->managedMode == kDesignModeId); + } + CHECK(idx.isManaged("{T}", "l0")); + + // Manual lane carries no mode. + CHECK(idx.setManual("{T}", "l1")); + const LaneOwnership* m = idx.query("{T}", "l1"); + CHECK(m != nullptr); + if (m) CHECK(m->isManual() && !m->isManaged()); + CHECK(!idx.isManaged("{T}", "l1")); + + // (guid, laneKey) is a composite key: same laneKey on a different track is distinct. + CHECK(idx.setManaged("{U}", "l0", kArrangeModeId)); + CHECK(idx.size() == 3); + CHECK(idx.isManaged("{U}", "l0")); + + // setManaged replaces a prior manual entry (retag a lane the tool now owns). + CHECK(idx.setManaged("{T}", "l1", kArrangeModeId)); + CHECK(idx.isManaged("{T}", "l1")); + + // Empty args are rejected without mutation. + CHECK(!idx.setManaged("", "l0", kDesignModeId)); + CHECK(!idx.setManaged("{T}", "", kDesignModeId)); + CHECK(!idx.setManaged("{T}", "l0", "")); + CHECK(!idx.setManual("", "l0")); + CHECK(!idx.setManual("{T}", "")); + CHECK(idx.size() == 3); + + // remove drops the entry (⇒ manual-by-default again); second remove is a no-op. + CHECK(idx.remove("{T}", "l0")); + CHECK(idx.query("{T}", "l0") == nullptr); + CHECK(!idx.isManaged("{T}", "l0")); + CHECK(!idx.remove("{T}", "l0")); +} + +// -- D2.3/D2.4 Managed-only: planner + query never emit a manual lane -------- +// +// Required case: a track with a manual lane + managed mode lanes — neither the planner +// nor the "which lanes may this toggle touch" query ever emit an op for the manual lane. + +static void testManagedOnlyPlannerAndQuery() { + ViewModeModel vm; + FolderTree tree; + tree.nodes.push_back(FolderNode{"{T}", "", false}); + + // Two managed lanes + one manual comp lane on the same track. + CHECK(vm.lanes().setManaged("{T}", "arr", kArrangeModeId)); + CHECK(vm.lanes().setManaged("{T}", "des", kDesignModeId)); + CHECK(vm.lanes().setManual("{T}", "comp")); // user's own comp take + + // Planner: emits ops for the two managed lanes only; the manual lane is untouched. + auto plan = vm.planToggle(tree, kDesignModeId); + CHECK(plan.lanes.size() == 2); + CHECK(lanePlaysFor(plan, "{T}", "des") == kLanePlaysExclusive); + CHECK(lanePlaysFor(plan, "{T}", "arr") == kLaneSilent); + CHECK(lanePlaysFor(plan, "{T}", "comp") == -999); // NEVER emitted for a manual lane + + // Query: managed lanes only; the manual lane is never in the result. + auto touched = vm.lanesTouchedByToggle(); + CHECK(touched.size() == 2); + CHECK(laneTouched(touched, "{T}", "arr")); + CHECK(laneTouched(touched, "{T}", "des")); + CHECK(!laneTouched(touched, "{T}", "comp")); + + // The query is target-mode-independent: the SET of touchable lanes is every managed + // lane regardless of which mode we would toggle to (the mode only sets the VALUE). + auto touchedA = vm.lanesTouchedByToggle(); + CHECK(touchedA == touched); + + // A lane absent from the index entirely is also never touched (manual by default). + CHECK(!laneTouched(touched, "{T}", "never-indexed")); +} + +// -- D2.5 Auto-tag decision -------------------------------------------------- +// +// New track/item GUIDs + active mode ⇒ membership writes; a new item on a manual lane +// is EXEMPT (no tag); pre-existing content (not reported new) stays Arrange by default. + +static bool hasTag(const std::vector& tags, const std::string& guid, + const std::string& mode) { + for (const auto& t : tags) + if (t.guid == guid && t.modeId == mode) return true; + return false; +} + +static void testAutoTagDecision() { + // A new track + a new item, active mode = Design ⇒ both tagged to Design. + { + std::vector tracks{"{NT}"}; + std::vector items{ NewItem{"{NI}", /*onManualLane=*/false} }; + auto tags = autoTagNewContent(tracks, items, kDesignModeId); + CHECK(tags.size() == 2); + CHECK(hasTag(tags, "{NT}", kDesignModeId)); + CHECK(hasTag(tags, "{NI}", kDesignModeId)); + } + + // A new item on a MANUAL lane is exempt — no tag emitted for it. + { + std::vector items{ + NewItem{"{NORMAL}", false}, + NewItem{"{MANUAL}", true}, // landed on a hand-managed lane ⇒ exempt + }; + auto tags = autoTagNewContent({}, items, kDesignModeId); + CHECK(tags.size() == 1); + CHECK(hasTag(tags, "{NORMAL}", kDesignModeId)); + CHECK(!hasTag(tags, "{MANUAL}", kDesignModeId)); // manual-lane exemption + } + + // Active mode = Arrange ⇒ new content is tagged to Arrange (the active-mode rule, + // even for the default stance). Empty GUIDs are skipped. + { + std::vector tracks{"{NT}", ""}; + auto tags = autoTagNewContent(tracks, {}, kArrangeModeId); + CHECK(tags.size() == 1); + CHECK(hasTag(tags, "{NT}", kArrangeModeId)); + } + + // Empty active mode ⇒ no tags at all (nothing to tag into). + { + auto tags = autoTagNewContent({"{NT}"}, {NewItem{"{NI}", false}}, ""); + CHECK(tags.empty()); + } + + // Pre-existing content resolves to Arrange: a GUID the shell does NOT report as new + // is never passed here, so it never gets tagged and stays untagged ⇒ Arrange by the + // membership default. Prove the default directly on a fresh model. + { + ViewModeModel vm; + CHECK(vm.membership().query("{PREEXISTING}") == nullptr); // absent from index + CHECK(vm.leafBelongsToMode("{PREEXISTING}", kArrangeModeId)); // ⇒ Arrange + CHECK(!vm.leafBelongsToMode("{PREEXISTING}", kDesignModeId)); + } +} + +// -- D2.6 JSON round-trip with lane index + membership ----------------------- + +static void testLaneJsonRoundTrip() { + ViewModeModel vm; + CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2})); + + // Membership populated (item + track GUIDs share the index). + vm.membership().tag("{TRACK}", kDesignModeId); + vm.membership().tag("{ITEM}", "mixdown"); + + // Lane ownership: managed lanes for two modes + a manual lane; a lane key with + // characters that exercise the string escaper. + CHECK(vm.lanes().setManaged("{T}", "lane:0", kArrangeModeId)); + CHECK(vm.lanes().setManaged("{T}", "lane\"1\"", kDesignModeId)); + CHECK(vm.lanes().setManual("{T}", "comp")); + CHECK(vm.lanes().setManaged("{U}", "lane:0", "mixdown")); // same key, other track + CHECK(vm.setActiveMode("mixdown")); + + std::string json = vm.serialize(); + auto back = ViewModeModel::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == vm); // deserialize(serialize(x)) == x + if (back) CHECK(back->serialize() == json); // stable second round-trip + + if (back) { + CHECK(back->lanes().size() == 4); + const LaneOwnership* a = back->lanes().query("{T}", "lane:0"); + CHECK(a && a->isManaged() && *a->managedMode == kArrangeModeId); + const LaneOwnership* d = back->lanes().query("{T}", "lane\"1\""); + CHECK(d && d->isManaged() && *d->managedMode == kDesignModeId); + const LaneOwnership* c = back->lanes().query("{T}", "comp"); + CHECK(c && c->isManual()); + const LaneOwnership* u = back->lanes().query("{U}", "lane:0"); + CHECK(u && u->isManaged() && *u->managedMode == "mixdown"); + } + + // A model with an EMPTY lane index still round-trips (D1-only project on D2 code). + ViewModeModel d1only; + d1only.membership().tag("{X}", kDesignModeId); + auto b2 = ViewModeModel::deserialize(d1only.serialize()); + CHECK(b2.has_value()); + CHECK(b2 && *b2 == d1only); + CHECK(b2 && b2->lanes().empty()); +} + +static void testLaneMalformedJson() { + const char* bad[] = { + "{\"lanes\":[{\"trackGuid\":\"{T}\",\"laneKey\":\"l0\"}]}", // missing "managed" + "{\"lanes\":[{\"trackGuid\":\"{T}\",\"managed\":true,\"mode\":\"design\"}]}", // missing laneKey + "{\"lanes\":[{\"laneKey\":\"l0\",\"managed\":false}]}", // missing trackGuid + "{\"lanes\":[{\"trackGuid\":\"\",\"laneKey\":\"l0\",\"managed\":false}]}", // empty trackGuid + "{\"lanes\":[{\"trackGuid\":\"{T}\",\"laneKey\":\"\",\"managed\":false}]}", // empty laneKey + "{\"lanes\":[{\"trackGuid\":\"{T}\",\"laneKey\":\"l0\",\"managed\":true}]}", // managed w/o mode + "{\"lanes\":[{\"trackGuid\":\"{T}\",\"laneKey\":\"l0\",\"managed\":true,\"mode\":\"\"}]}", // managed empty mode + "{\"lanes\":[{\"trackGuid\":\"{T}\",\"laneKey\":\"l0\",\"managed\":false,\"mode\":\"design\"}]}", // manual w/ mode + "{\"lanes\":[", // truncated + }; + for (const char* j : bad) { + auto r = ViewModeModel::deserialize(j); + CHECK(!r.has_value()); + } +} + int main() { testNModeRegistryAndMembership(); testParentDerivationMultiMode(); @@ -854,6 +1121,14 @@ int main() { testReconcileThenReparkLifecycleIntact(); testNextModeIdCycles(); + // D2 two-canvas lane extension + testLaneModeStateAndPlayValues(); + testLaneOwnershipIndex(); + testManagedOnlyPlannerAndQuery(); + testAutoTagDecision(); + testLaneJsonRoundTrip(); + testLaneMalformedJson(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }