B5: sample-remove verb — index-only drop, this-bank scope, confirm-on-last-reference

This commit is contained in:
2026-07-26 15:07:41 -04:00
parent 22f07d0654
commit 6c1efa0f25
5 changed files with 373 additions and 18 deletions
+124
View File
@@ -638,6 +638,124 @@ static void testDeserializeNamedBankCollidingWithPoolIsDisambiguated() {
if (back2) CHECK(back2->serialize() == back->serialize());
}
// --- B5: sample-remove (this-bank + latent all-banks) + last-reference query ------
//
// removeSample drops a Sample's index entry (index-only, non-destructive to the file).
// ThisBank (default, surfaced) drops from one named source bank; AllBanks (latent seam)
// purges the id book-wide. hashReferencedElsewhere backs the confirm-on-last-reference
// guardrail: does any OTHER bank still hold the content hash?
static void testRemoveDropsTargetEntry() {
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.bank("drums")->index.add(sampleWith("kick")) == AddResult::Added);
CHECK(book.bank("drums")->index.add(sampleWith("snare")) == AddResult::Added);
// Remove the kick from drums: dropped from the target bank, the sibling survives.
CHECK(book.removeSample("id-kick", "drums") == RemoveResult::Removed);
CHECK(book.bank("drums")->index.query("id-kick") == nullptr); // dropped
CHECK(book.bank("drums")->index.query("id-snare") != nullptr); // sibling kept
CHECK(book.bank("drums")->index.size() == 1);
}
static void testRemoveThisBankLeavesSameHashInAnotherBank() {
// Copy a sample into two banks (same hash in both), then remove from one under the
// default this-bank scope: the OTHER bank's entry survives — no cross-bank cascade.
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
CHECK(book.removeSample("id-clap", kPoolBankId, RemoveScope::ThisBank) ==
RemoveResult::Removed);
CHECK(book.pool().index.query("id-clap") == nullptr); // removed from pool
CHECK(book.bank("drums")->index.query("id-clap") != nullptr); // drums copy survives
CHECK(book.bank("drums")->index.findByHash("clap-hash") != nullptr);
}
static void testRemoveFromPoolAllowedContainerPrivilegesHold() {
// Pool CONTENTS are removable (the pool must not be a roach-motel); the pool
// CONTAINER privileges (un-deletable / un-renamable / un-evacuable) are untouched.
BankBook book;
CHECK(book.pool().index.add(sampleWith("loop")) == AddResult::Added);
CHECK(book.removeSample("id-loop", kPoolBankId) == RemoveResult::Removed);
CHECK(book.pool().index.empty()); // content removed
// Container privileges still enforced.
CHECK(!book.deleteBank(kPoolBankId));
CHECK(!book.renameBank(kPoolBankId, "NotPool"));
CHECK(!book.evacuate(kPoolBankId));
CHECK(book.size() == 1);
CHECK(book.pool().displayName == std::string(kPoolBankName));
}
static void testRemoveRejectionsNoMutation() {
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.bank("drums")->index.add(sampleWith("kick")) == AddResult::Added);
// Unknown bank → honest rejection, no mutation.
CHECK(book.removeSample("id-kick", "ghost") == RemoveResult::RejectedUnknownBank);
CHECK(book.bank("drums")->index.query("id-kick") != nullptr); // untouched
// Absent sample (right bank, wrong id) → honest rejection, no mutation.
CHECK(book.removeSample("id-missing", "drums") == RemoveResult::RejectedSampleAbsent);
CHECK(book.bank("drums")->index.size() == 1);
}
static void testHashReferencedElsewhere() {
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.createBank("hits", "Hits"));
// Same-bank-only: the hash lives ONLY in drums → not referenced elsewhere (true
// last-reference; removing from drums would orphan the file).
CHECK(book.bank("drums")->index.add(sampleWith("solo", "solo-hash")) == AddResult::Added);
CHECK(!book.hashReferencedElsewhere("solo-hash", "drums"));
// Copied-to-two-banks: the hash lives in drums AND hits → referenced elsewhere from
// either vantage (removing from one leaves the other's reference intact).
CHECK(book.bank("drums")->index.add(sampleWith("dup-d", "dup-hash")) == AddResult::Added);
CHECK(book.bank("hits")->index.add(sampleWith("dup-h", "dup-hash")) == AddResult::Added);
CHECK(book.hashReferencedElsewhere("dup-hash", "drums")); // hits still holds it
CHECK(book.hashReferencedElsewhere("dup-hash", "hits")); // drums still holds it
// A hash present in NO bank is referenced nowhere.
CHECK(!book.hashReferencedElsewhere("absent-hash", "drums"));
// An empty hash never matches (mirrors findByHash) → reads as not-referenced-else,
// the safe confirm-eliciting direction for an unhashed sample.
CHECK(book.pool().index.add(sampleWith("nohash", "")) == AddResult::Added);
CHECK(!book.hashReferencedElsewhere("", "drums"));
}
static void testRemoveAllBanksLatentScope() {
// The latent all-banks seam (fork R-A): unsurfaced in the UI but live at the model
// level. Purges the id from EVERY bank that holds it in one act; fromBankId ignored.
BankBook book;
CHECK(book.createBank("drums", "Drums"));
CHECK(book.createBank("hits", "Hits"));
CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added);
CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied);
CHECK(book.copySample("id-clap", kPoolBankId, "hits") == TransferResult::Copied);
// The id now lives in all three banks.
CHECK(book.pool().index.query("id-clap") != nullptr);
CHECK(book.bank("drums")->index.query("id-clap") != nullptr);
CHECK(book.bank("hits")->index.query("id-clap") != nullptr);
// AllBanks purge — fromBankId is ignored (pass a nonexistent bank to prove it).
CHECK(book.removeSample("id-clap", "ignored-bank", RemoveScope::AllBanks) ==
RemoveResult::Removed);
CHECK(book.pool().index.query("id-clap") == nullptr);
CHECK(book.bank("drums")->index.query("id-clap") == nullptr);
CHECK(book.bank("hits")->index.query("id-clap") == nullptr);
// A second all-banks purge of the now-absent id is an honest no-op rejection.
CHECK(book.removeSample("id-clap", "ignored-bank", RemoveScope::AllBanks) ==
RemoveResult::RejectedSampleAbsent);
}
int main() {
testPoolSeededAndDefaults();
testPoolPrivileges();
@@ -667,6 +785,12 @@ int main() {
testDeserializeCoalescesDuplicateFoldedNames();
testDeserializeCoalescesMultipleCollisions();
testDeserializeNamedBankCollidingWithPoolIsDisambiguated();
testRemoveDropsTargetEntry();
testRemoveThisBankLeavesSameHashInAnotherBank();
testRemoveFromPoolAllowedContainerPrivilegesHold();
testRemoveRejectionsNoMutation();
testHashReferencedElsewhere();
testRemoveAllBanksLatentScope();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;