diff --git a/src/actions.cpp b/src/actions.cpp index f180011..f639bf5 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -420,6 +420,7 @@ constexpr const char* kIdBankActivateNext = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE constexpr const char* kIdBankActivatePool = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_POOL"; constexpr const char* kIdBankMoveSel = "CEREBELLUM_REASAMPLER_BANK_MOVE_SELECTED"; constexpr const char* kIdBankCopySel = "CEREBELLUM_REASAMPLER_BANK_COPY_SELECTED"; +constexpr const char* kIdBankRemoveSel = "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"; constexpr const char* kIdBankPoolFull = "CEREBELLUM_REASAMPLER_BANK_POOL_FULLHEIGHT"; constexpr const char* kIdBankBanksFull = "CEREBELLUM_REASAMPLER_BANK_BANKS_FULLHEIGHT"; @@ -431,6 +432,7 @@ int g_cmdBankActivateNext = 0; int g_cmdBankActivatePool = 0; int g_cmdBankMoveSel = 0; int g_cmdBankCopySel = 0; +int g_cmdBankRemoveSel = 0; int g_cmdBankPoolFull = 0; int g_cmdBankBanksFull = 0; @@ -442,6 +444,7 @@ gaccel_register_t g_accelBankActivateNext{}; gaccel_register_t g_accelBankActivatePool{}; gaccel_register_t g_accelBankMoveSel{}; gaccel_register_t g_accelBankCopySel{}; +gaccel_register_t g_accelBankRemoveSel{}; gaccel_register_t g_accelBankPoolFull{}; gaccel_register_t g_accelBankBanksFull{}; @@ -747,6 +750,81 @@ void doBankTransferSelected(bool copy) { ShowConsoleMsg(log.c_str()); } +// Remove the panel's selected samples from the SOURCE bank (the focused region's +// displayed bank — bankPanelSelectedSourceBankId, same source as move/copy). Index-only +// and non-destructive to the file: a last-reference remove leaves the file on disk, +// orphaned until Phase R prune (remove NEVER deletes bytes — the manifest is untouched). +// +// SCOPE (fork R-A): this-bank only — the sole surfaced verb. The RemoveScope::AllBanks +// seam stays latent in the model; nothing here reaches for it. +// +// CONFIRM-ON-LAST-REFERENCE (guardrail): a remove that would orphan a file (no OTHER +// bank references its content hash after the remove) earns a confirm; a remove of a +// still-referenced sample does not. BATCH UX: for a multi-select we compute the +// last-reference set BEFORE mutating (removal changes the reference graph), then fire a +// SINGLE confirm summarizing the N that would orphan — not one dialog per sample. If +// none would orphan, no confirm fires at all (the confirm is earned by actual risk). +void doBankRemoveSelected() { + const std::vector selected = bankPanelSelectedSampleIds(); + if (selected.empty()) { + ShowConsoleMsg("ReaSampler: nothing selected in the bank panel to remove.\n"); + return; + } + const std::string srcId = bankPanelSelectedSourceBankId(); + BankBook& book = g_session->book(); + const Bank* src = book.bank(srcId); + if (src == nullptr) { + ShowConsoleMsg("ReaSampler: the selection's bank no longer exists.\n"); + return; + } + + // Count the samples whose file this remove would orphan — computed on the CURRENT + // (pre-mutation) reference graph so a same-hash sibling in another bank counts as a + // surviving reference. Resolve by id against the live source index (ids, not cached + // refs); an id no longer present is skipped (it removes to a no-op below). + int orphanCount = 0; + for (const std::string& sampleId : selected) { + const Sample* s = src->index.query(sampleId); + if (s == nullptr) continue; // already gone; not a last-reference orphan + if (!book.hashReferencedElsewhere(s->contentHash, srcId)) ++orphanCount; + } + + if (orphanCount > 0) { + const std::string msg = + std::to_string(orphanCount) + + (orphanCount == 1 ? " selected sample is" : " selected samples are") + + " in no other bank.\n\nRemoving " + + (orphanCount == 1 ? "it" : "them") + + " drops the index entry only — the file stays on disk until you prune " + "(it is never deleted by remove).\n\nRemove anyway?"; + const int r = ShowMessageBox(msg.c_str(), + "ReaSampler: remove last-reference sample(s)", 4); + if (r != 6) return; // 6 == YES; anything else cancels (SDK ~6544) + } + + // Perform the removes (this-bank scope). Pass ids by value — no BankIndex& is cached + // across the loop's mutations. Count real drops so the no-op guardrail can skip the + // undo point when nothing was removed (every id was already absent). + int removed = 0, absent = 0; + for (const std::string& sampleId : selected) { + switch (book.removeSample(sampleId, srcId, RemoveScope::ThisBank)) { + case RemoveResult::Removed: ++removed; break; + case RemoveResult::RejectedSampleAbsent: ++absent; break; + // Unknown bank cannot occur — srcId was resolved to a live bank above. + case RemoveResult::RejectedUnknownBank: break; + } + } + + // No-op guardrail (R-B): open an undo point only if the index actually mutated. + if (removed > 0) persistBankOp("ReaSampler: remove sample(s)"); + + std::string log = "ReaSampler: removed " + std::to_string(removed) + + (removed == 1 ? " sample" : " samples"); + if (absent) log += ", " + std::to_string(absent) + " no longer present"; + log += ".\n"; + ShowConsoleMsg(log.c_str()); +} + } // namespace void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) { @@ -768,6 +846,8 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) "ReaSampler: move selected samples to bank"); g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel, "ReaSampler: copy selected samples to bank"); + g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel, + "ReaSampler: remove selected samples"); g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull, "ReaSampler: toggle pool full-height"); g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, @@ -785,6 +865,7 @@ bool bankHandleCommand(int command) { if (command == g_cmdBankActivatePool) { doBankActivatePool(); return true; } if (command == g_cmdBankMoveSel) { doBankTransferSelected(false); return true; } if (command == g_cmdBankCopySel) { doBankTransferSelected(true); return true; } + if (command == g_cmdBankRemoveSel) { doBankRemoveSelected(); return true; } if (command == g_cmdBankPoolFull) { bankPanelToggledPoolFullHeight(); return true; } if (command == g_cmdBankBanksFull) { bankPanelToggledBanksFullHeight(); return true; } @@ -797,6 +878,8 @@ void bankUnregisterActions(reaper_plugin_info_t* rec) { rec->Register("-command_id", (void*)kIdBankBanksFull); rec->Register("-gaccel", (void*)&g_accelBankPoolFull); rec->Register("-command_id", (void*)kIdBankPoolFull); + rec->Register("-gaccel", (void*)&g_accelBankRemoveSel); + rec->Register("-command_id", (void*)kIdBankRemoveSel); rec->Register("-gaccel", (void*)&g_accelBankCopySel); rec->Register("-command_id", (void*)kIdBankCopySel); rec->Register("-gaccel", (void*)&g_accelBankMoveSel); diff --git a/src/bank_book.cpp b/src/bank_book.cpp index 9daebd3..44544f6 100644 --- a/src/bank_book.cpp +++ b/src/bank_book.cpp @@ -281,6 +281,39 @@ TransferResult BankBook::copySample(const std::string& sampleId, return applyDestAdd(to->index, copy, TransferResult::Copied); } +// --------------------------------------------------------------------------- +// Sample removal (index-only) + the last-reference query +// --------------------------------------------------------------------------- + +RemoveResult BankBook::removeSample(const std::string& sampleId, + const std::string& fromBankId, + RemoveScope scope) { + if (scope == RemoveScope::AllBanks) { + // Latent seam: purge the id from every bank that holds it. fromBankId is + // ignored (the id is dropped book-wide). Removed iff at least one drop landed. + bool any = false; + for (auto& b : banks_) + if (b.index.remove(sampleId)) any = true; + return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent; + } + + // ThisBank (default, the only surfaced verb): drop from the one named source bank. + Bank* from = bank(fromBankId); + if (from == nullptr) return RemoveResult::RejectedUnknownBank; + return from->index.remove(sampleId) ? RemoveResult::Removed + : RemoveResult::RejectedSampleAbsent; +} + +bool BankBook::hashReferencedElsewhere(const std::string& hash, + const std::string& exceptBankId) const { + if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash) + for (const auto& b : banks_) { + if (b.id == exceptBankId) continue; // the removed-from bank is excluded + if (b.index.findByHash(hash) != nullptr) return true; + } + return false; +} + // =========================================================================== // JSON — writer // =========================================================================== diff --git a/src/bank_book.h b/src/bank_book.h index 2a6084b..6cc7cd8 100644 --- a/src/bank_book.h +++ b/src/bank_book.h @@ -85,6 +85,30 @@ enum class TransferResult { RejectedSameBank, }; +// Scope of a sample-remove (fork R-A, settled 2026-07-24). ThisBank is the default +// and the ONLY behavior surfaced in the UI/action layer; AllBanks is a latent seam — +// live and tested at the model level, promotable later behind this parameter without +// a rewrite, but never wired to an affordance in B5. +// - ThisBank: drop the entry from the one named source bank only. A same-hash entry +// in another bank survives (no cross-bank cascade — dedup is per-bank). +// - AllBanks: drop the sample's entry from EVERY bank that holds the source id +// ("purge from the library"). Latent; unsurfaced. +enum class RemoveScope { + ThisBank, + AllBanks, +}; + +// Outcome of BankBook::removeSample. Mirrors TransferResult's honesty: the op reports +// what happened rather than silently mutating on a bad request. +// - Removed: at least one index entry was dropped. +// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only). +// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed). +enum class RemoveResult { + Removed, + RejectedUnknownBank, + RejectedSampleAbsent, +}; + // An ordered registry of banks with the pool seeded as bank-zero, per-bank sample // indices, an active-bank pointer, and lossless JSON round-trip. The heart of the // multi-bank phase — mirror of bank_model / view_mode_model. @@ -155,6 +179,37 @@ public: const std::string& fromBankId, const std::string& toBankId); + // -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) -- + + // Drops a sample's index entry (the sample-level sibling of move/copy/evacuate). + // Index-only and non-destructive to the file: a last-reference remove leaves the + // file on disk, orphaned until Phase R prune — remove NEVER deletes bytes. + // + // Scope (fork R-A): ThisBank (default, the only surfaced verb) drops the entry from + // `fromBankId` alone; AllBanks (latent seam) drops the sample id from every bank + // that holds it. See RemoveResult for the outcome set. + // * ThisBank: RejectedUnknownBank if `fromBankId` names no bank; RejectedSampleAbsent + // if that bank does not hold the id; Removed on a drop. + // * AllBanks: `fromBankId` is ignored (the id is purged book-wide); + // RejectedSampleAbsent if NO bank held the id; Removed otherwise. + // No mutation occurs on any Rejected outcome (no-op guardrail for the undo layer). + RemoveResult removeSample(const std::string& sampleId, + const std::string& fromBankId, + RemoveScope scope = RemoveScope::ThisBank); + + // Reference-count query backing the confirm-on-last-reference guardrail: does any + // bank OTHER than `exceptBankId` still hold an entry whose contentHash == `hash`? + // + // Identity is the CONTENT HASH, not the file path: hash is the canonical dedup key + // the whole model already reasons in (findByHash / collapse-by-hash), and two + // entries that share content share one file — so "some other bank still references + // this hash" is exactly "removing here does not orphan the file." An EMPTY hash is + // never matched (it does not participate in dedup, mirroring findByHash), so an + // empty-hash sample reads as referenced-nowhere-else — the safe, confirm-eliciting + // direction (we cannot prove another bank shares an unhashed file). + bool hashReferencedElsewhere(const std::string& hash, + const std::string& exceptBankId) const; + // -- Query --------------------------------------------------------------- // The bank with `id`, or nullptr. Pointer invalidated by any mutating call. diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 4595151..73f4761 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -1315,6 +1315,55 @@ void transferSamples(const std::vector& sampleIds, invalidatePanel(); } +// Remove `sampleIds` from `srcBankId` (index-only, this-bank scope). Non-destructive to +// the file: a last-reference remove leaves the file on disk, orphaned until Phase R +// prune — remove NEVER deletes bytes (the manifest is untouched). Confirm-on-last- +// reference guardrail: a single confirm summarizing the N whose files this would orphan +// (computed on the PRE-mutation reference graph), fired only when at least one would +// orphan. Ids passed by value — no BankIndex& cached across the loop's mutations. +void removeSamples(const std::vector& sampleIds, + const std::string& srcBankId) { + if (!book() || sampleIds.empty()) return; + const Bank* src = book()->bank(srcBankId); + if (!src) return; + + // Count files this remove would orphan — computed BEFORE mutating, so a same-hash + // sibling in another bank counts as a surviving reference. + int orphanCount = 0; + for (const std::string& sid : sampleIds) { + const Sample* s = src->index.query(sid); + if (!s) continue; // already gone; not a last-reference orphan + if (!book()->hashReferencedElsewhere(s->contentHash, srcBankId)) ++orphanCount; + } + + if (orphanCount > 0) { + const std::string msg = + std::to_string(orphanCount) + + (orphanCount == 1 ? " selected sample is" : " selected samples are") + + " in no other bank.\n\nRemoving " + + (orphanCount == 1 ? "it" : "them") + + " drops the index entry only — the file stays on disk until you prune " + "(it is never deleted by remove).\n\nRemove anyway?"; + // 4 == MB_YESNO. 6=Yes (SDK); anything else cancels. + const int r = ShowMessageBox(msg.c_str(), + "ReaSampler: remove last-reference sample(s)", 4); + if (r != 6) return; + } + + int removed = 0; + for (const std::string& sid : sampleIds) + if (book()->removeSample(sid, srcBankId, RemoveScope::ThisBank) == + RemoveResult::Removed) + ++removed; + if (removed == 0) return; // nothing changed — no persist, no undo point + + persistBook(); + // The selection indexed into the source; after a remove those indices are stale, so + // clear it (the fingerprint pass will also clear, but do it now for immediacy). + g_panel.selection = Selection{}; + invalidatePanel(); +} + // The selection's sample ids resolved against the FOCUSED region's bank (source of a // move/copy). Returns ids in bank order; empty when nothing selected. std::vector focusedSelectionIds() { @@ -1356,6 +1405,7 @@ enum : unsigned int { kMenuDelete, kMenuEvacuate, kMenuCreate, + kMenuRemove, // remove selected sample(s) from the source bank (B5) kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index }; @@ -1411,30 +1461,32 @@ void showSelectionMenu(int screenX, int screenY) { for (const Bank* bk : namedBanks()) if (bk->id != srcId) dests.push_back({bk->id, bk->displayName}); - HMENU menu = CreatePopupMenu(); - if (dests.empty()) { - menuAppend(menu, kMenuNone, "No other bank to move to", /*grayed=*/true); - TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); - DestroyMenu(menu); - return; - } - const std::string label = std::to_string(sel.size()) + (sel.size() == 1 ? " sample" : " samples"); - menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); - for (std::size_t i = 0; i < dests.size(); ++i) - menuAppend(menu, kMenuMoveBase + static_cast(i), - (" " + dests[i].name).c_str()); - menuSeparator(menu); - menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); - for (std::size_t i = 0; i < dests.size(); ++i) - menuAppend(menu, kMenuCopyBase + static_cast(i), - (" " + dests[i].name).c_str()); + + HMENU menu = CreatePopupMenu(); + // Move/copy blocks appear only when there is another bank to transfer to; Remove is + // always offered (it needs no destination — it drops the entry from the source). + if (!dests.empty()) { + menuAppend(menu, kMenuNone, ("Move " + label + " to:").c_str(), /*grayed=*/true); + for (std::size_t i = 0; i < dests.size(); ++i) + menuAppend(menu, kMenuMoveBase + static_cast(i), + (" " + dests[i].name).c_str()); + menuSeparator(menu); + menuAppend(menu, kMenuNone, ("Copy " + label + " to:").c_str(), /*grayed=*/true); + for (std::size_t i = 0; i < dests.size(); ++i) + menuAppend(menu, kMenuCopyBase + static_cast(i), + (" " + dests[i].name).c_str()); + menuSeparator(menu); + } + menuAppend(menu, kMenuRemove, ("Remove " + label + "\xE2\x80\xA6").c_str()); const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0, g_panel.hwnd, nullptr); DestroyMenu(menu); - if (cmd >= static_cast(kMenuMoveBase) && + if (cmd == static_cast(kMenuRemove)) { + removeSamples(sel, srcId); + } else if (cmd >= static_cast(kMenuMoveBase) && cmd < static_cast(kMenuMoveBase + dests.size())) { transferSamples(sel, srcId, dests[cmd - kMenuMoveBase].id, /*copy=*/false); } else if (cmd >= static_cast(kMenuCopyBase) && @@ -1670,6 +1722,14 @@ bool handleKey(int vk) { case VK_ESCAPE: stopAudition(); return true; + case VK_DELETE: { + // Remove the focused-region selection (B5). Same confirm-on-last-reference + // path the context-menu "Remove" uses; a no-op when nothing is selected. + const std::vector sel = focusedSelectionIds(); + if (sel.empty()) return false; // nothing selected — let the key fall through + removeSamples(sel, bankIdForRegion(g_panel.focusedRegion)); + return true; + } default: return false; } diff --git a/tests/test_bank_book.cpp b/tests/test_bank_book.cpp index 1058056..479550e 100644 --- a/tests/test_bank_book.cpp +++ b/tests/test_bank_book.cpp @@ -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;