Files
reasampler/tests/test_bake_wire.cpp
T
daniel 1800bd64c3 Prove the bake's answer writes by reading the key back
SetProjExtState's return covers the whole extname, so it never saw one key. The persist verdict now reaches the report, and a throw mid-write no longer claims the landing left nothing behind.
2026-08-02 12:00:34 -04:00

803 lines
39 KiB
C++

// 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; and the
// write-back verdict, driven by a modelled key store that accepts or drops the write.
#include "../src/core/wire/bake_wire.h"
#include "../src/core/version/app_version.h"
#include <cstdint>
#include <cstdio>
#include <map>
#include <optional>
#include <string>
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)
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: "<len>':'<digits>".
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<std::string>(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<std::string>(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<std::string>(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<std::string>(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<std::string>(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<std::string>(""), sent);
CHECK(clearedFromAbsent.kind == BakeAnswerKind::Cleared);
CHECK(clearedFromEmpty.kind == BakeAnswerKind::Cleared);
CHECK(clearedFromAbsent.kind == clearedFromEmpty.kind);
CHECK(classifyBakeAnswer(std::optional<std::string>("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<std::string>(encodeBakeRequest(sent)), sent)
.outcome.has_value());
CHECK(!classifyBakeAnswer(std::optional<std::string>("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<std::string>(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<std::string>(encodeBakeRequest(sent)), sent)) == nullptr);
CHECK(answeredOutcome(classifyBakeAnswer(std::optional<std::string>("garbage"), sent))
== nullptr);
BakeOutcome foreign = landed;
foreign.generation = sent.generation + 1;
CHECK(answeredOutcome(classifyBakeAnswer(
std::optional<std::string>(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;
clean.landed = 1;
CHECK(describeBakeScan(clean).empty());
// A refusal is still an answer, so it silences the summary the same way a landing
// does — the refusal's own sentence has already been printed.
BakeScanTally refusedOne = clean;
refusedOne.landed = 0;
CHECK(describeBakeScan(refusedOne).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);
// 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.writeConfirmed = true;
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);
// 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.writeConfirmed = false;
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 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("could not persist it") != std::string::npos);
CHECK(memoryOnly != ok);
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.writeConfirmed = true;
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.writeConfirmed = true;
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.writeConfirmed = true;
CHECK(describeBakeKey(key, cleared).find("cleared unanswered") != std::string::npos);
BakeKeyOutcome clearLost = cleared;
clearLost.writeConfirmed = false;
const std::string stuck = describeBakeKey(key, clearLost);
CHECK(stuck.find("the clear could NOT be read back") != std::string::npos);
CHECK(stuck.find("next pass will see it again") != std::string::npos);
CHECK(stuck.find("it was cleared") == 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 rejected write is REACHABLE, and it is what the tally and the line come from ---
// The shell used to judge this by SetProjExtState's return, which is the size of the
// WHOLE extname's state — `banks` alone keeps that non-zero in every case a bake can
// reach, so `writeFailed` could not be produced at all and the sentence for it was dead
// code. The verdict is now the read-back below, which a store that drops the write does
// produce. The store is modelled here; the shell binds these same two calls to
// SetProjExtState and its existing grow-loop GetProjExtState read.
{
struct FakeKeyStore {
std::map<std::string, std::string> 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<std::string> read(const std::string& k) const {
const auto it = values.find(k);
return it == values.end() ? std::nullopt
: std::optional<std::string>(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(!bakeWriteLanded(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;
tally.landed = 1;
BakeKeyOutcome report;
report.verdict = BakeScanVerdict::Land;
report.landing = BakeLanding::Banked;
report.detail = landedOk.message;
report.writeConfirmed = bakeWriteLanded(answer, rejecting.read(key));
if (!report.writeConfirmed) ++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(bakeWriteLanded(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(bakeWriteLanded("", clearing.read(key)));
FakeKeyStore clearDropped;
clearDropped.values[key] = encodeBakeRequest(pending);
clearDropped.dropWrites = true;
clearDropped.write(key, "");
CHECK(!bakeWriteLanded("", 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(!bakeWriteLanded(answer, std::optional<std::string>(
answer.substr(0, answer.size() - 1))));
CHECK(!bakeWriteLanded(answer, std::optional<std::string>(
encodeBakeRequest(pending))));
CHECK(!bakeWriteLanded(answer, std::nullopt));
CHECK(!bakeWriteLanded("", std::optional<std::string>("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 =
"the bake reached the bank in memory, but this pass could not persist it, so "
"the project's saved bank state does not carry it";
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;
}