Collapse a Design View mode switch and its background FX parking to ONE undo point

applyMode now returns with its undo block open; the reclaimer closes it when the drain
settles. A transport deferral or a tab switch ends it early -- two points, never a leak.
This commit is contained in:
2026-08-07 12:53:42 -04:00
parent 2173263bb9
commit 86aa8dabba
7 changed files with 449 additions and 50 deletions
+193 -1
View File
@@ -6,7 +6,9 @@
// the user's save rather than on the drain, and — via a host that invalidates from inside
// a write — that a re-entered invalidation reaches nothing the drain is using. The queue
// owns the project pin and the timer's wakeup for the same reason: a compare-only pointer
// makes the tab switch assertable.
// makes the tab switch assertable. The undo span is here on the same terms: its state
// machine is pure, so open-once/close-once and the wait it may or may not outlast are
// assertable; only the Undo_BeginBlock2/EndBlock2 calls themselves need a DAW.
#include "../src/shell/view/fx_reclaim.h"
@@ -59,6 +61,12 @@ struct FakeHost {
bool rolling = false; // the transport gate's input
// Stands in for the shell's one span instance: fx_reclaim.cpp opens it on the first
// real write of a drain, exactly here, and widens it to carry the FX bit. Only the
// Undo_BeginBlock2/EndBlock2 calls themselves are out of reach without a DAW.
UndoSpan* span = nullptr;
int spanOpens = 0;
double nowMs() const { return clock; }
bool transportRolling() const { return rolling; }
@@ -76,6 +84,10 @@ struct FakeHost {
if (fxIndex < 0 || fxIndex >= static_cast<int>(it->second.size())) return false;
if (it->second[static_cast<std::size_t>(fxIndex)].offline == offline) return false;
if (span) {
if (span->begin(project, kReclaimUndoLabel, 2)) ++spanOpens;
span->widen(2);
}
it->second[static_cast<std::size_t>(fxIndex)].offline = offline;
clock += stepCostMs;
writeLog.push_back(guid + ":" + std::to_string(fxIndex) + (offline ? "=off" : "=on"));
@@ -1011,6 +1023,180 @@ static void testAGatedTickStillGoesIdleWithNothingLeftToConverge() {
CHECK(q.run(host).settled);
}
// -- 10. the undo span ----------------------------------------------------------------
static void testTheSpanOpensOnceAndClosesOnceHoweverManyTimesItIsAsked() {
// The whole safety of the design is in these two answers: a caller may say begin or
// end unconditionally and still make no second Undo_BeginBlock2 and no unbalanced
// Undo_EndBlock2. A leaked open block swallows everything the user does afterwards.
UndoSpan span;
CHECK(!span.open());
CHECK(!span.end()); // nothing open — the caller owes REAPER nothing
CHECK(span.begin(kProjA, "ReaSampler: activate Design view", 9));
CHECK(span.open());
CHECK(span.project() == kProjA);
CHECK(span.label() == "ReaSampler: activate Design view");
CHECK(span.mask() == 9);
// The drain's own begin, arriving under a switch's span: it must adopt nothing —
// neither the other project, nor a label that would rename the user's switch.
CHECK(!span.begin(kProjB, kReclaimUndoLabel, 2));
CHECK(span.project() == kProjA);
CHECK(span.label() == "ReaSampler: activate Design view");
CHECK(span.mask() == 9);
span.widen(2); // ...but the FX bit it DID write under still has to reach the point
CHECK(span.mask() == 11);
span.relabel("ReaSampler: activate Arrange view", 1);
CHECK(span.label() == "ReaSampler: activate Arrange view");
CHECK(span.mask() == 1);
CHECK(span.end());
CHECK(!span.open());
CHECK(!span.end()); // the second close reaches nothing
CHECK(span.project() == nullptr);
// And nothing a closed span carries can be revived out from under the next opener.
span.widen(4);
span.relabel("stale", 8);
CHECK(span.mask() == 0);
CHECK(span.label().empty());
}
static void testTheSpanOutlastsADebounceWaitButNeverATransportDeferral() {
// The one policy call: the span may stay open across a wait it can BOUND. The 3s
// debounce is the dead time between the switch and its first FX write, and spanning
// it is the whole point. A transport deferral lasts as long as the user plays.
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
FakeHost host;
host.chains["{T}"] = onlineChain({"{FX-A}"});
FxReclaimQueue q = queueOf(vm, {"{T}"});
const ReclaimRunResult holding = q.run(host); // the hold starts at t=0
CHECK(!holding.settled);
CHECK(!holding.transportDeferred);
CHECK(!spanEndsAfter(holding)); // bounded by kParkHoldMs — the span holds
host.clock += kParkHoldMs;
host.rolling = true;
const ReclaimRunResult gated = q.run(host);
CHECK(gated.writes == 0);
CHECK(!gated.settled);
CHECK(gated.transportDeferred);
CHECK(spanEndsAfter(gated)); // unbounded — the switch's point closes without it
host.rolling = false;
const ReclaimRunResult done = q.run(host);
CHECK(done.writes == 1);
CHECK(done.settled);
CHECK(!done.transportDeferred);
CHECK(spanEndsAfter(done));
}
static void testAnAbortEndsTheSpanSoNoLaterSwitchNestsUnderIt() {
ViewModeModel vm = parkedDesignLeaf({"{FX-A}"});
FakeHost host;
host.chains["{T}"] = onlineChain({"{FX-A}"});
FxReclaimQueue q = queueOf(vm, {"{T}"});
q.run(host);
host.clock += kParkHoldMs;
host.reentrant = &q;
host.invalidateAfterWrite = 1;
const ReclaimRunResult aborted = q.run(host);
CHECK(aborted.aborted);
CHECK(!aborted.settled);
CHECK(spanEndsAfter(aborted)); // the model moved; this span's work is superseded
}
static void testOneSwitchAndItsMultiTickParkingAreOneSpan() {
// Three tracks at a cost that admits one per tick, so the parking really does span
// several timer callbacks — the case a per-write block turned into one undo point per
// FX toggle. The span must open exactly once and close exactly once, on the tick that
// settles, under the SWITCH's label rather than the reclaimer's.
ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}"}},
{"{T2}", {"{FX-B}"}},
{"{T3}", {"{FX-C}"}}});
FakeHost host;
host.chains["{T1}"] = onlineChain({"{FX-A}"});
host.chains["{T2}"] = onlineChain({"{FX-B}"});
host.chains["{T3}"] = onlineChain({"{FX-C}"});
host.stepCostMs = 50.0; // dearer than kOfflineBudgetMs ⇒ one track per tick
// What applyMode leaves behind on a real switch: an open span, its label set, and no
// close — the reclaimer's to finish.
UndoSpan span;
CHECK(span.begin(kProjA, "", 0));
span.relabel("ReaSampler: activate Design view", 9);
host.span = &span;
int closes = 0;
FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}", "{T3}"});
auto tick = [&]() {
const ReclaimRunResult r = q.run(host);
if (spanEndsAfter(r) && span.end()) ++closes;
return r;
};
CHECK(!tick().settled); // hold starts
host.clock += kParkHoldMs;
CHECK(!tick().settled); // {T1}
CHECK(!tick().settled); // {T2}
CHECK(tick().settled); // {T3}, and the queue is exhausted in the same pass
CHECK(host.writeLog.size() == 3);
CHECK(host.spanOpens == 0); // the switch's span was already open — no second begin
CHECK(closes == 1); // ...and exactly one close, on the settling tick
CHECK(!span.open());
// Extra ticks after the settle must not produce a second close.
tick();
CHECK(closes == 1);
}
static void testADrainNoSwitchOpenedMintsOnePointOfItsOwn() {
// The non-switch origins — a membership edit's reapply, the load tick, a drain
// resuming after the transport stopped. Nobody opened a span, so the drain opens its
// own: one point for the whole convergence, never one per FX toggle.
ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}},
{"{T2}", {"{FX-C}"}}});
FakeHost host;
host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"});
host.chains["{T2}"] = onlineChain({"{FX-C}"});
host.stepCostMs = 50.0;
UndoSpan span;
host.span = &span;
int closes = 0;
FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}"});
auto tick = [&]() {
const ReclaimRunResult r = q.run(host);
if (spanEndsAfter(r) && span.end()) ++closes;
return r;
};
CHECK(!tick().settled); // hold starts; nothing written, so nothing opened yet
CHECK(!span.open());
CHECK(host.spanOpens == 0);
host.clock += kParkHoldMs;
CHECK(!tick().settled); // {T1}'s chain: the first write opens the span
CHECK(host.spanOpens == 1);
CHECK(span.open());
CHECK(span.label() == kReclaimUndoLabel);
CHECK(span.project() == kProjA);
CHECK(tick().settled); // {T2}, still under the SAME span
CHECK(host.spanOpens == 1);
CHECK(host.writeLog.size() == 3);
CHECK(closes == 1);
CHECK(!span.open());
}
int main() {
testFastFlipMovesZeroPluginsButAHeldParkStillReclaims();
@@ -1048,6 +1234,12 @@ int main() {
testAHoldDropsWhenItsTrackStopsOwingOfflineWorkWhileRolling();
testAGatedTickStillGoesIdleWithNothingLeftToConverge();
testTheSpanOpensOnceAndClosesOnceHoweverManyTimesItIsAsked();
testTheSpanOutlastsADebounceWaitButNeverATransportDeferral();
testAnAbortEndsTheSpanSoNoLaterSwitchNestsUnderIt();
testOneSwitchAndItsMultiTickParkingAreOneSpan();
testADrainNoSwitchOpenedMintsOnePointOfItsOwn();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}