Files
reasampler/tests/test_prune_reconcile.cpp
T
daniel 7b53ba16f6 feat(prune): R3 guarded deletion — confirm-to-delete + panel button
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).
2026-07-26 19:15:54 -04:00

419 lines
19 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}