Make an FX reclaim step one whole track's chain: drop the resume cursor, yield only between tracks
This commit is contained in:
+81
-78
@@ -1,8 +1,8 @@
|
||||
// Standalone tests for the FX reclaimer's drain — no REAPER, no test framework. The
|
||||
// loop is templated on its host precisely so the four properties that make it safe to
|
||||
// run in the background are assertable here: the debounce, the per-tick budget's
|
||||
// yield-and-resume, the queue being a cache of intent that a model change discards, and
|
||||
// the user's own offline plugins surviving an online.
|
||||
// between-track yield, the queue being a cache of intent that a model change discards,
|
||||
// and the user's own offline plugins surviving an online.
|
||||
|
||||
#include "../src/shell/view/fx_reclaim.h"
|
||||
|
||||
@@ -95,16 +95,23 @@ std::vector<FakeFx> offlineChain(const std::vector<std::string>& guids) {
|
||||
return chain;
|
||||
}
|
||||
|
||||
// A Design leaf the tool has parked: record held, both halves, nothing user-offlined.
|
||||
ViewModeModel parkedDesignLeaf(const std::vector<std::string>& fxGuids) {
|
||||
// Design leaves the tool has parked: record held, both halves, nothing user-offlined.
|
||||
// Active mode stays Arrange, so every one of them is parked.
|
||||
ViewModeModel parkedDesignLeaves(const std::map<std::string, std::vector<std::string>>& tracks) {
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId); // active mode is Arrange ⇒ {T} is parked
|
||||
std::vector<FxOfflineState> fx;
|
||||
for (const std::string& g : fxGuids) fx.push_back(FxOfflineState{g, 0});
|
||||
vm.storeSnapshot("{T}", recordWithFx(fx));
|
||||
for (const auto& [guid, fxGuids] : tracks) {
|
||||
vm.membership().tag(guid, kDesignModeId);
|
||||
std::vector<FxOfflineState> fx;
|
||||
for (const std::string& g : fxGuids) fx.push_back(FxOfflineState{g, 0});
|
||||
vm.storeSnapshot(guid, recordWithFx(fx));
|
||||
}
|
||||
return vm;
|
||||
}
|
||||
|
||||
ViewModeModel parkedDesignLeaf(const std::vector<std::string>& fxGuids) {
|
||||
return parkedDesignLeaves({{"{T}", fxGuids}});
|
||||
}
|
||||
|
||||
// Puts the queue's offline items past their debounce the way the live loop does: the
|
||||
// hold starts at the FIRST observation, so advancing the clock before that one just
|
||||
// moves the origin and matures nothing.
|
||||
@@ -168,48 +175,72 @@ static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() {
|
||||
CHECK(host2.released.empty()); // an OFFLINE never releases — the record is still owed
|
||||
}
|
||||
|
||||
// -- 2. budget: yield and resume ------------------------------------------------
|
||||
// -- 2. budget: the step is a whole track ----------------------------------------
|
||||
|
||||
static void testOfflineDrainYieldsOnBudgetAndResumesWhereItStopped() {
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
static void testAWholeChainMovesInOneStepAndTheYieldFallsBetweenTracks() {
|
||||
// {T1}'s chain costs 4x the offline budget on its own. The budget cannot split it —
|
||||
// a step is one track — so the tick overruns to finish it and yields only once the
|
||||
// NEXT track would start. Slot 1 carries no identity, which the drain no longer has
|
||||
// to reason about at all now that nothing resumes mid-chain.
|
||||
ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "", "{FX-C}", "{FX-D}"}},
|
||||
{"{T2}", {"{FX-E}"}},
|
||||
{"{T3}", {"{FX-F}"}}});
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
host.stepCostMs = 50.0; // dearer than kOfflineBudgetMs ⇒ exactly one plugin per tick
|
||||
host.chains["{T1}"] = onlineChain({"{FX-A}", "", "{FX-C}", "{FX-D}"});
|
||||
host.chains["{T2}"] = onlineChain({"{FX-E}"});
|
||||
host.chains["{T3}"] = onlineChain({"{FX-F}"});
|
||||
host.stepCostMs = 50.0; // dearer than kOfflineBudgetMs ⇒ one TRACK per tick
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T1}", "{T2}", "{T3}"});
|
||||
CHECK(items.size() == 3);
|
||||
matureHold(items, clock, host);
|
||||
CHECK(drainTick(items, clock, host).writes == 1);
|
||||
CHECK(items[0].work.resumeAfterSlot == 0);
|
||||
CHECK(items[0].work.resumeAfterFxGuid == "{FX-A}");
|
||||
CHECK(!items[0].done);
|
||||
|
||||
CHECK(drainTick(items, clock, host).writes == 1);
|
||||
CHECK(items[0].work.resumeAfterSlot == 1);
|
||||
CHECK(drainTick(items, clock, host).writes == 4); // all of {T1}, 200ms over a 30ms budget
|
||||
CHECK(items[0].done && !items[1].done && !items[2].done);
|
||||
const std::vector<std::string> firstTick{"{T1}:0=off", "{T1}:1=off", "{T1}:2=off",
|
||||
"{T1}:3=off"};
|
||||
CHECK(host.writeLog == firstTick);
|
||||
|
||||
// Slots 2 and 3, one per tick; the tick that writes the last one also exhausts the
|
||||
// item's write list, so it settles in the same pass rather than costing a spare tick.
|
||||
// {T2} then {T3}, one track per tick; the tick that finishes the last one exhausts
|
||||
// the queue in the same pass rather than costing a spare tick.
|
||||
CHECK(drainToSettled(items, clock, host) == 2);
|
||||
|
||||
// Each slot moved exactly once and in order — no slot re-walked across a yield.
|
||||
const std::vector<std::string> expected{"{T}:0=off", "{T}:1=off", "{T}:2=off", "{T}:3=off"};
|
||||
std::vector<std::string> expected = firstTick;
|
||||
expected.push_back("{T2}:0=off");
|
||||
expected.push_back("{T3}:0=off");
|
||||
CHECK(host.writeLog == expected);
|
||||
}
|
||||
|
||||
static void testOnlineDrainGetsTheLargerBudget() {
|
||||
// Same per-plugin cost, same chain length: the only difference is the direction, and
|
||||
// Same per-plugin cost, same track count, one plugin each so neither direction is
|
||||
// distorted by an overrunning chain: the only difference is the direction, and
|
||||
// onlining is the one the user is waiting on.
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
vm.setActiveMode(kDesignModeId);
|
||||
vm.releaseSnapshotFlags("{T}");
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"});
|
||||
host.stepCostMs = 50.0;
|
||||
ParkClock clock;
|
||||
const std::map<std::string, std::vector<std::string>> tracks{
|
||||
{"{T1}", {"{FX-1}"}}, {"{T2}", {"{FX-2}"}}, {"{T3}", {"{FX-3}"}}, {"{T4}", {"{FX-4}"}}};
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
CHECK(items.size() == 1 && items[0].work.kind == FxWorkKind::Online);
|
||||
CHECK(drainTick(items, clock, host).writes == 3); // 120ms budget vs 30ms
|
||||
ViewModeModel back = parkedDesignLeaves(tracks);
|
||||
back.setActiveMode(kDesignModeId);
|
||||
FakeHost onlineHost;
|
||||
for (const auto& [guid, fx] : tracks) {
|
||||
back.releaseSnapshotFlags(guid);
|
||||
onlineHost.chains[guid] = offlineChain(fx);
|
||||
}
|
||||
onlineHost.stepCostMs = 50.0;
|
||||
ParkClock onlineClock;
|
||||
|
||||
std::vector<ReclaimItem> onlineItems = derive(back, {"{T1}", "{T2}", "{T3}", "{T4}"});
|
||||
CHECK(onlineItems.size() == 4 && onlineItems[0].work.kind == FxWorkKind::Online);
|
||||
CHECK(drainTick(onlineItems, onlineClock, onlineHost).writes == 3); // 120ms buys three
|
||||
|
||||
ViewModeModel parked = parkedDesignLeaves(tracks);
|
||||
FakeHost offlineHost;
|
||||
for (const auto& [guid, fx] : tracks) offlineHost.chains[guid] = onlineChain(fx);
|
||||
offlineHost.stepCostMs = 50.0;
|
||||
ParkClock offlineClock;
|
||||
|
||||
std::vector<ReclaimItem> offlineItems = derive(parked, {"{T1}", "{T2}", "{T3}", "{T4}"});
|
||||
matureHold(offlineItems, offlineClock, offlineHost);
|
||||
CHECK(drainTick(offlineItems, offlineClock, offlineHost).writes == 1); // 30ms buys one
|
||||
}
|
||||
|
||||
static void testATickStartedOnOnlineWorkDoesNotSpendIntoOfflineWork() {
|
||||
@@ -254,21 +285,19 @@ static void testModelChangeDiscardsDrainProgressButNotTheParkHold() {
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}", "{U}"});
|
||||
drainTick(items, clock, host); // observes both parked at t=0
|
||||
host.clock = kParkHoldMs + 100.0;
|
||||
drainTick(items, clock, host); // one plugin moves; {T} now carries a cursor
|
||||
CHECK(items[0].work.resumeAfterSlot >= 0);
|
||||
drainTick(items, clock, host); // {T}'s whole chain moves, then the tick yields
|
||||
CHECK(items[0].done && !items[1].done);
|
||||
|
||||
// The model changes: {T} is retagged into the active mode and restored, while {U}
|
||||
// stays exactly as parked as it was. The queue is discarded whole — direction,
|
||||
// cursors, completion marks and all — and re-derived.
|
||||
// completion marks and all — and re-derived.
|
||||
vm.membership().tag("{T}", kArrangeModeId);
|
||||
vm.releaseSnapshotFlags("{T}");
|
||||
items = derive(vm, {"{T}", "{U}"});
|
||||
CHECK(items.size() == 2);
|
||||
CHECK(items[0].work.kind == FxWorkKind::Online);
|
||||
CHECK(items[0].work.trackGuid == "{T}");
|
||||
CHECK(items[0].work.resumeAfterSlot == -1); // no drain progress survives a re-derive
|
||||
CHECK(items[0].work.resumeAfterFxGuid.empty());
|
||||
CHECK(!items[0].done);
|
||||
CHECK(!items[0].done && !items[1].done); // no drain progress survives a re-derive
|
||||
|
||||
// ...but {U}'s hold does survive it. {U} never stopped being parked, and restarting
|
||||
// its 3s on every unrelated model change would defer its reclaim indefinitely.
|
||||
@@ -313,34 +342,11 @@ static void testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain() {
|
||||
CHECK(host.released.count("{T}") == 1);
|
||||
}
|
||||
|
||||
// -- forward progress: the cursor cases that decide termination -----------------
|
||||
// -- forward progress -----------------------------------------------------------
|
||||
|
||||
static void testResumeCursorAdvancesPastAnFxWithNoIdentity() {
|
||||
// The case Wave 1's slot cursor exists for. Slot 1 is an FX REAPER reports no GUID
|
||||
// for; with the identity alone the cursor would read as "nothing done yet" and the
|
||||
// item would re-walk from slot 0 on every tick.
|
||||
ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = onlineChain({"{FX-A}", "", "{FX-C}"});
|
||||
host.stepCostMs = 50.0; // one plugin per tick on the offline budget
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
matureHold(items, clock, host);
|
||||
drainTick(items, clock, host);
|
||||
drainTick(items, clock, host);
|
||||
CHECK(items[0].work.resumeAfterFxGuid.empty()); // slot 1 has no identity to record
|
||||
CHECK(items[0].work.resumeAfterSlot == 1); // ...so the slot is what carries it
|
||||
|
||||
CHECK(drainToSettled(items, clock, host) == 1); // slot 2, and the item is exhausted
|
||||
const std::vector<std::string> expected{"{T}:0=off", "{T}:1=off", "{T}:2=off"};
|
||||
CHECK(host.writeLog == expected);
|
||||
}
|
||||
|
||||
static void testSlotKeyedOnlineRecordCarriesItsCursorAsASlot() {
|
||||
static void testSlotKeyedOnlineRecordRestoresByPosition() {
|
||||
// A record lifted from a project saved before FX identity existed: its targets hold
|
||||
// no identity, so a cursor taken from the LIVE identity would match none of them and
|
||||
// the item would re-walk its whole chain after every yield.
|
||||
// no identity at all, so position is the only thing that can address them.
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
vm.setActiveMode(kDesignModeId);
|
||||
@@ -351,16 +357,14 @@ static void testSlotKeyedOnlineRecordCarriesItsCursorAsASlot() {
|
||||
|
||||
FakeHost host;
|
||||
host.chains["{T}"] = offlineChain({"{FX-X}", "{FX-Y}", "{FX-Z}"});
|
||||
host.stepCostMs = kOnlineBudgetMs + 10.0; // one plugin per tick, then yield
|
||||
// Dearer per plugin than the whole online budget: the step still runs the chain to
|
||||
// the end, because the budget cannot split one track's move.
|
||||
host.stepCostMs = kOnlineBudgetMs + 10.0;
|
||||
ParkClock clock;
|
||||
|
||||
std::vector<ReclaimItem> items = derive(vm, {"{T}"});
|
||||
CHECK(items.size() == 1 && resumeBySlotOnly(items[0].work));
|
||||
drainTick(items, clock, host);
|
||||
CHECK(items[0].work.resumeAfterFxGuid.empty()); // NOT "{FX-X}", which matches no target
|
||||
CHECK(items[0].work.resumeAfterSlot == 0);
|
||||
|
||||
drainToSettled(items, clock, host);
|
||||
CHECK(items.size() == 1);
|
||||
CHECK(drainToSettled(items, clock, host) == 1);
|
||||
const std::vector<std::string> expected{"{T}:0=on", "{T}:2=on"};
|
||||
CHECK(host.writeLog == expected); // slot 1 stays offline, as the record captured
|
||||
CHECK(host.offlineAt("{T}", 1));
|
||||
@@ -381,7 +385,7 @@ static void testATrackThatLeftTheProjectCompletesRatherThanStalls() {
|
||||
int main() {
|
||||
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
|
||||
|
||||
testOfflineDrainYieldsOnBudgetAndResumesWhereItStopped();
|
||||
testAWholeChainMovesInOneStepAndTheYieldFallsBetweenTracks();
|
||||
testOnlineDrainGetsTheLargerBudget();
|
||||
testATickStartedOnOnlineWorkDoesNotSpendIntoOfflineWork();
|
||||
|
||||
@@ -389,8 +393,7 @@ int main() {
|
||||
|
||||
testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain();
|
||||
|
||||
testResumeCursorAdvancesPastAnFxWithNoIdentity();
|
||||
testSlotKeyedOnlineRecordCarriesItsCursorAsASlot();
|
||||
testSlotKeyedOnlineRecordRestoresByPosition();
|
||||
testATrackThatLeftTheProjectCompletesRatherThanStalls();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
|
||||
@@ -240,74 +240,6 @@ static void testSlotKeyedRecordStillRestoresByPosition() {
|
||||
CHECK(writeAt(res, 2, false));
|
||||
}
|
||||
|
||||
// -- the resume point ---------------------------------------------------------
|
||||
|
||||
static void testResumePointSkipsWhatTheDrainAlreadyDid() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
CHECK(vm.setActiveMode(kDesignModeId));
|
||||
std::vector<FxTrackWork> work = desiredFxState(vm, kDesignModeId, {"{T}"});
|
||||
CHECK(work.size() == 1);
|
||||
if (work.empty()) return;
|
||||
|
||||
work[0].resumeAfterFxGuid = "{FX-A}";
|
||||
const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(!anyWriteAt(res, 0)); // A is done
|
||||
CHECK(writeAt(res, 1, true));
|
||||
CHECK(writeAt(res, 2, false));
|
||||
|
||||
// Resuming after the LAST identity leaves nothing owed — the item is finished.
|
||||
work[0].resumeAfterFxGuid = "{FX-C}";
|
||||
CHECK(resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.empty());
|
||||
}
|
||||
|
||||
static void testResumePointThatLeftTheChainRestartsRatherThanStrands() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
CHECK(vm.setActiveMode(kDesignModeId));
|
||||
std::vector<FxTrackWork> work = desiredFxState(vm, kDesignModeId, {"{T}"});
|
||||
CHECK(work.size() == 1);
|
||||
if (work.empty()) return;
|
||||
|
||||
// The drain's cursor named a plugin the user has since deleted. Every write is
|
||||
// compare-before-write, so re-walking the item is cheap and correct; honoring a
|
||||
// cursor that matches nothing would strand the rest of the chain.
|
||||
work[0].resumeAfterFxGuid = "{FX-GONE}";
|
||||
const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(writeAt(res, 1, true));
|
||||
|
||||
// Same rule on the offline side, where the cursor keys into the live chain.
|
||||
ViewModeModel parked = parkedWithThreeFx(); // Arrange active ⇒ {T} parks
|
||||
std::vector<FxTrackWork> offlineWork = desiredFxState(parked, kArrangeModeId, {"{T}"});
|
||||
CHECK(offlineWork.size() == 1);
|
||||
if (offlineWork.empty()) return;
|
||||
offlineWork[0].resumeAfterFxGuid = "{FX-A}";
|
||||
CHECK(resolveFxWork(offlineWork[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.size() == 2);
|
||||
offlineWork[0].resumeAfterFxGuid = "{FX-GONE}";
|
||||
CHECK(resolveFxWork(offlineWork[0], {"{FX-A}", "{FX-B}", "{FX-C}"}).writes.size() == 3);
|
||||
}
|
||||
|
||||
static void testResumeAfterUnidentifiedFxMakesForwardProgress() {
|
||||
// The drain completed slot 1, an FX REAPER reports no GUID for. Before the
|
||||
// slot-cursor fix, an empty resumeAfterFxGuid was indistinguishable from
|
||||
// "nothing done yet" and this item would restart at slot 0 every tick,
|
||||
// forever — the non-terminating drain this test pins against.
|
||||
ViewModeModel parked = parkedWithThreeFx(); // Arrange active ⇒ {T} parks (Offline, slot-keyed)
|
||||
std::vector<FxTrackWork> work = desiredFxState(parked, kArrangeModeId, {"{T}"});
|
||||
CHECK(work.size() == 1);
|
||||
if (work.empty()) return;
|
||||
|
||||
work[0].resumeAfterFxGuid = "";
|
||||
work[0].resumeAfterSlot = 1;
|
||||
const FxRestoreResolution res =
|
||||
resolveFxWork(work[0], {"{FX-A}", "", "{FX-B}", "{FX-C}"});
|
||||
CHECK(res.writes.size() == 2); // slots 2 and 3 only — 0 and 1 already done
|
||||
CHECK(!anyWriteAt(res, 0));
|
||||
CHECK(!anyWriteAt(res, 1));
|
||||
CHECK(anyWriteAt(res, 2));
|
||||
CHECK(anyWriteAt(res, 3));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testActiveTrackIsOwedOnlineInactiveIsOwedOffline();
|
||||
testOnlineWorkSortsBeforeOfflineWork();
|
||||
@@ -321,10 +253,6 @@ int main() {
|
||||
testAddedFxIsLeftAlone();
|
||||
testSlotKeyedRecordStillRestoresByPosition();
|
||||
|
||||
testResumePointSkipsWhatTheDrainAlreadyDid();
|
||||
testResumePointThatLeftTheChainRestartsRatherThanStrands();
|
||||
testResumeAfterUnidentifiedFxMakesForwardProgress();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user