386 lines
17 KiB
C++
386 lines
17 KiB
C++
// 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 <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. 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 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 — 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. The original's lineage survives
|
|
// untouched — the Sample was rewritten in place, the birth record was not.
|
|
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");
|
|
// The ledger, not the (mutable) Sample, is authoritative for lineage: the
|
|
// recapture's differing parent did not rewrite the original's.
|
|
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;
|
|
}
|