7b53ba16f6
Extend BANK_PRUNE_FOLDER from report-only to dry-run→confirm-with-manifest→ delete exactly the pure-core orphan set (fresh recompute, stale entries skipped). Windows routes to Recycle Bin (SHFileOperation+FOF_ALLOWUNDO), else unlink. New pure prune_button module + footer button dispatching the action id. No ext-state write, no undo point (deletion is not REAPER-undoable).
419 lines
19 KiB
C++
419 lines
19 KiB
C++
// 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 <algorithm>
|
||
#include <cstdint>
|
||
#include <cstdio>
|
||
#include <string>
|
||
#include <unordered_map>
|
||
#include <vector>
|
||
|
||
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<std::string>& 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<std::string> present = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
|
||
const std::vector<std::string> owned = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
|
||
const std::vector<std::string> 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<std::string> present = {"bank/a.wav", "bank/b.wav",
|
||
"bank/c.wav", "bank/d.wav"};
|
||
const std::vector<std::string> owned = {"bank/a.wav", "bank/b.wav",
|
||
"bank/d.wav", "bank/e.wav"};
|
||
const std::vector<std::string> 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<std::string> present = {"bank/shared.wav"};
|
||
const std::vector<std::string> 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<std::string> present = {"bank/user_drop.wav", "bank/ours.wav"};
|
||
const std::vector<std::string> owned = {"bank/ours.wav"}; // NOT user_drop
|
||
const std::vector<std::string> 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<std::string> present = {"bank/a.wav", "bank/b.wav"};
|
||
const std::vector<std::string> 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<std::string> present = {"bank/a.wav", "bank/b.wav"};
|
||
const std::vector<std::string> 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<std::string> present = {"bank/here.wav"};
|
||
const std::vector<std::string> owned = {"bank/here.wav", "bank/gone.wav"};
|
||
const std::vector<std::string> 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<std::string> present = {"bank/z.wav", "bank/y.wav", "bank/x.wav"};
|
||
const std::vector<std::string> owned = {"bank/x.wav", "bank/y.wav", "bank/z.wav"};
|
||
const std::vector<std::string> 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<std::string> present = {"bank/a.wav", "bank/a.wav"};
|
||
const std::vector<std::string> 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<std::string> present = {"bank/a.wav"};
|
||
const std::vector<std::string> owned = {"bank/a.wav"};
|
||
const std::vector<std::string> 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<std::string> orphans = {"bank/a.wav", "bank/b.wav", "bank/c.wav"};
|
||
const std::unordered_map<std::string, std::uint64_t> 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<std::string> orphans = {"bank/a.wav", "bank/nosize.wav"};
|
||
const std::unordered_map<std::string, std::uint64_t> 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<std::string> orphans;
|
||
std::unordered_map<std::string, std::uint64_t> 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<std::string> 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<std::string> confirmed{"b/a.wav", "b/b.wav", "b/c.wav"};
|
||
const std::vector<std::string> fresh{"b/a.wav", "b/b.wav", "b/c.wav"};
|
||
const std::vector<std::string> 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<std::string> confirmed{"b/a.wav", "b/gone.wav", "b/c.wav"};
|
||
const std::vector<std::string> fresh{"b/a.wav", "b/c.wav"}; // gone.wav disappeared
|
||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||
CHECK((plan == std::vector<std::string>{"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<std::string> confirmed{"b/x.wav", "b/y.wav"};
|
||
const std::vector<std::string> fresh{"b/x.wav"}; // y.wav now referenced -> not a fresh orphan
|
||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||
CHECK((plan == std::vector<std::string>{"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<std::string> confirmed{"b/a.wav"};
|
||
const std::vector<std::string> fresh{"b/a.wav", "b/new_orphan.wav"};
|
||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||
CHECK((plan == std::vector<std::string>{"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<std::string> confirmed{"b/a.wav", "b/a.wav", "b/b.wav"};
|
||
const std::vector<std::string> fresh{"b/a.wav", "b/b.wav"};
|
||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||
CHECK((plan == std::vector<std::string>{"b/a.wav", "b/b.wav"}));
|
||
}
|
||
|
||
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();
|
||
|
||
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;
|
||
}
|