Ψ-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:
@@ -228,6 +228,25 @@ static void testResizeSweepNoOverlap() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mode-segment enablement (the playback gate's visible half) ---------------
|
||||
|
||||
static void testModeSegmentsAreLiveWithTheTransportStopped() {
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/false));
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/false));
|
||||
}
|
||||
|
||||
static void testInactiveSegmentGoesDeadWhileTheTransportRuns() {
|
||||
// The one that would fire a real switch — refused while playing/recording, so it
|
||||
// must read dead rather than invite a click that silently does nothing.
|
||||
CHECK(!modeSegmentEnabled(/*isActiveSegment=*/false, /*transportRunning=*/true));
|
||||
}
|
||||
|
||||
static void testActiveSegmentStaysLiveWhileTheTransportRuns() {
|
||||
// Clicking the mode you are already in is a reapply, which is never gated;
|
||||
// dimming it would read as "this mode is unavailable".
|
||||
CHECK(modeSegmentEnabled(/*isActiveSegment=*/true, /*transportRunning=*/true));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testWideFooterAllPlaced();
|
||||
testOffsetFooterAnchorsLeft();
|
||||
@@ -241,6 +260,9 @@ int main() {
|
||||
testHitEdgesAndOutside();
|
||||
testSuppressedClaimsNothing();
|
||||
testResizeSweepNoOverlap();
|
||||
testModeSegmentsAreLiveWithTheTransportStopped();
|
||||
testInactiveSegmentGoesDeadWhileTheTransportRuns();
|
||||
testActiveSegmentStaysLiveWhileTheTransportRuns();
|
||||
|
||||
if (g_fail == 0) std::printf("footer_bar: all tests passed\n");
|
||||
else std::printf("footer_bar: %d CHECK(s) FAILED\n", g_fail);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Standalone tests for reasampler::view::solo_cache — no REAPER, no test framework.
|
||||
//
|
||||
// Covers: the soloed-subset filter (zeros and empty GUIDs dropped, raw values kept),
|
||||
// the cache's store/query/clear lifecycle including the empty-set-removes rule, GUID
|
||||
// pruning on reconcile, and the restore plan's two drop rules (dead GUID, parked
|
||||
// track).
|
||||
|
||||
#include "../src/core/view/solo_cache.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::view;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- soloedTracks: the filter ------------------------------------------------
|
||||
|
||||
static void testUnsoloedProjectYieldsNothingToCache() {
|
||||
const std::map<std::string, int> soloed =
|
||||
soloedTracks({{"{A}", 0}, {"{B}", 0}, {"{C}", 0}});
|
||||
CHECK(soloed.empty());
|
||||
}
|
||||
|
||||
static void testSoloedTracksKeepsRawValueNotABoolean() {
|
||||
const std::map<std::string, int> soloed =
|
||||
soloedTracks({{"{A}", 1}, {"{B}", 0}, {"{C}", 2}, {"{D}", 6}});
|
||||
|
||||
CHECK(soloed.size() == 3);
|
||||
CHECK(soloed.at("{A}") == 1); // plain solo
|
||||
CHECK(soloed.at("{C}") == 2); // solo-in-place survives
|
||||
CHECK(soloed.at("{D}") == 6); // safe solo-in-place survives
|
||||
CHECK(soloed.count("{B}") == 0);
|
||||
}
|
||||
|
||||
static void testSoloedTracksDropsEmptyGuids() {
|
||||
const std::map<std::string, int> soloed = soloedTracks({{"", 1}, {"{A}", 1}});
|
||||
CHECK(soloed.size() == 1);
|
||||
CHECK(soloed.count("{A}") == 1);
|
||||
}
|
||||
|
||||
// --- SoloCache: store / query / clear ----------------------------------------
|
||||
|
||||
static void testStoreThenQueryReturnsTheStoredSet() {
|
||||
SoloCache cache;
|
||||
CHECK(cache.store("arrange", {{"{A}", 1}, {"{B}", 2}}));
|
||||
|
||||
const std::map<std::string, int>* got = cache.query("arrange");
|
||||
CHECK(got != nullptr);
|
||||
CHECK(got->size() == 2);
|
||||
CHECK(got->at("{A}") == 1);
|
||||
CHECK(got->at("{B}") == 2);
|
||||
CHECK(cache.query("design") == nullptr);
|
||||
}
|
||||
|
||||
static void testStoringAnEmptySetRemovesTheModeEntry() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}});
|
||||
CHECK(cache.query("arrange") != nullptr);
|
||||
|
||||
// Unsoloing everything and switching away must leave no record behind — an
|
||||
// empty record would not survive the serialize round trip.
|
||||
CHECK(cache.store("arrange", {}));
|
||||
CHECK(cache.query("arrange") == nullptr);
|
||||
CHECK(cache.empty());
|
||||
}
|
||||
|
||||
static void testStoreReplacesRatherThanMerges() {
|
||||
SoloCache cache;
|
||||
cache.store("design", {{"{A}", 1}, {"{B}", 1}});
|
||||
cache.store("design", {{"{C}", 2}});
|
||||
|
||||
const std::map<std::string, int>* got = cache.query("design");
|
||||
CHECK(got != nullptr);
|
||||
CHECK(got->size() == 1);
|
||||
CHECK(got->count("{C}") == 1);
|
||||
}
|
||||
|
||||
static void testStoreRejectsAnEmptyModeId() {
|
||||
SoloCache cache;
|
||||
CHECK(!cache.store("", {{"{A}", 1}}));
|
||||
CHECK(cache.empty());
|
||||
}
|
||||
|
||||
static void testClearConsumesOnlyTheNamedMode() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}});
|
||||
cache.store("design", {{"{B}", 1}});
|
||||
|
||||
CHECK(cache.clear("arrange"));
|
||||
CHECK(cache.query("arrange") == nullptr);
|
||||
CHECK(cache.query("design") != nullptr);
|
||||
CHECK(!cache.clear("arrange")); // already consumed
|
||||
}
|
||||
|
||||
// --- SoloCache::reconcile ----------------------------------------------------
|
||||
|
||||
static void testReconcileDropsDeadGuidsAcrossEveryMode() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{LIVE}", 1}, {"{DEAD}", 2}});
|
||||
cache.store("design", {{"{DEAD}", 1}});
|
||||
|
||||
CHECK(cache.reconcile({"{LIVE}"}) == 2);
|
||||
|
||||
const std::map<std::string, int>* arrange = cache.query("arrange");
|
||||
CHECK(arrange != nullptr);
|
||||
CHECK(arrange->size() == 1);
|
||||
CHECK(arrange->count("{LIVE}") == 1);
|
||||
// The design entry lost its only track, so the mode record goes with it.
|
||||
CHECK(cache.query("design") == nullptr);
|
||||
}
|
||||
|
||||
static void testReconcileWithEveryGuidLiveRemovesNothing() {
|
||||
SoloCache cache;
|
||||
cache.store("arrange", {{"{A}", 1}, {"{B}", 5}});
|
||||
CHECK(cache.reconcile({"{A}", "{B}", "{UNRELATED}"}) == 0);
|
||||
CHECK(cache.query("arrange")->size() == 2);
|
||||
}
|
||||
|
||||
// --- planSoloRestore ---------------------------------------------------------
|
||||
|
||||
static void testRestorePlanReplaysEveryLiveUnparkedEntryVerbatim() {
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{A}", 1}, {"{B}", 2}}, {"{A}", "{B}"}, {});
|
||||
|
||||
CHECK(ops.size() == 2);
|
||||
CHECK(ops[0] == (SoloOp{"{A}", 1}));
|
||||
CHECK(ops[1] == (SoloOp{"{B}", 2}));
|
||||
}
|
||||
|
||||
static void testRestorePlanSkipsAGuidThatNoLongerExists() {
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{GONE}", 1}, {"{HERE}", 1}}, {"{HERE}"}, {});
|
||||
|
||||
CHECK(ops.size() == 1);
|
||||
CHECK(ops[0].guid == "{HERE}");
|
||||
}
|
||||
|
||||
static void testRestorePlanSkipsATrackParkedInTheIncomingMode() {
|
||||
// Soloing a parked track would silence the mix while contributing nothing
|
||||
// audible, and the track is hidden, so the user could not undo it.
|
||||
const std::vector<SoloOp> ops =
|
||||
planSoloRestore({{"{PARKED}", 1}, {"{VISIBLE}", 2}}, {"{PARKED}", "{VISIBLE}"},
|
||||
{"{PARKED}"});
|
||||
|
||||
CHECK(ops.size() == 1);
|
||||
CHECK(ops[0] == (SoloOp{"{VISIBLE}", 2}));
|
||||
}
|
||||
|
||||
static void testRestorePlanOfAnEmptyCacheWritesNothing() {
|
||||
CHECK(planSoloRestore({}, {"{A}"}, {}).empty());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testUnsoloedProjectYieldsNothingToCache();
|
||||
testSoloedTracksKeepsRawValueNotABoolean();
|
||||
testSoloedTracksDropsEmptyGuids();
|
||||
|
||||
testStoreThenQueryReturnsTheStoredSet();
|
||||
testStoringAnEmptySetRemovesTheModeEntry();
|
||||
testStoreReplacesRatherThanMerges();
|
||||
testStoreRejectsAnEmptyModeId();
|
||||
testClearConsumesOnlyTheNamedMode();
|
||||
|
||||
testReconcileDropsDeadGuidsAcrossEveryMode();
|
||||
testReconcileWithEveryGuidLiveRemovesNothing();
|
||||
|
||||
testRestorePlanReplaysEveryLiveUnparkedEntryVerbatim();
|
||||
testRestorePlanSkipsAGuidThatNoLongerExists();
|
||||
testRestorePlanSkipsATrackParkedInTheIncomingMode();
|
||||
testRestorePlanOfAnEmptyCacheWritesNothing();
|
||||
|
||||
if (g_fail == 0) std::printf("solo_cache: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user