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:
@@ -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() {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user