Extend the park trust test to the FX chain, name the refused tracks, and stop reprinting an unchanged refusal

This commit is contained in:
2026-08-05 17:10:19 -04:00
parent 203961f41c
commit 5376ab085c
8 changed files with 318 additions and 86 deletions
+2 -2
View File
@@ -47,8 +47,8 @@ Checks for Θ, Ξ, Ψ, Ε, Ρ, Γ, and Ω work that no unit test can close. Buil
- [ ] Solo tracks in Arrange, switch to Design, solo different tracks, switch back — each mode restores its own solo set verbatim (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] Attempt a mode switch while the transport is playing, then while recording — both refuse, visibly (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] Click the footer mode segment, save, reopen the project — the mode persisted (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] `[verify — DAW]` Reproduce the strand: project with FX in both modes, switch to Design, save, close, reopen, toggle to Arrange. Toggle back and forth several more times — the arrangement's FX must NOT converge on permanently offline/hidden. Any track the tool now refuses to park says so once in the console (`src/shell/view/view_fx_park.h`'s `decidePark`)
- [ ] `[verify — DAW]` For a track the console reports as left unparked: re-enable its TCP/mixer visibility, main send and FX enable by hand, then switch modes twice it parks and restores normally from there, with no further console line (the refusal's self-healing claim; unit-tested only as a decision, never against a live chain)
- [ ] `[verify — DAW]` Reproduce the strand: project with FX in both modes, switch to Design, save, close, reopen, toggle to Arrange. Toggle back and forth several more times — the arrangement's FX must NOT converge on permanently offline/hidden. A track the tool refuses to park is NAMED in the console, and the same refusal is not reprinted on a later reapply unless the refused set changed (`src/shell/view/view_fx_park.h`'s `decidePark` / `reportRefusedParks`)
- [ ] `[verify — DAW]` For a track the console names as left unparked, follow the message's recovery IN FULL: re-enable its TCP/mixer visibility, main send and FX enable, **and set every FX in its chain online** (FX enable is the chain bypass — it does not bring an individually offlined FX back). Then switch modes twice: it parks and restores normally, with no further console line. Do the flag half ONLY on a second such track and confirm it is still refused — the FX half of the trust test, and the reason the message spells the step out (unit-tested only as a decision, never against a live chain)
## Actions and drops
+4 -3
View File
@@ -64,9 +64,10 @@ target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# The deferred FX-park queue's re-entrancy rule is pure (header-inline, no REAPER
# types), so it is CTest-covered like a core/ module. Declared here rather than in a
# src/shell/view/CMakeLists.txt because that directory deliberately has none — its
# TUs are compiled into this target directly. view_mode_model is linked for
# makeRestorePlan alone: the cancel path's round trip is a contract between the two,
# and pinning it against hand-built ops would not catch a change to either half.
# TUs are compiled into this target directly. view_mode_model is linked for the plan
# builders the pure half is a contract WITH — makeRestorePlan's round trip through
# the cancel path, and makeParkPlan's flag set behind the trust test; pinning either
# against hand-built ops would not catch a change to the other half.
reasampler_test(view_fx_park LINK fx_offline view_mode_model)
# Bank-package import: the promptless verb plus its action skin. Kept as its own
+45 -15
View File
@@ -30,15 +30,25 @@ decide membership or mode rules.
(hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and
`TrackFX_SetOffline(track, fx, true)` for each FX (reclaim CPU) — full CPU-park,
not mix-removal-only.
- **A snapshot is only ever taken from a chain no park has touched.** An absent
snapshot is NOT evidence of a clean chain — `discardDeferredFxParks` drops
intents whose flag writes already landed, so a reloaded model and an
already-parked project routinely coexist. A park meeting that pair REFUSES:
no flag write, no FX-offline enqueue, no snapshot, and one console line naming
the hand recovery. Leaving the track as found is the only non-destructive
answer once the pre-park truth is gone — snapshotting there would record park
state as the user's state and every later restore would faithfully write it
back. Decision at `view_fx_park`'s `decidePark`.
- **A snapshot is only ever taken from a chain no park has touched — BOTH halves
of it.** The trust test reads the four driven flags AND the FX chain, and either
reading parked alone is enough to refuse: each half is the only source of truth
for its own half of the snapshot, so one of them lying poisons the whole record.
The FX half asks "is ANY FX offline", not "every" — no read distinguishes a
park's leftover from one the user set by hand, and only "any" errs toward
committing nothing false. An absent snapshot is NOT evidence of a clean chain —
`discardDeferredFxParks` drops intents whose flag writes already landed, so a
reloaded model and an already-parked project routinely coexist. A park meeting
that pair REFUSES: no flag write, no FX-offline enqueue, no snapshot, and a
console report NAMING the refused tracks (they are hidden from both the TCP and
the mixer, so a count points at nothing the user can go and find) plus the hand
recovery, printed once per changed refused set per project rather than on every
reapply. Leaving the track as found is the only non-destructive answer once the
pre-park truth is gone. **The refusal's cost is deliberate and it is not
symmetric with "hidden forever":** because it skips the FX enqueue too, a track
whose flags read parked but whose FX are still online keeps them online for
good. Refusing guarantees nothing false is written; it does not promise to
finish the park. Decision at `view_fx_park`'s `decidePark`.
- **Non-destructive restore.** For every flag the tool drives, snapshot the prior
value BEFORE parking; on toggle-back restore FROM the snapshot, never to a
hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag
@@ -60,10 +70,18 @@ decide membership or mode rules.
offline/online is enqueued and applied on a later idle tick (`view_fx_park`),
so the new mode paints first. The deferral changes only WHEN the plugins move —
they still unload and re-instantiate, and un-persisted internal state is still
lost. What it does change is the undo record: the offline writes land outside
the switch's undo block, so the tool no longer re-drives them on an undo or a
redo — what a Ctrl-Z then leaves the chain at is REAPER's own FX-state record,
`[verify — DAW]`. **The idle tick is not the only drain point.** Any path that
lost. What it does change is the undo record, and REAPER's behaviour there is
now OBSERVED, not assumed: the drain's `TrackFX_SetOffline` calls (the only
`TrackFX_SetOffline` in the tree) run outside any undo block, and REAPER mints
ONE implicit undo point per call — a small project measured 10 points for one
switch, 1 for the switch itself and 9 for its FX. Ctrl-Z walks them one at a
time, each step re-onlining a single FX, so one Ctrl-Z after a switch does not
bring the plugins back. It also puts the switch point BELOW every FX point: an
undo deep enough to restore the driven flags and roll the `"reasampler"` ext
state back has already re-onlined every FX, so flags, FX and snapshot move
together. The per-FX fragmentation is itself a defect to be closed by collapsing
the drain's writes into one undo point; nothing here depends on them staying
separate. **The idle tick is not the only drain point.** Any path that
serializes the view model drains synchronously first (`persistViewState`,
`render_in_place`), because a save landing between a restore's synchronous flag
writes and its drain would record offline FX beside a model that no longer
@@ -116,7 +134,7 @@ applies the resulting lane state to live tracks.
## Modules
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN`, with per-FX offline deferred to `view_fx_park`), restores from snapshot. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.**
- `view_fx_park` — the park surface: the `TrackFX_GetFXGUID` identity read snapshot/park/restore share, the deferred intent queue that keeps `TrackFX_SetOffline` off the switch's synchronous path (at most one intent per track GUID, latest wins, an intent landing on its own pending inverse cancels it), the idle-tick drain `main.cpp`'s `OnTimer` calls, and the pure per-track park decision (`decidePark` over "does the model hold a snapshot" × "does the chain already read parked", plus `trackFlagParm` the ONE `Flag` → REAPER-parameter mapping park's live read and restore's write both address). **The drain owns no model state.** A snapshot is dropped where the restore is PLANNED — the flags are back at their captured values from that moment, and a model that still described the track as parked would let a persist or a reapply inside the drain window replan a restore over whatever the user changed since. What the deferral costs instead is that the live FX chain stops being a trustworthy snapshot source while an intent is pending: a park that CANCELS a pending restore takes the pre-park FX states from that restore's ops (`preParkFxFromCancelledRestore`), because the chain still reads the parked values and the cancel means no drain will ever fix them.
- `view_fx_park` — the park surface: the `TrackFX_GetFXGUID` identity read snapshot/park/restore share, the deferred intent queue that keeps `TrackFX_SetOffline` off the switch's synchronous path (at most one intent per track GUID, latest wins, an intent landing on its own pending inverse cancels it), the idle-tick drain `main.cpp`'s `OnTimer` calls, the per-track park decision (`decidePark` over "does the model hold a snapshot" × "does the chain already read parked", with `decideParkForTrack` owning the live reads BOTH halves of that second term needs, and `trackFlagParm` the ONE `Flag` → REAPER-parameter mapping snapshot's read, park's read and restore's write all address), and the refusal report (`reportRefusedParks`, which names the tracks and holds the last-reported set so a reapply does not reprint it). **The drain owns no model state.** A snapshot is dropped where the restore is PLANNED — the flags are back at their captured values from that moment, and a model that still described the track as parked would let a persist or a reapply inside the drain window replan a restore over whatever the user changed since. What the deferral costs instead is that the live FX chain stops being a trustworthy snapshot source while an intent is pending: a park that CANCELS a pending restore takes the pre-park FX states from that restore's ops (`preParkFxFromCancelledRestore`), because the chain still reads the parked values and the cancel means no drain will ever fix them.
- `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`.
## Gotchas
@@ -141,7 +159,19 @@ applies the resulting lane state to live tracks.
track whose reloaded model carries no snapshot — nothing plans its restore, so
FX left offline stay offline, and its next park REFUSES rather than
re-snapshotting the parked chain (snapshot-source invariant above). Stuck, but
never falsely committed. Full contract at `discardDeferredFxParks`.
never falsely committed — and never finished either, since the refusal skips the
FX enqueue too. Full contract at `discardDeferredFxParks`.
- **Which routes actually reach that no-snapshot-plus-parked-chain pair.** An undo
of a mode switch is NOT one of them: per the observed undo semantics above, the
flags, the FX and the ext-state snapshots all come back at the same step, and
stopping short of it leaves the snapshot in place (so the next park is a
`ParkOnly`, not a refusal). What remains: a model replaced without the project
rolling back with it (an unreadable or absent `view_state` against an `.rpp`
saved parked), a snapshot `reconcile` pruned while its track was out of the live
enumeration and then undo-restored with the track, and a hand or script edit
that leaves a chain park-shaped. A track the USER keeps hidden from both panels,
out of the mix and FX-bypassed reads identically to all of them and is refused
too — no lost state there at all, which is why the report asserts no cause.
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
attached to its plugin across a chain mutation while parked. That is
`[verify — DAW]` (see `fxGuidString` in `view_fx_park.cpp`) and SWS issue #802 is a
+14 -21
View File
@@ -31,7 +31,6 @@
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_PreventUIRefresh
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_TrackList_AdjustWindows
@@ -152,11 +151,14 @@ MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) {
// The FX half is snapshotFxOffline's — only it knows when the live chain has
// stopped being a trustworthy source.
TrackSnapshot snapshotTrack(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelledRestore) {
auto read = [tr](Flag f) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, trackFlagParm(f)));
};
TrackSnapshot snap;
snap.showInTcp = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP"));
snap.showInMixer = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINMIXER"));
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
snap.showInTcp = read(Flag::ShowInTcp);
snap.showInMixer = read(Flag::ShowInMixer);
snap.mainSend = read(Flag::MainSend);
snap.fxEnable = read(Flag::FxEnable);
PreParkFx fx = snapshotFxOffline(tr, cancelledRestore);
snap.fxOffline = std::move(fx.states);
@@ -440,7 +442,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
int undoMask = kApplyUndoMask;
bool laneModeChanged = false;
int refusedParks = 0;
std::vector<MediaTrack*> refusedParks;
Undo_BeginBlock2(proj);
{
@@ -462,16 +464,10 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr) continue; // stale GUID — prune
// Full contract at decidePark. Only an absent snapshot can refuse, so
// the short-circuit cannot change the answer (ParkOnly ignores the
// second term); a first park still pays the four reads, a reapply's
// re-park of a still-parked track does not.
const bool haveSnapshot = model.snapshot(guid) != nullptr;
const ParkAction action = decidePark(
haveSnapshot,
!haveSnapshot &&
parkFlagsAlreadyApplied(tp.flags, readParkFlagValues(tr, tp.flags)));
if (action == ParkAction::Refuse) { ++refusedParks; continue; }
// Full contract at decidePark; view_fx_park owns the live reads.
const ParkAction action =
decideParkForTrack(tr, model.snapshot(guid) != nullptr, tp.flags);
if (action == ParkAction::Refuse) { refusedParks.push_back(tr); continue; }
// Enqueued FIRST: a park landing on this track's own pending restore
// cancels it, and those ops are then the only surviving record of the
@@ -542,11 +538,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
Undo_EndBlock2(proj, undoLabel.c_str(), undoMask);
// After the undo block and the UI hold: a refusal is a report, not a project
// write, and it must not join what a Ctrl-Z rolls back. "!SHOW:" so an
// unattended project-load reapply cannot force the console open
// (reaper_plugin_functions.h:6536), same as the drain's drop report.
const std::string refusedMsg = describeRefusedParks(refusedParks);
if (!refusedMsg.empty()) ShowConsoleMsg(("!SHOW:" + refusedMsg).c_str());
// write, and it must not join what a Ctrl-Z rolls back.
reportRefusedParks(proj, refusedParks);
return true;
}
+54 -6
View File
@@ -5,6 +5,7 @@
#include "shell/view/view_fx_park.h"
#include <cstring> // strnlen — bounded read of GetTrackName's buffer
#include <string>
#include <unordered_map>
#include <vector>
@@ -16,6 +17,7 @@
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetTrackName
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetFXGUID
@@ -33,6 +35,10 @@ FxParkQueue g_queue;
ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against
bool g_draining = false;
// The last refusal report, so an unchanged one is not reprinted on every reapply.
ReaProject* g_reportedOwner = nullptr;
std::vector<std::string> g_reportedRefusals;
// Makes "one drain at a time" explicit rather than implied by the call sites.
// RAII because an apply can throw and a stuck flag would silence the queue for
// the rest of the session.
@@ -98,14 +104,56 @@ std::string fxGuidString(MediaTrack* tr, int fx) {
return std::string(buf);
}
// ANY offline FX, not every one: a park offlines the whole chain, but a partial
// undo or a half-finished hand recovery leaves some back online, and there is no
// read that tells a park's leftover offline from one the user set. So the test
// refuses in the direction that commits nothing false — the cost is a chain
// carrying a hand-offlined plugin that the tool then declines to park.
bool anyFxOffline(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
for (int fx = 0; fx < fxCount; ++fx)
if (TrackFX_GetOffline(tr, fx)) return true;
return false;
}
// "Track N" for an unnamed track, matching REAPER's own convention (GetTrackName,
// not P_NAME, for the same reason capture::trackName picks it). The buffer is
// read bounded — the SDK documents no NUL within bufOut_sz. A track the report
// cannot name still has to appear in it, so the read failing yields a placeholder
// rather than dropping the entry.
std::string trackDisplayName(MediaTrack* tr) {
std::vector<char> buf(1024, '\0');
if (!GetTrackName || !GetTrackName(tr, buf.data(), static_cast<int>(buf.size())))
return "(name unavailable)";
return std::string(buf.data(), strnlen(buf.data(), buf.size()));
}
} // namespace
std::vector<int> readParkFlagValues(MediaTrack* tr, const std::vector<TrackFlagOp>& parkFlags) {
std::vector<int> live;
live.reserve(parkFlags.size());
for (const TrackFlagOp& op : parkFlags)
live.push_back(static_cast<int>(GetMediaTrackInfo_Value(tr, trackFlagParm(op.flag))));
return live;
ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot,
const std::vector<TrackFlagOp>& parkFlags) {
if (haveSnapshot) return decidePark(true, false); // second term unread — skip the reads
const bool flagsRead = parkFlagsAlreadyApplied(parkFlags, [tr](Flag f) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, trackFlagParm(f)));
});
return decidePark(false, chainReadsParked(flagsRead, anyFxOffline(tr)));
}
void reportRefusedParks(ReaProject* proj, const std::vector<MediaTrack*>& refused) {
ReaProject* const owner = proj ? proj : currentProject();
std::vector<std::string> names;
names.reserve(refused.size());
for (MediaTrack* tr : refused) names.push_back(trackDisplayName(tr));
if (owner == g_reportedOwner && names == g_reportedRefusals) return;
g_reportedOwner = owner;
g_reportedRefusals = names;
const std::string msg = describeRefusedParks(names);
// "!SHOW:" so an unattended project-load reapply cannot force the console open
// (reaper_plugin_functions.h:6536), same as the drain's drop report.
if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str());
}
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
+53 -19
View File
@@ -115,6 +115,11 @@ private:
// and silent. A model carrying no snapshot is NOT evidence the chain is clean:
// discardDeferredFxParks drops intents whose flag writes already landed, so a
// reloaded model and an already-parked project routinely coexist.
//
// A snapshot has TWO halves and the test covers both, disjunctively: the flags
// can read clean while the FX are still offline (a hand recovery that stopped at
// the flags, a reload) and vice versa. Either half reading parked poisons the
// whole snapshot, so either half alone refuses.
// The REAPER track parameter each driven flag addresses. One home for the
// mapping — park's live read, restore's write and the trust test below must
@@ -129,19 +134,28 @@ inline const char* trackFlagParm(Flag f) {
return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet
}
// The live values of exactly the flags `parkFlags` would write, in that order.
std::vector<int> readParkFlagValues(MediaTrack* tr, const std::vector<TrackFlagOp>& parkFlags);
// Does the track already sit at every value this park would write? Then a park
// reached it. An empty plan is evidence of nothing and answers false.
inline bool parkFlagsAlreadyApplied(const std::vector<TrackFlagOp>& parkFlags,
const std::vector<int>& liveValues) {
if (parkFlags.empty() || parkFlags.size() != liveValues.size()) return false;
for (std::size_t i = 0; i < parkFlags.size(); ++i)
if (liveValues[i] != parkFlags[i].value) return false;
// reached it. `readFlag` answers one Flag's live value; templating on it lets the
// live path compare as it reads (no per-track vector) while the tests drive the
// same fold from a table, keyed by Flag rather than by plan order. An empty plan
// is evidence of nothing and answers false.
template <class ReadFlag>
bool parkFlagsAlreadyApplied(const std::vector<TrackFlagOp>& parkFlags, ReadFlag readFlag) {
if (parkFlags.empty()) return false;
for (const TrackFlagOp& op : parkFlags)
if (readFlag(op.flag) != op.value) return false;
return true;
}
// The two halves folded. DISJUNCTIVE, and that is the whole point: each half is
// the sole source of truth for its own half of the snapshot, so one of them
// reading parked poisons the snapshot whatever the other says. An AND would let a
// flags-clean/FX-offline chain — what the documented hand recovery produces after
// its first step — bake the parked FX states in as the user's.
inline bool chainReadsParked(bool parkFlagsRead, bool anyFxOffline) {
return parkFlagsRead || anyFxOffline;
}
enum class ParkAction {
SnapshotThenPark, // clean chain — capture the pre-park truth, then park
ParkOnly, // still parked; the held snapshot IS the pre-park truth
@@ -158,18 +172,38 @@ inline ParkAction decidePark(bool haveSnapshot, bool chainReadsParked) {
return chainReadsParked ? ParkAction::Refuse : ParkAction::SnapshotThenPark;
}
// One console line for the tracks an apply refused, empty when none. Names the
// hand recovery: nothing else will bring a refused track back.
inline std::string describeRefusedParks(int trackCount) {
if (trackCount <= 0) return {};
return "ReaSampler: " + std::to_string(trackCount) + " track" +
(trackCount == 1 ? "" : "s") +
" left unparked — the pre-park state was lost (an undo/redo or a project "
"reload dropped it), and parking now would record the parked state as yours. "
"Restore each track's TCP/mixer visibility, main send and FX enable by hand; "
"the next mode switch will then park it correctly.\n";
// `decidePark` over the live track: the four driven flags AND the FX chain, which
// a snapshot taken here would record both halves of. Skips the reads on the
// ParkOnly path, where decidePark ignores the second term anyway.
ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot,
const std::vector<TrackFlagOp>& parkFlags);
// The console report for the tracks one apply refused, empty when none. NAMES
// them: a refused track is hidden from both the TCP and the mixer, so a count
// alone points at nothing the user can go and find. It asserts no cause — the
// pair it detects has several routes (src/shell/view/CLAUDE.md), and only some of
// them are anyone's mistake.
inline std::string describeRefusedParks(const std::vector<std::string>& trackNames) {
if (trackNames.empty()) return {};
std::string msg = "ReaSampler: " + std::to_string(trackNames.size()) + " track" +
(trackNames.size() == 1 ? "" : "s") +
" left unparked — no stored pre-park state, and a live state that "
"already reads as parked; parking now would record that as your own "
"state.\n";
for (const std::string& name : trackNames) msg += " " + name + "\n";
msg += "To bring a track back under mode control: restore its TCP and mixer visibility, "
"its main send and its FX enable, AND set every FX in its chain online — FX enable "
"is the chain bypass and does not bring an individually offlined FX back. The next "
"mode switch will then park it correctly.\n";
return msg;
}
// Prints describeRefusedParks for `refused` (track handles, named here), ONCE per
// changed set per project: the same refusal recurs on every reapply — every
// project open, every tag/untag — and repeating it verbatim is noise the user
// cannot act on any better the second time.
void reportRefusedParks(ReaProject* proj, const std::vector<MediaTrack*>& refused);
// The FX half of a snapshot, with the keying it must be read back under.
struct PreParkFx {
std::vector<FxOfflineState> states;
+62 -18
View File
@@ -242,31 +242,54 @@ static void testRestorePlanOpsRebuildTheSlotKeyedSnapshotVerbatim() {
// an absent snapshot — discardDeferredFxParks drops intents whose flag writes
// already landed — so the chain itself has to be asked.
// The live values the fold reads, keyed by Flag — so an assertion names the flag
// it varies rather than a position in makeParkPlan's op order.
struct FlagValues {
int showInTcp = 0, showInMixer = 0, mainSend = 0, fxEnable = 0;
int operator()(Flag f) const {
switch (f) {
case Flag::ShowInTcp: return showInTcp;
case Flag::ShowInMixer: return showInMixer;
case Flag::MainSend: return mainSend;
case Flag::FxEnable: return fxEnable;
}
return -1;
}
};
static void testAChainSittingAtEveryValueTheParkWouldWriteReadsAsParked() {
// The four zeros are what a parked track's driven flags actually read; if
// makeParkPlan ever writes something else, this is the test that says so.
const TrackPlan park = makeParkPlan("{A}", /*fxCount=*/0);
CHECK(park.flags.size() == 4);
CHECK(parkFlagsAlreadyApplied(park.flags, {0, 0, 0, 0}));
CHECK(parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 0, 0}));
}
static void testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain() {
const TrackPlan park = makeParkPlan("{A}", /*fxCount=*/0);
// Flag order is ShowInTcp, ShowInMixer, MainSend, FxEnable — each alone is
// enough to prove the chain was never parked.
CHECK(!parkFlagsAlreadyApplied(park.flags, {1, 0, 0, 0}));
CHECK(!parkFlagsAlreadyApplied(park.flags, {0, 1, 0, 0}));
CHECK(!parkFlagsAlreadyApplied(park.flags, {0, 0, 1, 0}));
CHECK(!parkFlagsAlreadyApplied(park.flags, {0, 0, 0, 1}));
// Each case varies exactly one flag, so together they also prove all four are
// in the plan: a missing op makes its case read parked and fail here.
CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{1, 0, 0, 0}));
CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 1, 0, 0}));
CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 1, 0}));
CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 0, 1}));
}
static void testAnEmptyOrMismatchedPlanProvesNothing() {
const TrackPlan park = makeParkPlan("{A}", /*fxCount=*/0);
static void testAnEmptyPlanProvesNothing() {
CHECK(!parkFlagsAlreadyApplied(std::vector<TrackFlagOp>{}, FlagValues{0, 0, 0, 0}));
}
CHECK(!parkFlagsAlreadyApplied({}, {}));
CHECK(!parkFlagsAlreadyApplied(park.flags, {0, 0, 0}));
static void testEitherHalfOfTheChainReadingParkedIsEnoughToRefuse() {
// The disjunction, pinned: a snapshot has a flag half and an FX half, and each
// is the only source of truth for its own. An AND here would re-open the
// defect one layer down — flags clean, FX still offline, snapshotted as truth.
CHECK(!chainReadsParked(/*parkFlagsRead=*/false, /*anyFxOffline=*/false));
CHECK(chainReadsParked(/*parkFlagsRead=*/true, /*anyFxOffline=*/false));
CHECK(chainReadsParked(/*parkFlagsRead=*/false, /*anyFxOffline=*/true));
CHECK(chainReadsParked(/*parkFlagsRead=*/true, /*anyFxOffline=*/true));
}
static void testACleanChainIsSnapshottedThenParked() {
@@ -288,11 +311,30 @@ static void testAParkedChainWithNoSnapshotIsRefusedRatherThanResnapshotted() {
CHECK(decidePark(/*haveSnapshot=*/false, /*chainReadsParked=*/true) == ParkAction::Refuse);
}
static void testRefusalIsReportedWithItsTrackCountAndSaysNothingWhenNoneWereRefused() {
CHECK(describeRefusedParks(0).empty());
CHECK(describeRefusedParks(-1).empty());
CHECK(describeRefusedParks(1).find("1 track ") != std::string::npos);
CHECK(describeRefusedParks(3).find("3 tracks ") != std::string::npos);
static void testRefusalNamesEveryRefusedTrackAndSaysNothingWhenNoneWere() {
CHECK(describeRefusedParks({}).empty());
const std::string one = describeRefusedParks({"Bass"});
CHECK(one.find("1 track ") != std::string::npos);
CHECK(one.find("Bass") != std::string::npos);
const std::string two = describeRefusedParks({"Bass", "Drum bus"});
CHECK(two.find("2 tracks ") != std::string::npos);
CHECK(two.find("Bass") != std::string::npos);
CHECK(two.find("Drum bus") != std::string::npos);
}
static void testRefusalRecoveryNamesThePerFxHalfAndAssertsNoCause() {
const std::string msg = describeRefusedParks({"Bass"});
// I_FXEN is the chain bypass: a user who restores only the four flags leaves
// every individually offlined FX offline and walks straight back into a
// refusal, so the recovery has to spell the per-FX step out.
CHECK(msg.find("every FX in its chain online") != std::string::npos);
// And it must not name a cause: the pair has several routes, and on a track
// the user themselves keeps hidden/bypassed there was no lost state at all.
CHECK(msg.find("undo") == std::string::npos);
CHECK(msg.find("lost") == std::string::npos);
}
// -- re-entrancy -------------------------------------------------------------
@@ -361,11 +403,13 @@ int main() {
testRestorePlanOpsRebuildTheSlotKeyedSnapshotVerbatim();
testAChainSittingAtEveryValueTheParkWouldWriteReadsAsParked();
testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain();
testAnEmptyOrMismatchedPlanProvesNothing();
testAnEmptyPlanProvesNothing();
testEitherHalfOfTheChainReadingParkedIsEnoughToRefuse();
testACleanChainIsSnapshottedThenParked();
testAHeldSnapshotIsNeverOverwrittenWhateverTheChainReads();
testAParkedChainWithNoSnapshotIsRefusedRatherThanResnapshotted();
testRefusalIsReportedWithItsTrackCountAndSaysNothingWhenNoneWereRefused();
testRefusalNamesEveryRefusedTrackAndSaysNothingWhenNoneWere();
testRefusalRecoveryNamesThePerFxHalfAndAssertsNoCause();
testTakeDetachesEverythingAndLeavesTheQueueEmpty();
testIntentsArrivingDuringADrainSurviveIt();
+84 -2
View File
@@ -756,9 +756,15 @@ namespace {
using LiveFlags = std::map<std::string, int>;
// Runs one applyMode-equivalent toggle against `vm` + `live`. `guard` selects the
// fixed (snapshot-once) behavior vs. the original buggy (snapshot-every-park) one.
// fixed behavior vs. the original buggy (snapshot-every-park) one.
// Returns nothing; mutates `vm` snapshots/active mode and `live` flags in place,
// exactly mirroring view.cpp's park then restore then setActiveMode ordering.
//
// Guarded, the park mirrors decidePark's three ways: a held snapshot parks
// without recapturing, a chain that already reads parked with no snapshot is
// REFUSED (left exactly as found), and only a clean chain is snapshotted. The one
// `live` flag stands in for the whole park-flag-plus-FX fold the shell reads —
// the model half of the defect is identical either way.
void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tree,
const std::string& target, bool guard) {
TogglePlan plan = vm.planToggle(tree, target);
@@ -767,7 +773,14 @@ void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tre
for (const auto& tp : plan.park) {
if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid;
if (!guard || vm.snapshot(guid) == nullptr) {
if (guard) {
if (vm.snapshot(guid) == nullptr) {
if (live[guid] == 0) continue; // reads parked, no snapshot — refuse
TrackSnapshot snap;
snap.showInTcp = live[guid];
vm.storeSnapshot(guid, snap);
}
} else {
TrackSnapshot snap;
snap.showInTcp = live[guid]; // capture the LIVE visible flag
vm.storeSnapshot(guid, snap);
@@ -868,6 +881,74 @@ static void testNestedToggleSnapshotSurvivesRepark() {
}
}
// -- 9b. Reload strand: snapshots gone, live flags still parked ---------------
//
// The pair the park refusal exists for, driven through the same harness. A model
// replaced without the project rolling back with it — an unreadable view_state, a
// snapshot reconciled away while its track was deleted, a hand or script edit —
// leaves tracks sitting at parked values with nothing recording what they were
// before. Snapshotting there commits the parked state as the user's, and the very
// next toggle writes it back over whatever they have since fixed by hand.
static void testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting() {
const FolderTree tree = nestedTree();
// GUARDED (fixed shell).
{
ViewModeModel vm;
vm.membership().tag("{L1}", kDesignModeId);
LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}};
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true);
CHECK(live["{L2}"] == 0 && live["{L3}"] == 0);
CHECK(vm.snapshot("{L2}") != nullptr);
// The reload: the model comes back with no snapshots while the project's
// tracks are still parked.
vm.clearSnapshot("{L2}");
vm.clearSnapshot("{L3}");
// The load tick reapplies the saved active mode. Both leaves are refused —
// nothing written, and nothing false captured.
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true);
CHECK(vm.snapshot("{L2}") == nullptr && vm.snapshot("{L3}") == nullptr);
CHECK(live["{L2}"] == 0 && live["{L3}"] == 0);
// The documented hand recovery, on {L2} only, while still in Design.
// Toggling back must leave it where the user put it: with no snapshot
// there is nothing to restore FROM, so the restore writes nothing.
live["{L2}"] = 1;
simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true);
CHECK(live["{L2}"] == 1); // the hand fix survives
CHECK(live["{L3}"] == 0); // never recovered — stuck, but never falsely committed
// And {L2} is back under mode control from there: the next park sees a
// clean chain, captures the user's value, and restores it.
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true);
CHECK(live["{L2}"] == 0 && vm.snapshot("{L2}") != nullptr);
simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true);
CHECK(live["{L2}"] == 1);
}
// UNGUARDED (the defect): the reapply after the reload recaptures the parked
// zero, so the very next toggle writes it back over the hand recovery.
{
ViewModeModel vm;
vm.membership().tag("{L1}", kDesignModeId);
LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}};
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/false);
vm.clearSnapshot("{L2}");
vm.clearSnapshot("{L3}");
simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/false);
live["{L2}"] = 1; // the same hand recovery
simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/false);
CHECK(live["{L2}"] == 0); // wiped — the false snapshot won
}
}
// ===========================================================================
// D2 two-canvas lane extension tests
// ===========================================================================
@@ -2125,6 +2206,7 @@ int main() {
testUntaggedLeavesManagedByModeSystem();
testTaggedLeafBehaviorUnchangedWithUntagged();
testNestedToggleSnapshotSurvivesRepark();
testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting();
testReconcilePrunesOrphanedSnapshots();
testReconcileFullLiveSetIsNoOp();
testReconcileThenReparkLifecycleIntact();