314cd7bf36
Drive C_LANESCOLLAPSED=2 and hide lane buttons only on tracks the tool itself splits (gated on the pre-write I_FREEMODE read, so user comp-lane display prefs are never stomped). Lazy-mint managed lanes only for modes with own content, never an empty reserved lane; confinement holds via C_LANEPLAYS on the lone lane.
1538 lines
70 KiB
C++
1538 lines
70 KiB
C++
// Standalone tests for reasampler::ViewModeModel — no REAPER, no test framework.
|
|
// Mirror of test_bank_model: iterate the hard logic outside the DAW.
|
|
//
|
|
// Covers (PLAN.md D1 test cases):
|
|
// 1. N-mode proven — >=3 modes, membership + derivation still correct.
|
|
// 2. Parent derivation — a folder with descendant leaves in different modes is
|
|
// visible in each of those modes.
|
|
// 3. Restore round-trip — snapshot -> park -> restore returns every driven flag to
|
|
// its captured value; includes the "flag already at 0 stays 0" (no default).
|
|
// 4. show-both leaf never appears in a park op-list and is visible in all modes.
|
|
// 5. Unknown/stale GUID tolerated (ignore-and-prune, no crash).
|
|
// 6. JSON round-trip lossless: modes + membership + show-both + snapshots + active.
|
|
// 7. planToggle park path: fxOffline is empty (shell-expands-FX contract).
|
|
// 9. Nested-folder toggle: the snapshot store/clear lifecycle survives a re-park
|
|
// (park-while-parked) so untagged leaves return to visible after toggling back;
|
|
// guards the in-DAW "all leaves hidden after toggling twice" regression.
|
|
|
|
#include "../src/view_mode_model.h"
|
|
#include "../src/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys
|
|
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <string>
|
|
|
|
using namespace reasampler;
|
|
|
|
static int g_fail = 0;
|
|
#define CHECK(cond) do { if(!(cond)) { \
|
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
|
|
|
// -- helpers -----------------------------------------------------------------
|
|
|
|
static bool visibleHas(const std::set<std::string>& v, const std::string& g) {
|
|
return v.count(g) > 0;
|
|
}
|
|
|
|
// Does any park TrackPlan in the plan target `guid`?
|
|
static bool parkTargets(const TogglePlan& plan, const std::string& guid) {
|
|
for (const auto& p : plan.park)
|
|
for (const auto& f : p.flags)
|
|
if (f.guid == guid) return true;
|
|
return false;
|
|
}
|
|
|
|
// Find the single restore plan for `guid`, or nullptr.
|
|
static const TrackPlan* restoreFor(const TogglePlan& plan, const std::string& guid) {
|
|
for (const auto& p : plan.restore)
|
|
if (!p.flags.empty() && p.flags.front().guid == guid) return &p;
|
|
return nullptr;
|
|
}
|
|
|
|
static int flagValue(const TrackPlan& p, Flag f) {
|
|
for (const auto& op : p.flags)
|
|
if (op.flag == f) return op.value;
|
|
return -999; // sentinel: flag absent
|
|
}
|
|
|
|
// -- 1. N-mode proven --------------------------------------------------------
|
|
|
|
static void testNModeRegistryAndMembership() {
|
|
ViewModeModel vm;
|
|
// Seeded: Arrange + Design.
|
|
CHECK(vm.modes().size() == 2);
|
|
CHECK(vm.modes().contains(kArrangeModeId));
|
|
CHECK(vm.modes().contains(kDesignModeId));
|
|
CHECK(vm.activeModeId() == kArrangeModeId);
|
|
|
|
// Add a third mode — proves N-mode, not boolean.
|
|
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
|
CHECK(vm.modes().size() == 3);
|
|
CHECK(vm.modes().contains("mixdown"));
|
|
|
|
// Duplicate id and empty id are rejected without mutation.
|
|
CHECK(!vm.modes().add(Mode{"mixdown", "Dup", 5}));
|
|
CHECK(!vm.modes().add(Mode{"", "Empty", 6}));
|
|
CHECK(vm.modes().size() == 3);
|
|
|
|
// Membership across three modes.
|
|
CHECK(vm.membership().tag("{A}", kArrangeModeId));
|
|
CHECK(vm.membership().tag("{D}", kDesignModeId));
|
|
CHECK(vm.membership().tag("{M}", "mixdown"));
|
|
|
|
// Leaf-belongs rule holds in each mode.
|
|
CHECK(vm.leafBelongsToMode("{D}", kDesignModeId));
|
|
CHECK(!vm.leafBelongsToMode("{D}", kArrangeModeId));
|
|
CHECK(vm.leafBelongsToMode("{M}", "mixdown"));
|
|
CHECK(!vm.leafBelongsToMode("{M}", kDesignModeId));
|
|
|
|
// Untagged leaf defaults to Arrange, and only Arrange.
|
|
CHECK(vm.leafBelongsToMode("{UNTAGGED}", kArrangeModeId));
|
|
CHECK(!vm.leafBelongsToMode("{UNTAGGED}", kDesignModeId));
|
|
|
|
// Retag moves the leaf (single-mode semantics).
|
|
CHECK(vm.membership().tag("{D}", "mixdown"));
|
|
CHECK(vm.leafBelongsToMode("{D}", "mixdown"));
|
|
CHECK(!vm.leafBelongsToMode("{D}", kDesignModeId));
|
|
|
|
// Untag returns to the Arrange default.
|
|
CHECK(vm.membership().untag("{D}"));
|
|
CHECK(vm.leafBelongsToMode("{D}", kArrangeModeId));
|
|
CHECK(!vm.membership().untag("{D}")); // second untag is a no-op
|
|
|
|
// setActiveMode rejects an unregistered id, accepts a registered one.
|
|
CHECK(!vm.setActiveMode("nope"));
|
|
CHECK(vm.activeModeId() == kArrangeModeId);
|
|
CHECK(vm.setActiveMode("mixdown"));
|
|
CHECK(vm.activeModeId() == "mixdown");
|
|
}
|
|
|
|
// -- 2. Parent derivation ----------------------------------------------------
|
|
|
|
static void testParentDerivationMultiMode() {
|
|
ViewModeModel vm;
|
|
// Folder {F} holds two leaves: {L1} in Arrange (untagged default), {L2} in Design.
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
|
|
tree.nodes.push_back(FolderNode{"{L1}", "{F}", false});
|
|
tree.nodes.push_back(FolderNode{"{L2}", "{F}", false});
|
|
vm.membership().tag("{L2}", kDesignModeId);
|
|
// {L1} stays untagged ⇒ Arrange.
|
|
|
|
auto arrange = vm.visibleTracks(tree, kArrangeModeId);
|
|
auto design = vm.visibleTracks(tree, kDesignModeId);
|
|
|
|
// The parent is visible in BOTH modes because it has a descendant in each.
|
|
CHECK(visibleHas(arrange, "{F}"));
|
|
CHECK(visibleHas(design, "{F}"));
|
|
|
|
// Leaves appear only in their own mode.
|
|
CHECK(visibleHas(arrange, "{L1}") && !visibleHas(arrange, "{L2}"));
|
|
CHECK(visibleHas(design, "{L2}") && !visibleHas(design, "{L1}"));
|
|
|
|
// Nested folder chain: grandparent {G} > parent {F2} > leaf {L3} (Design).
|
|
// The whole chain up to the root must be visible in Design.
|
|
FolderTree nested;
|
|
nested.nodes.push_back(FolderNode{"{G}", "", true});
|
|
nested.nodes.push_back(FolderNode{"{F2}", "{G}", true});
|
|
nested.nodes.push_back(FolderNode{"{L3}", "{F2}", false});
|
|
ViewModeModel vm2;
|
|
vm2.membership().tag("{L3}", kDesignModeId);
|
|
auto d2 = vm2.visibleTracks(nested, kDesignModeId);
|
|
CHECK(visibleHas(d2, "{L3}"));
|
|
CHECK(visibleHas(d2, "{F2}"));
|
|
CHECK(visibleHas(d2, "{G}"));
|
|
// In Arrange: the Design leaf {L3} stays hidden (leaf visibility unchanged), but
|
|
// the untagged parents {F2}/{G} are Arrange members by their own default, so they
|
|
// are visible in Arrange under the corrected own-membership OR derived rule.
|
|
// (See testParentOwnMembershipVisibility for the full case matrix.)
|
|
auto a2 = vm2.visibleTracks(nested, kArrangeModeId);
|
|
CHECK(!visibleHas(a2, "{L3}"));
|
|
CHECK(visibleHas(a2, "{F2}") && visibleHas(a2, "{G}"));
|
|
|
|
// A parent is NEVER parked, in either mode.
|
|
auto planD = vm.planToggle(tree, kDesignModeId);
|
|
auto planA = vm.planToggle(tree, kArrangeModeId);
|
|
CHECK(!parkTargets(planD, "{F}"));
|
|
CHECK(!parkTargets(planA, "{F}"));
|
|
}
|
|
|
|
// -- 2b. Parent own-membership visibility (untagged folder + own FX/media) ---
|
|
//
|
|
// Corrected rule: a parent is visible in mode M if EITHER a descendant leaf is
|
|
// visible in M (existing derived rule) OR the parent belongs to M by its OWN
|
|
// membership (leafBelongsToMode on the parent's own GUID; untagged ⇒ Arrange).
|
|
// The reported case: an untagged folder whose leaves are all Design vanished in
|
|
// Arrange despite carrying its own FX/media. It must now show in Arrange (own
|
|
// default) AND Design (derived from children). Parents remain never parked.
|
|
|
|
static void testParentOwnMembershipVisibility() {
|
|
// Case A [reported]: untagged folder {F}, all leaves Design ⇒ folder visible in
|
|
// BOTH Arrange (own default) and Design (derived from children).
|
|
{
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
|
|
tree.nodes.push_back(FolderNode{"{L1}", "{F}", false});
|
|
tree.nodes.push_back(FolderNode{"{L2}", "{F}", false});
|
|
vm.membership().tag("{L1}", kDesignModeId);
|
|
vm.membership().tag("{L2}", kDesignModeId);
|
|
// {F} itself untagged ⇒ Arrange member by default.
|
|
|
|
auto arrange = vm.visibleTracks(tree, kArrangeModeId);
|
|
auto design = vm.visibleTracks(tree, kDesignModeId);
|
|
CHECK(visibleHas(arrange, "{F}")); // own default (would FAIL under derived-only rule)
|
|
CHECK(visibleHas(design, "{F}")); // derived from Design children
|
|
// Leaves appear only in Design; neither is visible in Arrange.
|
|
CHECK(visibleHas(design, "{L1}") && visibleHas(design, "{L2}"));
|
|
CHECK(!visibleHas(arrange, "{L1}") && !visibleHas(arrange, "{L2}"));
|
|
// Still never parked, in either mode.
|
|
CHECK(!parkTargets(vm.planToggle(tree, kArrangeModeId), "{F}"));
|
|
CHECK(!parkTargets(vm.planToggle(tree, kDesignModeId), "{F}"));
|
|
}
|
|
|
|
// Case B: untagged folder, all leaves Arrange ⇒ folder visible in Arrange only.
|
|
{
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{F}", "", true});
|
|
tree.nodes.push_back(FolderNode{"{L1}", "{F}", false}); // untagged ⇒ Arrange
|
|
tree.nodes.push_back(FolderNode{"{L2}", "{F}", false}); // untagged ⇒ Arrange
|
|
|
|
CHECK(visibleHas(vm.visibleTracks(tree, kArrangeModeId), "{F}"));
|
|
CHECK(!visibleHas(vm.visibleTracks(tree, kDesignModeId), "{F}"));
|
|
}
|
|
|
|
// Case C: untagged folder, mixed Arrange + Design leaves ⇒ visible in both.
|
|
{
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{F}", "", true});
|
|
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); // untagged ⇒ Arrange
|
|
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
|
|
vm.membership().tag("{LD}", kDesignModeId);
|
|
|
|
CHECK(visibleHas(vm.visibleTracks(tree, kArrangeModeId), "{F}"));
|
|
CHECK(visibleHas(vm.visibleTracks(tree, kDesignModeId), "{F}"));
|
|
}
|
|
|
|
// Case D: nested untagged grandparent {G} > untagged parent {F} > Design leaves.
|
|
// Both intermediate folders visible in BOTH modes: own-default Arrange (they are
|
|
// untagged), and derived Design (a Design leaf lives under each).
|
|
{
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{G}", "", true});
|
|
tree.nodes.push_back(FolderNode{"{F}", "{G}", true});
|
|
tree.nodes.push_back(FolderNode{"{L1}", "{F}", false});
|
|
tree.nodes.push_back(FolderNode{"{L2}", "{F}", false});
|
|
vm.membership().tag("{L1}", kDesignModeId);
|
|
vm.membership().tag("{L2}", kDesignModeId);
|
|
|
|
auto arrange = vm.visibleTracks(tree, kArrangeModeId);
|
|
auto design = vm.visibleTracks(tree, kDesignModeId);
|
|
CHECK(visibleHas(arrange, "{G}") && visibleHas(arrange, "{F}")); // own default
|
|
CHECK(visibleHas(design, "{G}") && visibleHas(design, "{F}")); // derived
|
|
// The Design leaves stay Design-only; not visible in Arrange.
|
|
CHECK(!visibleHas(arrange, "{L1}") && !visibleHas(arrange, "{L2}"));
|
|
}
|
|
|
|
// Case E: existing behavior unchanged — a TAGGED-Design folder holding a visible
|
|
// Design leaf still shows in Design; and a folder made visible only by a visible
|
|
// tagged-Design descendant (own membership Arrange) still derives into Design.
|
|
// Leaf visibility itself is unchanged: a Design leaf is Design-only.
|
|
{
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{F}", "", true});
|
|
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
|
|
vm.membership().tag("{F}", kDesignModeId); // folder tagged into Design itself
|
|
vm.membership().tag("{LD}", kDesignModeId);
|
|
|
|
auto design = vm.visibleTracks(tree, kDesignModeId);
|
|
CHECK(visibleHas(design, "{F}")); // own Design membership + derived from {LD}
|
|
CHECK(visibleHas(design, "{LD}"));
|
|
// A Design-tagged folder is NOT an Arrange member ⇒ not visible in Arrange
|
|
// unless a child is; here the only child is Design, so folder hidden in Arrange.
|
|
auto arrange = vm.visibleTracks(tree, kArrangeModeId);
|
|
CHECK(!visibleHas(arrange, "{F}"));
|
|
CHECK(!visibleHas(arrange, "{LD}")); // leaf visibility unchanged
|
|
}
|
|
}
|
|
|
|
// -- 3. Restore round-trip (the trust anchor) --------------------------------
|
|
|
|
static void testRestoreRoundTripSnapshotValues() {
|
|
// Direct planner check: park is fixed zeros; restore is snapshot verbatim.
|
|
TrackSnapshot snap;
|
|
snap.showInTcp = 1;
|
|
snap.showInMixer = 1;
|
|
snap.mainSend = 0; // user had it OUT of the mix for their own reason
|
|
snap.fxEnable = 1;
|
|
snap.fxOffline = {0, 1, 0}; // slot 1 was already offline before parking
|
|
|
|
TrackPlan park = makeParkPlan("{T}", /*fxCount=*/3);
|
|
CHECK(flagValue(park, Flag::ShowInTcp) == 0);
|
|
CHECK(flagValue(park, Flag::ShowInMixer) == 0);
|
|
CHECK(flagValue(park, Flag::MainSend) == 0);
|
|
CHECK(flagValue(park, Flag::FxEnable) == 0);
|
|
CHECK(park.fxOffline.size() == 3);
|
|
for (const auto& op : park.fxOffline) CHECK(op.offline == true);
|
|
|
|
TrackPlan restore = makeRestorePlan("{T}", snap);
|
|
// Every flag returns to its CAPTURED value — not a hardcoded "on".
|
|
CHECK(flagValue(restore, Flag::ShowInTcp) == 1);
|
|
CHECK(flagValue(restore, Flag::ShowInMixer) == 1);
|
|
CHECK(flagValue(restore, Flag::MainSend) == 0); // the "already at 0 stays 0" case
|
|
CHECK(flagValue(restore, Flag::FxEnable) == 1);
|
|
CHECK(restore.fxOffline.size() == 3);
|
|
CHECK(restore.fxOffline[0].offline == false);
|
|
CHECK(restore.fxOffline[1].offline == true); // was offline pre-park ⇒ stays offline
|
|
CHECK(restore.fxOffline[2].offline == false);
|
|
|
|
// A snapshot entirely at 0 must restore entirely to 0 (no default leaks in).
|
|
TrackSnapshot zero; // all zeros, empty fxOffline
|
|
TrackPlan rz = makeRestorePlan("{Z}", zero);
|
|
CHECK(flagValue(rz, Flag::ShowInTcp) == 0);
|
|
CHECK(flagValue(rz, Flag::ShowInMixer) == 0);
|
|
CHECK(flagValue(rz, Flag::MainSend) == 0);
|
|
CHECK(flagValue(rz, Flag::FxEnable) == 0);
|
|
CHECK(rz.fxOffline.empty());
|
|
|
|
// End-to-end via planToggle: a leaf tagged Design, snapshotted, parked while in
|
|
// Arrange, then restored when we toggle back to Design.
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{DES}", "", false});
|
|
vm.membership().tag("{DES}", kDesignModeId);
|
|
vm.storeSnapshot("{DES}", snap);
|
|
|
|
// Toggle to Arrange: {DES} is inactive ⇒ parked.
|
|
auto toArrange = vm.planToggle(tree, kArrangeModeId);
|
|
CHECK(parkTargets(toArrange, "{DES}"));
|
|
CHECK(restoreFor(toArrange, "{DES}") == nullptr); // not restored while inactive
|
|
|
|
// Toggle to Design: {DES} is active AND has a snapshot ⇒ restored from it.
|
|
auto toDesign = vm.planToggle(tree, kDesignModeId);
|
|
CHECK(!parkTargets(toDesign, "{DES}"));
|
|
const TrackPlan* r = restoreFor(toDesign, "{DES}");
|
|
CHECK(r != nullptr);
|
|
if (r) {
|
|
CHECK(flagValue(*r, Flag::MainSend) == 0); // captured 0 comes back 0
|
|
CHECK(flagValue(*r, Flag::ShowInTcp) == 1);
|
|
}
|
|
}
|
|
|
|
// -- 4. show-both leaf --------------------------------------------------------
|
|
|
|
static void testShowBothNeverParkedVisibleEverywhere() {
|
|
ViewModeModel vm;
|
|
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
|
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{SB}", "", false});
|
|
// Tag into Design, then pin show-both.
|
|
vm.membership().tag("{SB}", kDesignModeId);
|
|
CHECK(vm.membership().setShowBoth("{SB}", true));
|
|
CHECK(vm.membership().isShowBoth("{SB}"));
|
|
|
|
// Visible in EVERY mode.
|
|
CHECK(visibleHas(vm.visibleTracks(tree, kArrangeModeId), "{SB}"));
|
|
CHECK(visibleHas(vm.visibleTracks(tree, kDesignModeId), "{SB}"));
|
|
CHECK(visibleHas(vm.visibleTracks(tree, "mixdown"), "{SB}"));
|
|
|
|
// Never parked, in any mode — even a mode it isn't tagged into.
|
|
CHECK(!parkTargets(vm.planToggle(tree, kArrangeModeId), "{SB}"));
|
|
CHECK(!parkTargets(vm.planToggle(tree, kDesignModeId), "{SB}"));
|
|
CHECK(!parkTargets(vm.planToggle(tree, "mixdown"), "{SB}"));
|
|
|
|
// Clearing show-both restores normal one-mode parking: now in Arrange it parks.
|
|
CHECK(vm.membership().setShowBoth("{SB}", false));
|
|
CHECK(parkTargets(vm.planToggle(tree, kArrangeModeId), "{SB}"));
|
|
CHECK(!parkTargets(vm.planToggle(tree, kDesignModeId), "{SB}"));
|
|
}
|
|
|
|
// -- 5. Unknown/stale GUID tolerated -----------------------------------------
|
|
|
|
static void testStaleGuidTolerated() {
|
|
ViewModeModel vm;
|
|
// Tag two leaves, but the tree only knows one — the other GUID is stale (its
|
|
// track was deleted / restructured while parked).
|
|
vm.membership().tag("{LIVE}", kDesignModeId);
|
|
vm.membership().tag("{GHOST}", kDesignModeId);
|
|
vm.storeSnapshot("{GHOST}", TrackSnapshot{}); // stale snapshot too
|
|
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{LIVE}", "", false});
|
|
// {GHOST} absent from the tree.
|
|
|
|
// No crash; the stale GUID is simply ignored (prune-safe).
|
|
auto plan = vm.planToggle(tree, kArrangeModeId);
|
|
CHECK(parkTargets(plan, "{LIVE}")); // live leaf still planned
|
|
CHECK(!parkTargets(plan, "{GHOST}")); // stale leaf never emitted
|
|
|
|
// Visibility derivation also ignores the stale GUID without incident.
|
|
auto vis = vm.visibleTracks(tree, kDesignModeId);
|
|
CHECK(visibleHas(vis, "{LIVE}"));
|
|
CHECK(!visibleHas(vis, "{GHOST}"));
|
|
|
|
// An empty tree with tagged members: nothing planned, no crash.
|
|
FolderTree empty;
|
|
auto emptyPlan = vm.planToggle(empty, kDesignModeId);
|
|
CHECK(emptyPlan.park.empty() && emptyPlan.restore.empty());
|
|
}
|
|
|
|
// -- 6. JSON round-trip lossless ---------------------------------------------
|
|
|
|
static void testJsonRoundTrip() {
|
|
ViewModeModel vm;
|
|
// Modes: seeded pair + a third; also an out-of-order ordinal to prove sorting
|
|
// survives round-trip.
|
|
CHECK(vm.modes().add(Mode{"print", "Print \"stem\"\n", 5}));
|
|
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
|
|
|
// Membership: a plain Design leaf, a show-both leaf, an Arrange leaf, and a
|
|
// leaf carrying multiple modes (representable via restore; exercises the set).
|
|
vm.membership().tag("{A}", kArrangeModeId);
|
|
vm.membership().tag("{D}", kDesignModeId);
|
|
vm.membership().tag("{SB}", "mixdown");
|
|
vm.membership().setShowBoth("{SB}", true);
|
|
Membership multi;
|
|
multi.modeIds = {kDesignModeId, "mixdown"};
|
|
multi.showBoth = false;
|
|
CHECK(vm.membership().restore("{MULTI}", multi));
|
|
|
|
// Snapshots: one full, one with a per-FX vector, including the tricky 0-values.
|
|
TrackSnapshot s1; s1.showInTcp = 1; s1.showInMixer = 0; s1.mainSend = 1;
|
|
s1.fxEnable = 0; s1.fxOffline = {1, 0, 1, 1};
|
|
vm.storeSnapshot("{D}", s1);
|
|
TrackSnapshot s2; // all zeros, empty fx vector
|
|
vm.storeSnapshot("{A}", s2);
|
|
|
|
// Active mode set to a non-default.
|
|
CHECK(vm.setActiveMode("mixdown"));
|
|
|
|
std::string json = vm.serialize();
|
|
auto back = ViewModeModel::deserialize(json);
|
|
CHECK(back.has_value());
|
|
CHECK(back && *back == vm);
|
|
// String form is stable across a second round-trip.
|
|
if (back) CHECK(back->serialize() == json);
|
|
|
|
// Spot-check the load-bearing bits survived.
|
|
if (back) {
|
|
CHECK(back->activeModeId() == "mixdown");
|
|
CHECK(back->modes().size() == 4);
|
|
const Mode* print = back->modes().query("print");
|
|
CHECK(print && print->displayName == "Print \"stem\"\n" && print->ordinal == 5);
|
|
CHECK(back->membership().isShowBoth("{SB}"));
|
|
const Membership* mm = back->membership().query("{MULTI}");
|
|
CHECK(mm && mm->modeIds.size() == 2 && mm->modeIds.count("mixdown"));
|
|
const TrackSnapshot* snap = back->snapshot("{D}");
|
|
CHECK(snap && snap->mainSend == 1 && snap->fxEnable == 0);
|
|
CHECK(snap && snap->fxOffline.size() == 4 && snap->fxOffline[1] == 0);
|
|
}
|
|
}
|
|
|
|
static void testEmptyModelRoundTrip() {
|
|
ViewModeModel vm; // default: Arrange + Design seeded, active = Arrange, no members
|
|
std::string json = vm.serialize();
|
|
auto back = ViewModeModel::deserialize(json);
|
|
CHECK(back.has_value());
|
|
CHECK(back && *back == vm);
|
|
|
|
// Lenient empty root ⇒ a default-seeded model.
|
|
auto empty = ViewModeModel::deserialize("{}");
|
|
CHECK(empty.has_value());
|
|
CHECK(empty && empty->modes().size() == 2);
|
|
CHECK(empty && empty->activeModeId() == kArrangeModeId);
|
|
CHECK(empty && empty->membership().empty());
|
|
}
|
|
|
|
static void testMalformedJson() {
|
|
const char* bad[] = {
|
|
"",
|
|
"{",
|
|
"not json",
|
|
"{\"modes\":[",
|
|
"{\"modes\":[{\"id\":\"x\"", // truncated mode
|
|
"{\"activeMode\":\"ghost\"}", // active mode not registered
|
|
"{\"modes\":[{\"id\":\"a\",\"ordinal\":0},{\"id\":\"a\",\"ordinal\":1}]}", // dup id
|
|
"{\"membership\":[{\"guid\":\"\"}]}", // empty guid
|
|
"{\"snapshots\":[{\"showInTcp\":1}]}", // snapshot without guid
|
|
"{\"snapshots\":[{\"guid\":\"x\",\"fxOffline\":[1,notanumber]}]}",
|
|
"{\"modes\":[]}trailing", // trailing garbage
|
|
};
|
|
for (const char* j : bad) {
|
|
auto r = ViewModeModel::deserialize(j);
|
|
CHECK(!r.has_value());
|
|
}
|
|
}
|
|
|
|
// -- 7. planToggle park path: fxOffline is empty (shell-expands-FX contract) --
|
|
//
|
|
// planToggle calls makeParkPlan(guid, /*fxCount=*/0) for each inactive leaf.
|
|
// The D2 shell is responsible for expanding per-FX offline ops using
|
|
// TrackFX_GetCount — the pure model has no access to REAPER FX counts at plan
|
|
// time. This test pins that contract so a regression that passes a non-zero
|
|
// count (and emits FX ops prematurely) is caught immediately.
|
|
// The direct makeParkPlan(guid, 3) path (non-zero fxCount) is covered by
|
|
// testRestoreRoundTripSnapshotValues above.
|
|
|
|
static void testPlanToggleParkHasEmptyFxOffline() {
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{LEAF}", "", false});
|
|
vm.membership().tag("{LEAF}", kDesignModeId);
|
|
|
|
// Toggle to Arrange: {LEAF} is inactive ⇒ park plan emitted.
|
|
auto plan = vm.planToggle(tree, kArrangeModeId);
|
|
CHECK(plan.park.size() == 1);
|
|
// The park plan must have an empty fxOffline — the shell expands FX ops.
|
|
CHECK(plan.park[0].fxOffline.empty());
|
|
// Scalar flags must still be present (the four park zeros).
|
|
CHECK(plan.park[0].flags.size() == 4);
|
|
}
|
|
|
|
// -- 7b. Untagged leaves are managed by the mode system ----------------------
|
|
//
|
|
// The core semantic fix: an untagged leaf is an Arrange member. planToggle must
|
|
// enumerate EVERY leaf in the tree (not just membership_.all()), so an untagged
|
|
// leaf — absent from the membership index — parks in every non-Arrange mode and
|
|
// restores in Arrange, identically to a tagged leaf. Parents and show-both leaves
|
|
// remain never-parked. Tagged-leaf behavior is unchanged.
|
|
|
|
static void testUntaggedLeavesManagedByModeSystem() {
|
|
ViewModeModel vm;
|
|
|
|
// A tree of leaves NONE of which are in the membership index (all untagged),
|
|
// plus a parent folder and a show-both leaf to prove they stay untouched.
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{P}", "", /*isParent=*/true});
|
|
tree.nodes.push_back(FolderNode{"{U1}", "{P}", false}); // untagged leaf
|
|
tree.nodes.push_back(FolderNode{"{U2}", "{P}", false}); // untagged leaf
|
|
tree.nodes.push_back(FolderNode{"{SB}", "", false}); // show-both leaf
|
|
vm.membership().setShowBoth("{SB}", true);
|
|
|
|
// (iii) Enumeration covers leaves absent from the membership index: {U1}/{U2}
|
|
// are NOT in membership_.all(), yet the planner reaches them.
|
|
CHECK(vm.membership().query("{U1}") == nullptr);
|
|
CHECK(vm.membership().query("{U2}") == nullptr);
|
|
|
|
// (i) Toggling to Design (non-Arrange): every untagged leaf is parked.
|
|
auto toDesign = vm.planToggle(tree, kDesignModeId);
|
|
CHECK(parkTargets(toDesign, "{U1}"));
|
|
CHECK(parkTargets(toDesign, "{U2}"));
|
|
|
|
// (iv) The parent and the show-both leaf are NEVER parked, in either mode.
|
|
CHECK(!parkTargets(toDesign, "{P}"));
|
|
CHECK(!parkTargets(toDesign, "{SB}"));
|
|
|
|
// (ii) Toggling to Arrange: the untagged leaves are Arrange members ⇒ active and
|
|
// NOT parked. (No snapshot stored yet ⇒ no restore op either; just not parked.)
|
|
auto toArrange = vm.planToggle(tree, kArrangeModeId);
|
|
CHECK(!parkTargets(toArrange, "{U1}"));
|
|
CHECK(!parkTargets(toArrange, "{U2}"));
|
|
CHECK(restoreFor(toArrange, "{U1}") == nullptr);
|
|
CHECK(!parkTargets(toArrange, "{P}"));
|
|
CHECK(!parkTargets(toArrange, "{SB}"));
|
|
|
|
// (vi) Restore-from-snapshot fidelity for a previously-parked UNTAGGED leaf:
|
|
// an untagged leaf parked while in Design carries a snapshot; toggling back to
|
|
// Arrange restores it from that snapshot verbatim, never a hardcoded default.
|
|
TrackSnapshot snap;
|
|
snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 0; snap.fxEnable = 1;
|
|
snap.fxOffline = {0, 1};
|
|
vm.storeSnapshot("{U1}", snap); // as the shell would, before parking it in Design
|
|
auto backToArrange = vm.planToggle(tree, kArrangeModeId);
|
|
const TrackPlan* r = restoreFor(backToArrange, "{U1}");
|
|
CHECK(r != nullptr);
|
|
if (r) {
|
|
CHECK(flagValue(*r, Flag::ShowInTcp) == 1);
|
|
CHECK(flagValue(*r, Flag::ShowInMixer) == 1);
|
|
CHECK(flagValue(*r, Flag::MainSend) == 0); // captured 0 comes back 0
|
|
CHECK(flagValue(*r, Flag::FxEnable) == 1);
|
|
CHECK(r->fxOffline.size() == 2);
|
|
CHECK(r->fxOffline[0].offline == false);
|
|
CHECK(r->fxOffline[1].offline == true);
|
|
}
|
|
}
|
|
|
|
// (v) Tagged-leaf park/restore behavior is unchanged after the untagged fix: a leaf
|
|
// tagged Design parks in Arrange and is active (not parked) in Design, and a mix of
|
|
// tagged + untagged leaves each land on the correct side of the toggle.
|
|
|
|
static void testTaggedLeafBehaviorUnchangedWithUntagged() {
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{DES}", "", false}); // tagged into Design
|
|
tree.nodes.push_back(FolderNode{"{UNT}", "", false}); // untagged ⇒ Arrange
|
|
vm.membership().tag("{DES}", kDesignModeId);
|
|
|
|
// In Design: {DES} active (not parked); {UNT} inactive ⇒ parked.
|
|
auto design = vm.planToggle(tree, kDesignModeId);
|
|
CHECK(!parkTargets(design, "{DES}"));
|
|
CHECK(parkTargets(design, "{UNT}"));
|
|
|
|
// In Arrange: {DES} inactive ⇒ parked; {UNT} active (not parked).
|
|
auto arrange = vm.planToggle(tree, kArrangeModeId);
|
|
CHECK(parkTargets(arrange, "{DES}"));
|
|
CHECK(!parkTargets(arrange, "{UNT}"));
|
|
}
|
|
|
|
// -- 10. reconcile: prune orphaned snapshots on track delete -----------------
|
|
//
|
|
// Closes the "reconcile on delete/restructure" hardening item. reconcile prunes a
|
|
// snapshot whose GUID is not in the live set (its track was deleted while parked),
|
|
// preventing both the slow snapshot leak and an incorrect restore if REAPER reuses
|
|
// the GUID. Membership is deliberately KEPT (undo-delete restores the same GUID, so
|
|
// dropping the tag would silently lose it). A full live set is a no-op — this is why
|
|
// folder RESTRUCTURE, which leaves every GUID live, needs no special handling.
|
|
|
|
static void testReconcilePrunesOrphanedSnapshots() {
|
|
ViewModeModel vm;
|
|
// Two parked tracks (both snapshotted + tagged); {DEL} is about to be deleted.
|
|
vm.membership().tag("{LIVE}", kDesignModeId);
|
|
vm.membership().tag("{DEL}", kDesignModeId);
|
|
TrackSnapshot sLive; sLive.showInTcp = 1; sLive.fxOffline = {0, 1};
|
|
TrackSnapshot sDel; sDel.showInTcp = 1; sDel.fxEnable = 1;
|
|
vm.storeSnapshot("{LIVE}", sLive);
|
|
vm.storeSnapshot("{DEL}", sDel);
|
|
CHECK(vm.snapshots().size() == 2);
|
|
|
|
// {DEL} is deleted from the project ⇒ absent from the live GUID set.
|
|
std::set<std::string> liveGuids{"{LIVE}"};
|
|
std::size_t removed = vm.reconcile(liveGuids);
|
|
|
|
// The orphaned snapshot is pruned; the live one is retained verbatim.
|
|
CHECK(removed == 1);
|
|
CHECK(vm.snapshot("{DEL}") == nullptr);
|
|
const TrackSnapshot* kept = vm.snapshot("{LIVE}");
|
|
CHECK(kept != nullptr);
|
|
if (kept) CHECK(kept->showInTcp == 1 && kept->fxOffline.size() == 2);
|
|
|
|
// Membership is NOT pruned — the deleted GUID keeps its Design tag so an
|
|
// undo-delete (which restores the same GUID) brings the track back correctly
|
|
// tagged. This is the load-bearing design call.
|
|
CHECK(vm.membership().query("{DEL}") != nullptr);
|
|
CHECK(vm.leafBelongsToMode("{DEL}", kDesignModeId));
|
|
CHECK(vm.membership().query("{LIVE}") != nullptr);
|
|
}
|
|
|
|
static void testReconcileFullLiveSetIsNoOp() {
|
|
// The restructure case: tracks moved between folders but none deleted ⇒ every
|
|
// GUID stays live ⇒ reconcile prunes nothing.
|
|
ViewModeModel vm;
|
|
vm.storeSnapshot("{A}", TrackSnapshot{});
|
|
vm.storeSnapshot("{B}", TrackSnapshot{});
|
|
vm.storeSnapshot("{C}", TrackSnapshot{});
|
|
|
|
std::set<std::string> liveGuids{"{A}", "{B}", "{C}"};
|
|
std::size_t removed = vm.reconcile(liveGuids);
|
|
|
|
CHECK(removed == 0);
|
|
CHECK(vm.snapshots().size() == 3);
|
|
CHECK(vm.snapshot("{A}") && vm.snapshot("{B}") && vm.snapshot("{C}"));
|
|
|
|
// A superset of live GUIDs (tracks exist that were never parked) is also a no-op:
|
|
// reconcile only ever removes, never adds.
|
|
std::set<std::string> superset{"{A}", "{B}", "{C}", "{NEVER_PARKED}"};
|
|
CHECK(vm.reconcile(superset) == 0);
|
|
CHECK(vm.snapshots().size() == 3);
|
|
|
|
// Empty live set (whole project emptied) prunes everything.
|
|
CHECK(vm.reconcile(std::set<std::string>{}) == 3);
|
|
CHECK(vm.snapshots().empty());
|
|
}
|
|
|
|
static void testReconcileThenReparkLifecycleIntact() {
|
|
// No regression to the park/restore lifecycle: after reconcile prunes a deleted
|
|
// track's snapshot, a still-live tagged leaf toggled back to its mode still
|
|
// restores from its retained snapshot, and a re-park recaptures fresh state.
|
|
ViewModeModel vm;
|
|
FolderTree tree;
|
|
tree.nodes.push_back(FolderNode{"{DES}", "", false});
|
|
vm.membership().tag("{DES}", kDesignModeId);
|
|
|
|
TrackSnapshot snap; snap.showInTcp = 1; snap.mainSend = 0; snap.fxEnable = 1;
|
|
vm.storeSnapshot("{DES}", snap); // parked while in Arrange
|
|
vm.storeSnapshot("{ORPHAN}", TrackSnapshot{}); // a since-deleted parked track
|
|
|
|
// Reconcile with {DES} live, {ORPHAN} gone.
|
|
CHECK(vm.reconcile(std::set<std::string>{"{DES}"}) == 1);
|
|
CHECK(vm.snapshot("{ORPHAN}") == nullptr);
|
|
|
|
// Toggle back to Design: {DES} restores from its retained snapshot verbatim.
|
|
auto toDesign = vm.planToggle(tree, kDesignModeId);
|
|
const TrackPlan* r = restoreFor(toDesign, "{DES}");
|
|
CHECK(r != nullptr);
|
|
if (r) {
|
|
CHECK(flagValue(*r, Flag::ShowInTcp) == 1);
|
|
CHECK(flagValue(*r, Flag::MainSend) == 0);
|
|
CHECK(flagValue(*r, Flag::FxEnable) == 1);
|
|
}
|
|
}
|
|
|
|
// -- 8. nextModeId cycle (D4 toggle helper) ----------------------------------
|
|
|
|
static void testNextModeIdCycles() {
|
|
ModeRegistry seeded; // Arrange(0) + Design(1)
|
|
|
|
// Two-mode cycle: Arrange -> Design -> Arrange (wraps past the last).
|
|
CHECK(nextModeId(seeded, kArrangeModeId) == kDesignModeId);
|
|
CHECK(nextModeId(seeded, kDesignModeId) == kArrangeModeId);
|
|
|
|
// Extends to cycle-through-all with >2 modes, in ordinal order.
|
|
ModeRegistry three;
|
|
CHECK(three.add(Mode{"mixdown", "Mixdown", 2}));
|
|
CHECK(nextModeId(three, kArrangeModeId) == kDesignModeId);
|
|
CHECK(nextModeId(three, kDesignModeId) == "mixdown");
|
|
CHECK(nextModeId(three, "mixdown") == kArrangeModeId); // wraps
|
|
|
|
// Unknown/stale current id -> first mode (a sane home, not "").
|
|
CHECK(nextModeId(seeded, "does-not-exist") == kArrangeModeId);
|
|
|
|
// Empty registry -> "" (nothing to cycle to).
|
|
ModeRegistry empty = ModeRegistry::makeEmpty();
|
|
CHECK(nextModeId(empty, kArrangeModeId).empty());
|
|
}
|
|
|
|
// -- 9. Nested-folder toggle: snapshot lifecycle survives a re-park -----------
|
|
//
|
|
// Regression for the in-DAW bug: a nested structure (root parent > intermediate
|
|
// parent > leaves), ONE leaf tagged Design, toggled twice, hid ALL leaves for good.
|
|
//
|
|
// Root cause: the D2 shell's park loop stored a fresh snapshot on EVERY park. If a
|
|
// track is parked again while already parked — which happens on any redundant
|
|
// same-mode re-apply (a re-activate of the current mode, the segmented switch, a
|
|
// tag/untag reapply) — the second snapshot captures the track's already-HIDDEN
|
|
// flags, so a later restore returns it to hidden and the leaf vanishes permanently.
|
|
//
|
|
// The pure model can't run the REAPER shell, but the corruption is entirely in the
|
|
// snapshot store/clear lifecycle, which is model state. This harness mirrors
|
|
// applyMode's park/restore loops faithfully: it maintains a per-track live "visible"
|
|
// flag (proxy for B_SHOWINTCP), and for each toggle it runs planToggle, then for
|
|
// each park op it (a) snapshots the live flag BEFORE parking — GUARDED to only
|
|
// capture when no snapshot exists yet, exactly like the fixed shell — and (b) hides
|
|
// the track; for each restore op it writes the snapshot's flag back and clears the
|
|
// snapshot. Asserting the live flags after the sequence proves the fix; a parallel
|
|
// UNGUARDED run reproduces the original corruption.
|
|
|
|
namespace {
|
|
|
|
// A tiny stand-in for the shell's live REAPER flag reads/writes: guid -> visible.
|
|
using LiveFlags = std::map<std::string, int>;
|
|
|
|
// Runs one applyMode-equivalent toggle against `vm` + `live`. `guard` selects the
|
|
// fixed (snapshot-once) behavior vs. the original buggy (snapshot-every-park) one.
|
|
// Returns nothing; mutates `vm` snapshots/active mode and `live` flags in place,
|
|
// exactly mirroring view.cpp's park then restore then setActiveMode ordering.
|
|
void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tree,
|
|
const std::string& target, bool guard) {
|
|
TogglePlan plan = vm.planToggle(tree, target);
|
|
|
|
// PARK: snapshot-before-hide (guarded or not), then hide.
|
|
for (const auto& tp : plan.park) {
|
|
if (tp.flags.empty()) continue;
|
|
const std::string& guid = tp.flags.front().guid;
|
|
if (!guard || vm.snapshot(guid) == nullptr) {
|
|
TrackSnapshot snap;
|
|
snap.showInTcp = live[guid]; // capture the LIVE visible flag
|
|
vm.storeSnapshot(guid, snap);
|
|
}
|
|
live[guid] = 0; // park hides it
|
|
}
|
|
|
|
// RESTORE: write snapshot flag back, then drop the snapshot.
|
|
for (const auto& tp : plan.restore) {
|
|
if (tp.flags.empty()) continue;
|
|
const std::string& guid = tp.flags.front().guid;
|
|
if (const TrackSnapshot* snap = vm.snapshot(guid))
|
|
live[guid] = snap->showInTcp; // restore the captured visible flag
|
|
vm.clearSnapshot(guid);
|
|
}
|
|
|
|
vm.setActiveMode(target);
|
|
}
|
|
|
|
// The nested tree Daniel reported: root parent {R} > intermediate parent {I} >
|
|
// leaves {L1} (tagged Design) and {L2}, {L3} (untagged => Arrange).
|
|
FolderTree nestedTree() {
|
|
FolderTree t;
|
|
t.nodes.push_back(FolderNode{"{R}", "", /*isParent=*/true});
|
|
t.nodes.push_back(FolderNode{"{I}", "{R}", /*isParent=*/true});
|
|
t.nodes.push_back(FolderNode{"{L1}", "{I}", false});
|
|
t.nodes.push_back(FolderNode{"{L2}", "{I}", false});
|
|
t.nodes.push_back(FolderNode{"{L3}", "{I}", false});
|
|
return t;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
static void testNestedToggleSnapshotSurvivesRepark() {
|
|
const FolderTree tree = nestedTree();
|
|
|
|
// Structural preconditions: buildFolderTree-shaped 3-level tree is classified
|
|
// correctly and the intermediate node is a parent (never parked), and its
|
|
// visibility derives from its descendant leaves.
|
|
{
|
|
ViewModeModel probe;
|
|
probe.membership().tag("{L1}", kDesignModeId);
|
|
// {I} and {R} are parents => never parked, in either mode.
|
|
auto pd = probe.planToggle(tree, kDesignModeId);
|
|
auto pa = probe.planToggle(tree, kArrangeModeId);
|
|
CHECK(!parkTargets(pd, "{I}") && !parkTargets(pd, "{R}"));
|
|
CHECK(!parkTargets(pa, "{I}") && !parkTargets(pa, "{R}"));
|
|
// Design: {L1} (its only Design leaf) is visible => {I} and {R} derive visible.
|
|
auto vd = probe.visibleTracks(tree, kDesignModeId);
|
|
CHECK(visibleHas(vd, "{I}") && visibleHas(vd, "{R}"));
|
|
// Arrange: {L2}/{L3} are Arrange leaves => {I} and {R} still derive visible.
|
|
auto va = probe.visibleTracks(tree, kArrangeModeId);
|
|
CHECK(visibleHas(va, "{I}") && visibleHas(va, "{R}"));
|
|
}
|
|
|
|
// GUARDED (fixed shell): drive the reported sequence and assert every leaf is
|
|
// returned to its correct visibility. Include a redundant same-mode re-apply
|
|
// (the real trigger) between toggles to force a park-while-parked.
|
|
{
|
|
ViewModeModel vm;
|
|
vm.membership().tag("{L1}", kDesignModeId);
|
|
LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}}; // all visible at start
|
|
|
|
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true);
|
|
// In Design: {L2}/{L3} parked (hidden), {L1} visible.
|
|
CHECK(live["{L1}"] == 1 && live["{L2}"] == 0 && live["{L3}"] == 0);
|
|
|
|
// Redundant re-apply of the CURRENT mode (segmented switch / re-activate).
|
|
// With the guard this must NOT recapture the now-hidden snapshots.
|
|
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true);
|
|
CHECK(live["{L1}"] == 1 && live["{L2}"] == 0 && live["{L3}"] == 0);
|
|
|
|
simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true);
|
|
// Back in Arrange: {L2}/{L3} RESTORED to visible; {L1} parked.
|
|
CHECK(live["{L2}"] == 1 && live["{L3}"] == 1 && live["{L1}"] == 0);
|
|
|
|
// A second full round to match "toggle twice" exactly.
|
|
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true);
|
|
CHECK(live["{L1}"] == 1 && live["{L2}"] == 0 && live["{L3}"] == 0);
|
|
simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true);
|
|
CHECK(live["{L2}"] == 1 && live["{L3}"] == 1 && live["{L1}"] == 0);
|
|
}
|
|
|
|
// UNGUARDED (original shell): the same sequence corrupts — the redundant re-apply
|
|
// recaptures {L2}/{L3}'s hidden flags, so toggling back to Arrange restores them
|
|
// to HIDDEN and they never return. This pins the exact regression the guard fixes.
|
|
{
|
|
ViewModeModel vm;
|
|
vm.membership().tag("{L1}", kDesignModeId);
|
|
LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}};
|
|
|
|
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/false);
|
|
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/false); // re-park corrupts
|
|
simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/false);
|
|
|
|
// The bug: Arrange leaves stay hidden after returning to Arrange.
|
|
CHECK(live["{L2}"] == 0 && live["{L3}"] == 0);
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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.2b Last-writer-wins ownership replace (round-trip) --------------------
|
|
//
|
|
// setManual then setManaged on the SAME (guid, laneKey) must leave EXACTLY ONE
|
|
// managed entry — the ownership record is replaced, not accumulated. Guards the
|
|
// "retag a lane the tool now owns" contract and its persistence: the replace must
|
|
// survive a serialize/deserialize round-trip with no stray manual duplicate.
|
|
static void testLaneOwnershipLastWriterWins() {
|
|
ViewModeModel vm;
|
|
|
|
// Manual first, then managed on the same lane — the managed write replaces.
|
|
CHECK(vm.lanes().setManual("{T}", "l0"));
|
|
CHECK(vm.lanes().setManaged("{T}", "l0", kDesignModeId));
|
|
CHECK(vm.lanes().size() == 1); // one entry, not two
|
|
const LaneOwnership* o = vm.lanes().query("{T}", "l0");
|
|
CHECK(o && o->isManaged() && *o->managedMode == kDesignModeId);
|
|
|
|
// The reverse also replaces: managed -> manual leaves exactly one manual entry.
|
|
CHECK(vm.lanes().setManual("{T}", "l0"));
|
|
CHECK(vm.lanes().size() == 1);
|
|
const LaneOwnership* m = vm.lanes().query("{T}", "l0");
|
|
CHECK(m && m->isManual());
|
|
|
|
// Back to managed, then round-trip: exactly one managed entry survives, no stray
|
|
// manual duplicate resurrected by (de)serialization.
|
|
CHECK(vm.lanes().setManaged("{T}", "l0", kArrangeModeId));
|
|
auto back = ViewModeModel::deserialize(vm.serialize());
|
|
CHECK(back.has_value());
|
|
if (back) {
|
|
CHECK(back->lanes().size() == 1);
|
|
const LaneOwnership* r = back->lanes().query("{T}", "l0");
|
|
CHECK(r && r->isManaged() && *r->managedMode == kArrangeModeId);
|
|
}
|
|
}
|
|
|
|
// -- 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.7 Lane minting decision (Wave 3) -------------------------------------
|
|
//
|
|
// planLaneMinting: a track with content of only ONE mode is NOT split (D1 unchanged);
|
|
// a track that holds >1 mode's content mints one managed lane per mode and assigns EVERY
|
|
// managed-eligible item (incl. pre-existing) to its mode's lane; manual-lane items are
|
|
// exempt (never counted, never reassigned, their lane never minted-over).
|
|
|
|
static bool hasMint(const LaneMintPlan& p, const std::string& track,
|
|
const std::string& mode) {
|
|
for (const auto& m : p.mints)
|
|
if (m.trackGuid == track && m.modeId == mode &&
|
|
m.laneKey == laneNameForMode(mode))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
static bool hasAssign(const LaneMintPlan& p, const std::string& item,
|
|
const std::string& track, const std::string& mode) {
|
|
for (const auto& a : p.assigns)
|
|
if (a.itemGuid == item && a.trackGuid == track &&
|
|
a.laneKey == laneNameForMode(mode))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
static int splitLaneCount(const LaneMintPlan& p, const std::string& track) {
|
|
for (const auto& s : p.splits)
|
|
if (s.trackGuid == track) return s.laneCount;
|
|
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.
|
|
std::vector<LaneTrack> tracks{
|
|
LaneTrack{"{T}", {
|
|
LaneItem{"{i1}", kArrangeModeId, false},
|
|
LaneItem{"{i2}", kArrangeModeId, false},
|
|
}},
|
|
};
|
|
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(bareModel(), emptyTree(), {LaneTrack{"{E}", {}}}).empty());
|
|
}
|
|
|
|
static void testLaneMintingMultiModeMintsAndAssignsAll() {
|
|
// A track that gained a second mode's item: it now holds Arrange + Design content.
|
|
// Both modes get a managed lane; ALL managed-eligible items are assigned — including
|
|
// the pre-existing Arrange item (retroactive lane assignment), not only the new one.
|
|
std::vector<LaneTrack> tracks{
|
|
LaneTrack{"{T}", {
|
|
LaneItem{"{arr1}", kArrangeModeId, false}, // pre-existing single-mode item
|
|
LaneItem{"{arr2}", kArrangeModeId, false}, // pre-existing single-mode item
|
|
LaneItem{"{des1}", kDesignModeId, false}, // the newly-added 2nd-mode item
|
|
}},
|
|
};
|
|
const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks);
|
|
CHECK(!plan.empty());
|
|
|
|
// One split with two managed lanes (one per involved mode).
|
|
CHECK(splitLaneCount(plan, "{T}") == 2);
|
|
CHECK(plan.mints.size() == 2);
|
|
CHECK(hasMint(plan, "{T}", kArrangeModeId));
|
|
CHECK(hasMint(plan, "{T}", kDesignModeId));
|
|
|
|
// EVERY managed-eligible item assigned to its mode's lane — pre-existing included.
|
|
CHECK(plan.assigns.size() == 3);
|
|
CHECK(hasAssign(plan, "{arr1}", "{T}", kArrangeModeId)); // retroactive
|
|
CHECK(hasAssign(plan, "{arr2}", "{T}", kArrangeModeId)); // retroactive
|
|
CHECK(hasAssign(plan, "{des1}", "{T}", kDesignModeId)); // the new item
|
|
}
|
|
|
|
static void testLaneMintingManualLaneExempt() {
|
|
// A track with Arrange + Design managed-eligible content AND an item the user placed
|
|
// on a manual lane: the manual item is EXEMPT — it is not counted, not assigned, and
|
|
// its lane is never minted-over. The managed split proceeds around it.
|
|
std::vector<LaneTrack> tracks{
|
|
LaneTrack{"{T}", {
|
|
LaneItem{"{arr}", kArrangeModeId, false},
|
|
LaneItem{"{des}", kDesignModeId, false},
|
|
LaneItem{"{comp}", kDesignModeId, /*onManualLane=*/true}, // user's comp take
|
|
}},
|
|
};
|
|
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);
|
|
CHECK(plan.assigns.size() == 2);
|
|
CHECK(hasAssign(plan, "{arr}", "{T}", kArrangeModeId));
|
|
CHECK(hasAssign(plan, "{des}", "{T}", kDesignModeId));
|
|
for (const auto& a : plan.assigns)
|
|
CHECK(a.itemGuid != "{comp}"); // manual-lane item NEVER reassigned
|
|
|
|
// Manual-lane exemption can also SUPPRESS a split: if the ONLY second mode is
|
|
// supplied by a manual-lane item, the managed-eligible items are single-mode ⇒ NO
|
|
// split (the user's manual lane is not a mode the tool separates).
|
|
std::vector<LaneTrack> t2{
|
|
LaneTrack{"{U}", {
|
|
LaneItem{"{a}", kArrangeModeId, false},
|
|
LaneItem{"{d}", kDesignModeId, /*onManualLane=*/true}, // only 2nd mode, exempt
|
|
}},
|
|
};
|
|
// managed-eligible content is single-mode ⇒ no split (leaf, no tree derivation).
|
|
CHECK(planLaneMinting(bareModel(), emptyTree(), t2).empty());
|
|
}
|
|
|
|
static void testLaneMintingThreeModesAndOwnershipKeys() {
|
|
// N-mode proof + the ownership writes the shell will apply: three modes on one track
|
|
// mint three managed lanes, each keyed by its durable name (== laneNameForMode), each
|
|
// owning the right mode. Applying the mints to a real ownership index reproduces the
|
|
// managed classification the toggle planner then gates on.
|
|
ViewModeModel vm;
|
|
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
|
|
|
std::vector<LaneTrack> tracks{
|
|
LaneTrack{"{T}", {
|
|
LaneItem{"{a}", kArrangeModeId, false},
|
|
LaneItem{"{d}", kDesignModeId, false},
|
|
LaneItem{"{m}", "mixdown", false},
|
|
}},
|
|
};
|
|
// 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);
|
|
|
|
// Apply the mints exactly as the shell does — record managed ownership — then assert
|
|
// the ownership index classifies each lane managed-for-its-mode and the toggle
|
|
// planner would drive exactly these three lanes (managed-only invariant intact).
|
|
for (const auto& m : plan.mints)
|
|
CHECK(vm.lanes().setManaged(m.trackGuid, m.laneKey, m.modeId));
|
|
CHECK(vm.lanes().size() == 3);
|
|
CHECK(vm.lanes().isManaged("{T}", laneNameForMode(kArrangeModeId)));
|
|
CHECK(vm.lanes().isManaged("{T}", laneNameForMode(kDesignModeId)));
|
|
CHECK(vm.lanes().isManaged("{T}", laneNameForMode("mixdown")));
|
|
CHECK(vm.lanesTouchedByToggle().size() == 3);
|
|
|
|
// Persist round-trip of the just-minted lane-split project: the ownership index (and
|
|
// the whole model) survives serialize/deserialize unchanged, so a saved lane-split
|
|
// project restores its managed classification without re-minting.
|
|
auto back = ViewModeModel::deserialize(vm.serialize());
|
|
CHECK(back.has_value());
|
|
CHECK(back && *back == vm);
|
|
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 splits
|
|
// {F} and lanes {own} onto the Design lane — so it hides+silences whenever Arrange is
|
|
// active. LAZY-MINT: {F} mints ONLY the Design lane (holding the item), NOT an empty
|
|
// reserved Arrange lane — confinement holds via C_LANEPLAYS=0 on the lone Design lane when
|
|
// 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<LaneTrack> 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());
|
|
|
|
// LAZY-MINT: ONE lane only — the Design lane that holds the item. No empty reserved
|
|
// Arrange lane is minted, even though {F} is derived-visible in Arrange. The Arrange
|
|
// lane appears on demand when an Arrange item first lands on {F}.
|
|
CHECK(splitLaneCount(plan, "{F}") == 1); // Design lane only — no reserved lane
|
|
CHECK(plan.mints.size() == 1);
|
|
CHECK(hasMint(plan, "{F}", kDesignModeId)); // Design lane (holds the item)
|
|
CHECK(!hasMint(plan, "{F}", kArrangeModeId)); // NO empty reserved Arrange lane
|
|
|
|
// The own item is confined to its tagged (Design) lane — the exact hide-in-Arrange fix.
|
|
// With only the Design lane present, toggling to Arrange sets its C_LANEPLAYS to 0, so
|
|
// the item hides+silences and the track reads as an empty normal track (no leak).
|
|
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)));
|
|
}
|
|
|
|
// LAZY-MINT confinement proof. Same single-own-mode / dual-visibility folder, but instead
|
|
// of asserting the mint COUNT we prove the FUNCTIONAL confinement the lazy split preserves:
|
|
// apply the minted lane's ownership to a live model, then drive the toggle planner and show
|
|
// the lone Design lane SILENCES when Arrange is active (C_LANEPLAYS = 0). That is the whole
|
|
// point — a single managed lane still hides its item in every other mode, so removing the
|
|
// empty reserved Arrange lane costs nothing functionally. Fails if the split ever leaves the
|
|
// Design item audible in Arrange (the leak the D2 fix closed) or mints a spurious lane.
|
|
static void testLaneMintingLazySingleLaneStillConfines() {
|
|
ViewModeModel vm;
|
|
vm.membership().tag("{LD}", kDesignModeId); // Design leaf ⇒ folder visible in Design
|
|
// {LA} untagged ⇒ Arrange member ⇒ folder ALSO derived-visible in Arrange.
|
|
vm.membership().tag("{own}", kDesignModeId); // the folder's one own 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});
|
|
|
|
std::vector<LaneTrack> tracks{
|
|
LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}},
|
|
};
|
|
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
|
|
|
|
// Exactly one lane minted (the Design lane) — no empty reserved Arrange lane.
|
|
CHECK(plan.mints.size() == 1);
|
|
CHECK(hasMint(plan, "{F}", kDesignModeId));
|
|
|
|
// Apply the mint's ownership exactly as the shell does, then drive the toggle planner.
|
|
for (const auto& m : plan.mints)
|
|
CHECK(vm.lanes().setManaged(m.trackGuid, m.laneKey, m.modeId));
|
|
CHECK(vm.lanes().size() == 1); // one managed lane on {F}, not two
|
|
|
|
const std::string designLane = laneNameForMode(kDesignModeId);
|
|
|
|
// Active = Design: the lone Design lane PLAYS (item visible+audible in its own mode).
|
|
const auto design = vm.planToggle(tree, kDesignModeId);
|
|
CHECK(lanePlaysFor(design, "{F}", designLane) == kLanePlaysExclusive);
|
|
|
|
// Active = Arrange: the lone Design lane SILENCES — with no lane playing, the track
|
|
// reads as an empty normal track and the Design item does NOT leak. This is the
|
|
// confinement guarantee that lets us drop the reserved Arrange lane.
|
|
const auto arrange = vm.planToggle(tree, kArrangeModeId);
|
|
CHECK(lanePlaysFor(arrange, "{F}", designLane) == kLaneSilent);
|
|
}
|
|
|
|
// 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<LaneTrack> 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<LaneTrack> 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<LaneTrack> 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<LaneTrack> 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<LaneTrack> tracks{LaneTrack{"{F}", {}}}; // no own media
|
|
CHECK(planLaneMinting(vm, tree, tracks).empty());
|
|
}
|
|
|
|
// -- 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();
|
|
testParentOwnMembershipVisibility();
|
|
testRestoreRoundTripSnapshotValues();
|
|
testShowBothNeverParkedVisibleEverywhere();
|
|
testStaleGuidTolerated();
|
|
testJsonRoundTrip();
|
|
testEmptyModelRoundTrip();
|
|
testMalformedJson();
|
|
testPlanToggleParkHasEmptyFxOffline();
|
|
testUntaggedLeavesManagedByModeSystem();
|
|
testTaggedLeafBehaviorUnchangedWithUntagged();
|
|
testNestedToggleSnapshotSurvivesRepark();
|
|
testReconcilePrunesOrphanedSnapshots();
|
|
testReconcileFullLiveSetIsNoOp();
|
|
testReconcileThenReparkLifecycleIntact();
|
|
testNextModeIdCycles();
|
|
|
|
// D2 two-canvas lane extension
|
|
testLaneModeStateAndPlayValues();
|
|
testLaneOwnershipIndex();
|
|
testLaneOwnershipLastWriterWins();
|
|
testManagedOnlyPlannerAndQuery();
|
|
testAutoTagDecision();
|
|
testLaneMintingSingleModeNoSplit();
|
|
testLaneMintingMultiModeMintsAndAssignsAll();
|
|
testLaneMintingManualLaneExempt();
|
|
testLaneMintingThreeModesAndOwnershipKeys();
|
|
testLaneMintingFolderDerivedVisibleSplitsOwnMedia();
|
|
testLaneMintingLazySingleLaneStillConfines();
|
|
testLaneMintingFolderOwnItemsSpanBothModes();
|
|
testLaneMintingShowBothNotForceSplit();
|
|
testLaneMintingSingleModeLeafVisibleOnceNoSplit();
|
|
testLaneMintingEmptyFolderNotSplit();
|
|
testLaneJsonRoundTrip();
|
|
testLaneMalformedJson();
|
|
|
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
|
return g_fail ? 1 : 0;
|
|
}
|