tracking: one ledger, one authority — prune protection and replace-vs-add answered from the same records, fail-safe on unreadable state

This commit is contained in:
2026-07-30 19:44:11 -04:00
parent 7bd911d58b
commit 7f70d94228
40 changed files with 1546 additions and 633 deletions
+274
View File
@@ -0,0 +1,274 @@
// Standalone tests for reasampler::tracking::OriginLedger — no REAPER, no framework.
//
// The record family behind file tracking. Covers: the relative-paths-only invariant,
// dedup, insertion order, the JSON round-trip (incl. a golden byte literal), the
// no-backfill rule, the legacy path-only lift, and the three-way Fresh /
// Loaded / Unreadable classification that keeps never-recorded apart from
// unreadable.
#include "../src/core/tracking/origin_ledger.h"
#include <cstdio>
#include <string>
using namespace reasampler::tracking;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static OriginRecord rec(const std::string& path, OriginKind kind,
const std::string& id = "", const std::string& parent = "") {
OriginRecord r;
r.relativePath = path;
r.kind = kind;
r.sampleId = id;
r.parentSampleId = parent;
return r;
}
// --- empty ledger ------------------------------------------------------------
static void testEmptyLedger() {
OriginLedger l;
CHECK(l.empty());
CHECK(l.size() == 0);
CHECK(l.ownedPaths().empty());
CHECK(!l.contains("anything.wav"));
CHECK(l.find("anything.wav") == nullptr);
const std::string json = l.serialize();
auto back = OriginLedger::deserialize(json);
CHECK(back.has_value());
CHECK(*back == l);
CHECK(back->empty());
}
// --- the relative-paths-only invariant ---------------------------------------
static void testRejectsEmptyAndAbsolutePaths() {
OriginLedger l;
CHECK(l.record(rec("", OriginKind::Capture)) == RecordResult::RejectedEmptyPath);
CHECK(l.record(rec("/abs/a.wav", OriginKind::Capture)) ==
RecordResult::RejectedAbsolutePath);
CHECK(l.record(rec("\\\\server\\share\\a.wav", OriginKind::Capture)) ==
RecordResult::RejectedAbsolutePath);
CHECK(l.record(rec("C:/bank/a.wav", OriginKind::Capture)) ==
RecordResult::RejectedAbsolutePath);
CHECK(l.record(rec("C:rel.wav", OriginKind::Capture)) ==
RecordResult::RejectedAbsolutePath);
CHECK(l.empty()); // no mutation on any rejection
CHECK(l.record(rec("reasampler_bank/a.wav", OriginKind::Capture)) ==
RecordResult::Recorded);
CHECK(l.size() == 1);
}
// --- dedup + insertion order -------------------------------------------------
static void testDedupPreservesInsertionOrder() {
OriginLedger l;
CHECK(l.record(rec("b.wav", OriginKind::Capture)) == RecordResult::Recorded);
CHECK(l.record(rec("a.wav", OriginKind::Ingest)) == RecordResult::Recorded);
CHECK(l.record(rec("b.wav", OriginKind::Capture)) == RecordResult::AlreadyPresent);
const std::vector<std::string> paths = l.ownedPaths();
CHECK(paths.size() == 2);
CHECK(paths[0] == "b.wav"); // insertion order, not sorted
CHECK(paths[1] == "a.wav");
}
// --- lineage is written at birth and NEVER backfilled ------------------------
// A second record() for the same path must leave the stored record untouched, so a
// later, less-informed (or differently-informed) writer can never rewrite history.
static void testLineageIsNeverBackfilled() {
OriginLedger l;
CHECK(l.record(rec("child.wav", OriginKind::Resample, "S-child", "S-parent")) ==
RecordResult::Recorded);
// A later write with different lineage is refused outright.
CHECK(l.record(rec("child.wav", OriginKind::Capture, "S-other", "S-wrong")) ==
RecordResult::AlreadyPresent);
const OriginRecord* stored = l.find("child.wav");
CHECK(stored != nullptr);
CHECK(stored->kind == OriginKind::Resample);
CHECK(stored->sampleId == "S-child");
CHECK(stored->parentSampleId == "S-parent");
// The same holds in the other direction: a never-recorded (Unknown) record is
// not upgraded by a later lineage-bearing write.
OriginLedger legacy;
CHECK(legacy.record(rec("old.wav", OriginKind::Unknown)) == RecordResult::Recorded);
CHECK(legacy.record(rec("old.wav", OriginKind::Resample, "S1", "S0")) ==
RecordResult::AlreadyPresent);
CHECK(legacy.find("old.wav")->parentSampleId.empty());
}
// --- lineage present at creation, queryable immediately ----------------------
// The no-silent-gaps property at the record layer: nothing intervenes between
// record() and a successful find().
static void testLineageQueryableImmediatelyAfterRecording() {
OriginLedger l;
l.record(rec("bake-1.wav", OriginKind::Resample, "S-1", "S-src"));
const OriginRecord* r = l.find("bake-1.wav");
CHECK(r != nullptr);
CHECK(r->parentSampleId == "S-src");
// A second creation immediately after does not disturb the first.
l.record(rec("bake-2.wav", OriginKind::Resample, "S-2", "S-1"));
CHECK(l.find("bake-1.wav")->parentSampleId == "S-src");
CHECK(l.find("bake-2.wav")->parentSampleId == "S-1");
CHECK(l.ownedPaths().size() == 2);
}
// --- JSON round-trip ---------------------------------------------------------
static void testRoundTripWithLineage() {
OriginLedger l;
l.record(rec("reasampler_bank/a.wav", OriginKind::Capture, "S-a"));
l.record(rec("reasampler_bank/b.wav", OriginKind::Resample, "S-b", "S-a"));
l.record(rec("reasampler_bank/c.wav", OriginKind::Ingest, "S-c"));
l.record(rec("reasampler_bank/d.wav", OriginKind::Recapture, "S-d", "S-a"));
auto back = OriginLedger::deserialize(l.serialize());
CHECK(back.has_value());
CHECK(*back == l);
}
// Metacharacters must survive: a path or id is written through the escaper, so a
// quote or backslash cannot break the surrounding document.
static void testRoundTripWithJsonMetacharacters() {
OriginLedger l;
l.record(rec("bank/quote\".wav", OriginKind::Capture, "id\\with\\slashes"));
l.record(rec("bank/tab\tnewline\n.wav", OriginKind::Capture, "", "par\"ent"));
auto back = OriginLedger::deserialize(l.serialize());
CHECK(back.has_value());
CHECK(*back == l);
}
// Golden byte literal: pins the EXACT serialized bytes, so a format drift both
// writer and reader agree on still fails here.
static void testSerializeGoldenLiteral() {
OriginLedger l;
l.record(rec("reasampler_bank/a.wav", OriginKind::Capture, "S-a"));
l.record(rec("reasampler_bank/b.wav", OriginKind::Resample, "S-b", "S-a"));
const std::string expected =
"{\"v\":2,\"records\":["
"{\"path\":\"reasampler_bank/a.wav\",\"kind\":1,\"sample\":\"S-a\",\"parent\":\"\"},"
"{\"path\":\"reasampler_bank/b.wav\",\"kind\":4,\"sample\":\"S-b\",\"parent\":\"S-a\"}"
"]}";
CHECK(l.serialize() == expected);
}
// --- malformed input ---------------------------------------------------------
static void testMalformedParsesToNullopt() {
CHECK(!OriginLedger::deserialize("").has_value());
CHECK(!OriginLedger::deserialize("not json").has_value());
CHECK(!OriginLedger::deserialize("{\"v\":2,\"records\":[").has_value());
CHECK(!OriginLedger::deserialize("{\"records\":[{\"path\":]}").has_value());
CHECK(!OriginLedger::deserialize("[]").has_value());
// Unknown keys are tolerated (forward-compat), unknown kind values degrade to
// Unknown rather than failing the whole ledger — a vocabulary gap must not halt
// the prune.
auto tolerated = OriginLedger::deserialize(
"{\"v\":3,\"future\":{\"x\":1},\"records\":[{\"path\":\"a.wav\",\"kind\":99}]}");
CHECK(tolerated.has_value());
CHECK(tolerated->size() == 1);
CHECK(tolerated->find("a.wav")->kind == OriginKind::Unknown);
}
// A hand-edited or corrupt blob cannot smuggle an absolute or duplicate path past
// the load — record() re-asserts the invariants on the way in.
static void testLoadReassertsInvariants() {
auto loaded = OriginLedger::deserialize(
"{\"v\":2,\"records\":["
"{\"path\":\"a.wav\",\"kind\":1},"
"{\"path\":\"/etc/passwd\",\"kind\":1},"
"{\"path\":\"a.wav\",\"kind\":2},"
"{\"path\":\"\",\"kind\":1}]}");
CHECK(loaded.has_value());
CHECK(loaded->size() == 1);
CHECK(loaded->contains("a.wav"));
CHECK(!loaded->contains("/etc/passwd"));
CHECK(loaded->find("a.wav")->kind == OriginKind::Capture); // first wins
}
// --- the pre-existing bank lifts in ------------------------------------------
// A legacy path-only manifest keeps every protection it had (the paths stay owned,
// so prune still reclaims them and still refuses hand-dropped files) and gains NO
// invented lineage.
static void testLegacyPathOnlyManifestLiftsIn() {
const std::string legacy =
"{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}";
LedgerLoad load = loadLedger(legacy);
CHECK(load.status == LedgerStatus::Loaded);
CHECK(load.ledger.size() == 2);
const std::vector<std::string> paths = load.ledger.ownedPaths();
CHECK(paths.size() == 2);
CHECK(paths[0] == "reasampler_bank/a.wav");
CHECK(paths[1] == "reasampler_bank/b.wav");
for (const OriginRecord& r : load.ledger.records()) {
CHECK(r.kind == OriginKind::Unknown); // no invented origin
CHECK(r.sampleId.empty());
CHECK(r.parentSampleId.empty()); // no spurious lineage
}
// Re-saving upgrades the shape without losing or inventing anything.
auto resaved = OriginLedger::deserialize(load.ledger.serialize());
CHECK(resaved.has_value());
CHECK(*resaved == load.ledger);
}
// --- never-recorded vs unreadable --------------------------------------------
// The two absences demand opposite treatment, so they must be distinguishable at
// the load boundary — this is the only place that distinction is made.
static void testFreshLoadedUnreadableAreDistinct() {
// Absent key: an empty string is not valid JSON, so only the loader can tell
// "no key yet" from "corrupt value".
CHECK(loadLedger("").status == LedgerStatus::Fresh);
CHECK(loadLedger("").ledger.empty());
// A well-formed empty ledger is Loaded, not Fresh — it is a positive record
// that nothing has been created, not an absence.
const LedgerLoad emptyButStored = loadLedger(OriginLedger{}.serialize());
CHECK(emptyButStored.status == LedgerStatus::Loaded);
CHECK(emptyButStored.ledger.empty());
const LedgerLoad broken = loadLedger("{\"records\":[{oops");
CHECK(broken.status == LedgerStatus::Unreadable);
CHECK(broken.ledger.empty()); // never a partial value
OriginLedger real;
real.record(rec("a.wav", OriginKind::Capture, "S-a"));
const LedgerLoad good = loadLedger(real.serialize());
CHECK(good.status == LedgerStatus::Loaded);
CHECK(good.ledger == real);
}
int main() {
testEmptyLedger();
testRejectsEmptyAndAbsolutePaths();
testDedupPreservesInsertionOrder();
testLineageIsNeverBackfilled();
testLineageQueryableImmediatelyAfterRecording();
testRoundTripWithLineage();
testRoundTripWithJsonMetacharacters();
testSerializeGoldenLiteral();
testMalformedParsesToNullopt();
testLoadReassertsInvariants();
testLegacyPathOnlyManifestLiftsIn();
testFreshLoadedUnreadableAreDistinct();
if (g_fail == 0) std::printf("origin_ledger: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}
-185
View File
@@ -1,185 +0,0 @@
// Standalone tests for reasampler::OwnedFileManifest — no REAPER, no framework.
// The owned-file manifest seam (Phase B B-cap): a deduplicated, insertion-ordered
// set of project-relative files the capture path created, with JSON round-trip.
//
// Covers (brief-named): JSON round-trip, dedup of repeated adds, the empty manifest.
// Plus: the relative-paths-only invariant (reject empty / absolute), contains()
// semantics, insertion-order preservation, malformed-parse -> nullopt (the persist
// shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters.
#include "../src/core/model/owned_manifest.h"
#include <cstdio>
#include <string>
using namespace reasampler;
using namespace reasampler::model;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- empty manifest ----------------------------------------------------------
static void testEmptyManifest() {
OwnedFileManifest m;
CHECK(m.empty());
CHECK(m.size() == 0);
CHECK(m.paths().empty());
CHECK(!m.contains("anything.wav"));
// An empty manifest serializes to a well-formed shape and round-trips to empty.
const std::string json = m.serialize();
auto back = OwnedFileManifest::deserialize(json);
CHECK(back.has_value());
CHECK(*back == m);
CHECK(back->empty());
}
// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for a
// small fixture (two paths), not just self-consistent re-serialization — a
// format drift that both writer and reader agree on would slip past the
// round-trip tests but not this. The format is frozen as-shipped; the literal
// below is the captured current output.
static void testSerializeGoldenLiteral() {
OwnedFileManifest m;
m.add("reasampler_bank/a.wav");
m.add("reasampler_bank/b.wav");
CHECK(m.serialize() == "{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}");
}
// --- add / contains / order --------------------------------------------------
static void testAddAndContains() {
OwnedFileManifest m;
CHECK(m.add("reasampler_bank/a.wav") == ManifestAddResult::Added);
CHECK(m.add("reasampler_bank/b.wav") == ManifestAddResult::Added);
CHECK(m.size() == 2);
CHECK(m.contains("reasampler_bank/a.wav"));
CHECK(m.contains("reasampler_bank/b.wav"));
CHECK(!m.contains("reasampler_bank/c.wav"));
// Exact-string match — not a prefix / substring match.
CHECK(!m.contains("reasampler_bank/a"));
CHECK(!m.contains("a.wav"));
// Insertion order is preserved (deterministic ext-state).
CHECK(m.paths().size() == 2);
CHECK(m.paths()[0] == "reasampler_bank/a.wav");
CHECK(m.paths()[1] == "reasampler_bank/b.wav");
}
// --- dedup of repeated adds --------------------------------------------------
static void testDedupRepeatedAdds() {
OwnedFileManifest m;
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::Added);
// A repeat capture of an identical request must not double-record the file.
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
CHECK(m.size() == 1);
CHECK(m.paths().size() == 1);
}
// --- relative-paths-only invariant -------------------------------------------
static void testRejectsEmptyAndAbsolute() {
OwnedFileManifest m;
CHECK(m.add("") == ManifestAddResult::RejectedEmptyPath);
// Every absolute form bank_model rejects, the manifest rejects too.
CHECK(m.add("/abs/take.wav") == ManifestAddResult::RejectedAbsolutePath); // POSIX root
CHECK(m.add("\\\\host\\share\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* UNC */
CHECK(m.add("C:/bank/x.wav") == ManifestAddResult::RejectedAbsolutePath); // Win drive /
CHECK(m.add("C:\\bank\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* Win drive backslash */
CHECK(m.add("C:x.wav") == ManifestAddResult::RejectedAbsolutePath); // drive-relative
// A rejected add never mutates.
CHECK(m.empty());
CHECK(!m.contains("/abs/take.wav"));
}
// --- JSON round-trip ---------------------------------------------------------
static void testRoundTrip() {
OwnedFileManifest m;
m.add("reasampler_bank/one.wav");
m.add("reasampler_bank/two.wav");
m.add("reasampler_bank/three.wav");
const std::string json = m.serialize();
auto back = OwnedFileManifest::deserialize(json);
CHECK(back.has_value());
CHECK(*back == m);
// Order + membership survive.
CHECK(back->paths().size() == 3);
CHECK(back->paths()[0] == "reasampler_bank/one.wav");
CHECK(back->paths()[2] == "reasampler_bank/three.wav");
// serialize(deserialize(serialize(x))) is stable.
CHECK(back->serialize() == json);
}
// A path carrying JSON metacharacters must survive the escape/unescape round-trip.
static void testRoundTripEscaping() {
OwnedFileManifest m;
m.add("reasampler_bank/od\"d name.wav"); // embedded quote
m.add("reasampler_bank/back\\slash.wav"); // embedded backslash
m.add("reasampler_bank/tab\tafter.wav"); // control char
auto back = OwnedFileManifest::deserialize(m.serialize());
CHECK(back.has_value());
CHECK(*back == m);
CHECK(back->contains("reasampler_bank/od\"d name.wav"));
CHECK(back->contains("reasampler_bank/back\\slash.wav"));
CHECK(back->contains("reasampler_bank/tab\tafter.wav"));
}
// --- malformed / tolerant parse ----------------------------------------------
static void testMalformedParse() {
// The persist shell's warn+fallback hinges on nullopt for a corrupt blob.
CHECK(!OwnedFileManifest::deserialize("").has_value()); // empty string
CHECK(!OwnedFileManifest::deserialize("not json").has_value());
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[").has_value()); // unterminated array
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[1,2]}").has_value()); // non-string element
CHECK(!OwnedFileManifest::deserialize("{\"owned\":\"x\"}").has_value()); // wrong value type
// An explicit empty array parses to an empty manifest.
auto empty = OwnedFileManifest::deserialize("{\"owned\":[]}");
CHECK(empty.has_value());
CHECK(empty->empty());
// An unknown sibling key is tolerated (forward-compat) — the owned array still loads.
auto fwd = OwnedFileManifest::deserialize(
"{\"future\":{\"nested\":[1,2]},\"owned\":[\"reasampler_bank/x.wav\"]}");
CHECK(fwd.has_value());
CHECK(fwd->size() == 1);
CHECK(fwd->contains("reasampler_bank/x.wav"));
// A stored blob cannot smuggle a duplicate or absolute path past the load-time
// invariant re-assertion (deserialize routes each element through add()).
auto dupe = OwnedFileManifest::deserialize(
"{\"owned\":[\"reasampler_bank/x.wav\",\"reasampler_bank/x.wav\"]}");
CHECK(dupe.has_value());
CHECK(dupe->size() == 1);
auto absolute = OwnedFileManifest::deserialize(
"{\"owned\":[\"reasampler_bank/ok.wav\",\"/etc/evil.wav\"]}");
CHECK(absolute.has_value());
CHECK(absolute->size() == 1);
CHECK(absolute->contains("reasampler_bank/ok.wav"));
CHECK(!absolute->contains("/etc/evil.wav"));
}
int main() {
testSerializeGoldenLiteral();
testEmptyManifest();
testAddAndContains();
testDedupRepeatedAdds();
testRejectsEmptyAndAbsolute();
testRoundTrip();
testRoundTripEscaping();
testMalformedParse();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+50 -17
View File
@@ -52,6 +52,12 @@ UsageRecord makeRecord(const std::string& trackGuid, const std::string& nonce,
return r;
}
// One enumerated key's read result. Keys are synthesized per index because the fold
// only uses them to name an offender; a nullopt record is the unreadable case.
DecodedUsage decodedOf(const std::string& key, std::optional<UsageRecord> rec) {
return DecodedUsage{key, std::move(rec)};
}
bool holdsContainPath(const std::vector<UsageHold>& holds, const std::string& path) {
for (const UsageHold& h : holds)
if (h.relativePath == path) return true;
@@ -349,36 +355,61 @@ static void testTakeFxAttributedRecordIsProtected() {
// delete nothing) — silently reduced protection is the delete direction.
static void testUnreadableRecordAbortsPrune() {
std::vector<std::optional<UsageRecord>> decoded;
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
decoded.push_back(std::nullopt); // one unreadable record among readable ones
std::vector<DecodedUsage> decoded;
decoded.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
decoded.push_back(decodedOf("rsusage_BAD", std::nullopt)); // one unreadable among readable
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{"{T1}"}, true);
CHECK(fold.abortPrune);
// The offender is named so the action can tell the operator which key to recover.
CHECK(fold.offendingKeys.size() == 1);
CHECK(fold.offendingKeys[0] == "rsusage_BAD");
// Attribution is exactly what an unreadable record destroys — no record may be
// reported as counted while one is unreadable.
CHECK(fold.counted.empty());
// All readable -> no abort, normal liveness fold.
std::vector<std::optional<UsageRecord>> ok;
ok.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
// All readable -> no abort, normal liveness fold, and the live record is attributed.
std::vector<DecodedUsage> ok;
ok.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
const UsageFoldResult okFold =
foldUsageRecords(ok, std::unordered_set<std::string>{"{T1}"}, true);
CHECK(!okFold.abortPrune);
CHECK(okFold.offendingKeys.empty());
CHECK(okFold.heldPaths.size() == 1);
CHECK(okFold.heldPaths[0] == "pa.wav");
CHECK(okFold.counted.size() == 1);
CHECK(okFold.counted[0].key == "rsusage_1");
// Empty input (no records enumerated) -> empty, no abort.
const UsageFoldResult empty =
foldUsageRecords({}, std::unordered_set<std::string>{}, false);
CHECK(!empty.abortPrune);
CHECK(empty.heldPaths.empty());
CHECK(empty.counted.empty());
// Readable records + zero identified -> the protect-all net applies through the
// fold too (belt and braces with the abort).
std::vector<std::optional<UsageRecord>> unmatched;
unmatched.push_back(makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}}));
// fold too (belt and braces with the abort), and every record counts.
std::vector<DecodedUsage> unmatched;
unmatched.push_back(decodedOf("rsusage_9", makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}})));
const UsageFoldResult net =
foldUsageRecords(unmatched, std::unordered_set<std::string>{}, false);
CHECK(!net.abortPrune);
CHECK(net.heldPaths.size() == 1);
CHECK(net.counted.size() == 1);
}
// A dead-track record must be excluded from `counted` as well as from heldPaths —
// attribution and protection must name the same records or the two consumers drift.
static void testCountedMirrorsHeldPaths() {
std::vector<DecodedUsage> decoded;
decoded.push_back(decodedOf("rsusage_LIVE", makeRecord("{LIVE}", "N1", {UsageHold{"a", "pa.wav"}})));
decoded.push_back(decodedOf("rsusage_DEAD", makeRecord("{DEAD}", "N2", {UsageHold{"b", "pb.wav"}})));
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{"{LIVE}"}, true);
CHECK(fold.heldPaths.size() == 1);
CHECK(fold.heldPaths[0] == "pa.wav");
CHECK(fold.counted.size() == 1);
CHECK(fold.counted[0].key == "rsusage_LIVE");
}
// --- abort returns the protect-all set (belt-and-braces) ---------------------------
@@ -388,10 +419,10 @@ static void testUnreadableRecordAbortsPrune() {
// delete-ward).
static void testAbortFoldReturnsProtectAllSet() {
// Two readable records + one unreadable (nullopt) in between.
std::vector<std::optional<UsageRecord>> decoded;
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
decoded.push_back(std::nullopt); // triggers abort
decoded.push_back(makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}}));
std::vector<DecodedUsage> decoded;
decoded.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
decoded.push_back(decodedOf("rsusage_BAD", std::nullopt)); // triggers abort
decoded.push_back(decodedOf("rsusage_2", makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}})));
// Live: only {T1} — so without protect-all, T2's path would be excluded.
const UsageFoldResult fold =
@@ -409,8 +440,8 @@ static void testAbortFoldReturnsProtectAllSet() {
CHECK(hasPB);
// All nullopt (every key unreadable): abort + empty heldPaths (nothing readable).
std::vector<std::optional<UsageRecord>> allNull;
allNull.push_back(std::nullopt);
std::vector<DecodedUsage> allNull;
allNull.push_back(decodedOf("rsusage_BAD", std::nullopt));
const UsageFoldResult allNullFold =
foldUsageRecords(allNull, std::unordered_set<std::string>{}, false);
CHECK(allNullFold.abortPrune);
@@ -445,8 +476,9 @@ static void testTruncatedWalkProtectsAll() {
CHECK(hasB);
// Belt-and-braces: the same scenario through foldUsageRecords also protects all.
std::vector<std::optional<UsageRecord>> decoded;
for (const UsageRecord& r : records) decoded.push_back(r);
std::vector<DecodedUsage> decoded;
for (std::size_t i = 0; i < records.size(); ++i)
decoded.push_back(decodedOf("rsusage_" + std::to_string(i), records[i]));
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{}, false);
CHECK(!fold.abortPrune);
@@ -561,6 +593,7 @@ int main() {
testHeldPathsDedupAndEmptyPathSkip();
testTakeFxAttributedRecordIsProtected();
testUnreadableRecordAbortsPrune();
testCountedMirrorsHeldPaths();
testAbortFoldReturnsProtectAllSet();
testTruncatedWalkProtectsAll();
testIdentityMatcher();
+419
View File
@@ -0,0 +1,419 @@
// Standalone tests for reasampler::tracking's consolidated answers — no REAPER, no
// framework. This is the safety-critical file in the territory: it proves that the
// prune's protected set and the resample's replace-vs-add decision come out of ONE
// state, that neither ever answers destructively from ambiguity, and that the
// replace-vs-add universe is a strict subset of the prune-protection universe.
//
// The prune half is composed end-to-end against the real pure core
// (prune_reconcile + BankBook) rather than a mock, so "a tied usage can never reach
// the orphan set" is proven at the layer that actually deletes.
#include "../src/core/tracking/tracking_authority.h"
#include <algorithm>
#include <cstdio>
#include <string>
#include <unordered_set>
#include <vector>
#include "../src/core/model/bank_book.h"
#include "../src/core/reclaim/prune_reconcile.h"
using namespace reasampler;
using namespace reasampler::tracking;
using reasampler::wire::DecodedUsage;
using reasampler::wire::UsageFoldResult;
using reasampler::wire::UsageHold;
using reasampler::wire::UsageRecord;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// -- fixtures ----------------------------------------------------------------
static OriginRecord originOf(const std::string& path, OriginKind kind,
const std::string& id = "",
const std::string& parent = "") {
OriginRecord r;
r.relativePath = path;
r.kind = kind;
r.sampleId = id;
r.parentSampleId = parent;
return r;
}
static DecodedUsage usage(const std::string& key, const std::string& trackGuid,
const std::vector<UsageHold>& holds, bool unioned = false) {
UsageRecord rec;
rec.trackGuid = trackGuid;
rec.ownerNonce = key + "-nonce";
rec.unioned = unioned;
rec.holds = holds;
return DecodedUsage{key, rec};
}
static DecodedUsage unreadable(const std::string& key) {
return DecodedUsage{key, std::nullopt};
}
static UsageFoldResult foldLive(const std::vector<DecodedUsage>& decoded,
const std::vector<std::string>& liveTracks) {
const std::unordered_set<std::string> live(liveTracks.begin(), liveTracks.end());
return wire::foldUsageRecords(decoded, live, !live.empty());
}
static bool contains(const std::vector<std::string>& v, const std::string& s) {
return std::find(v.begin(), v.end(), s) != v.end();
}
// Adds a bank entry so the path lands in BankBook::referencedPaths().
static void addToBank(BankBook& book, const std::string& id, const std::string& path) {
model::Sample s;
s.id = id;
s.displayName = id;
s.relativePath = path;
book.activeIndex().add(s);
}
// -- 1. the prune protected set ----------------------------------------------
// Live-held protected; project-referenced protected; foreign untouchable;
// system-owned orphan reclaimable — all four from one computation.
static void testPruneProtectedSet() {
// On disk: a referenced capture, a live-held capture, a foreign file, and a
// system-owned orphan.
const std::vector<std::string> present = {
"bank/referenced.wav", "bank/held.wav", "bank/foreign.wav", "bank/orphan.wav"};
BankBook book;
addToBank(book, "S-ref", "bank/referenced.wav");
OriginLedger ledger;
ledger.record(originOf("bank/referenced.wav", OriginKind::Capture, "S-ref"));
ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));
ledger.record(originOf("bank/orphan.wav", OriginKind::Capture, "S-orphan"));
// "bank/foreign.wav" is deliberately absent — the system did not create it.
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}})}, {"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
const std::vector<std::string> orphans = reclaim::pruneOrphans(
present, reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths),
answer.ownedPaths);
CHECK(orphans.size() == 1);
CHECK(contains(orphans, "bank/orphan.wav")); // system-owned, unreferenced
CHECK(!contains(orphans, "bank/referenced.wav")); // a bank references it
CHECK(!contains(orphans, "bank/held.wav")); // a live instance holds it
CHECK(!contains(orphans, "bank/foreign.wav")); // never system-created
}
// A capture whose bank entry was REMOVED while an instance kept playing it is still
// protected — the held-path union, not the index, is what saves it.
static void testLiveHoldProtectsDeReferencedCapture() {
const std::vector<std::string> present = {"bank/held.wav"};
BankBook book; // no entry at all
OriginLedger ledger;
ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}})}, {"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
const std::vector<std::string> orphans = reclaim::pruneOrphans(
present, reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths),
answer.ownedPaths);
CHECK(orphans.empty());
}
// -- 2. unreadable tracking state blocks the prune ---------------------------
static void testUnreadableUsageBlocksPruneAndNamesIt() {
OriginLedger ledger;
ledger.record(originOf("bank/orphan.wav", OriginKind::Capture, "S-1"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S-x", "bank/x.wav"}}),
unreadable("rsusage_BROKEN")},
{"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(answer.blocked);
CHECK(!answer.ledgerUnreadable);
CHECK(answer.unreadableUsageKeys.size() == 1);
CHECK(answer.unreadableUsageKeys[0] == "rsusage_BROKEN");
}
// An unreadable ledger blocks too, AND leaves ownedPaths empty — so a caller that
// ignored `blocked` still computes an empty orphan set rather than deleting.
static void testUnreadableLedgerBlocksAndYieldsNoOrphans() {
// Deliberately NON-empty: loadLedger() hands back an empty ledger on Unreadable,
// so an empty fixture here would assert nothing. The guard must suppress records
// it was given, not merely pass an empty vector through.
OriginLedger populated;
populated.record(originOf("bank/would-be-orphan.wav", OriginKind::Capture, "S-1"));
const UsageFoldResult fold = foldLive({}, {});
const TrackingState state{LedgerStatus::Unreadable, populated, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(answer.blocked);
CHECK(answer.ledgerUnreadable);
CHECK(answer.ownedPaths.empty());
// Belt-and-braces: the orphan set computed from this answer is empty even
// though the file is present, unreferenced, and recorded as owned.
const std::vector<std::string> orphans = reclaim::pruneOrphans(
{"bank/would-be-orphan.wav"}, {}, answer.ownedPaths);
CHECK(orphans.empty());
}
// Both blockers at once must both be reported — the operator needs to fix both.
static void testBothBlockersReported() {
const OriginLedger empty;
const UsageFoldResult fold = foldLive({unreadable("rsusage_BROKEN")}, {"{T1}"});
const TrackingState state{LedgerStatus::Unreadable, empty, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(answer.blocked);
CHECK(answer.ledgerUnreadable);
CHECK(answer.unreadableUsageKeys.size() == 1);
}
// A record that exists but whose track hosts no identified instance still protects
// everything (the zero-identified net) — the prune runs, but reclaims nothing held.
static void testZeroIdentifiedInstancesStillProtects() {
OriginLedger ledger;
ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T-GONE}", {UsageHold{"S-held", "bank/held.wav"}})}, {});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
CHECK(contains(answer.heldPaths, "bank/held.wav"));
const std::vector<std::string> orphans =
reclaim::pruneOrphans({"bank/held.wav"},
reclaim::mergeReferenced({}, answer.heldPaths),
answer.ownedPaths);
CHECK(orphans.empty());
}
// -- 3. unreadable state never takes the replace branch ----------------------
static void testUnreadableStateNeverAnswersNo() {
OriginLedger ledger;
ledger.record(originOf("bank/a.wav", OriginKind::Capture, "S-a"));
// Unreadable usage record.
const UsageFoldResult brokenUsage = foldLive({unreadable("rsusage_BROKEN")}, {"{T1}"});
const TrackingState usageBad{LedgerStatus::Loaded, ledger, brokenUsage};
CHECK(tiedUsageExists(usageBad, "bank/a.wav", "") == Answer::Indeterminate);
// Unreadable ledger.
const OriginLedger empty;
const UsageFoldResult okUsage = foldLive({}, {});
const TrackingState ledgerBad{LedgerStatus::Unreadable, empty, okUsage};
CHECK(tiedUsageExists(ledgerBad, "bank/a.wav", "") == Answer::Indeterminate);
// An empty capture path is a caller error, not a licence to replace.
const TrackingState good{LedgerStatus::Loaded, ledger, okUsage};
CHECK(tiedUsageExists(good, "", "") == Answer::Indeterminate);
}
// -- 4. the tie itself --------------------------------------------------------
static void testTiedUsageYesNoAndSelfExclusion() {
OriginLedger ledger;
ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));
ledger.record(originOf("bank/lonely.wav", OriginKind::Capture, "S-lonely"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}}),
usage("rsusage_OTHER", "{T2}", {UsageHold{"S-src", "bank/src.wav"}})},
{"{T1}", "{T2}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
// Counting every holder: a tie exists.
CHECK(tiedUsageExists(state, "bank/src.wav", "") == Answer::Yes);
// Excluding my own key still leaves the other instance's tie.
CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::Yes);
// Nobody holds this one.
CHECK(tiedUsageExists(state, "bank/lonely.wav", "") == Answer::No);
}
// Sole holder excluding itself: definitively No, so the bake may replace in place.
static void testSoleHolderExcludingItselfAnswersNo() {
OriginLedger ledger;
ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}})}, {"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::No);
}
// A `unioned` record carries more than one incarnation's holds, so it can never be
// attributed to a single owner — excluding it could hide a sibling's tie, which is
// the under-protecting direction.
static void testUnionedRecordIsNeverExcludedAsOwn() {
OriginLedger ledger;
ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}},
/*unioned=*/true)},
{"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::Yes);
}
// -- 5. no silent gaps --------------------------------------------------------
// A system-created file simulated through the same recordCreated path is tracked
// the instant it exists: an immediately-following prune sees it as owned (and so
// reclaimable, not foreign), and an immediately-following second creation is
// unaffected. Its lineage is queryable with no intervening save/load.
static void testCreateThenImmediatePruneAndSecondCreate() {
OriginLedger ledger;
const UsageFoldResult noUsage = foldLive({}, {});
// Create #1 — a resample carrying lineage from birth.
ledger.record(originOf("bank/bake-1.wav", OriginKind::Resample, "S-1", "S-src"));
{
const TrackingState state{LedgerStatus::Loaded, ledger, noUsage};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
// Tracked instantly: it is owned, so it is reclaimable rather than foreign.
CHECK(contains(answer.ownedPaths, "bank/bake-1.wav"));
const std::vector<std::string> orphans =
reclaim::pruneOrphans({"bank/bake-1.wav"}, {}, answer.ownedPaths);
CHECK(orphans.size() == 1);
// Lineage present with nothing in between.
CHECK(ledger.find("bank/bake-1.wav")->parentSampleId == "S-src");
}
// Create #2 immediately after — both records intact, neither disturbed.
ledger.record(originOf("bank/bake-2.wav", OriginKind::Resample, "S-2", "S-1"));
{
const TrackingState state{LedgerStatus::Loaded, ledger, noUsage};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(contains(answer.ownedPaths, "bank/bake-1.wav"));
CHECK(contains(answer.ownedPaths, "bank/bake-2.wav"));
CHECK(ledger.find("bank/bake-1.wav")->parentSampleId == "S-src");
CHECK(ledger.find("bank/bake-2.wav")->parentSampleId == "S-1");
}
}
// -- 6. the pre-existing bank -------------------------------------------------
// Lifted from a legacy path-only manifest: protections intact, no spurious lineage,
// and the tie query gives a definite answer rather than abstaining.
static void testPreExistingBankKeepsProtectionsAndAnswersDefinitely() {
const LedgerLoad load = loadLedger("{\"owned\":[\"bank/legacy.wav\"]}");
CHECK(load.status == LedgerStatus::Loaded);
CHECK(load.ledger.find("bank/legacy.wav")->parentSampleId.empty());
// Foreign files stay untouchable and the legacy file is still reclaimable when
// nothing references it — exactly the protections it had before the lift.
const UsageFoldResult noUsage = foldLive({}, {});
const TrackingState state{load.status, load.ledger, noUsage};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
const std::vector<std::string> orphans = reclaim::pruneOrphans(
{"bank/legacy.wav", "bank/handdropped.wav"}, {}, answer.ownedPaths);
CHECK(orphans.size() == 1);
CHECK(contains(orphans, "bank/legacy.wav"));
CHECK(!contains(orphans, "bank/handdropped.wav"));
// Never-recorded is decidable, not indeterminate.
CHECK(tiedUsageExists(state, "bank/legacy.wav", "") == Answer::No);
CHECK(tiedUsageExists(state, "bank/handdropped.wav", "") == Answer::No);
// And a live hold on the legacy file still ties, lineage record or not.
const UsageFoldResult held = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S-legacy", "bank/legacy.wav"}})},
{"{T1}"});
const TrackingState heldState{load.status, load.ledger, held};
CHECK(tiedUsageExists(heldState, "bank/legacy.wav", "") == Answer::Yes);
}
// -- 7. the consumers cannot disagree ----------------------------------------
// Every Yes from the tie query is a path the prune protects, over a constructed
// record set that mixes live, dead, self-held and unheld paths. The reverse does
// NOT hold — that asymmetry is the point, so it is asserted too.
static void testTiedUniverseIsStrictSubsetOfProtectedUniverse() {
BankBook book;
addToBank(book, "S-ref", "bank/bank-only.wav");
OriginLedger ledger;
for (const char* p : {"bank/live-a.wav", "bank/live-b.wav", "bank/self.wav",
"bank/dead.wav", "bank/bank-only.wav", "bank/unused.wav"})
ledger.record(originOf(p, OriginKind::Capture, std::string("S") + p));
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S1", "bank/live-a.wav"},
UsageHold{"S2", "bank/live-b.wav"}}),
usage("rsusage_ME", "{T1}", {UsageHold{"S3", "bank/self.wav"}}),
usage("rsusage_DEAD", "{T-GONE}", {UsageHold{"S4", "bank/dead.wav"}})},
{"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
const std::vector<std::string> referenced =
reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths);
const std::vector<std::string> universe = {
"bank/live-a.wav", "bank/live-b.wav", "bank/self.wav",
"bank/dead.wav", "bank/bank-only.wav", "bank/unused.wav"};
std::size_t tiedCount = 0;
for (const std::string& path : universe) {
const Answer tied = tiedUsageExists(state, path, "rsusage_ME");
if (tied != Answer::Yes) continue;
++tiedCount;
// Yes => protected: the path is in the referenced union, so pruneOrphans
// cannot emit it even though it is present and owned.
CHECK(contains(referenced, path));
CHECK(reclaim::pruneOrphans({path}, referenced, answer.ownedPaths).empty());
}
CHECK(tiedCount == 2); // live-a, live-b — self is excluded, dead is not live
// Strictly narrower: bank-only.wav is protected (a bank references it) and
// self.wav is protected (a live instance holds it), yet neither is a tie.
CHECK(contains(referenced, "bank/bank-only.wav"));
CHECK(tiedUsageExists(state, "bank/bank-only.wav", "rsusage_ME") == Answer::No);
CHECK(contains(referenced, "bank/self.wav"));
CHECK(tiedUsageExists(state, "bank/self.wav", "rsusage_ME") == Answer::No);
}
int main() {
testPruneProtectedSet();
testLiveHoldProtectsDeReferencedCapture();
testUnreadableUsageBlocksPruneAndNamesIt();
testUnreadableLedgerBlocksAndYieldsNoOrphans();
testBothBlockersReported();
testZeroIdentifiedInstancesStillProtects();
testUnreadableStateNeverAnswersNo();
testTiedUsageYesNoAndSelfExclusion();
testSoleHolderExcludingItselfAnswersNo();
testUnionedRecordIsNeverExcludedAsOwn();
testCreateThenImmediatePruneAndSecondCreate();
testPreExistingBankKeepsProtectionsAndAnswersDefinitely();
testTiedUniverseIsStrictSubsetOfProtectedUniverse();
if (g_fail == 0) std::printf("tracking_authority: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}