Files
reasampler/tests/test_prune_reconcile.cpp
T
daniel c1473c42e1 feat(prune): R2 dry-run prune shell + persist wiring, report-only
Add ReaSamplerSession::pruneDryRun enumerating the resolved current bank
folder, feeding the R1 core, and returning a PruneReport (count/bytes/list).
New pure bankRelativeForName + buildPruneReport keep spelling and tally
testable. Register forever-stable BANK_PRUNE_FOLDER action, report-only. No deletion.
2026-07-26 19:12:07 -04:00

354 lines
16 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);
}
int main() {
testNullTestAllReferenced();
testFormulaMixedPopulations();
testCopiedFileSurvivesViaUnion();
testUnionSurvivesWhenOnlySecondBankReferences();
testHandDroppedNeverReclaimed();
testEmptyFolder();
testEmptyReferencedSet();
testEmptyManifest();
testAllEmpty();
testOwnedButAbsentNoOrphan();
testOutputPreservesPresentOrder();
testDuplicatePresentDeduped();
testExactStringMatchNotNormalized();
testReferencedPathsUnionAcrossBanksAndPool();
testReferencedPathsEmptyBook();
testReferencedPathsSkipsEmptyPath();
testReportCountSizeAndFullList();
testReportMissingSizeCountsZeroNotDropped();
testReportTruncatesListButKeepsExactCountAndSize();
testReportUncappedWhenCapZero();
testReportEmptyOrphanSet();
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;
}