// Standalone tests for reasampler::tracking::OriginLedger — no REAPER, no framework. // // The record family behind file tracking. Covers: the relative-paths-only invariant, // exact-string ownership, dedup, insertion order, the JSON round-trip (incl. golden // byte literals over every persisted enum value), the no-backfill rule, the legacy // path-only lift, and the Fresh / Loaded / Unreadable / FutureVersion classification // that keeps never-recorded apart from the two degraded states. #include "../src/core/tracking/origin_ledger.h" #include #include 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 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. EVERY OriginKind appears, because the // integers are persisted — a swap of two values in both the enum and kindFromInt // round-trips perfectly and would mislabel every existing project. This literal is // the only thing standing in the way of that. static void testSerializeGoldenLiteralPinsEveryPersistedKind() { OriginLedger l; l.record(rec("bank/unknown.wav", OriginKind::Unknown)); l.record(rec("bank/capture.wav", OriginKind::Capture, "S-a")); l.record(rec("bank/ingest.wav", OriginKind::Ingest, "S-b")); l.record(rec("bank/recapture.wav", OriginKind::Recapture, "S-c", "S-a")); l.record(rec("bank/resample.wav", OriginKind::Resample, "S-d", "S-a")); const std::string expected = "{\"v\":2,\"records\":[" "{\"path\":\"bank/unknown.wav\",\"kind\":0,\"sample\":\"\",\"parent\":\"\"}," "{\"path\":\"bank/capture.wav\",\"kind\":1,\"sample\":\"S-a\",\"parent\":\"\"}," "{\"path\":\"bank/ingest.wav\",\"kind\":2,\"sample\":\"S-b\",\"parent\":\"\"}," "{\"path\":\"bank/recapture.wav\",\"kind\":3,\"sample\":\"S-c\",\"parent\":\"S-a\"}," "{\"path\":\"bank/resample.wav\",\"kind\":4,\"sample\":\"S-d\",\"parent\":\"S-a\"}" "]}"; CHECK(l.serialize() == expected); // And the reader agrees with the writer on the same bytes, kind by kind. auto back = OriginLedger::deserialize(expected); CHECK(back.has_value()); CHECK(back->find("bank/unknown.wav")->kind == OriginKind::Unknown); CHECK(back->find("bank/capture.wav")->kind == OriginKind::Capture); CHECK(back->find("bank/ingest.wav")->kind == OriginKind::Ingest); CHECK(back->find("bank/recapture.wav")->kind == OriginKind::Recapture); CHECK(back->find("bank/resample.wav")->kind == OriginKind::Resample); } // contains() is an EXACT-string predicate, never a prefix or substring match — the // prune's whole ownership attribution rests on it, and a looser match would attribute // (and so expose to deletion) a file the system never created. static void testContainsIsExactStringNotPrefixOrSubstring() { OriginLedger l; l.record(rec("reasampler_bank/a.wav", OriginKind::Capture)); CHECK(l.contains("reasampler_bank/a.wav")); CHECK(!l.contains("reasampler_bank/a")); // prefix of the stored path CHECK(!l.contains("a.wav")); // suffix of the stored path CHECK(!l.contains("reasampler_bank/a.wave")); // stored path is a prefix of this CHECK(!l.contains("REASAMPLER_BANK/A.WAV")); // no case folding } // --- 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\":2,\"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); } // Trailing garbage is rejected outright: accepting it would turn a detectably-corrupt // blob into a silently-partial ledger, and the records it dropped would then be lost // on the next save. static void testTrailingGarbageIsRejected() { CHECK(!OriginLedger::deserialize("{\"v\":2,\"records\":[]}JUNK").has_value()); CHECK(!OriginLedger::deserialize("{\"owned\":[\"a.wav\"]} {\"owned\":[]}").has_value()); // ... but trailing whitespace alone is not garbage. CHECK(OriginLedger::deserialize("{\"v\":2,\"records\":[]} \n").has_value()); } // The legacy shape's own error cases: a type error is a REJECTION (blocking, blob // preserved), never a silent degrade to empty — while a genuinely empty legacy list // is valid and yields an empty, non-blocking ledger. static void testLegacyShapeTypeErrorsAreRejectedButEmptyIsValid() { CHECK(loadLedger("{\"owned\":[1,2]}").status == LedgerStatus::Unreadable); CHECK(loadLedger("{\"owned\":\"x\"}").status == LedgerStatus::Unreadable); const LedgerLoad emptyLegacy = loadLedger("{\"owned\":[]}"); CHECK(emptyLegacy.status == LedgerStatus::Loaded); CHECK(emptyLegacy.ledger.empty()); } // A blob written by a newer build is its own status: this build cannot trust records // it parsed under v2 rules, so it must neither answer a destructive question from // them nor overwrite the blob with its own truncation. static void testFutureVersionIsNeitherLoadedNorMalformed() { const std::string v3 = "{\"v\":3,\"records\":[{\"path\":\"a.wav\",\"kind\":1}],\"newshape\":[1]}"; const LedgerLoad load = loadLedger(v3); CHECK(load.status == LedgerStatus::FutureVersion); CHECK(load.ledger.empty()); // never a partial value CHECK(ledgerDegraded(load.status)); // blocks answers AND suppresses the write CHECK(!OriginLedger::deserialize(v3).has_value()); // Key order must not matter — "v" is validated after the object closes. CHECK(loadLedger("{\"records\":[{\"path\":\"a.wav\"}],\"v\":9}").status == LedgerStatus::FutureVersion); // The versions this build does read stay readable, and a nonsense version is // corruption rather than a future shape. CHECK(loadLedger("{\"v\":2,\"records\":[]}").status == LedgerStatus::Loaded); CHECK(loadLedger("{\"v\":1,\"owned\":[\"a.wav\"]}").status == LedgerStatus::Loaded); CHECK(loadLedger("{\"v\":-1,\"records\":[]}").status == LedgerStatus::Unreadable); // A future version whose record shape actually changed — not just grew — fails // parseRecord under these v2 rules ("kind" retyped from int to string). That must // still classify as FutureVersion, not Unreadable: this is the exact "changed // record shape" case the version ladder comment says v3 means, and Unreadable // would steer the operator into clearing a newer build's ledger. CHECK(loadLedger("{\"v\":3,\"records\":[{\"path\":\"a.wav\",\"kind\":\"loud\"}]}") .status == LedgerStatus::FutureVersion); // Same, with the array itself retyped to an object. CHECK(loadLedger("{\"v\":3,\"records\":{\"path\":\"a.wav\"}}").status == LedgerStatus::FutureVersion); } // 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 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 — pinned as a // byte literal, not just a round-trip, so the lifted record's persisted integers // (kind 0) and empty ids are fixed rather than merely self-consistent. const std::string expected = "{\"v\":2,\"records\":[" "{\"path\":\"reasampler_bank/a.wav\",\"kind\":0,\"sample\":\"\",\"parent\":\"\"}," "{\"path\":\"reasampler_bank/b.wav\",\"kind\":0,\"sample\":\"\",\"parent\":\"\"}" "]}"; CHECK(load.ledger.serialize() == expected); auto resaved = OriginLedger::deserialize(expected); CHECK(resaved.has_value()); CHECK(*resaved == load.ledger); } // A recapture regenerates the audio behind ONE bank id under a NEW file name // (makeUniqueTag guarantees it), so the ledger legitimately ends up holding two // records with the same sampleId and different paths. This proves that shape and // that take-1's record is untouched by take-2's insert (record() never overwrites — // see testLineageIsNeverBackfilled for the same-path case). It does NOT exercise the // ledger-wins-over-the-mutable-Sample claim: nothing here reads Sample.provenance, and // no production code reads OriginRecord.parentSampleId today. static void testRecaptureAddsSecondRecordUnderTheSameSampleId() { OriginLedger l; l.record(rec("bank/take-1.wav", OriginKind::Capture, "S-1", "S-parent")); CHECK(l.record(rec("bank/take-2.wav", OriginKind::Recapture, "S-1", "S-other")) == RecordResult::Recorded); CHECK(l.size() == 2); CHECK(l.find("bank/take-1.wav")->sampleId == "S-1"); CHECK(l.find("bank/take-2.wav")->sampleId == "S-1"); CHECK(l.find("bank/take-1.wav")->parentSampleId == "S-parent"); CHECK(l.find("bank/take-1.wav")->kind == OriginKind::Capture); // Both stay owned, so the superseded file is reclaimable rather than foreign. CHECK(l.ownedPaths().size() == 2); } // --- 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(); testContainsIsExactStringNotPrefixOrSubstring(); testLineageIsNeverBackfilled(); testLineageQueryableImmediatelyAfterRecording(); testRoundTripWithLineage(); testRoundTripWithJsonMetacharacters(); testSerializeGoldenLiteralPinsEveryPersistedKind(); testMalformedParsesToNullopt(); testTrailingGarbageIsRejected(); testLegacyShapeTypeErrorsAreRejectedButEmptyIsValid(); testFutureVersionIsNeitherLoadedNorMalformed(); testLoadReassertsInvariants(); testLegacyPathOnlyManifestLiftsIn(); testRecaptureAddsSecondRecordUnderTheSameSampleId(); testFreshLoadedUnreadableAreDistinct(); if (g_fail == 0) std::printf("origin_ledger: all tests passed\n"); return g_fail == 0 ? 0 : 1; }