Files
reasampler/tests/test_origin_ledger.cpp
T

275 lines
11 KiB
C++

// 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;
}