tracking: read the ledger's version, not just write it; clear owned on any block; channel-correct prune recovery
This commit is contained in:
+125
-14
@@ -1,10 +1,10 @@
|
||||
// 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.
|
||||
// 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"
|
||||
|
||||
@@ -151,17 +151,49 @@ static void testRoundTripWithJsonMetacharacters() {
|
||||
}
|
||||
|
||||
// Golden byte literal: pins the EXACT serialized bytes, so a format drift both
|
||||
// writer and reader agree on still fails here.
|
||||
static void testSerializeGoldenLiteral() {
|
||||
// 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("reasampler_bank/a.wav", OriginKind::Capture, "S-a"));
|
||||
l.record(rec("reasampler_bank/b.wav", OriginKind::Resample, "S-b", "S-a"));
|
||||
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\":\"reasampler_bank/a.wav\",\"kind\":1,\"sample\":\"S-a\",\"parent\":\"\"},"
|
||||
"{\"path\":\"reasampler_bank/b.wav\",\"kind\":4,\"sample\":\"S-b\",\"parent\":\"S-a\"}"
|
||||
"{\"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 ---------------------------------------------------------
|
||||
@@ -177,12 +209,57 @@ static void testMalformedParsesToNullopt() {
|
||||
// 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}]}");
|
||||
"{\"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() {
|
||||
@@ -222,12 +299,41 @@ static void testLegacyPathOnlyManifestLiftsIn() {
|
||||
CHECK(r.parentSampleId.empty()); // no spurious lineage
|
||||
}
|
||||
|
||||
// Re-saving upgrades the shape without losing or inventing anything.
|
||||
auto resaved = OriginLedger::deserialize(load.ledger.serialize());
|
||||
// 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.
|
||||
@@ -259,14 +365,19 @@ int main() {
|
||||
testEmptyLedger();
|
||||
testRejectsEmptyAndAbsolutePaths();
|
||||
testDedupPreservesInsertionOrder();
|
||||
testContainsIsExactStringNotPrefixOrSubstring();
|
||||
testLineageIsNeverBackfilled();
|
||||
testLineageQueryableImmediatelyAfterRecording();
|
||||
testRoundTripWithLineage();
|
||||
testRoundTripWithJsonMetacharacters();
|
||||
testSerializeGoldenLiteral();
|
||||
testSerializeGoldenLiteralPinsEveryPersistedKind();
|
||||
testMalformedParsesToNullopt();
|
||||
testTrailingGarbageIsRejected();
|
||||
testLegacyShapeTypeErrorsAreRejectedButEmptyIsValid();
|
||||
testFutureVersionIsNeitherLoadedNorMalformed();
|
||||
testLoadReassertsInvariants();
|
||||
testLegacyPathOnlyManifestLiftsIn();
|
||||
testRecaptureAddsSecondRecordUnderTheSameSampleId();
|
||||
testFreshLoadedUnreadableAreDistinct();
|
||||
|
||||
if (g_fail == 0) std::printf("origin_ledger: all tests passed\n");
|
||||
|
||||
@@ -148,8 +148,44 @@ static void testUnreadableUsageBlocksPruneAndNamesIt() {
|
||||
const ProtectionAnswer answer = pruneProtection(state);
|
||||
CHECK(answer.blocked);
|
||||
CHECK(!answer.ledgerUnreadable);
|
||||
CHECK(!answer.ledgerFutureVersion);
|
||||
CHECK(answer.unreadableUsageKeys.size() == 1);
|
||||
CHECK(answer.unreadableUsageKeys[0] == "rsusage_BROKEN");
|
||||
|
||||
// The belt-and-braces guard is symmetric: a usage-only block ALSO withholds
|
||||
// ownedPaths, even though the ledger itself read fine, so a caller that ignored
|
||||
// `blocked` computes an empty orphan set rather than deleting with degraded
|
||||
// protection.
|
||||
CHECK(answer.ownedPaths.empty());
|
||||
CHECK(reclaim::pruneOrphans({"bank/orphan.wav"}, {}, answer.ownedPaths).empty());
|
||||
|
||||
// heldPaths is the one list that stays populated on a block — it only ever widens
|
||||
// the protected set, so withholding it would be the unsafe direction.
|
||||
CHECK(contains(answer.heldPaths, "bank/x.wav"));
|
||||
}
|
||||
|
||||
// A ledger written by a NEWER build blocks exactly like a corrupt one, but is
|
||||
// reported separately: the operator advice differs (clearing a corrupt blob is
|
||||
// repair; clearing a newer build's is destruction).
|
||||
static void testFutureVersionLedgerBlocksAndIsReportedSeparately() {
|
||||
const LedgerLoad load = loadLedger("{\"v\":99,\"records\":[]}");
|
||||
CHECK(load.status == LedgerStatus::FutureVersion);
|
||||
|
||||
OriginLedger populated;
|
||||
populated.record(originOf("bank/would-be-orphan.wav", OriginKind::Capture, "S-1"));
|
||||
const UsageFoldResult fold = foldLive({}, {});
|
||||
|
||||
const TrackingState state{load.status, populated, fold};
|
||||
const ProtectionAnswer answer = pruneProtection(state);
|
||||
CHECK(answer.blocked);
|
||||
CHECK(answer.ledgerFutureVersion);
|
||||
CHECK(!answer.ledgerUnreadable);
|
||||
CHECK(answer.ownedPaths.empty());
|
||||
CHECK(reclaim::pruneOrphans({"bank/would-be-orphan.wav"}, {},
|
||||
answer.ownedPaths).empty());
|
||||
|
||||
// And the replace-vs-add question abstains rather than allowing a replace.
|
||||
CHECK(tiedUsageExists(state, "bank/would-be-orphan.wav", "") == Answer::Indeterminate);
|
||||
}
|
||||
|
||||
// An unreadable ledger blocks too, AND leaves ownedPaths empty — so a caller that
|
||||
@@ -178,12 +214,19 @@ static void testUnreadableLedgerBlocksAndYieldsNoOrphans() {
|
||||
// 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 UsageFoldResult fold = foldLive(
|
||||
{usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}}),
|
||||
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);
|
||||
// heldPaths survives a double block: the fold's protect-all set only ever widens
|
||||
// what prune protects, so withholding it would be the unsafe direction.
|
||||
CHECK(contains(answer.heldPaths, "bank/held.wav"));
|
||||
CHECK(answer.ownedPaths.empty());
|
||||
}
|
||||
|
||||
// A record that exists but whose track hosts no identified instance still protects
|
||||
@@ -260,9 +303,6 @@ static void testSoleHolderExcludingItselfAnswersNo() {
|
||||
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"));
|
||||
@@ -404,6 +444,7 @@ int main() {
|
||||
testLiveHoldProtectsDeReferencedCapture();
|
||||
testUnreadableUsageBlocksPruneAndNamesIt();
|
||||
testUnreadableLedgerBlocksAndYieldsNoOrphans();
|
||||
testFutureVersionLedgerBlocksAndIsReportedSeparately();
|
||||
testBothBlockersReported();
|
||||
testZeroIdentifiedInstancesStillProtects();
|
||||
testUnreadableStateNeverAnswersNo();
|
||||
|
||||
Reference in New Issue
Block a user