// Standalone tests for reasampler::pruneOrphans (Phase R, Wave 1) and the additive // BankBook::referencedPaths() union query it consumes — no REAPER, no framework. // // The safety-critical computation: orphans = (owned ∩ present) − referenced. Every // test below would FAIL if the set algebra were wrong (a missing ∩ present, a missing // − referenced, or a non-union referenced-set) — see the negative assertions. // // Covers (brief-named): // 1. Prune null test: all present files referenced -> empty orphan set. // 2. Formula: orphans = (owned ∩ present) − referenced, mixed populations. // 3. Copied file referenced by a SECOND bank survives (union semantics). // 4. Present-but-unowned (hand-dropped) file is never reclaimed. // 5. Edge cases: empty folder, empty book/referenced-set, empty manifest. // 6. Owned-but-absent file (manifest entry, no file on disk) -> no orphan, no error. // Plus: determinism (present-order output), duplicate-`present` de-dup, exact-string // (non-normalizing) match, and the referencedPaths() union query directly. #include "../src/bank_book.h" #include "../src/prune_reconcile.h" #include #include #include #include using namespace reasampler; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // --- helpers ----------------------------------------------------------------- static bool contains(const std::vector& v, const std::string& s) { return std::find(v.begin(), v.end(), s) != v.end(); } // A minimal Sample carrying just the fields prune cares about (id + relativePath). static Sample sampleAt(const std::string& id, const std::string& relPath, const std::string& hash = "") { Sample s; s.id = id; s.relativePath = relPath; s.contentHash = hash; return s; } // --- 1. prune null test: all present referenced -> empty -------------------- static void testNullTestAllReferenced() { const std::vector present = {"bank/a.wav", "bank/b.wav", "bank/c.wav"}; const std::vector owned = {"bank/a.wav", "bank/b.wav", "bank/c.wav"}; const std::vector referenced= {"bank/a.wav", "bank/b.wav", "bank/c.wav"}; const auto orphans = pruneOrphans(present, referenced, owned); // The core invariant: a folder whose every file is referenced deletes NOTHING. CHECK(orphans.empty()); } // --- 2. formula: (owned ∩ present) − referenced, mixed populations ---------- static void testFormulaMixedPopulations() { // present : a b c d (on disk) // owned : a b d e (system created; e is owned-but-absent) // ref'd : a (still in an index) // expected orphans: owned ∩ present = {a,b,d}; minus referenced {a} = {b,d} // - c present but NOT owned -> excluded (hand-dropped) // - e owned but NOT present -> excluded (no file on disk) // - a owned+present but referenced -> excluded const std::vector present = {"bank/a.wav", "bank/b.wav", "bank/c.wav", "bank/d.wav"}; const std::vector owned = {"bank/a.wav", "bank/b.wav", "bank/d.wav", "bank/e.wav"}; const std::vector referenced = {"bank/a.wav"}; const auto orphans = pruneOrphans(present, referenced, owned); CHECK(orphans.size() == 2); CHECK(contains(orphans, "bank/b.wav")); CHECK(contains(orphans, "bank/d.wav")); // Negative assertions — each would fire if a clause of the formula were dropped: CHECK(!contains(orphans, "bank/a.wav")); // − referenced CHECK(!contains(orphans, "bank/c.wav")); // ∩ owned (hand-dropped) CHECK(!contains(orphans, "bank/e.wav")); // ∩ present (absent) } // --- 3. copied file referenced by a second bank survives (union) ------------ static void testCopiedFileSurvivesViaUnion() { // A file present + owned, whose ONLY index reference lives in a second (non-active) // bank via a copy. The union across the whole book must keep it out of the orphan // set. Built through the real BankBook so referencedPaths()'s union is exercised. BankBook book; book.createBank("b1", "Drums"); // Add to the pool, then copy into b1 (same file referenced by two banks). book.index(std::string(kPoolBankId))->add(sampleAt("s1", "bank/shared.wav", "h1")); CHECK(book.copySample("s1", kPoolBankId, "b1") == TransferResult::Copied); const auto referenced = book.referencedPaths(); // Union de-dups: one path though two banks reference it. CHECK(referenced.size() == 1); CHECK(referenced[0] == "bank/shared.wav"); const std::vector present = {"bank/shared.wav"}; const std::vector owned = {"bank/shared.wav"}; const auto orphans = pruneOrphans(present, referenced, owned); // Referenced by b1 (the copy) -> never an orphan even if the pool later dropped it. CHECK(orphans.empty()); } // A sharper union test: remove the file from the pool but keep the copy in b1. The // file must STILL survive (referenced by b1 alone) — the whole point of union safety. static void testUnionSurvivesWhenOnlySecondBankReferences() { BankBook book; book.createBank("b1", "Drums"); book.index(std::string(kPoolBankId))->add(sampleAt("s1", "bank/shared.wav", "h1")); CHECK(book.copySample("s1", kPoolBankId, "b1") == TransferResult::Copied); // Drop the pool's reference; b1 still references the file. CHECK(book.removeSample("s1", kPoolBankId) == RemoveResult::Removed); const auto referenced = book.referencedPaths(); CHECK(referenced.size() == 1); CHECK(contains(referenced, "bank/shared.wav")); const auto orphans = pruneOrphans({"bank/shared.wav"}, referenced, {"bank/shared.wav"}); CHECK(orphans.empty()); // still referenced by b1 -> not reclaimable } // --- 4. present-but-unowned (hand-dropped) file never reclaimed ------------- static void testHandDroppedNeverReclaimed() { // A file on disk that no index references AND the manifest does not own. It is a // hand-dropped file — prune must never reclaim it (ownership attribution, fork R-D). const std::vector present = {"bank/user_drop.wav", "bank/ours.wav"}; const std::vector owned = {"bank/ours.wav"}; // NOT user_drop const std::vector referenced = {}; // neither referenced const auto orphans = pruneOrphans(present, referenced, owned); CHECK(orphans.size() == 1); CHECK(contains(orphans, "bank/ours.wav")); // ours, unreferenced -> orphan CHECK(!contains(orphans, "bank/user_drop.wav")); // hand-dropped -> protected } // --- 5. edge cases: empty folder / book / manifest -------------------------- static void testEmptyFolder() { // No files present -> nothing to reclaim regardless of owned/referenced. const auto orphans = pruneOrphans({}, {"bank/a.wav"}, {"bank/a.wav"}); CHECK(orphans.empty()); } static void testEmptyReferencedSet() { // Empty book (nothing referenced): every present ∩ owned file is an orphan. const std::vector present = {"bank/a.wav", "bank/b.wav"}; const std::vector owned = {"bank/a.wav", "bank/b.wav"}; const auto orphans = pruneOrphans(present, /*referenced*/ {}, owned); CHECK(orphans.size() == 2); CHECK(contains(orphans, "bank/a.wav")); CHECK(contains(orphans, "bank/b.wav")); } static void testEmptyManifest() { // Empty manifest (owns nothing): nothing is reclaimable even if present+unreferenced. const std::vector present = {"bank/a.wav", "bank/b.wav"}; const std::vector referenced = {}; const auto orphans = pruneOrphans(present, referenced, /*owned*/ {}); CHECK(orphans.empty()); } static void testAllEmpty() { const auto orphans = pruneOrphans({}, {}, {}); CHECK(orphans.empty()); } // --- 6. owned-but-absent file -> no orphan, no error ------------------------ static void testOwnedButAbsentNoOrphan() { // A manifest entry whose file is NOT on disk (e.g. deleted out-of-band). It must // yield no orphan entry (∩ present excludes it) and no error/crash. const std::vector present = {"bank/here.wav"}; const std::vector owned = {"bank/here.wav", "bank/gone.wav"}; const std::vector referenced = {}; const auto orphans = pruneOrphans(present, referenced, owned); CHECK(orphans.size() == 1); CHECK(contains(orphans, "bank/here.wav")); CHECK(!contains(orphans, "bank/gone.wav")); // owned but absent -> not an orphan } // --- determinism + duplicate-present + exact-string match ------------------- static void testOutputPreservesPresentOrder() { // Output order follows `present` order (deterministic dry-run file list), NOT the // owned/referenced order. const std::vector present = {"bank/z.wav", "bank/y.wav", "bank/x.wav"}; const std::vector owned = {"bank/x.wav", "bank/y.wav", "bank/z.wav"}; const std::vector referenced = {}; const auto orphans = pruneOrphans(present, referenced, owned); CHECK(orphans.size() == 3); CHECK(orphans[0] == "bank/z.wav"); CHECK(orphans[1] == "bank/y.wav"); CHECK(orphans[2] == "bank/x.wav"); } static void testDuplicatePresentDeduped() { // A defensive property: a duplicated `present` spelling appears once in the output. const std::vector present = {"bank/a.wav", "bank/a.wav"}; const std::vector owned = {"bank/a.wav"}; const auto orphans = pruneOrphans(present, /*referenced*/ {}, owned); CHECK(orphans.size() == 1); CHECK(orphans[0] == "bank/a.wav"); } static void testExactStringMatchNotNormalized() { // Safety-critical: the core matches VERBATIM (mirror of the model's exact-string // convention). A referenced file spelled with backslashes is a DIFFERENT string // from the forward-slash present spelling — the core does NOT normalize them to // equal. This documents the invariant: consistent spelling is the R2 shell's // contract. If the core silently normalized, this would (wrongly) treat the file // as referenced and the assertion below would fail. const std::vector present = {"bank/a.wav"}; const std::vector owned = {"bank/a.wav"}; const std::vector referenced = {"bank\\a.wav"}; // different spelling const auto orphans = pruneOrphans(present, referenced, owned); CHECK(orphans.size() == 1); // not matched -> still an orphan CHECK(orphans[0] == "bank/a.wav"); } // --- BankBook::referencedPaths() union query directly ----------------------- static void testReferencedPathsUnionAcrossBanksAndPool() { BankBook book; book.createBank("b1", "Drums"); book.createBank("b2", "Bass"); book.index(std::string(kPoolBankId))->add(sampleAt("p1", "bank/pool.wav", "hp")); book.index("b1")->add(sampleAt("d1", "bank/drum.wav", "hd")); book.index("b2")->add(sampleAt("s1", "bank/bass.wav", "hb")); const auto refs = book.referencedPaths(); // Union includes the pool AND every named bank. CHECK(refs.size() == 3); CHECK(contains(refs, "bank/pool.wav")); CHECK(contains(refs, "bank/drum.wav")); CHECK(contains(refs, "bank/bass.wav")); // Pool-first ordinal order. CHECK(refs[0] == "bank/pool.wav"); } static void testReferencedPathsEmptyBook() { BankBook book; // pool only, no samples CHECK(book.referencedPaths().empty()); } static void testReferencedPathsSkipsEmptyPath() { // A sample with an empty relativePath references no file — it must not appear. BankBook book; book.index(std::string(kPoolBankId))->add(sampleAt("p1", "", "hp")); book.index(std::string(kPoolBankId))->add(sampleAt("p2", "bank/real.wav", "hr")); const auto refs = book.referencedPaths(); CHECK(refs.size() == 1); CHECK(refs[0] == "bank/real.wav"); } int main() { testNullTestAllReferenced(); testFormulaMixedPopulations(); testCopiedFileSurvivesViaUnion(); testUnionSurvivesWhenOnlySecondBankReferences(); testHandDroppedNeverReclaimed(); testEmptyFolder(); testEmptyReferencedSet(); testEmptyManifest(); testAllEmpty(); testOwnedButAbsentNoOrphan(); testOutputPreservesPresentOrder(); testDuplicatePresentDeduped(); testExactStringMatchNotNormalized(); testReferencedPathsUnionAcrossBanksAndPool(); testReferencedPathsEmptyBook(); testReferencedPathsSkipsEmptyPath(); if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n"); else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail); return g_fail == 0 ? 0 : 1; }