// Standalone tests for shell/package/import_landing — no REAPER, no framework, real // package bytes on a real filesystem. Packages are FRAMED BY HAND (not by // encodePackage) so the version-ladder suites can dial formatVersion and // minReaderVersion independently, and so an encode-side regression cannot hide the // import's behaviour from itself. #include "../src/shell/package/import_landing.h" #include #include #include #include #include #include #include "../src/core/capture/wav_codec.h" #include "../src/core/package/bank_package.h" #include "../src/core/tracking/origin_ledger.h" #include "../src/core/version/app_version.h" #include "../src/shell/package/package_path.h" using namespace reasampler; using namespace reasampler::package; using reasampler::model::Sample; 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) static const char* kTag = "1754000000"; static const char* kBankId = "import-test-bank"; // --- scratch project --------------------------------------------------------- // One project directory per suite, torn down after, so no suite can observe another's // bank folder in listFolderFileNames. class Scratch { public: explicit Scratch(const std::string& name) : dir_(pathToUtf8(fs::current_path() / utf8Path("import_scratch_" + name))) { std::error_code ec; fs::remove_all(utf8Path(dir_), ec); fs::create_directories(utf8Path(dir_), ec); } ~Scratch() { std::error_code ec; fs::remove_all(utf8Path(dir_), ec); } const std::string& projectDir() const { return dir_; } std::string bankDir() const { return bankFolderDir(dir_); } std::string packagePath() const { return dir_ + "/bank.rsbank"; } std::vector bankFiles() const { std::vector out; std::error_code ec; for (const auto& e : fs::directory_iterator(utf8Path(bankDir()), ec)) if (e.is_regular_file(ec)) out.push_back(pathToUtf8(e.path().filename())); return out; } private: std::string dir_; }; static void writeBytes(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 readBytes(const std::string& path) { std::ifstream f(utf8Path(path), std::ios::binary); return std::vector(std::istreambuf_iterator(f), std::istreambuf_iterator()); } // --- hand-rolled package framing -------------------------------------------- static void putU32(std::vector& out, std::uint32_t v) { for (int b = 0; b < 4; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFFu)); } static std::vector frame(std::uint32_t formatVersion, std::uint32_t minReaderVersion, const std::string& writerSemver, const std::string& manifestJson, const std::vector>& payloads) { std::vector out(kPackageMagic, kPackageMagic + 4); putU32(out, formatVersion); putU32(out, minReaderVersion); putU32(out, static_cast(writerSemver.size())); out.insert(out.end(), writerSemver.begin(), writerSemver.end()); putU32(out, static_cast(manifestJson.size())); out.insert(out.end(), manifestJson.begin(), manifestJson.end()); for (const auto& p : payloads) out.insert(out.end(), p.begin(), p.end()); return out; } static std::vector payloadOf(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 * 13u); return v; } struct Fixture { PackageManifest manifest; std::vector> payloads; }; static void addEntry(Fixture& f, const std::string& fileName, const std::string& id, const std::string& contentHash, std::uint8_t seed) { std::vector payload = payloadOf(48 + seed, seed); PackageEntry e; e.fileName = fileName; e.byteLength = payload.size(); e.byteHash = capture::hashBytes(payload.data(), payload.size()); e.sample.id = id; e.sample.displayName = id; e.sample.relativePath = "reasampler_bank/" + fileName; e.sample.contentHash = contentHash; f.manifest.entries.push_back(std::move(e)); f.payloads.push_back(std::move(payload)); } static Fixture twoEntryFixture(const std::string& bankName) { Fixture f; f.manifest.bankDisplayName = bankName; addEntry(f, "kick.wav", "cap-kick", "h-kick", 1); addEntry(f, "snare.wav", "cap-snare", "h-snare", 2); return f; } // Frames `f` with this build's own ladder pair unless overridden. static std::vector packageBytes(const Fixture& f, std::uint32_t formatVersion = kPackageFormatVersion, std::uint32_t minReader = kPackageMinReaderVersion, const std::string& manifestOverride = {}) { const auto json = serializeManifest(f.manifest); const std::string body = manifestOverride.empty() ? *json : manifestOverride; return frame(formatVersion, minReader, version::stampVersion(), body, f.payloads); } // --- the sequence the verb runs, minus REAPER -------------------------------- struct RunResult { ImportLanding landing; bool applied = false; tracking::OriginLedger ledger; }; static RunResult runImport(const Scratch& scratch, BankBook& book, const std::string& bankId = kBankId) { RunResult r; LandedFileJournal journal; r.landing = landPackage(scratch.packagePath(), scratch.projectDir(), book, kTag, journal); if (r.landing.outcome != ImportOutcome::Landed) return r; r.applied = applyImportedBank(book, bankId, r.landing.plan, [&r](const Sample& s) { tracking::OriginRecord rec; rec.relativePath = s.relativePath; rec.kind = tracking::OriginKind::PackageImport; rec.sampleId = s.id; r.ledger.record(rec); }); if (r.applied) journal.markIndexCommitted(); return r; } // --- suites ------------------------------------------------------------------ static void testCleanImportLandsEveryPayloadByteExact() { Scratch scratch("clean"); const Fixture f = twoEntryFixture("Drums"); writeBytes(scratch.packagePath(), packageBytes(f)); BankBook book; const RunResult r = runImport(scratch, book); CHECK(r.landing.outcome == ImportOutcome::Landed); CHECK(r.applied); CHECK(scratch.bankFiles().size() == 2); for (std::size_t i = 0; i < 2; ++i) { const std::string name = r.landing.plan.entries[i].destFileName; CHECK(readBytes(scratch.bankDir() + "/" + name) == f.payloads[i]); } const Bank* bank = book.bank(kBankId); CHECK(bank != nullptr && bank->displayName == "Drums"); CHECK(bank->index.size() == 2); CHECK(PayloadBuffer::alive() == 0); } static void testEveryLandedFileHasAPackageImportBirthRecord() { Scratch scratch("births"); const Fixture f = twoEntryFixture("Drums"); writeBytes(scratch.packagePath(), packageBytes(f)); BankBook book; const RunResult r = runImport(scratch, book); CHECK(r.applied); // Read the ledger back rather than counting calls. CHECK(r.ledger.size() == 2); for (const std::string& name : scratch.bankFiles()) { const tracking::OriginRecord* rec = r.ledger.find("reasampler_bank/" + name); CHECK(rec != nullptr); if (rec) CHECK(rec->kind == tracking::OriginKind::PackageImport); } } static void testACollapsedEntryLeavesNoFileBehind() { Scratch scratch("collapse"); Fixture f; f.manifest.bankDisplayName = "Drums"; addEntry(f, "kick.wav", "cap-a", "same-hash", 1); addEntry(f, "kick_copy.wav", "cap-b", "same-hash", 2); writeBytes(scratch.packagePath(), packageBytes(f)); BankBook book; const RunResult r = runImport(scratch, book); CHECK(r.landing.outcome == ImportOutcome::Landed); // One file, one entry, one birth record — the dedup never manufactured an orphan. CHECK(scratch.bankFiles().size() == 1); CHECK(book.bank(kBankId)->index.size() == 1); CHECK(r.ledger.size() == 1); } static void testHashMismatchLandsNothingAndMutatesNothing() { Scratch scratch("integrity"); const Fixture f = twoEntryFixture("Drums"); std::vector bytes = packageBytes(f); bytes.back() ^= 0xFFu; // corrupt the LAST entry's payload writeBytes(scratch.packagePath(), bytes); BankBook book; const BankBook before = book; const RunResult r = runImport(scratch, book); CHECK(r.landing.outcome == ImportOutcome::IntegrityFailed); CHECK(r.landing.failedEntryName == "snare.wav"); // Refused BEFORE landing anything, not landed-then-rolled-back: the bank folder is // created on the way into the write loop, so its absence dates the refusal. CHECK(!fs::exists(utf8Path(scratch.bankDir()))); CHECK(r.landing.rollback.deletedCount == 0); CHECK(book == before); // zero index mutation CHECK(!r.applied); } static void testWriteFailureAtEntryTwoRollsBackEntryOne() { Scratch scratch("rollback"); const Fixture f = twoEntryFixture("Drums"); writeBytes(scratch.packagePath(), packageBytes(f)); // Occupy the second entry's destination with a DIRECTORY: listFolderFileNames sees // regular files only, so the plan does not rename around it, and the exclusive // create then fails exactly where the injection wants it. std::error_code ec; fs::create_directories(utf8Path(scratch.bankDir()), ec); fs::create_directory(utf8Path(scratch.bankDir() + "/snare.wav"), ec); BankBook book; const BankBook before = book; const RunResult r = runImport(scratch, book); CHECK(r.landing.outcome == ImportOutcome::WriteFailed); CHECK(r.landing.failedEntryName == "snare.wav"); CHECK(r.landing.rollback.deletedCount == 1); // entry one was rolled back CHECK(scratch.bankFiles().empty()); // nothing from this import survives CHECK(book == before); CHECK(PayloadBuffer::alive() == 0); } static void testTooNewRefusesWholeAndNamesTheWriter() { Scratch scratch("toonew"); const Fixture f = twoEntryFixture("Drums"); writeBytes(scratch.packagePath(), packageBytes(f, kPackageFormatVersion + 1, kPackageFormatVersion + 1)); BankBook book; const BankBook before = book; const RunResult r = runImport(scratch, book); CHECK(r.landing.outcome == ImportOutcome::TooNew); // All three facts the refusal must name are available from the landing. CHECK(r.landing.header.minReaderVersion == kPackageFormatVersion + 1); CHECK(r.landing.header.writerVersion == version::stampVersion()); // The refusal returns before the bank folder is even created. CHECK(!fs::exists(utf8Path(scratch.bankDir()))); CHECK(book == before); } static void testNewerFormatVersionStillImportsWhenTheReaderIsReachable() { Scratch scratch("additive"); const Fixture f = twoEntryFixture("Drums"); // An additive newer writer: formatVersion moved, minReaderVersion did not, and the // manifest carries a key this build has never heard of. std::string json = *serializeManifest(f.manifest); CHECK(!json.empty() && json.front() == '{'); json.insert(1, "\"futureKey\":{\"nested\":[1,2,3]},"); writeBytes(scratch.packagePath(), packageBytes(f, kPackageFormatVersion + 1, kPackageMinReaderVersion, json)); BankBook book; const RunResult r = runImport(scratch, book); CHECK(r.landing.outcome == ImportOutcome::Landed); CHECK(r.landing.header.formatVersion == kPackageFormatVersion + 1); CHECK(book.bank(kBankId)->index.size() == 2); CHECK(scratch.bankFiles().size() == 2); } static void testTruncationAndGarbageReportMalformedNotTooNew() { { Scratch scratch("truncated"); const Fixture f = twoEntryFixture("Drums"); std::vector bytes = packageBytes(f); bytes.pop_back(); writeBytes(scratch.packagePath(), bytes); BankBook book; const BankBook before = book; const RunResult r = runImport(scratch, book); CHECK(r.landing.outcome == ImportOutcome::Malformed); CHECK(book == before); } { Scratch scratch("garbage"); writeBytes(scratch.packagePath(), payloadOf(200, 9)); BankBook book; CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Malformed); } } static void testMissingPackageFileIsUnreadableNotMalformed() { Scratch scratch("absent"); BankBook book; CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Unreadable); } static void testUnsavedProjectRefusesBeforeAnythingIsRead() { Scratch scratch("noproject"); writeBytes(scratch.packagePath(), packageBytes(twoEntryFixture("Drums"))); BankBook book; LandedFileJournal journal; const ImportLanding landing = landPackage(scratch.packagePath(), std::string{}, book, kTag, journal); CHECK(landing.outcome == ImportOutcome::NoProject); } static void testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal() { Scratch scratch("roundtrip"); const Fixture f = twoEntryFixture("B"); writeBytes(scratch.packagePath(), packageBytes(f)); // A project that already holds bank "B" with the package's own entries. BankBook book; book.createBank("bank-b", "B"); for (const PackageEntry& e : f.manifest.entries) book.index("bank-b")->add(e.sample); const BankModel originalB = *book.index("bank-b"); const RunResult second = runImport(scratch, book, "bank-b2"); CHECK(second.landing.outcome == ImportOutcome::Landed); CHECK(book.bank("bank-b2")->displayName == "B 2"); CHECK(book.bank("bank-b2")->index.size() == 2); CHECK(*book.index("bank-b") == originalB); // B itself unmutated for (const Sample& s : book.index("bank-b2")->all()) { CHECK(s.id.rfind(kImportIdPrefix, 0) == 0); CHECK(s.id != "cap-kick" && s.id != "cap-snare"); } const RunResult third = runImport(scratch, book, "bank-b3"); CHECK(third.landing.outcome == ImportOutcome::Landed); CHECK(book.bank("bank-b3")->displayName == "B 3"); CHECK(*book.index("bank-b") == originalB); // Four distinct files: two per re-import, never overwritten. Bank "B"'s own two // entries were seeded index-only above (book.index("bank-b")->add), never written // to disk, so they don't add to this count. CHECK(scratch.bankFiles().size() == 4); } int main() { testCleanImportLandsEveryPayloadByteExact(); testEveryLandedFileHasAPackageImportBirthRecord(); testACollapsedEntryLeavesNoFileBehind(); testHashMismatchLandsNothingAndMutatesNothing(); testWriteFailureAtEntryTwoRollsBackEntryOne(); testTooNewRefusesWholeAndNamesTheWriter(); testNewerFormatVersionStillImportsWhenTheReaderIsReachable(); testTruncationAndGarbageReportMalformedNotTooNew(); testMissingPackageFileIsUnreadableNotMalformed(); testUnsavedProjectRefusesBeforeAnythingIsRead(); testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal(); if (g_fail == 0) std::printf("import_landing: all tests passed\n"); return g_fail == 0 ? 0 : 1; }