// Standalone tests for shell/package/export_bank — no REAPER, no framework. The // export reads the session through inline accessors only, so a real ReaSamplerSession // and a real bank folder on disk are both constructible here. // // Mid-stream failure is INJECTED rather than simulated: the digest pass and the // stream pass are separate public calls, so a source file removed or rewritten // between them is exactly the concurrent-edit case the stream pass re-checks for. #include "../src/shell/package/export_bank.h" #include #include #include #include #include #include #include #include #include #include #include "../src/core/capture/wav_codec.h" #include "../src/core/model/bank_book.h" #include "../src/core/package/bank_package.h" #include "../src/shell/package/package_io.h" #include "../src/shell/package/package_path.h" #include "../src/shell/persist/session.h" using namespace reasampler; namespace fs = std::filesystem; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // --- scratch filesystem ------------------------------------------------------- static std::string g_root; static std::string scratchRoot() { if (g_root.empty()) { std::error_code ec; const fs::path p = fs::temp_directory_path(ec) / "reasampler_export_tests"; fs::remove_all(p, ec); fs::create_directories(p, ec); g_root = p.generic_string(); } return g_root; } static std::vector patternBytes(std::size_t n, std::uint8_t seed) { std::vector v(n); for (std::size_t i = 0; i < n; ++i) v[i] = static_cast(seed + i * 7u); return v; } static void writeFile(const std::string& path, const std::vector& bytes) { std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc); f.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); } static std::vector readFile(const std::string& path) { std::ifstream f(utf8Path(path), std::ios::binary); return std::vector(std::istreambuf_iterator(f), std::istreambuf_iterator()); } static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); } // --- fixture project ---------------------------------------------------------- // One scratch project directory with a bank folder, plus the session whose book // names its contents. `fileNames` are written into the bank folder with distinct // byte patterns; a name in `omit` gets an index entry but NO file on disk. struct Fixture { std::string projectDir; ReaSamplerSession session; std::string bankId = "bank-1"; explicit Fixture(const std::string& tag) { projectDir = scratchRoot() + "/" + tag; std::error_code ec; fs::create_directories(utf8Path(projectDir + "/reasampler_bank"), ec); session.book().createBank(bankId, "Drums " + tag); } void addSample(const std::string& id, const std::string& fileName, std::size_t bytes, std::uint8_t seed, bool writeToDisk = true, const std::string& displayName = "") { model::Sample s; s.id = id; s.displayName = displayName.empty() ? id : displayName; s.relativePath = std::string("reasampler_bank/") + fileName; s.sampleRate = 48000; s.channelCount = 2; s.contentHash = "hash-" + id; CHECK(session.book().index(bankId)->add(s) == model::AddResult::Added); session.book().reconcileSlots(); if (writeToDisk) writeFile(absPathOf(fileName), patternBytes(bytes, seed)); } std::string absPathOf(const std::string& fileName) const { return projectDir + "/reasampler_bank/" + fileName; } std::string destPath() const { return projectDir + "/out.rsbank"; } ExportRequest request() const { ExportRequest req; req.projectDir = projectDir; req.bankId = bankId; req.destAbsPath = destPath(); req.exportTimestamp = 1234567890; return req; } }; // --- package readback --------------------------------------------------------- // Decodes an emitted package straight off disk, growing the prefix read the way the // format's own requiredPrefixSize seam asks callers to. static package::DecodedPackage decodeFromDisk(const std::string& path) { PackageFileReader reader(path); const std::uint64_t size = reader.fileSize(); std::vector prefix; for (int guard = 0; guard < 8; ++guard) { const std::optional need = package::requiredPrefixSize(prefix); if (!need) break; if (*need <= prefix.size()) break; PayloadBuffer buf = reader.readRange(0, *need); if (buf.empty()) break; prefix.assign(buf.data(), buf.data() + buf.size()); } return package::decodePackage(prefix, size); } // --- tests -------------------------------------------------------------------- static void testHealthyExportCarriesEveryPayloadByteExact() { Fixture fx("healthy"); fx.addSample("s1", "kick.wav", 800, 1); fx.addSample("s2", "snare.wav", 1300, 60); fx.addSample("s3", "hat.wav", 97, 200); const ExportOutcome out = exportBank(fx.session, fx.request()); CHECK(out.status == ExportStatus::Written); CHECK(out.entriesWritten == 3); CHECK(out.excluded.empty()); CHECK(PayloadBuffer::alive() == 0); // Point-in-time alive() == 0 alone cannot fail on a whole-package-in-memory // shape (N buffers allocated and freed one at a time still ends at 0); the // high-water mark can, across this three-entry export and everything the test // binary ran before it — it must never exceed the "at most one payload" claim. CHECK(PayloadBuffer::highWaterMark() == 1); const package::DecodedPackage decoded = decodeFromDisk(fx.destPath()); CHECK(decoded.status == package::PackageReadability::Readable); CHECK(decoded.manifest.entries.size() == 3); CHECK(decoded.layout.size() == 3); CHECK(decoded.manifest.bankDisplayName == "Drums healthy"); CHECK(decoded.manifest.exportTimestamp == 1234567890); // PER ENTRY, not in aggregate: the digest the package records, the digest of the // payload actually stored at that entry's span, and the digest of the source file // on disk must all be the same string. PackageFileReader reader(fx.destPath()); const std::vector sourceNames = {"kick.wav", "snare.wav", "hat.wav"}; CHECK(decoded.manifest.entries.size() == sourceNames.size()); for (std::size_t i = 0; i < decoded.manifest.entries.size(); ++i) { const package::PackageEntry& entry = decoded.manifest.entries[i]; const PayloadBuffer stored = reader.readRange(decoded.layout[i].offset, decoded.layout[i].length); CHECK(!stored.empty()); const std::vector source = readFile(fx.absPathOf(sourceNames[i])); const std::string sourceDigest = capture::hashBytes(source.data(), source.size()); CHECK(entry.byteLength == source.size()); CHECK(entry.byteHash == sourceDigest); CHECK(capture::hashBytes(stored.data(), stored.size()) == sourceDigest); CHECK(stored.size() == source.size()); CHECK(std::equal(source.begin(), source.end(), stored.data())); } CHECK(PayloadBuffer::alive() == 0); } // Every occurrence of `"key":"value"` in `json`, value returned raw (escape-aware // only enough to not stop early on an escaped quote — this file's own writer output // never nests an unescaped quote, so that is sufficient here). static std::vector jsonStringValuesForKey(const std::string& json, const std::string& key) { std::vector values; const std::string marker = "\"" + key + "\":\""; std::size_t pos = 0; while ((pos = json.find(marker, pos)) != std::string::npos) { std::size_t i = pos + marker.size(); while (i < json.size() && json[i] != '"') { if (json[i] == '\\') ++i; // skip the escaped char too ++i; } values.push_back(json.substr(pos + marker.size(), i - (pos + marker.size()))); pos = i; } return values; } // True for a value opening with an ':' drive-relative prefix (":" alone is // JSON's own key separator, so this is checked on isolated VALUES, never on raw text). static bool looksLikeDriveForm(const std::string& value) { return value.size() >= 2 && std::isalpha(static_cast(value[0])) && value[1] == ':'; } static void testEmittedManifestBytesCarryNoPath() { Fixture fx("nopath"); // The bank's own display name AND a sample's displayName each carry a literal // '/' — free text, unlike the entry `name` / nested `relativePath` fields this // test actually polices (docs/product/bank-package.md:282-289: the destination is // derived from the entry name, never from free text). Present in the fixture so // the scan below proves it is scoped correctly rather than merely holding by // accident on names that happen not to collide with the rule. CHECK(fx.session.book().renameBank(fx.bankId, "Drums/Bus")); // Both source records carry a directory component; neither may reach the file. fx.addSample("s1", "kick.wav", 200, 3, /*writeToDisk=*/true, "Kick / alt take"); fx.addSample("s2", "snare take 2.wav", 200, 9); CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written); // Locate the MANIFEST REGION of the emitted file by walking the frozen header the // way a reader does: magic | fv | minReader | len+semver | len+JSON. The binary // length fields are deliberately excluded — a length whose byte happens to be // 0x2F is not a separator. const std::vector file = readFile(fx.destPath()); CHECK(file.size() > 20); auto le32 = [&](std::size_t at) { return static_cast(file[at]) | (static_cast(file[at + 1]) << 8) | (static_cast(file[at + 2]) << 16) | (static_cast(file[at + 3]) << 24); }; const std::uint32_t semverLen = le32(12); const std::size_t manifestLenAt = 16 + semverLen; const std::uint32_t manifestLen = le32(manifestLenAt); const std::size_t manifestAt = manifestLenAt + 4; CHECK(manifestAt + manifestLen <= file.size()); const std::string manifest(reinterpret_cast(file.data() + manifestAt), manifestLen); CHECK(manifest.find("kick.wav") != std::string::npos); // the scan is looking at the manifest // The boundary, pinned rather than assumed: free text legitimately carries '/'. CHECK(manifest.find("Drums/Bus") != std::string::npos); CHECK(manifest.find("Kick / alt take") != std::string::npos); // The rule itself: scoped to the two fields the importer derives a destination // from — the entry `name` and the nested Sample's own `relativePath` — never to // `displayName` or the manifest's `bankDisplayName`. const std::vector names = jsonStringValuesForKey(manifest, "name"); const std::vector relPaths = jsonStringValuesForKey(manifest, "relativePath"); CHECK(!names.empty()); CHECK(!relPaths.empty()); for (const std::string& v : names) { CHECK(v.find('/') == std::string::npos); CHECK(v.find('\\') == std::string::npos); CHECK(v.find("..") == std::string::npos); CHECK(!looksLikeDriveForm(v)); } for (const std::string& v : relPaths) { CHECK(v.find('/') == std::string::npos); CHECK(v.find('\\') == std::string::npos); CHECK(v.find("..") == std::string::npos); CHECK(!looksLikeDriveForm(v)); } } static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() { Fixture fx("midwrite"); fx.addSample("s1", "kick.wav", 500, 1); fx.addSample("s2", "snare.wav", 500, 2); const std::vector prior = patternBytes(64, 99); writeFile(fx.destPath(), prior); ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId); CHECK(survey.plan.verdict == package::ExportVerdict::Ready); package::PackageManifest manifest = survey.plan.manifest; const std::vector sources = {fx.absPathOf("kick.wav"), fx.absPathOf("snare.wav")}; std::string failed; CHECK(digestSources(manifest, sources, failed)); const std::optional encoded = package::encodePackage(manifest); CHECK(encoded.has_value()); // Injection: the second payload vanishes after the framing that claims it was // already encoded, so the failure lands with the prefix and one payload written. std::error_code ec; fs::remove(utf8Path(fx.absPathOf("snare.wav")), ec); const ExportOutcome out = writePackageFile(*encoded, manifest, sources, fx.destPath()); CHECK(out.status == ExportStatus::SourceReadFailed); CHECK(out.offendingName == "snare.wav"); CHECK(readFile(fx.destPath()) == prior); // the prior file is untouched CHECK(!exists(fx.destPath() + ".rsbanktmp")); // and no debris is left behind CHECK(PayloadBuffer::alive() == 0); } static void testCommitFailureIsAGenuineMidWriteAbandon() { // The mid-READ injection above (a source vanishing between digest and stream) is // not what "mid-write" names in export_bank.cpp:115-118/122-125 — those guard a // failure IN the write itself: appendPayload's stream going bad, or commit's // rename failing. A directory squatting on the destination (test_package_io.cpp's // own precedent for a real, not simulated, commit failure) makes every payload // stream fine and only the final rename fail. Fixture fx("commitfail"); fx.addSample("s1", "kick.wav", 300, 1); fx.addSample("s2", "snare.wav", 300, 2); std::error_code ec; fs::create_directory(utf8Path(fx.destPath()), ec); CHECK(!ec); ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId); CHECK(survey.plan.verdict == package::ExportVerdict::Ready); package::PackageManifest manifest = survey.plan.manifest; const std::vector sources = {fx.absPathOf("kick.wav"), fx.absPathOf("snare.wav")}; std::string failed; CHECK(digestSources(manifest, sources, failed)); const std::optional encoded = package::encodePackage(manifest); CHECK(encoded.has_value()); const ExportOutcome out = writePackageFile(*encoded, manifest, sources, fx.destPath()); CHECK(out.status == ExportStatus::WriteFailed); CHECK(fs::is_directory(utf8Path(fx.destPath()))); // the squatting dir is untouched CHECK(!exists(fx.destPath() + ".rsbanktmp")); // commit()'s own self-clean ran CHECK(PayloadBuffer::alive() == 0); fs::remove(utf8Path(fx.destPath()), ec); } static void testPayloadChangedBetweenDigestAndStreamAborts() { Fixture fx("changed"); fx.addSample("s1", "kick.wav", 500, 1); CHECK(!exists(fx.destPath())); ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId); package::PackageManifest manifest = survey.plan.manifest; const std::vector sources = {fx.absPathOf("kick.wav")}; std::string failed; CHECK(digestSources(manifest, sources, failed)); const std::optional encoded = package::encodePackage(manifest); CHECK(encoded.has_value()); // Same length, different bytes — only the digest re-check can catch this. writeFile(fx.absPathOf("kick.wav"), patternBytes(500, 77)); const ExportOutcome out = writePackageFile(*encoded, manifest, sources, fx.destPath()); CHECK(out.status == ExportStatus::SourceChanged); CHECK(out.offendingName == "kick.wav"); CHECK(!exists(fx.destPath())); } static void testExportTouchesNoProjectState() { Fixture fx("readonly"); fx.addSample("s1", "kick.wav", 400, 5); fx.addSample("s2", "snare.wav", 400, 6); // saveToActiveProject writes seven keys (shell/persist/ext_state_io.cpp): `banks`, // the legacy-key clear, `view_state`, the tail setting, the tracking ledger, the // version stamp, and the bank-generation counter. This asserts byte-identity of // the three that have an in-memory string to diff (`banks`, `view_state`, the tail // setting) plus bankGeneration() (the bank-generation-counter key IS its // serialization). The legacy-key clear and the version stamp are session-external, // nothing here to diff against. The tracking ledger has no public accessor to diff // either, but needs none: exportBank/digestSources/writePackageFile all take the // session by `const&`, and ReaSamplerSession::recordCreated — the ledger's one // writer (session.h) — is non-const, so it is not reachable through this call at // all; the compiler enforces "untouched" here rather than a runtime check proving it. const std::string bankBookBefore = fx.session.book().serialize(); const std::string viewBefore = fx.session.view().serialize(); const std::string tailBefore = capture::serializeTailSetting(fx.session.tail()); const std::int64_t generationBefore = fx.session.bankGeneration(); CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written); CHECK(fx.session.book().serialize() == bankBookBefore); CHECK(fx.session.view().serialize() == viewBefore); CHECK(capture::serializeTailSetting(fx.session.tail()) == tailBefore); CHECK(fx.session.bankGeneration() == generationBefore); } static void testEmptyBankExportsAsAValidZeroEntryPackage() { Fixture fx("empty"); const ExportOutcome out = exportBank(fx.session, fx.request()); CHECK(out.status == ExportStatus::Written); CHECK(out.entriesWritten == 0); const package::DecodedPackage decoded = decodeFromDisk(fx.destPath()); CHECK(decoded.status == package::PackageReadability::Readable); CHECK(decoded.manifest.entries.empty()); CHECK(decoded.layout.empty()); CHECK(decoded.manifest.bankDisplayName == "Drums empty"); // The size proof is decodePackage's, and it ran against the real on-disk size. CHECK(decoded.prefixSize == readFile(fx.destPath()).size()); } static void testIncompleteBankRefusesUntilConfirmed() { Fixture fx("incomplete"); fx.addSample("s1", "kick.wav", 300, 1); fx.addSample("s2", "gone.wav", 300, 2, /*writeToDisk=*/false); ExportRequest req = fx.request(); const ExportOutcome refused = exportBank(fx.session, req); CHECK(refused.status == ExportStatus::RefusedIncomplete); CHECK(refused.excluded.size() == 1); CHECK(refused.excluded[0].sampleId == "s2"); CHECK(refused.excluded[0].reason == package::ExclusionReason::FileMissing); CHECK(!exists(fx.destPath())); req.allowIncomplete = true; const ExportOutcome allowed = exportBank(fx.session, req); CHECK(allowed.status == ExportStatus::Written); CHECK(allowed.entriesWritten == 1); CHECK(allowed.excluded.size() == 1); // the report survives into the summary CHECK(decodeFromDisk(fx.destPath()).manifest.entries.size() == 1); } static void testExistingDestinationRefusesUntilConfirmed() { Fixture fx("overwrite"); fx.addSample("s1", "kick.wav", 300, 1); const std::vector prior = patternBytes(32, 11); writeFile(fx.destPath(), prior); ExportRequest req = fx.request(); const ExportOutcome refused = exportBank(fx.session, req); CHECK(refused.status == ExportStatus::RefusedDestinationExists); CHECK(readFile(fx.destPath()) == prior); req.allowOverwrite = true; CHECK(exportBank(fx.session, req).status == ExportStatus::Written); CHECK(readFile(fx.destPath()) != prior); } static void testUnknownBankAndUnsavedProjectAreNamedSeparately() { Fixture fx("guards"); ExportRequest req = fx.request(); req.bankId = "no-such-bank"; CHECK(exportBank(fx.session, req).status == ExportStatus::NoSuchBank); ExportRequest unsaved = fx.request(); unsaved.projectDir.clear(); CHECK(exportBank(fx.session, unsaved).status == ExportStatus::NoProjectDir); CHECK(!exists(fx.destPath())); } int main() { testHealthyExportCarriesEveryPayloadByteExact(); testEmittedManifestBytesCarryNoPath(); testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile(); testCommitFailureIsAGenuineMidWriteAbandon(); testPayloadChangedBetweenDigestAndStreamAborts(); testExportTouchesNoProjectState(); testEmptyBankExportsAsAValidZeroEntryPackage(); testIncompleteBankRefusesUntilConfirmed(); testExistingDestinationRefusesUntilConfirmed(); testUnknownBankAndUnsavedProjectAreNamedSeparately(); if (g_fail == 0) { std::printf("export_bank_tests: all passed\n"); return 0; } std::printf("export_bank_tests: %d failure(s)\n", g_fail); return 1; }