Merge Design View FX-GUID keying: parked FX-offline state follows the plugin, not the slot
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// Standalone tests for reasampler's per-FX offline restore resolution — no
|
||||
// REAPER, no test framework.
|
||||
//
|
||||
// The property under test: a captured FX-offline state lands on the FX it was
|
||||
// captured FROM, whatever that FX's slot has become while the track was parked.
|
||||
// Every scenario below is a chain mutation performed while parked.
|
||||
|
||||
#include "../src/core/view/fx_offline.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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 -----------------------------------------------------------------
|
||||
|
||||
// The ops the restore planner emits for one track from an identity-keyed
|
||||
// snapshot: capture-order entries, each carrying the FX's own identity.
|
||||
static std::vector<FxOfflineOp> identityOps(
|
||||
const std::vector<std::pair<std::string, bool>>& captured) {
|
||||
std::vector<FxOfflineOp> ops;
|
||||
for (std::size_t i = 0; i < captured.size(); ++i) {
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, captured[i].first,
|
||||
static_cast<int>(i), captured[i].second});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
// The ops a snapshot lifted from a pre-identity project yields: no identities,
|
||||
// position is the slot.
|
||||
static std::vector<FxOfflineOp> slotOps(const std::vector<bool>& captured) {
|
||||
std::vector<FxOfflineOp> ops;
|
||||
for (std::size_t i = 0; i < captured.size(); ++i) {
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Slot, {},
|
||||
static_cast<int>(i), captured[i]});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
static bool hasWrite(const FxRestoreResolution& res, int fxIndex, bool offline) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return w.offline == offline;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool writesTouch(const FxRestoreResolution& res, int fxIndex) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// -- 1. Chain reordered while parked -----------------------------------------
|
||||
|
||||
static void testReorderedChainRestoresEachPluginItsOwnState() {
|
||||
// Captured with A, B, C in slots 0,1,2 — B was already offline pre-park.
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}});
|
||||
|
||||
// While parked the user dragged C to the front: the chain is now C, A, B.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{C}", "{A}", "{B}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 1, false)); // A, now at slot 1
|
||||
CHECK(hasWrite(res, 2, true)); // B's offline state followed B to slot 2
|
||||
CHECK(hasWrite(res, 0, false)); // C, now at slot 0
|
||||
|
||||
// The slot-keyed reading of the SAME capture is what the old code did: it
|
||||
// would have written B's `true` to slot 1, which is now A. Pinning the
|
||||
// divergence keeps a well-meant "just use the index" from coming back.
|
||||
const FxRestoreResolution bySlot =
|
||||
resolveFxRestore(slotOps({false, true, false}), {"{C}", "{A}", "{B}"});
|
||||
CHECK(hasWrite(bySlot, 1, true)); // the defect, reproduced deliberately
|
||||
}
|
||||
|
||||
// -- 2. FX deleted while parked ----------------------------------------------
|
||||
|
||||
static void testDeletedFxDropsExplicitlyAndTouchesNothingElse() {
|
||||
// B (true) and C (false) carry OPPOSITE captured states — the discriminator.
|
||||
// A test where both carried `true` couldn't tell "C's own state followed it
|
||||
// to slot 1" apart from "B's dropped state leaked onto whatever moved there".
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}});
|
||||
|
||||
// B was deleted while parked; A and C closed the gap.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{C}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.missingIdentity == 1);
|
||||
CHECK(res.drops.unidentified == 0);
|
||||
CHECK(res.drops.slotOutOfRange == 0);
|
||||
CHECK(hasWrite(res, 0, false)); // A
|
||||
// C's OWN captured `false` landed at slot 1, not B's dropped `true`.
|
||||
CHECK(hasWrite(res, 1, false));
|
||||
|
||||
// The drop is reportable, not silent.
|
||||
const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1);
|
||||
CHECK(!msg.empty());
|
||||
CHECK(msg.find("1 captured FX offline state(s) on 1 track(s)") != std::string::npos);
|
||||
CHECK(msg.find("no longer in the chain") != std::string::npos);
|
||||
CHECK(msg.back() == '\n');
|
||||
|
||||
// Nothing dropped ⇒ nothing said.
|
||||
CHECK(describeFxRestoreDrops(FxRestoreDrops{}, 0).empty());
|
||||
}
|
||||
|
||||
static void testDescribeNamesAllThreeDropKinds() {
|
||||
FxRestoreDrops drops;
|
||||
drops.missingIdentity = 2;
|
||||
drops.unidentified = 1;
|
||||
drops.slotOutOfRange = 3;
|
||||
CHECK(drops.total() == 6);
|
||||
|
||||
const std::string msg = describeFxRestoreDrops(drops, /*trackCount=*/2);
|
||||
CHECK(msg.find("6 captured FX offline state(s) on 2 track(s)") != std::string::npos);
|
||||
CHECK(msg.find("2 FX no longer in the chain") != std::string::npos);
|
||||
CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos);
|
||||
CHECK(msg.find("3 from a project saved before FX identity was recorded")
|
||||
!= std::string::npos);
|
||||
}
|
||||
|
||||
// -- 3. FX added while parked -------------------------------------------------
|
||||
|
||||
static void testAddedFxIsNotTouched() {
|
||||
const std::vector<FxOfflineOp> ops = identityOps({{"{A}", false}, {"{B}", true}});
|
||||
|
||||
// D was inserted at the FRONT while parked — the case where an index-keyed
|
||||
// restore would have written every captured state onto the wrong plugin.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{D}", "{A}", "{B}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 1, false)); // A
|
||||
CHECK(hasWrite(res, 2, true)); // B
|
||||
CHECK(!writesTouch(res, 0)); // D is never written at all
|
||||
}
|
||||
|
||||
// -- 4. Two instances of the SAME plugin type --------------------------------
|
||||
|
||||
static void testTwoInstancesOfOnePluginKeyIndependently() {
|
||||
// Two copies of one plugin: distinct instances, distinct identities, and the
|
||||
// two carry OPPOSITE captured states — a scheme keyed on plugin type or name
|
||||
// could not tell them apart and would restore both the same way.
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{EQ-1}", true}, {"{EQ-2}", false}, {"{COMP}", false}});
|
||||
|
||||
// Swapped while parked: EQ-2, COMP, EQ-1.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{EQ-2}", "{COMP}", "{EQ-1}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 2, true)); // EQ-1's offline=true followed it to slot 2
|
||||
CHECK(hasWrite(res, 0, false)); // EQ-2 stayed online at slot 0
|
||||
CHECK(hasWrite(res, 1, false)); // COMP
|
||||
}
|
||||
|
||||
// -- 5. Slot-keyed (pre-identity) snapshots ----------------------------------
|
||||
|
||||
static void testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks() {
|
||||
// All a pre-identity blob's bytes can support: position addressing.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(slotOps({false, true}), {"{A}", "{B}", "{C}"});
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 0, false));
|
||||
CHECK(hasWrite(res, 1, true));
|
||||
CHECK(!writesTouch(res, 2)); // an FX the snapshot never covered stays untouched
|
||||
|
||||
// A slot that no longer exists is dropped and counted, never clamped.
|
||||
const FxRestoreResolution shrunk =
|
||||
resolveFxRestore(slotOps({false, true, true}), {"{A}"});
|
||||
CHECK(shrunk.writes.size() == 1);
|
||||
CHECK(shrunk.drops.slotOutOfRange == 2);
|
||||
CHECK(shrunk.drops.missingIdentity == 0);
|
||||
}
|
||||
|
||||
// -- 6. Degenerate inputs -----------------------------------------------------
|
||||
|
||||
static void testUnresolvableIdentityNeverFallsBackToItsSlot() {
|
||||
// An identity-keyed entry with NO identity (REAPER reported none at capture)
|
||||
// is a drop — the slot it happens to carry must not be used as a substitute.
|
||||
// Counted as `unidentified`, not `missingIdentity`: unlike a real captured
|
||||
// identity going missing, this FX may still be sitting right there — we
|
||||
// simply never had a name for it, and the report must say that, not "no
|
||||
// longer in the chain".
|
||||
std::vector<FxOfflineOp> ops = identityOps({{"{A}", false}});
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, "", /*slot=*/1, /*offline=*/true});
|
||||
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{B}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 0, false));
|
||||
CHECK(!writesTouch(res, 1)); // {B} would have been the slot-1 victim
|
||||
CHECK(res.drops.missingIdentity == 0);
|
||||
CHECK(res.drops.unidentified == 1);
|
||||
|
||||
const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1);
|
||||
CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos);
|
||||
CHECK(msg.find("no longer in the chain") == std::string::npos); // not this FX's story
|
||||
}
|
||||
|
||||
static void testLiveFxWithNoIdentityIsNeverARestoreTarget() {
|
||||
// The mirror case: a live FX REAPER reports no GUID for cannot be matched by
|
||||
// an empty captured identity either.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}}), {"", "{A}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 1, true));
|
||||
CHECK(!writesTouch(res, 0));
|
||||
CHECK(res.drops.total() == 0);
|
||||
}
|
||||
|
||||
static void testEmptyInputsProduceNoWrites() {
|
||||
CHECK(resolveFxRestore({}, {"{A}"}).writes.empty());
|
||||
CHECK(resolveFxRestore({}, {}).drops.total() == 0);
|
||||
|
||||
// Every FX gone (the whole chain cleared while parked): all dropped, none written.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}, {"{B}", false}}), {});
|
||||
CHECK(res.writes.empty());
|
||||
CHECK(res.drops.missingIdentity == 2);
|
||||
}
|
||||
|
||||
static void testDuplicateLiveIdentityResolvesToTheFirstSlotOnly() {
|
||||
// Not producible by REAPER (one GUID per instance) — pinned so a corrupt or
|
||||
// hand-edited chain writes once, deterministically, instead of twice.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}}), {"{A}", "{A}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 0, true));
|
||||
CHECK(!writesTouch(res, 1));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testReorderedChainRestoresEachPluginItsOwnState();
|
||||
testDeletedFxDropsExplicitlyAndTouchesNothingElse();
|
||||
testDescribeNamesAllThreeDropKinds();
|
||||
testAddedFxIsNotTouched();
|
||||
testTwoInstancesOfOnePluginKeyIndependently();
|
||||
testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks();
|
||||
testUnresolvableIdentityNeverFallsBackToItsSlot();
|
||||
testLiveFxWithNoIdentityIsNeverARestoreTarget();
|
||||
testEmptyInputsProduceNoWrites();
|
||||
testDuplicateLiveIdentityResolvesToTheFirstSlotOnly();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
// 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).
|
||||
// 8. Per-FX offline is keyed by FX identity, and the v1 (slot-keyed) blob lifts
|
||||
// into that keying without losing its restore.
|
||||
// 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.
|
||||
@@ -64,7 +66,7 @@ static int flagValue(const TrackPlan& p, Flag f) {
|
||||
static void testSerializeGoldenLiteral() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.serialize() ==
|
||||
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"{\"version\":2,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[],"
|
||||
"\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}");
|
||||
@@ -284,7 +286,9 @@ static void testRestoreRoundTripSnapshotValues() {
|
||||
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
|
||||
// Slot 1's plugin was already offline before parking; each entry carries the
|
||||
// identity of the FX it came from.
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}};
|
||||
|
||||
TrackPlan park = makeParkPlan("{T}", /*fxCount=*/3);
|
||||
CHECK(flagValue(park, Flag::ShowInTcp) == 0);
|
||||
@@ -304,6 +308,10 @@ static void testRestoreRoundTripSnapshotValues() {
|
||||
CHECK(restore.fxOffline[0].offline == false);
|
||||
CHECK(restore.fxOffline[1].offline == true); // was offline pre-park ⇒ stays offline
|
||||
CHECK(restore.fxOffline[2].offline == false);
|
||||
// Each restore op names the FX it was captured from, not just a position —
|
||||
// resolveFxRestore has something to key on even if the chain moved.
|
||||
CHECK(restore.fxOffline[1].keying == FxKeying::Identity);
|
||||
CHECK(restore.fxOffline[1].fxGuid == "{FX-B}");
|
||||
|
||||
// A snapshot entirely at 0 must restore entirely to 0 (no default leaks in).
|
||||
TrackSnapshot zero; // all zeros, empty fxOffline
|
||||
@@ -419,7 +427,8 @@ static void testJsonRoundTrip() {
|
||||
|
||||
// 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};
|
||||
s1.fxEnable = 0;
|
||||
s1.fxOffline = {{"{FX-1}", 1}, {"{FX-2}", 0}, {"{FX-3}", 1}, {"{FX-4}", 1}};
|
||||
vm.storeSnapshot("{D}", s1);
|
||||
TrackSnapshot s2; // all zeros, empty fx vector
|
||||
vm.storeSnapshot("{A}", s2);
|
||||
@@ -445,7 +454,11 @@ static void testJsonRoundTrip() {
|
||||
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);
|
||||
CHECK(snap && snap->fxOffline.size() == 4 && snap->fxOffline[1].offline == 0);
|
||||
// The FX identities survive the round-trip — without them the restore is
|
||||
// back to guessing at slots.
|
||||
CHECK(snap && snap->fxKeying == FxKeying::Identity);
|
||||
CHECK(snap && snap->fxOffline[3].fxGuid == "{FX-4}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +489,9 @@ static void testMalformedJson() {
|
||||
"{\"membership\":[{\"guid\":\"\"}]}", // empty guid
|
||||
"{\"snapshots\":[{\"showInTcp\":1}]}", // snapshot without guid
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fxOffline\":[1,notanumber]}]}",
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[{\"guid\":\"{F}\"}]}]}", // fx without offline
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[{\"offline\":1}]}]}", // fx without guid
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[", // truncated fx
|
||||
"{\"modes\":[]}trailing", // trailing garbage
|
||||
};
|
||||
for (const char* j : bad) {
|
||||
@@ -557,7 +573,7 @@ static void testUntaggedLeavesManagedByModeSystem() {
|
||||
// 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};
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 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}");
|
||||
@@ -609,7 +625,8 @@ static void testReconcilePrunesOrphanedSnapshots() {
|
||||
// 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 sLive; sLive.showInTcp = 1;
|
||||
sLive.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}};
|
||||
TrackSnapshot sDel; sDel.showInTcp = 1; sDel.fxEnable = 1;
|
||||
vm.storeSnapshot("{LIVE}", sLive);
|
||||
vm.storeSnapshot("{DEL}", sDel);
|
||||
@@ -1848,7 +1865,7 @@ 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}});
|
||||
vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {{"{FX-A}", 0}, {"{FX-B}", 1}}});
|
||||
CHECK(vm.lanes().setManaged("{T}", "lane:0", kArrangeModeId));
|
||||
|
||||
// Every non-zero I_SOLO variant, across more than one mode, plus a GUID that
|
||||
@@ -1924,6 +1941,175 @@ static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() {
|
||||
CHECK(design && design->count("{LIVE}") == 1);
|
||||
}
|
||||
|
||||
// -- Per-FX offline: identity keying and the v1 -> v2 snapshot ladder ---------
|
||||
//
|
||||
// The restore path end-to-end, at the seam the shell actually uses: a stored
|
||||
// snapshot -> planToggle -> makeRestorePlan ops -> resolveFxRestore against the
|
||||
// chain as it stands now. The chain mutations happen while the track is parked,
|
||||
// which is the whole reason a slot cannot be the key.
|
||||
|
||||
namespace {
|
||||
|
||||
// The plan's restore ops for one parked-then-reactivated leaf.
|
||||
std::vector<FxOfflineOp> restoreOpsFor(ViewModeModel& vm, const std::string& guid) {
|
||||
FolderTree tree;
|
||||
tree.nodes.push_back(FolderNode{guid, "", false});
|
||||
const TogglePlan plan = vm.planToggle(tree, kDesignModeId);
|
||||
const TrackPlan* r = restoreFor(plan, guid);
|
||||
return r ? r->fxOffline : std::vector<FxOfflineOp>{};
|
||||
}
|
||||
|
||||
bool writeAt(const FxRestoreResolution& res, int fxIndex, bool offline) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return w.offline == offline;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool anyWriteAt(const FxRestoreResolution& res, int fxIndex) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A leaf tagged Design, parked with three identified FX — the state every case
|
||||
// below starts from.
|
||||
ViewModeModel parkedWithThreeFx() {
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1;
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}};
|
||||
vm.storeSnapshot("{T}", snap);
|
||||
return vm;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void testParkReorderRestoreLandsEachPluginItsOwnState() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// Reordered to C, A, B while parked.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(restoreOpsFor(vm, "{T}"), {"{FX-C}", "{FX-A}", "{FX-B}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(writeAt(res, 1, false)); // A
|
||||
CHECK(writeAt(res, 2, true)); // B's captured offline followed B, not slot 1
|
||||
CHECK(writeAt(res, 0, false)); // C
|
||||
}
|
||||
|
||||
static void testParkDeleteFxRestoreDropsExplicitly() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// B deleted while parked.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(restoreOpsFor(vm, "{T}"), {"{FX-A}", "{FX-C}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.missingIdentity == 1);
|
||||
CHECK(writeAt(res, 0, false)); // A
|
||||
CHECK(writeAt(res, 1, false)); // C — and NOT B's captured `true`
|
||||
CHECK(!describeFxRestoreDrops(res.drops, 1).empty());
|
||||
}
|
||||
|
||||
static void testParkAddFxRestoreLeavesItAlone() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// A new plugin inserted at the head while parked.
|
||||
const FxRestoreResolution res = resolveFxRestore(
|
||||
restoreOpsFor(vm, "{T}"), {"{FX-NEW}", "{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(!anyWriteAt(res, 0)); // the added FX is never written
|
||||
CHECK(writeAt(res, 1, false));
|
||||
CHECK(writeAt(res, 2, true));
|
||||
CHECK(writeAt(res, 3, false));
|
||||
}
|
||||
|
||||
static void testV2WritesTheLegacySlotArrayBesideIdentities() {
|
||||
// The downgrade half of the ladder: a build that predates identity keying
|
||||
// reads "fxOffline" and skips "fx", so it keeps exactly the behavior it had
|
||||
// instead of losing every captured FX state to an unknown key.
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
const std::string json = vm.serialize();
|
||||
|
||||
CHECK(json.find("\"fxOffline\":[0,1,0]") != std::string::npos);
|
||||
CHECK(json.find("\"fx\":[{\"guid\":\"{FX-A}\",\"offline\":0},"
|
||||
"{\"guid\":\"{FX-B}\",\"offline\":1},"
|
||||
"{\"guid\":\"{FX-C}\",\"offline\":0}]") != std::string::npos);
|
||||
|
||||
auto back = ViewModeModel::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == vm);
|
||||
|
||||
// The downgrade path itself, at the only point it can be reached from here:
|
||||
// an unknown array-of-objects key beside "fxOffline" is skipped and the slot
|
||||
// array is still read — the same skipValue branch an older build takes on
|
||||
// "fx". (An actual older binary is not runnable from this test.)
|
||||
auto asOlder = ViewModeModel::deserialize(
|
||||
"{\"snapshots\":[{\"guid\":\"{T}\",\"fxOffline\":[0,1,0],"
|
||||
"\"futureKey\":[{\"guid\":\"{FX-A}\",\"offline\":0}]}]}");
|
||||
CHECK(asOlder.has_value());
|
||||
CHECK(asOlder && asOlder->snapshot("{T}") &&
|
||||
asOlder->snapshot("{T}")->fxOffline.size() == 3);
|
||||
CHECK(asOlder && asOlder->snapshot("{T}") &&
|
||||
asOlder->snapshot("{T}")->fxOffline[1].offline == 1);
|
||||
}
|
||||
|
||||
static void testLegacyBlobLiftsToSlotKeyingAndStillRestores() {
|
||||
// A view_state written before FX identity existed: no "fx" key anywhere.
|
||||
const char* v1 =
|
||||
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[{\"guid\":\"{T}\","
|
||||
"\"modes\":[\"design\"],\"showBoth\":false}],\"snapshots\":[{\"guid\":\"{T}\","
|
||||
"\"showInTcp\":1,\"showInMixer\":1,\"mainSend\":1,\"fxEnable\":1,"
|
||||
"\"fxOffline\":[0,1,0]}],\"lanes\":[]}";
|
||||
|
||||
auto loaded = ViewModeModel::deserialize(v1);
|
||||
CHECK(loaded.has_value());
|
||||
if (!loaded) return;
|
||||
|
||||
const TrackSnapshot* snap = loaded->snapshot("{T}");
|
||||
CHECK(snap != nullptr);
|
||||
CHECK(snap && snap->fxKeying == FxKeying::Slot); // no identities to key on
|
||||
CHECK(snap && snap->fxOffline.size() == 3);
|
||||
CHECK(snap && snap->fxOffline[1].offline == 1);
|
||||
CHECK(snap && snap->fxOffline[1].fxGuid.empty());
|
||||
CHECK(snap && snap->showInTcp == 1 && snap->mainSend == 1);
|
||||
|
||||
// It still restores — by position, which is all its bytes can support, and
|
||||
// is exactly what the pre-change build would have done with them.
|
||||
const std::vector<FxOfflineOp> ops = restoreOpsFor(*loaded, "{T}");
|
||||
CHECK(ops.size() == 3);
|
||||
CHECK(!ops.empty() && ops[0].keying == FxKeying::Slot);
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{FX-X}", "{FX-Y}", "{FX-Z}"});
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(writeAt(res, 0, false));
|
||||
CHECK(writeAt(res, 1, true));
|
||||
CHECK(writeAt(res, 2, false));
|
||||
|
||||
// Re-saving a lifted snapshot does NOT invent identities for it: the "fx"
|
||||
// key stays absent, and a second load reads the same slot-keyed shape.
|
||||
const std::string resaved = loaded->serialize();
|
||||
CHECK(resaved.find("\"fx\":") == std::string::npos);
|
||||
CHECK(resaved.find("\"fxOffline\":[0,1,0]") != std::string::npos);
|
||||
auto again = ViewModeModel::deserialize(resaved);
|
||||
CHECK(again.has_value());
|
||||
CHECK(again && *again == *loaded);
|
||||
CHECK(again && again->snapshot("{T}") &&
|
||||
again->snapshot("{T}")->fxKeying == FxKeying::Slot);
|
||||
|
||||
// And the lift is one-shot: a restore consumes the snapshot, so the next park
|
||||
// captures identities and the project leaves the legacy shape behind.
|
||||
loaded->clearSnapshot("{T}");
|
||||
TrackSnapshot fresh;
|
||||
fresh.fxOffline = {{"{FX-X}", 1}};
|
||||
loaded->storeSnapshot("{T}", fresh);
|
||||
CHECK(loaded->serialize().find("\"fx\":[{\"guid\":\"{FX-X}\",\"offline\":1}]")
|
||||
!= std::string::npos);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSerializeGoldenLiteral();
|
||||
testNModeRegistryAndMembership();
|
||||
@@ -1974,6 +2160,13 @@ int main() {
|
||||
testSoloCacheMalformedJson();
|
||||
testReconcilePrunesTheSoloCacheAlongsideSnapshots();
|
||||
|
||||
// Per-FX offline identity keying + the v1 -> v2 snapshot ladder
|
||||
testParkReorderRestoreLandsEachPluginItsOwnState();
|
||||
testParkDeleteFxRestoreDropsExplicitly();
|
||||
testParkAddFxRestoreLeavesItAlone();
|
||||
testV2WritesTheLegacySlotArrayBesideIdentities();
|
||||
testLegacyBlobLiftsToSlotKeyingAndStillRestores();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user