// 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 #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"); } // --- buildPruneReport: count / byte-sum / truncation (Phase R, R2) ---------- static void testReportCountSizeAndFullList() { // A known orphan set with known sizes -> exact count, exact byte sum, full list // (under the cap). Order follows the orphan input order (deterministic). const std::vector orphans = {"bank/a.wav", "bank/b.wav", "bank/c.wav"}; const std::unordered_map sizes = { {"bank/a.wav", 100}, {"bank/b.wav", 250}, {"bank/c.wav", 50}}; const PruneReport r = buildPruneReport(orphans, sizes, /*displayCap=*/64); CHECK(r.count == 3); CHECK(r.totalBytes == 400); // 100 + 250 + 50 — would fail if sizes mis-summed CHECK(r.orphans.size() == 3); CHECK(!r.truncated); CHECK(r.orphans[0] == "bank/a.wav"); // input order preserved CHECK(r.orphans[1] == "bank/b.wav"); CHECK(r.orphans[2] == "bank/c.wav"); } static void testReportMissingSizeCountsZeroNotDropped() { // An orphan with no stat'd size contributes 0 to the sum but is STILL listed/counted. const std::vector orphans = {"bank/a.wav", "bank/nosize.wav"}; const std::unordered_map sizes = {{"bank/a.wav", 10}}; const PruneReport r = buildPruneReport(orphans, sizes, 64); CHECK(r.count == 2); // both counted (missing size must not drop the orphan) CHECK(r.totalBytes == 10); // nosize contributes 0 CHECK(r.orphans.size() == 2); } static void testReportTruncatesListButKeepsExactCountAndSize() { // More orphans than the display cap: list clips to the cap, but count and byte sum // stay EXACT over the whole set, and truncated flags the clip. std::vector orphans; std::unordered_map sizes; for (int i = 0; i < 10; ++i) { const std::string p = "bank/f" + std::to_string(i) + ".wav"; orphans.push_back(p); sizes[p] = 5; // 10 files * 5 bytes = 50 total } const PruneReport r = buildPruneReport(orphans, sizes, /*displayCap=*/3); CHECK(r.count == 10); // exact, not clipped CHECK(r.totalBytes == 50); // summed over ALL 10, not just the shown 3 CHECK(r.orphans.size() == 3); // list clipped to the cap CHECK(r.truncated); CHECK(r.orphans[0] == "bank/f0.wav"); // first-N in input order } static void testReportUncappedWhenCapZero() { // displayCap == 0 means "no cap": the whole list is emitted, truncated stays false. const std::vector orphans = {"bank/a.wav", "bank/b.wav"}; const PruneReport r = buildPruneReport(orphans, /*sizes*/ {}, /*displayCap=*/0); CHECK(r.count == 2); CHECK(r.orphans.size() == 2); CHECK(!r.truncated); } static void testReportEmptyOrphanSet() { const PruneReport r = buildPruneReport({}, {}, 64); CHECK(r.count == 0); CHECK(r.totalBytes == 0); CHECK(r.orphans.empty()); CHECK(!r.truncated); } // --- pruneDeletePlan (R3 staleness guard) ------------------------------------ // // plan = confirmed ∩ freshOrphans, in confirmed order. Guards BOTH directions so // "what was shown is what is deleted" holds after a recompute at delete time. // No change between confirm and delete: the plan is the confirmed set exactly, in order. static void testDeletePlanStableEqualsConfirmed() { const std::vector confirmed{"b/a.wav", "b/b.wav", "b/c.wav"}; const std::vector fresh{"b/a.wav", "b/b.wav", "b/c.wav"}; const std::vector plan = pruneDeletePlan(confirmed, fresh); CHECK((plan == confirmed)); // exact set AND order } // A confirmed file that VANISHED (gone from fresh present -> not a fresh orphan) is a // skip: it drops out of the plan. Would FAIL if the plan ignored freshOrphans. static void testDeletePlanSkipsVanishedFile() { const std::vector confirmed{"b/a.wav", "b/gone.wav", "b/c.wav"}; const std::vector fresh{"b/a.wav", "b/c.wav"}; // gone.wav disappeared const std::vector plan = pruneDeletePlan(confirmed, fresh); CHECK((plan == std::vector{"b/a.wav", "b/c.wav"})); CHECK(!contains(plan, "b/gone.wav")); } // A confirmed file that became REFERENCED between confirm and delete drops OUT of the // fresh orphan set (pruneOrphans excludes it), so the plan skips it — never deletes a // now-referenced file. Modelled here as its absence from `fresh`. Load-bearing safety. static void testDeletePlanSkipsNowReferencedFile() { const std::vector confirmed{"b/x.wav", "b/y.wav"}; const std::vector fresh{"b/x.wav"}; // y.wav now referenced -> not a fresh orphan const std::vector plan = pruneDeletePlan(confirmed, fresh); CHECK((plan == std::vector{"b/x.wav"})); } // A NEWLY-APPEARED orphan (in fresh, NOT in confirmed) is NEVER swept: it was not shown, // so it must not be deleted without its own confirm. Would FAIL if plan = fresh. static void testDeletePlanNeverSweepsUnconfirmed() { const std::vector confirmed{"b/a.wav"}; const std::vector fresh{"b/a.wav", "b/new_orphan.wav"}; const std::vector plan = pruneDeletePlan(confirmed, fresh); CHECK((plan == std::vector{"b/a.wav"})); CHECK(!contains(plan, "b/new_orphan.wav")); } // Empty inputs: an empty confirmed (nothing shown) -> empty plan regardless of fresh; an // empty fresh (everything went stale) -> empty plan (all skipped). static void testDeletePlanEmptyInputs() { CHECK(pruneDeletePlan({}, {"b/a.wav"}).empty()); CHECK(pruneDeletePlan({"b/a.wav"}, {}).empty()); CHECK(pruneDeletePlan({}, {}).empty()); } // Duplicate spellings in confirmed are de-duplicated in the plan (mirror of pruneOrphans). static void testDeletePlanDeduplicatesConfirmed() { const std::vector confirmed{"b/a.wav", "b/a.wav", "b/b.wav"}; const std::vector fresh{"b/a.wav", "b/b.wav"}; const std::vector plan = pruneDeletePlan(confirmed, fresh); CHECK((plan == std::vector{"b/a.wav", "b/b.wav"})); } // --- mergeReferenced: the pS-usage referenced-union (bank refs ∪ instance holds) ----- // The load-bearing property: a file held ONLY by a sampler instance (not referenced by // any bank — e.g. its bank entry was deleted while the instance kept its v10 ref) joins // `referenced` through the union, so pruneOrphans can never emit it. Removing the hold // (the instance went away — liveness filtered its record out upstream) reclaims it again. static void testMergeReferencedProtectsInstanceHeldFile() { const std::vector present{"b/held.wav", "b/orphan.wav"}; const std::vector owned{"b/held.wav", "b/orphan.wav"}; const std::vector bankRefs{}; // no bank references either file const std::vector withHold = pruneOrphans(present, mergeReferenced(bankRefs, {"b/held.wav"}), owned); CHECK((withHold == std::vector{"b/orphan.wav"})); const std::vector withoutHold = pruneOrphans(present, mergeReferenced(bankRefs, {}), owned); CHECK(withoutHold.size() == 2); // no live hold -> both reclaim (no permanent block) } // Order-preserving exact-string de-dup: primary first, then the extras not already seen. static void testMergeReferencedOrderDedupAndExactMatch() { const std::vector merged = mergeReferenced( {"b/a.wav", "b/b.wav", "b/a.wav"}, {"b/b.wav", "b/c.wav", "B/A.WAV"}); // Case differs -> distinct entry (exact-string convention, never case-folded here). CHECK((merged == std::vector{"b/a.wav", "b/b.wav", "b/c.wav", "B/A.WAV"})); CHECK(mergeReferenced({}, {}).empty()); } int main() { testNullTestAllReferenced(); testFormulaMixedPopulations(); testCopiedFileSurvivesViaUnion(); testUnionSurvivesWhenOnlySecondBankReferences(); testHandDroppedNeverReclaimed(); testEmptyFolder(); testEmptyReferencedSet(); testEmptyManifest(); testAllEmpty(); testOwnedButAbsentNoOrphan(); testOutputPreservesPresentOrder(); testDuplicatePresentDeduped(); testExactStringMatchNotNormalized(); testReferencedPathsUnionAcrossBanksAndPool(); testReferencedPathsEmptyBook(); testReferencedPathsSkipsEmptyPath(); testReportCountSizeAndFullList(); testReportMissingSizeCountsZeroNotDropped(); testReportTruncatesListButKeepsExactCountAndSize(); testReportUncappedWhenCapZero(); testReportEmptyOrphanSet(); testDeletePlanStableEqualsConfirmed(); testDeletePlanSkipsVanishedFile(); testDeletePlanSkipsNowReferencedFile(); testDeletePlanNeverSweepsUnconfirmed(); testDeletePlanEmptyInputs(); testDeletePlanDeduplicatesConfirmed(); testMergeReferencedProtectsInstanceHeldFile(); testMergeReferencedOrderDedupAndExactMatch(); 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; }