feat(view_mode_model): add D2 pure lane model (ownership index, lane ops, auto-tag)

Extends the REAPER-free view_mode_model for per-item mode membership via fixed
lanes: lane->mode C_LANEPLAYS mapping, managed-vs-manual lane-ownership index,
managed-only item-lane ops in the toggle planner, "which lanes may this toggle
touch" query, auto-tag decision (manual-lane items exempt), and JSON round-trip
of the lane index. D1 behavior and tests unchanged.
This commit is contained in:
2026-07-23 16:56:36 -04:00
parent 2ae4c420c5
commit 6e901c198c
3 changed files with 594 additions and 1 deletions
+141 -1
View File
@@ -89,6 +89,63 @@ std::set<std::string> MembershipIndex::modesOf(const std::string& guid) const {
return m ? m->modeIds : std::set<std::string>{}; return m ? m->modeIds : std::set<std::string>{};
} }
// ---------------------------------------------------------------------------
// 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<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
const std::vector<NewItem>& newItems,
const std::string& activeMode) {
std::vector<AutoTag> 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 // 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; return plan;
} }
std::set<LaneRef> 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<LaneRef> touched;
for (const auto& [ref, ownership] : lanes_.all()) {
if (ownership.isManaged()) touched.insert(ref);
}
return touched;
}
bool ViewModeModel::operator==(const ViewModeModel& o) const { 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_; activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_;
} }
@@ -403,6 +484,25 @@ std::string ViewModeModel::serialize() const {
} }
} }
out += ']'; 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) } // root closes here (see bank_model note on NRVO + deferred close)
return out; return out;
} }
@@ -447,6 +547,7 @@ private:
bool parseModes(ModeRegistry& reg); bool parseModes(ModeRegistry& reg);
bool parseMembership(MembershipIndex& idx); bool parseMembership(MembershipIndex& idx);
bool parseSnapshots(std::map<std::string, TrackSnapshot>& snaps); bool parseSnapshots(std::map<std::string, TrackSnapshot>& snaps);
bool parseLanes(LaneOwnershipIndex& idx);
bool parseIntArray(std::vector<int>& out); bool parseIntArray(std::vector<int>& out);
}; };
@@ -701,6 +802,41 @@ bool Parser::parseSnapshots(std::map<std::string, TrackSnapshot>& snaps) {
return consume(']'); 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) { bool Parser::parseModel(ViewModeModel& out) {
if (!consume('{')) return false; if (!consume('{')) return false;
skipWs(); skipWs();
@@ -711,6 +847,7 @@ bool Parser::parseModel(ViewModeModel& out) {
std::string activeMode; std::string activeMode;
bool haveActive = false; bool haveActive = false;
MembershipIndex membership; MembershipIndex membership;
LaneOwnershipIndex lanes;
std::map<std::string, TrackSnapshot> snaps; std::map<std::string, TrackSnapshot> snaps;
do { do {
@@ -728,6 +865,8 @@ bool Parser::parseModel(ViewModeModel& out) {
if (!parseMembership(membership)) return false; if (!parseMembership(membership)) return false;
} else if (key == "snapshots") { } else if (key == "snapshots") {
if (!parseSnapshots(snaps)) return false; if (!parseSnapshots(snaps)) return false;
} else if (key == "lanes") {
if (!parseLanes(lanes)) return false;
} else { } else {
// Unknown keys and the "version" field are skipped here. // Unknown keys and the "version" field are skipped here.
// "version" is serialized as a forward-compat placeholder — there is no // "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; if (haveModes) out.modes() = reg;
out.membership() = membership; out.membership() = membership;
out.lanes() = lanes;
for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap); for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap);
if (haveActive) { if (haveActive) {
if (!out.setActiveMode(activeMode)) return false; // active mode must exist if (!out.setActiveMode(activeMode)) return false; // active mode must exist
+178
View File
@@ -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<std::string> 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<LaneRef, LaneOwnership>& 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<LaneRef, LaneOwnership> 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. // GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange.
// Keyed by track GUID string, never index (reorder-safe). // Keyed by track GUID string, never index (reorder-safe).
class MembershipIndex { 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. // 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. // 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 // fxOffline is emitted per known FX slot: on park, from the snapshot's slot count
@@ -239,6 +359,12 @@ struct TrackPlan {
struct TogglePlan { struct TogglePlan {
std::vector<TrackPlan> park; // inactive leaves -> parked (fixed zeros) std::vector<TrackPlan> park; // inactive leaves -> parked (fixed zeros)
std::vector<TrackPlan> restore; // active leaves returning -> snapshot values std::vector<TrackPlan> 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<LanePlayOp> lanes;
}; };
// -- The view mode model ----------------------------------------------------- // -- The view mode model -----------------------------------------------------
@@ -255,6 +381,8 @@ public:
const ModeRegistry& modes() const { return modes_; } const ModeRegistry& modes() const { return modes_; }
MembershipIndex& membership() { return membership_; } MembershipIndex& membership() { return membership_; }
const MembershipIndex& membership() const { 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_; } const std::string& activeModeId() const { return activeModeId_; }
// Sets the active mode. Returns false (no change) if the id is not registered. // 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. // access to REAPER FX counts at plan time.
TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const; 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<LaneRef> lanesTouchedByToggle() const;
bool operator==(const ViewModeModel& o) const; bool operator==(const ViewModeModel& o) const;
std::string serialize() const; std::string serialize() const;
@@ -328,6 +466,7 @@ public:
private: private:
ModeRegistry modes_; ModeRegistry modes_;
MembershipIndex membership_; MembershipIndex membership_;
LaneOwnershipIndex lanes_; // (guid, laneKey) -> ownership
std::string activeModeId_; // always a registered id std::string activeModeId_; // always a registered id
std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot std::map<std::string, TrackSnapshot> snapshots_; // guid -> pre-park snapshot
}; };
@@ -341,6 +480,45 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount);
// restore-contract invariant directly. // restore-contract invariant directly.
TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap); 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<AutoTag> autoTagNewContent(const std::vector<std::string>& newTrackGuids,
const std::vector<NewItem>& newItems,
const std::string& activeMode);
// The next mode id in the registry's ordinal order, cycling past `currentModeId` // 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 // 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). // the two seed modes; the same cycle scales to N modes with no call-site change).
+275
View File
@@ -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<LaneRef>& 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<AutoTag>& 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<std::string> tracks{"{NT}"};
std::vector<NewItem> 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<NewItem> 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<std::string> 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() { int main() {
testNModeRegistryAndMembership(); testNModeRegistryAndMembership();
testParentDerivationMultiMode(); testParentDerivationMultiMode();
@@ -854,6 +1121,14 @@ int main() {
testReconcileThenReparkLifecycleIntact(); testReconcileThenReparkLifecycleIntact();
testNextModeIdCycles(); testNextModeIdCycles();
// D2 two-canvas lane extension
testLaneModeStateAndPlayValues();
testLaneOwnershipIndex();
testManagedOnlyPlannerAndQuery();
testAutoTagDecision();
testLaneJsonRoundTrip();
testLaneMalformedJson();
if (g_fail == 0) std::printf("All tests passed.\n"); if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }