// Standalone tests for reasampler::wire::bake_wire — no VST3, no REAPER, no framework. // Same fast assert loop as the sibling wire tests. // // Covers: the exact bytes each record encodes to (the two artifacts ship independently, so // a field reorder or an inserted field must fail here rather than pass a round-trip and // break a mixed-version pair); request + outcome round-trips including bytes that would // break a delimiter-based format; the refusals every house wire record shares (wrong tag, // truncation, trailing garbage, a swapped record kind); an unrecognized status integer // degrading to Failed rather than to Ok; the action lookup name's leading underscore and // its channel fork; the two key classifiers each end reads the shared key through; the // write-back verdict, driven by a modelled key store that accepts or drops the write; and // the persist/upgrade state machine that is the ONLY route to a Banked landing, at both // persist outcomes. #include "../src/core/wire/bake_wire.h" #include "../src/core/version/app_version.h" #include "../src/core/wire/ext_state_read.h" // extStateWriteLanded (the write-proof peer) #include #include #include #include #include using namespace reasampler::wire; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // The one answer bake_land emits for a landing its pass could not persist. Pinned once // here and used by both blocks below that need it, so the test suite does not become a // third place the sentence lives. static const std::string kUnpersistedAnswer = "the bake reached the bank in memory, but this pass's persist did not report success, " "so this answer cannot promise a reload will find it"; int main() { // --- The exact bytes on the wire ------------------------------------------------- // A round-trip alone would pass a reordered or inserted field; the tags exist to guard // the LAYOUT, so the layout is what is pinned. Changing either literal below means an // already-shipped pair of artifacts can no longer talk — bump the tag, don't edit it. { BakeRequest req; req.instanceGuid = "abcd"; req.stagedFilePath = "T/b.wav"; req.sourceSampleId = "cap-1"; req.sourceRelativePath = "bank/k.wav"; req.sourceDisplayName = "Kick"; req.ownUsageKey = "rsusage_abcd"; req.rootNote = 36; req.generation = 1893456000; CHECK(encodeBakeRequest(req) == "rsbakereq1" "4:abcd" "7:T/b.wav" "5:cap-1" "10:bank/k.wav" "4:Kick" "12:rsusage_abcd" "2:36" "10:1893456000"); BakeOutcome out; out.status = BakeStatus::Ok; out.sampleId = "bake-1"; out.relativePath = "bank/k2.wav"; out.displayName = "Kick r2"; out.rootNote = 36; out.channelCount = 2; out.replaced = true; out.message = "replaced"; out.generation = 1893456000; CHECK(encodeBakeOutcome(out) == "rsbakeout1" "1:0" "6:bake-1" "11:bank/k2.wav" "7:Kick r2" "2:36" "1:2" "1:1" "8:replaced" "10:1893456000"); } // --- Request round-trip, with hostile field content ----------------------------- { BakeRequest req; req.instanceGuid = "0123abcd"; req.stagedFilePath = "C:/Temp/re:sampler 9000/bake 12:34.wav"; // colons + spaces req.sourceSampleId = "cap-1"; req.sourceRelativePath = "reasampler_bank/kick.wav"; req.sourceDisplayName = "Kick r2"; req.ownUsageKey = "rsusage_0123abcd"; req.rootNote = 36; req.generation = 1893456000; const std::string encoded = encodeBakeRequest(req); const auto decoded = decodeBakeRequest(encoded); CHECK(decoded.has_value()); CHECK(*decoded == req); // Empty strings and a zero generation survive too (a first, un-named source). BakeRequest bare; CHECK(decodeBakeRequest(encodeBakeRequest(bare)) == bare); } // --- Outcome round-trip ----------------------------------------------------------- { BakeOutcome out; out.status = BakeStatus::Ok; out.sampleId = "bake-1893456000-kick_1893456000.wav"; out.relativePath = "reasampler_bank/kick_1893456000.wav"; out.displayName = "Kick r3"; out.rootNote = 36; out.channelCount = 2; out.replaced = true; out.message = "replaced the bank entry"; out.generation = 1893456000; const auto decoded = decodeBakeOutcome(encodeBakeOutcome(out)); CHECK(decoded.has_value()); CHECK(*decoded == out); CHECK(decoded->replaced); out.replaced = false; CHECK(decodeBakeOutcome(encodeBakeOutcome(out))->replaced == false); } // --- Malformed input is refused, never half-parsed --------------------------------- { BakeRequest req; req.instanceGuid = "abc"; req.rootNote = 60; const std::string good = encodeBakeRequest(req); CHECK(!decodeBakeRequest("").has_value()); CHECK(!decodeBakeRequest("rsbakereq0" + good.substr(10)).has_value()); // wrong tag CHECK(!decodeBakeRequest(good.substr(0, good.size() - 3)).has_value()); // truncated CHECK(!decodeBakeRequest(good + "junk").has_value()); // trailing // The two records share a key; each must refuse the other's bytes outright. CHECK(!decodeBakeOutcome(good).has_value()); CHECK(!decodeBakeRequest(encodeBakeOutcome(BakeOutcome{})).has_value()); } // --- A status integer this build does not know reads as a FAILURE ------------------ { // Hand-built with a future status value; every other field is well-formed, so only // the vocabulary gap is under test. BakeOutcome out; out.status = BakeStatus::Ok; out.generation = 7; std::string wire = encodeBakeOutcome(out); // The status field is the first after the tag: "':'". const std::string okField = "1:0"; const std::size_t at = wire.find(okField); CHECK(at != std::string::npos); wire.replace(at, okField.size(), "2:99"); const auto decoded = decodeBakeOutcome(wire); CHECK(decoded.has_value()); CHECK(decoded->status == BakeStatus::Failed); // never Ok CHECK(decoded->generation == 7); // Every status this build DOES know survives its own round trip — including the // most recently appended one, which an older reader will see as Failed. for (const BakeStatus s : {BakeStatus::Ok, BakeStatus::Failed, BakeStatus::NoProject, BakeStatus::StagedMissing, BakeStatus::NoSource, BakeStatus::IndexRejected, BakeStatus::WrongProject}) { BakeOutcome one; one.status = s; const auto back = decodeBakeOutcome(encodeBakeOutcome(one)); CHECK(back.has_value() && back->status == s); } } // --- The lookup name carries the underscore the registration string does not ------- // The load-bearing half is the REGISTRATION string: main.cpp registers that spelling // verbatim, and NamedCommandLookup needs exactly one underscore in front of it. If // channelCommandId ever grew one of its own, the lookup would carry two and resolve to // nothing. { const std::string registered = reasampler::version::channelCommandId(kBakeActionSuffix); const std::string lookup = bakeActionLookupName(); const std::size_t suffixLen = std::string(kBakeActionSuffix).size(); CHECK(!registered.empty()); CHECK(registered.front() != '_'); CHECK(lookup.size() == registered.size() + 1); CHECK(lookup.front() == '_' && lookup[1] != '_'); CHECK(lookup.compare(1, std::string::npos, registered) == 0); // The suffix is the TAIL of the id — the channel prefix goes in front of it, and a // suffix that drifted into the middle would name a different action. CHECK(lookup.size() > suffixLen); CHECK(lookup.compare(lookup.size() - suffixLen, suffixLen, kBakeActionSuffix) == 0); } // --- The action id and the ext-state namespace fork on the SAME channel bit --------- // Both frozen families are spelled out, so this holds whichever channel the test binary // was built for. Consequence for a cross-channel pair (stable VST + beta extension or // the reverse): NamedCommandLookup resolves nothing, bakeAvailable paints the affordance // Disabled, and the click cannot reach the no-answer path at all. { const std::string suffix = kBakeActionSuffix; const std::string stableLookup = "_CEREBELLUM_REASAMPLER_" + suffix; const std::string betaLookup = "_CEREBELLUM_REASAMPLER_BETA_" + suffix; const bool beta = reasampler::version::isBeta(); CHECK(stableLookup != betaLookup); CHECK(bakeActionLookupName() == (beta ? betaLookup : stableLookup)); CHECK(bakeActionLookupName() != (beta ? stableLookup : betaLookup)); CHECK(reasampler::version::extStateNamespace() == (beta ? "reasampler_beta" : "reasampler")); } // --- What the instrument finds under its own key, after the action returned --------- { BakeRequest sent; sent.instanceGuid = "0123abcd"; sent.stagedFilePath = "C:/Temp/reasampler_bake_0123abcd_1893456000.wav"; sent.sourceSampleId = "cap-1"; sent.sourceRelativePath = "reasampler_bank/kick.wav"; sent.sourceDisplayName = "Kick"; sent.ownUsageKey = "rsusage_0123abcd"; sent.rootNote = 36; sent.generation = 1893456000; BakeOutcome answered; answered.status = BakeStatus::Ok; answered.sampleId = "bake-1"; answered.message = "added as a distinct capture"; answered.generation = sent.generation; const BakeAnswer ok = classifyBakeAnswer(std::optional(encodeBakeOutcome(answered)), sent); CHECK(ok.kind == BakeAnswerKind::Answered); CHECK(ok.outcome.has_value() && ok.outcome->sampleId == "bake-1"); // A refusal is an ANSWER — it must never fold into a no-answer kind, or the user is // sent to reinstall a binary that in fact answered them. BakeOutcome refused; refused.status = BakeStatus::WrongProject; refused.message = "this bake's project tab is not the one the extension has loaded"; refused.generation = sent.generation; const BakeAnswer refusal = classifyBakeAnswer(std::optional(encodeBakeOutcome(refused)), sent); CHECK(refusal.kind == BakeAnswerKind::Answered); CHECK(refusal.outcome.has_value() && refusal.outcome->status == BakeStatus::WrongProject); BakeOutcome older = answered; older.generation = sent.generation - 1; const BakeAnswer foreignOut = classifyBakeAnswer(std::optional(encodeBakeOutcome(older)), sent); CHECK(foreignOut.kind == BakeAnswerKind::ForeignOutcome); CHECK(foreignOut.outcome.has_value()); // Nothing on the extension side read the key: the request is still sitting there // byte-for-byte. THE diagnostic that separates a stale/absent landing from a refusal. CHECK(classifyBakeAnswer(std::optional(encodeBakeRequest(sent)), sent) .kind == BakeAnswerKind::Unanswered); // A request that is not ours: two instances copied from one another share a // persisted instanceGuid, so they name one key. BakeRequest sibling = sent; sibling.sourceSampleId = "cap-2"; sibling.generation = sent.generation + 1; CHECK(classifyBakeAnswer(std::optional(encodeBakeRequest(sibling)), sent) .kind == BakeAnswerKind::ForeignRequest); // Cleared folds two genuinely different bridge outcomes -- key absent, and key // present but empty -- into ONE kind, because the bridge cannot label which // occurred. Both must land on the identical kind: a caller's message may claim // no more than what the fold actually preserves. const BakeAnswer clearedFromAbsent = classifyBakeAnswer(std::nullopt, sent); const BakeAnswer clearedFromEmpty = classifyBakeAnswer(std::optional(""), sent); CHECK(clearedFromAbsent.kind == BakeAnswerKind::Cleared); CHECK(clearedFromEmpty.kind == BakeAnswerKind::Cleared); CHECK(clearedFromAbsent.kind == clearedFromEmpty.kind); CHECK(classifyBakeAnswer(std::optional("rsbakeout9 whatever"), sent) .kind == BakeAnswerKind::Undecodable); // No non-Answered kind may carry an outcome the caller could read as a landing. CHECK(!classifyBakeAnswer(std::nullopt, sent).outcome.has_value()); CHECK(!classifyBakeAnswer(std::optional(encodeBakeRequest(sent)), sent) .outcome.has_value()); CHECK(!classifyBakeAnswer(std::optional("garbage"), sent) .outcome.has_value()); } // --- answeredOutcome: the guarded read, not switch exhaustiveness alone ------------- { BakeRequest sent; sent.instanceGuid = "abcd"; sent.generation = 42; BakeOutcome landed; landed.status = BakeStatus::Ok; landed.sampleId = "bake-1"; landed.generation = sent.generation; // The one state that may yield a non-null pointer, and it points at the SAME // outcome classifyBakeAnswer decoded. const BakeAnswer answered = classifyBakeAnswer(std::optional(encodeBakeOutcome(landed)), sent); const BakeOutcome* ptr = answeredOutcome(answered); CHECK(ptr != nullptr); CHECK(ptr->sampleId == "bake-1"); // Every other real classification nulls out. CHECK(answeredOutcome(classifyBakeAnswer(std::nullopt, sent)) == nullptr); CHECK(answeredOutcome(classifyBakeAnswer( std::optional(encodeBakeRequest(sent)), sent)) == nullptr); CHECK(answeredOutcome(classifyBakeAnswer(std::optional("garbage"), sent)) == nullptr); BakeOutcome foreign = landed; foreign.generation = sent.generation + 1; CHECK(answeredOutcome(classifyBakeAnswer( std::optional(encodeBakeOutcome(foreign)), sent)) == nullptr); // The defensive arm: a hand-built Answered with no outcome set (what a future // BakeAnswerKind enumerator falling through an un-updated switch would look like // to this accessor) fails closed rather than being dereferenced. BakeAnswer malformed; malformed.kind = BakeAnswerKind::Answered; CHECK(!malformed.outcome.has_value()); CHECK(answeredOutcome(malformed) == nullptr); } // --- kMaxRequestAgeSeconds: a regression pin on the bound itself -------------------- // The bound's meaning depends on the instrument stamping `generation` immediately // before publishing (AFTER staging the WAV) -- a shell-side ordering fix this pure // suite cannot observe directly. Pinning the literal at least catches a silent // widen/narrow of the budget itself. CHECK(kMaxRequestAgeSeconds == 30); // --- The extension's per-key verdict over the open tabs ----------------------------- { const std::int64_t now = 1893456000; // The session has polled a project and that project is REAPER's active tab — the // steady state a project opened from disk reaches on the next timer tick. const BakeScanContext loadedAndActive{true}; BakeScanKey own{true, true, now, true}; CHECK(classifyBakeScan(loadedAndActive, own, now) == BakeScanVerdict::Land); // A request found in a tab the extension has NOT loaded — the multi-tab case. It is // REFUSED, which is an answer the asking instance can read; it is never landed into // the loaded tab's bank, and never dropped silently. BakeScanKey otherTab{true, true, now, false}; CHECK(classifyBakeScan(loadedAndActive, otherTab, now) == BakeScanVerdict::RefuseWrongProject); // The loaded tab is no longer the active one, so a persist would write elsewhere: // even the loaded tab's own request is refused rather than half-landed. CHECK(classifyBakeScan(BakeScanContext{false}, own, now) == BakeScanVerdict::RefuseWrongProject); // The window before any project is loaded: matchesLoadedProject cannot be true for // ANY key here — a key can only match a project that is loaded — so this is the // shell-reachable stand-in for "no book yet", not `own` (which the shell could // never actually pair with an unloaded session). CHECK(classifyBakeScan(BakeScanContext{false}, BakeScanKey{true, true, now, false}, now) == BakeScanVerdict::RefuseWrongProject); // Stale in EITHER direction (a clock that moved backwards counts too). BakeScanKey old{true, true, now - kMaxRequestAgeSeconds - 1, true}; BakeScanKey future{true, true, now + kMaxRequestAgeSeconds + 1, true}; CHECK(classifyBakeScan(loadedAndActive, old, now) == BakeScanVerdict::ClearStale); CHECK(classifyBakeScan(loadedAndActive, future, now) == BakeScanVerdict::ClearStale); // Exactly at the bound is still landable — the ceiling is inclusive. BakeScanKey atBound{true, true, now - kMaxRequestAgeSeconds, true}; CHECK(classifyBakeScan(loadedAndActive, atBound, now) == BakeScanVerdict::Land); // Staleness outranks the project check: a request nobody can read is cleared, not // answered, wherever it sits. BakeScanKey oldElsewhere{true, true, now - kMaxRequestAgeSeconds - 1, false}; CHECK(classifyBakeScan(loadedAndActive, oldElsewhere, now) == BakeScanVerdict::ClearStale); // A value that is not a request (an outcome the writer has not collected) is left // alone — answering it would clobber an answer in flight. BakeScanKey notARequest{true, false, 0, true}; CHECK(classifyBakeScan(loadedAndActive, notARequest, now) == BakeScanVerdict::IgnoreNotARequest); CHECK(classifyBakeScan(BakeScanContext{false}, notARequest, now) == BakeScanVerdict::IgnoreNotARequest); // A key the enumerator listed but the reader could not return whole is its OWN // verdict, not folded into the one above: the extension failing to read a request // that may well be there is a different fault from a key holding something else, // and neither is visible from the instrument's end. BakeScanKey unreadable{false, false, 0, true}; CHECK(classifyBakeScan(loadedAndActive, unreadable, now) == BakeScanVerdict::IgnoreUnreadable); CHECK(classifyBakeScan(BakeScanContext{false}, unreadable, now) == BakeScanVerdict::IgnoreUnreadable); // Unreadable outranks even staleness — an age read off a request that was never // decoded would be a made-up number. BakeScanKey unreadableOld{false, false, now - kMaxRequestAgeSeconds - 1, true}; CHECK(classifyBakeScan(loadedAndActive, unreadableOld, now) == BakeScanVerdict::IgnoreUnreadable); } // --- The scan summary: silent ONLY when nothing went unanswered --------------------- // `answered` is pass-wide and counts a QUEUED write, so gating on it alone let two real // faults print nothing: a pass that answered another key while skipping ours, and a // pass whose own answer write was rejected. Those two are what the gate below pins. { BakeScanTally clean; clean.tabsScanned = 1; clean.keysFound = 1; clean.activeTabKeys = 1; clean.answered = 1; CHECK(describeBakeScan(clean).empty()); // THE regression: some OTHER key was answered while ours was skipped. Under the old // `answered > 0` gate this printed nothing at all, and the instrument then told the // user a silent console proved the action never ran. for (int BakeScanTally::*skip : {&BakeScanTally::unreadable, &BakeScanTally::notARequest, &BakeScanTally::staleCleared}) { BakeScanTally mixed = clean; mixed.keysFound = 2; mixed.*skip = 1; const std::string said = describeBakeScan(mixed); CHECK(!said.empty()); CHECK(said.find("left 1 unanswered") != std::string::npos); } // THE other regression: our answer was queued (so `answered` counted it) and the // SetProjExtState write was rejected. Nothing else went wrong, and it must still // speak. BakeScanTally rejected = clean; rejected.writeFailed = 1; const std::string wrote = describeBakeScan(rejected); CHECK(!wrote.empty()); CHECK(wrote.find("1 answer could not be written back") != std::string::npos); CHECK(wrote.find("sees no answer at all") != std::string::npos); // Plural agrees, and the count is the tally's own, not a hardcoded 1. BakeScanTally rejectedTwo = clean; rejectedTwo.answered = 2; rejectedTwo.writeFailed = 2; CHECK(describeBakeScan(rejectedTwo).find("2 answers could not be written back") != std::string::npos); // An answer whose write-and-check THREW is neither landed nor known-failed, and it // must break the silence too: the whole point of the gate is that no fault in the // pass leaves the asking instance with a no-answer and an empty console. BakeScanTally unproven = clean; unproven.writeUnproven = 1; const std::string unsure = describeBakeScan(unproven); CHECK(!unsure.empty()); CHECK(unsure.find("1 answer could not be checked after writing") != std::string::npos); CHECK(unsure.find("is unknown") != std::string::npos); // ...and it is NOT reported as a rejection, which is a different claim. CHECK(unsure.find("could not be written back") == std::string::npos); BakeScanTally unprovenTwo = clean; unprovenTwo.answered = 2; unprovenTwo.writeUnproven = 2; CHECK(describeBakeScan(unprovenTwo).find("2 answers could not be checked") != std::string::npos); // Nothing found at all: the observation is stated and the candidates listed, with // no winner picked — the enumeration's visibility of a key set but not yet saved is // undocumented, so "not the same project's ext state" may not name it. BakeScanTally nothing; nothing.tabsScanned = 2; const std::string none = describeBakeScan(nothing); CHECK(!none.empty()); CHECK(none.find("2 project tabs") != std::string::npos); CHECK(none.find("No rsbake_ key was visible") != std::string::npos); CHECK(none.find("this pass cannot tell which apart") != std::string::npos); CHECK(none.find("are not reading the same project") == std::string::npos); CHECK(none.back() == '\n'); // One tab is singular, not "1 project tabs". BakeScanTally oneTab; oneTab.tabsScanned = 1; CHECK(describeBakeScan(oneTab).find("1 project tab.") != std::string::npos); // Found but unreadable — the fact the old scan discarded. BakeScanTally unreadable; unreadable.tabsScanned = 1; unreadable.keysFound = 1; unreadable.activeTabKeys = 1; unreadable.unreadable = 1; const std::string unread = describeBakeScan(unreadable); CHECK(unread.find("1 could not be read back") != std::string::npos); CHECK(unread.find("held something other than a request") == std::string::npos); // keysFound counts EVERY rsbake_ key, not pending requests only — a label that // said otherwise produced "3 pending request keys ... 3 held something else". CHECK(unread.find("1 rsbake_ key") != std::string::npos); CHECK(unread.find("pending request key") == std::string::npos); // Found, readable, but not a request. BakeScanTally foreign; foreign.tabsScanned = 1; foreign.keysFound = 1; foreign.activeTabKeys = 1; foreign.notARequest = 1; const std::string other = describeBakeScan(foreign); CHECK(other.find("1 held something other than a request") != std::string::npos); CHECK(other.find("could not be read back") == std::string::npos); // Cleared as stale: an answer was never written, so this too must report. The // summary may NOT say the clears were written — whether each one took is a per-key // read-back, and only describeBakeKey has seen it. BakeScanTally stale; stale.tabsScanned = 1; stale.keysFound = 1; stale.activeTabKeys = 1; stale.staleCleared = 1; const std::string aged = describeBakeScan(stale); CHECK(aged.find("past the age bound") != std::string::npos); CHECK(aged.find("were cleared") == std::string::npos); // activeTabKeys is REAPER's ACTIVE tab and nothing more. It cannot discriminate the // multi-tab mis-target — a genuine background-tab request classifies as // RefuseWrongProject, which is an ANSWER — so no sentence may claim it does, and // none may call it "the tab this bake was fired against" (whether REAPER makes the // invoking instance's project current is DAW-unverified). BakeScanTally elsewhere; elsewhere.tabsScanned = 2; elsewhere.keysFound = 1; elsewhere.activeTabKeys = 0; elsewhere.notARequest = 1; const std::string away = describeBakeScan(elsewhere); CHECK(away.find("0 of them in REAPER's active tab") != std::string::npos); CHECK(away.find("fired against") == std::string::npos); } // --- The per-key line: the only thing that names WHICH key --------------------------- // The tally counts; it cannot say whose key was skipped. This line is printed for every // enumerated key, answered or not, and the key carries the asking instance's guid — so // it is what lets one instance find its own verdict in a multi-instance session. { const std::string key = "rsbake_0123abcd"; BakeKeyOutcome landed; landed.verdict = BakeScanVerdict::Land; landed.landing = BakeLanding::Banked; landed.proof = BakeWriteProof::Confirmed; landed.detail = "added as a distinct capture"; const std::string ok = describeBakeKey(key, landed); CHECK(ok.find(key) != std::string::npos); CHECK(ok.find("landed into the bank") != std::string::npos); CHECK(ok.find("The answer was written back") != std::string::npos); CHECK(ok.back() == '\n'); // The outcome's own reason rides the SAME line: one self-contained line per key, // rather than a keyed verdict and an unkeyed reason the reader has to pair up. CHECK(ok.find("(added as a distinct capture)") != std::string::npos); // The sentence names the OBSERVATION behind the flag (see BakeLanding) and stops // there — the write was ISSUED into a saved project. It may not claim REAPER took // the value, nor that the .rpp on disk already holds the entry. CHECK(ok.find("issued its bank write into the saved project") != std::string::npos); CHECK(ok.find("read back") == std::string::npos); CHECK(ok.find(".rpp") == std::string::npos); CHECK(ok.find("carries it") == std::string::npos); // A landing whose answer write was REJECTED — the state the instrument reads as // Unanswered. This line is the only place it is ever named. BakeKeyOutcome lost = landed; lost.proof = BakeWriteProof::Rejected; const std::string dropped = describeBakeKey(key, lost); CHECK(dropped.find("landed into the bank") != std::string::npos); CHECK(dropped.find("could NOT be written back") != std::string::npos); CHECK(dropped.find("will report no answer") != std::string::npos); // The write-and-check itself failing is a THIRD state, not a rejection: claiming // the answer did not land would be a fact this pass never observed. BakeKeyOutcome unchecked = landed; unchecked.proof = BakeWriteProof::Unknown; const std::string unsure = describeBakeKey(key, unchecked); CHECK(unsure.find("is unknown") != std::string::npos); CHECK(unsure.find("could NOT be written back") == std::string::npos); CHECK(unsure.find("The answer was written back") == std::string::npos); CHECK(unsure != ok && unsure != dropped); // The four landing states are four different lines: a bake the project never // persisted, and one that threw mid-write, may not read as a clean success or as a // clean refusal. BakeKeyOutcome unpersisted = landed; unpersisted.landing = BakeLanding::Unpersisted; const std::string memoryOnly = describeBakeKey(key, unpersisted); CHECK(memoryOnly.find("IN MEMORY ONLY") != std::string::npos); CHECK(memoryOnly.find("persist did not report success") != std::string::npos); CHECK(memoryOnly != ok); // It may NOT claim what the project's saved state holds: the persist may never // have run at all, and a dedup hit's target may have been in the project since // long before this pass. CHECK(memoryOnly.find("does not carry it") == std::string::npos); BakeKeyOutcome partial = landed; partial.landing = BakeLanding::Partial; partial.detail = "the landing failed while writing: bad allocation"; const std::string half = describeBakeKey(key, partial); CHECK(half.find("after it had begun writing") != std::string::npos); CHECK(half.find("may have left a file") != std::string::npos); CHECK(half.find("the landing was refused") == std::string::npos); BakeKeyOutcome bankRefused; bankRefused.verdict = BakeScanVerdict::Land; bankRefused.landing = BakeLanding::Refused; bankRefused.proof = BakeWriteProof::Confirmed; bankRefused.detail = "the bank refused the new capture"; const std::string refusedLine = describeBakeKey(key, bankRefused); CHECK(refusedLine.find("the landing was refused") != std::string::npos); // The refusal's REASON is what the keyed line used to lack entirely. CHECK(refusedLine.find("(the bank refused the new capture)") != std::string::npos); BakeKeyOutcome wrongTab; wrongTab.verdict = BakeScanVerdict::RefuseWrongProject; wrongTab.proof = BakeWriteProof::Confirmed; wrongTab.detail = "this bake's project tab is not the one the extension has loaded -- focus that " "tab and try again"; const std::string refused = describeBakeKey(key, wrongTab); CHECK(refused.find("not the one the extension has loaded") != std::string::npos); CHECK(refused.find("focus that tab and try again") != std::string::npos); CHECK(refused.find("The answer was written back") != std::string::npos); // A clear is a write too: it may be claimed only where it was read back. BakeKeyOutcome cleared; cleared.verdict = BakeScanVerdict::ClearStale; cleared.proof = BakeWriteProof::Confirmed; CHECK(describeBakeKey(key, cleared).find("cleared unanswered") != std::string::npos); BakeKeyOutcome clearLost = cleared; clearLost.proof = BakeWriteProof::Rejected; const std::string stuck = describeBakeKey(key, clearLost); CHECK(stuck.find("the clear did NOT take") != std::string::npos); CHECK(stuck.find("next pass will see it again") != std::string::npos); CHECK(stuck.find("it was cleared") == std::string::npos); // Rejected also covers a clear that was never issued, so the line may not describe // a read that did not happen. CHECK(stuck.find("read back") == std::string::npos); // And the clear's third state, same as an answer's: unchecked is not disproven. BakeKeyOutcome clearUnchecked = cleared; clearUnchecked.proof = BakeWriteProof::Unknown; const std::string maybeCleared = describeBakeKey(key, clearUnchecked); CHECK(maybeCleared.find("whether the clear took is unknown") != std::string::npos); CHECK(maybeCleared.find("it was cleared unanswered") == std::string::npos); CHECK(maybeCleared.find("could NOT be read back") == std::string::npos); BakeKeyOutcome notRequest; notRequest.verdict = BakeScanVerdict::IgnoreNotARequest; CHECK(describeBakeKey(key, notRequest).find("other than a pending request") != std::string::npos); // Absent and Overflow both yield IgnoreUnreadable, but they are different faults to // go fix, so the line keeps them apart where the verdict cannot. BakeKeyOutcome empty; empty.verdict = BakeScanVerdict::IgnoreUnreadable; BakeKeyOutcome huge = empty; huge.oversized = true; const std::string emptyLine = describeBakeKey(key, empty); const std::string hugeLine = describeBakeKey(key, huge); CHECK(emptyLine.find("read back empty") != std::string::npos); CHECK(hugeLine.find("too large to read back whole") != std::string::npos); CHECK(emptyLine != hugeLine); // Every line names its own key, so two instances' lines are never confusable. CHECK(describeBakeKey("rsbake_ffff0000", landed).find("rsbake_ffff0000") != std::string::npos); CHECK(describeBakeKey("rsbake_ffff0000", landed) != ok); // A LANDING this build has no word for must not be reported as a VERDICT gap: the // two are different enums and send a reader to two different places. BakeKeyOutcome futureLanding = landed; futureLanding.landing = static_cast(99); const std::string unnamed = describeBakeKey(key, futureLanding); CHECK(unnamed.find("a landing state this build has no word for") != std::string::npos); CHECK(unnamed.find("a verdict this build has no word for") == std::string::npos); // A VERDICT gap still reports as one. BakeKeyOutcome futureVerdict; futureVerdict.verdict = static_cast(99); CHECK(describeBakeKey(key, futureVerdict).find( "a verdict this build has no word for") != std::string::npos); } // --- The persist/upgrade state machine: the ONE route to Banked ----------------------- // The shell assigns every Land verdict's landing without knowing whether the pass's // persist ran, then threads the flag through here. The bug this pins: a dedup hit used // to be answered Banked directly, on the grounds that it changed nothing — false // exactly when the entry it deduped against was one the SAME pass had just added and // then failed to persist, which answers Ok for an entry the project does not carry. // // Both limbs are live in the shell, not just in this table: it assigns the landing // inside a guarded scan and sets its `persisted` local only in the block after it, so a // throw between the two reaches Unpersisted with a real landing behind it. { // A dedup hit and a fresh add are INDISTINGUISHABLE here, by construction: the shell // assigns Unpersisted to both, so both need the same observation to be promoted. CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, true) == BakeLanding::Banked); CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, false) == BakeLanding::Unpersisted); // Exhaustive over both arguments: (Unpersisted, persisted) is the ONLY pair that // produces Banked from anything else, and nothing else is altered at all — so a // persist that ran cannot launder a refusal or a half-written landing into a // success, and a persist that did not cannot demote one. for (const BakeLanding from : {BakeLanding::Refused, BakeLanding::Partial, BakeLanding::Unpersisted, BakeLanding::Banked}) { for (const bool persisted : {false, true}) { const BakeLanding to = bakeLandingAfterPersist(from, persisted); const bool promotes = from == BakeLanding::Unpersisted && persisted; CHECK(to == (promotes ? BakeLanding::Banked : from)); CHECK((to == BakeLanding::Banked) == (promotes || from == BakeLanding::Banked)); } } // A dedup hit driven end to end at both persist outcomes: what the instrument is // told and what the console prints both follow the flag, and they never disagree. for (const bool persisted : {true, false}) { BakeKeyOutcome deduped; deduped.verdict = BakeScanVerdict::Land; deduped.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, persisted); // A landing the pass could not persist is re-encoded as a FAILURE, not the Ok // it was headed for, so no instance adopts an entry a reload may not find. BakeOutcome answer; answer.status = BakeStatus::Ok; answer.sampleId = "bake-1"; answer.generation = 1893456000; if (deduped.landing == BakeLanding::Unpersisted) { answer.status = BakeStatus::Failed; answer.message = kUnpersistedAnswer; } const auto back = decodeBakeOutcome(encodeBakeOutcome(answer)); CHECK(back.has_value()); CHECK(back.has_value() && back->status == (persisted ? BakeStatus::Ok : BakeStatus::Failed)); // The console line and the wire answer never disagree about the same key. deduped.proof = BakeWriteProof::Confirmed; const std::string line = describeBakeKey("rsbake_0123abcd", deduped); CHECK((line.find("IN MEMORY ONLY") != std::string::npos) == !persisted); } } // --- A rejected write is REACHABLE, and it is what the tally and the line come from --- // Judging this by SetProjExtState's return made `writeFailed` unproducible on the // landing path and its sentence dead code (extStateWriteLanded owns why). The verdict is // the read-back, which a store that drops the write does produce. The store is modelled // here; the two shells that bind the verdict make these same two calls, to // SetProjExtState and the grow-loop read. { struct FakeKeyStore { std::map values; bool dropWrites = false; void write(const std::string& k, const std::string& v) { if (dropWrites) return; // REAPER rejected it if (v.empty()) values.erase(k); // SetProjExtState("") deletes else values[k] = v; } std::optional read(const std::string& k) const { const auto it = values.find(k); return it == values.end() ? std::nullopt : std::optional(it->second); } }; const std::string key = "rsbake_0123abcd"; BakeRequest pending; pending.instanceGuid = "0123abcd"; pending.generation = 1893456000; BakeOutcome landedOk; landedOk.status = BakeStatus::Ok; landedOk.sampleId = "bake-1"; landedOk.message = "added as a distinct capture"; landedOk.generation = pending.generation; const std::string answer = encodeBakeOutcome(landedOk); // Rejected: the key still holds the request the instrument left there. FakeKeyStore rejecting; rejecting.values[key] = encodeBakeRequest(pending); rejecting.dropWrites = true; rejecting.write(key, answer); CHECK(!extStateWriteLanded(answer, rejecting.read(key))); // ...which is exactly what the ASKING end reads as a no-answer. The two classifiers // agree about this one state, which is why the extension has to name it. CHECK(classifyBakeAnswer(rejecting.read(key), pending).kind == BakeAnswerKind::Unanswered); // The shell's write loop from that verdict through to both strings it prints. BakeScanTally tally; tally.tabsScanned = 1; tally.keysFound = 1; tally.activeTabKeys = 1; tally.answered = 1; BakeKeyOutcome report; report.verdict = BakeScanVerdict::Land; // The shell reaches Banked only through the upgrade — never by assignment. report.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, true); report.detail = landedOk.message; report.proof = extStateWriteLanded(answer, rejecting.read(key)) ? BakeWriteProof::Confirmed : BakeWriteProof::Rejected; if (report.proof == BakeWriteProof::Rejected) ++tally.writeFailed; CHECK(tally.writeFailed == 1); CHECK(describeBakeKey(key, report).find("could NOT be written back") != std::string::npos); CHECK(describeBakeScan(tally).find("1 answer could not be written back") != std::string::npos); // The same store ACCEPTING the write: the verdict flips, the tally stays at zero and // the summary goes silent — so neither answer above is a constant. FakeKeyStore accepting; accepting.values[key] = encodeBakeRequest(pending); accepting.write(key, answer); CHECK(extStateWriteLanded(answer, accepting.read(key))); CHECK(classifyBakeAnswer(accepting.read(key), pending).kind == BakeAnswerKind::Answered); BakeScanTally clean = tally; clean.writeFailed = 0; CHECK(describeBakeScan(clean).empty()); // A CLEAR is proven the other way round: it lands as an ABSENT key, and a dropped // clear leaves the stale request standing for the next pass to find. FakeKeyStore clearing; clearing.values[key] = encodeBakeRequest(pending); clearing.write(key, ""); CHECK(extStateWriteLanded("", clearing.read(key))); FakeKeyStore clearDropped; clearDropped.values[key] = encodeBakeRequest(pending); clearDropped.dropWrites = true; clearDropped.write(key, ""); CHECK(!extStateWriteLanded("", clearDropped.read(key))); // Byte equality is the claim the line makes: a truncated or foreign value under the // key is not the answer we wrote, and an unreadable key proves nothing at all. CHECK(!extStateWriteLanded(answer, std::optional( answer.substr(0, answer.size() - 1)))); CHECK(!extStateWriteLanded(answer, std::optional( encodeBakeRequest(pending)))); CHECK(!extStateWriteLanded(answer, std::nullopt)); CHECK(!extStateWriteLanded("", std::optional("leftover"))); } // --- Every outcome bake_land actually emits survives the key round trip ------------- // The landing writes these and the instrument reads them back; a field the encoder and // the decoder disagreed about would strand exactly the bake that produced it. { BakeOutcome added; added.status = BakeStatus::Ok; added.sampleId = "bake-1893456000-a1b2c3d4-kick_1893456000.wav"; added.relativePath = "reasampler_bank/kick_1893456000-a1b2c3d4.wav"; added.displayName = "Kick 2"; added.rootNote = 36; added.channelCount = 2; added.replaced = false; added.message = "added as a distinct capture"; added.generation = 1893456000; BakeOutcome replaced = added; replaced.replaced = true; replaced.displayName = "Kick"; replaced.message = "replaced the bank entry"; BakeOutcome deduped = added; deduped.message = "identical to an existing capture -- pointed at it"; BakeOutcome noProject; noProject.status = BakeStatus::NoProject; noProject.message = "no saved project, so the bank has no location"; noProject.generation = added.generation; BakeOutcome stagedMissing = noProject; stagedMissing.status = BakeStatus::StagedMissing; stagedMissing.message = "the staged render is not a usable WAV"; BakeOutcome noSource = noProject; noSource.status = BakeStatus::NoSource; noSource.message = "the resampled capture is not in any bank"; BakeOutcome indexRejected = noProject; indexRejected.status = BakeStatus::IndexRejected; indexRejected.message = "the bank refused the new capture"; BakeOutcome wrongProject = noProject; wrongProject.status = BakeStatus::WrongProject; wrongProject.message = "this bake's project tab is not the one the extension has " "loaded -- focus that tab and try again"; BakeOutcome writeFailed = noProject; writeFailed.status = BakeStatus::Failed; writeFailed.message = "could not write the bake into the bank folder"; // A landing the project would not persist is answered as a FAILURE, not as the Ok // it was on its way to being: the entry exists in memory only, so an instance that // adopted it would be pointing at something no reload will have. BakeOutcome unpersisted = noProject; unpersisted.status = BakeStatus::Failed; unpersisted.message = kUnpersistedAnswer; BakeOutcome partial = noProject; partial.status = BakeStatus::Failed; partial.message = "the landing failed while writing: bad allocation"; for (const BakeOutcome& emitted : {added, replaced, deduped, noProject, stagedMissing, noSource, indexRejected, wrongProject, writeFailed, unpersisted, partial}) { const auto back = decodeBakeOutcome(encodeBakeOutcome(emitted)); CHECK(back.has_value()); CHECK(back.has_value() && *back == emitted); // Field for field as well: operator== is hand-written, so a field missing from // BOTH the codec and the comparison would pass the aggregate check above. CHECK(back.has_value() && back->status == emitted.status && back->sampleId == emitted.sampleId && back->relativePath == emitted.relativePath && back->displayName == emitted.displayName && back->rootNote == emitted.rootNote && back->channelCount == emitted.channelCount && back->replaced == emitted.replaced && back->message == emitted.message && back->generation == emitted.generation); } } if (g_fail == 0) std::printf("bake_wire: all tests passed\n"); return g_fail ? 1 : 0; }