Files
reasampler/tests/test_tracking_authority.cpp
T

461 lines
20 KiB
C++

// 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.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
// 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(
{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
// 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);
}
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();
testFutureVersionLedgerBlocksAndIsReportedSeparately();
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;
}