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:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user