Ψ-W1-T2: disjoint per-mode solo surfaces and a playback-gated mode switch

Solo is cached, cleared and replayed per mode on a real switch only; the switch is refused visibly while the transport runs. The footer segment now routes through the activate actions, so a panel switch finally persists.
This commit is contained in:
2026-08-01 19:43:18 -04:00
parent 8bf6841f7b
commit 9c234c2e6b
23 changed files with 811 additions and 49 deletions
+89 -1
View File
@@ -67,7 +67,7 @@ static void testSerializeGoldenLiteral() {
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[],"
"\"snapshots\":[],\"lanes\":[]}");
"\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}");
}
// -- 1. N-mode proven --------------------------------------------------------
@@ -1800,6 +1800,88 @@ static void testLaneMalformedJson() {
}
}
// -- Per-mode solo cache: persistence + reconcile participation ---------------
static void testSoloCacheJsonRoundTrip() {
ViewModeModel vm;
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
vm.membership().tag("{T}", kDesignModeId);
vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {0, 1}});
CHECK(vm.lanes().setManaged("{T}", "lane:0", kArrangeModeId));
// Every non-zero I_SOLO variant, across more than one mode, plus a GUID that
// exercises the string escaper.
CHECK(vm.soloCache().store(kArrangeModeId, {{"{A}", 1}, {"{B\"q\"}", 2}}));
CHECK(vm.soloCache().store("mixdown", {{"{C}", 5}, {"{D}", 6}}));
CHECK(vm.setActiveMode(kDesignModeId));
const 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) {
const std::map<std::string, int>* arrange = back->soloCache().query(kArrangeModeId);
CHECK(arrange != nullptr);
CHECK(arrange && arrange->at("{A}") == 1);
CHECK(arrange && arrange->at("{B\"q\"}") == 2);
const std::map<std::string, int>* mix = back->soloCache().query("mixdown");
CHECK(mix != nullptr);
CHECK(mix && mix->at("{C}") == 5);
CHECK(mix && mix->at("{D}") == 6);
CHECK(back->soloCache().query(kDesignModeId) == nullptr);
}
}
static void testBlobWithoutSoloCacheKeyStillParses() {
// The compatibility case both ways: a project saved by a build that predates the
// key parses to an empty cache, and its own output stays readable here.
const char* older =
"{\"version\":1,\"activeMode\":\"design\",\"modes\":[{\"id\":\"arrange\","
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[{\"guid\":\"{T}\","
"\"modes\":[\"design\"],\"showBoth\":false}],\"snapshots\":[],\"lanes\":[]}";
auto back = ViewModeModel::deserialize(older);
CHECK(back.has_value());
CHECK(back && back->soloCache().empty());
CHECK(back && back->activeModeId() == kDesignModeId);
CHECK(back && back->membership().query("{T}") != nullptr);
}
static void testSoloCacheMalformedJson() {
const char* bad[] = {
"{\"soloCache\":[{\"tracks\":[{\"guid\":\"{A}\",\"solo\":1}]}]}", // missing mode
"{\"soloCache\":[{\"mode\":\"\",\"tracks\":[{\"guid\":\"{A}\",\"solo\":1}]}]}", // empty mode
"{\"soloCache\":[{\"mode\":\"arrange\"}]}", // missing tracks
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[]}]}", // empty tracks
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"solo\":1}]}]}", // missing guid
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"guid\":\"{A}\"}]}]}", // missing solo
"{\"soloCache\":[{\"mode\":\"arrange\",\"tracks\":[{\"guid\":\"\",\"solo\":1}]}]}", // empty guid
"{\"soloCache\":[", // truncated
};
for (const char* j : bad) {
auto r = ViewModeModel::deserialize(j);
CHECK(!r.has_value());
}
}
static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() {
ViewModeModel vm;
vm.storeSnapshot("{LIVE}", TrackSnapshot{1, 1, 1, 1, {}});
vm.storeSnapshot("{DEAD}", TrackSnapshot{1, 1, 1, 1, {}});
vm.soloCache().store(kDesignModeId, {{"{LIVE}", 1}, {"{DEAD}", 2}});
// The return stays the SNAPSHOT count; the solo cache is pruned by the same call.
CHECK(vm.reconcile({"{LIVE}"}) == 1);
const std::map<std::string, int>* design = vm.soloCache().query(kDesignModeId);
CHECK(design != nullptr);
CHECK(design && design->size() == 1);
CHECK(design && design->count("{LIVE}") == 1);
}
int main() {
testSerializeGoldenLiteral();
testNModeRegistryAndMembership();
@@ -1843,6 +1925,12 @@ int main() {
testLaneJsonRoundTrip();
testLaneMalformedJson();
// Per-mode solo cache
testSoloCacheJsonRoundTrip();
testBlobWithoutSoloCacheKeyStillParses();
testSoloCacheMalformedJson();
testReconcilePrunesTheSoloCacheAlongsideSnapshots();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}