// Standalone tests for reasampler::bank_book — no REAPER, no test framework. // The heart of the multi-bank phase (Phase B1); the third instance of the pure // "registry + JSON round-trip, unit-tested outside the DAW" pattern. // // Covers (PLAN.md B1 test cases): pool privileges (delete/rename/evacuate rejected, // never zero banks); create / rename / reorder named banks; move source-loses / // dest-gains; copy source-retained / dest-gains; evacuate empties source into pool // with dest collapse; cross-bank same-hash coexistence; destination collapse on // move/copy into a bank already holding the hash; active-bank get/set (defaults to // pool, set named, invalid id); JSON round-trip lossless (full book); legacy // bank_index → pool migration. #include "../src/core/model/bank_book.h" #include #include #include using namespace reasampler; using namespace reasampler::model; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // A minimal, valid sample. `seed` disambiguates id + hash; `hash` overrides the // content hash so tests can force collapses. relativePath is always relative. static Sample sampleWith(const std::string& seed, const std::string& hash) { Sample s; s.id = "id-" + seed; s.displayName = "sample " + seed; s.relativePath = "bank/" + seed + ".wav"; s.sourceMode = SourceMode::MasterMix; s.channelCount = 2; s.sampleRate = 48000; s.tier = Tier::Scratch; s.contentHash = hash; s.createdTimestamp = 1753080000LL; return s; } static Sample sampleWith(const std::string& seed) { return sampleWith(seed, "hash-" + seed); } // --------------------------------------------------------------------------- static void testPoolSeededAndDefaults() { BankBook book; // Pool present as bank-zero with fixed id + name + ordinal 0. CHECK(book.size() == 1); CHECK(book.banks()[0].id == kPoolBankId); CHECK(book.banks()[0].displayName == std::string(kPoolBankName)); CHECK(book.banks()[0].ordinal == 0); CHECK(book.pool().id == kPoolBankId); // Active bank defaults to the pool and resolves the pool's index. CHECK(book.activeBankId() == std::string(kPoolBankId)); CHECK(&book.activeIndex() == &book.pool().index); } static void testPoolPrivileges() { BankBook book; CHECK(book.createBank("drums", "Drums")); // Delete-pool rejected; rename-pool rejected; evacuate-pool rejected. CHECK(!book.deleteBank(kPoolBankId)); CHECK(!book.renameBank(kPoolBankId, "NotPool")); CHECK(!book.evacuate(kPoolBankId)); CHECK(book.pool().displayName == std::string(kPoolBankName)); // unchanged // Reserved pool id cannot be minted as a named bank. CHECK(!book.createBank(kPoolBankId, "Imposter")); // Deleting the only named bank still leaves the pool — never zero banks. CHECK(book.deleteBank("drums")); CHECK(book.size() == 1); CHECK(book.pool().id == kPoolBankId); } static void testCreateRenameReorder() { BankBook book; CHECK(book.createBank("a", "Alpha")); CHECK(book.createBank("b", "Beta")); CHECK(book.createBank("c", "Gamma")); CHECK(book.size() == 4); // pool + 3 // Duplicate id rejected; empty id rejected. CHECK(!book.createBank("a", "dup")); CHECK(!book.createBank("", "empty")); // Ordinals: pool 0, named 1..3 in creation order. CHECK(book.bank("a")->ordinal == 1); CHECK(book.bank("b")->ordinal == 2); CHECK(book.bank("c")->ordinal == 3); // Rename a named bank; pool rename still rejected. CHECK(book.renameBank("b", "Beta-renamed")); CHECK(book.bank("b")->displayName == "Beta-renamed"); CHECK(!book.renameBank("missing", "x")); // Renaming back to a non-colliding name keeps working. CHECK(book.renameBank("b", "Beta")); // Reorder: move "c" to the front of the named region (ordinal 1). CHECK(book.reorderBank("c", 1)); CHECK(book.pool().ordinal == 0); CHECK(book.bank("c")->ordinal == 1); CHECK(book.bank("a")->ordinal == 2); CHECK(book.bank("b")->ordinal == 3); // banks() is ordinal order, pool first. CHECK(book.banks()[0].id == kPoolBankId); CHECK(book.banks()[1].id == "c"); CHECK(book.banks()[2].id == "a"); CHECK(book.banks()[3].id == "b"); // Reorder past the end clamps to the last named slot. CHECK(book.reorderBank("c", 999)); CHECK(book.banks()[3].id == "c"); // Reorder the pool is rejected; unknown id rejected. CHECK(!book.reorderBank(kPoolBankId, 2)); CHECK(!book.reorderBank("missing", 1)); } static void testDisplayNameUniqueness() { BankBook book; CHECK(book.createBank("a", "Drums")); // A unique name is accepted. CHECK(book.createBank("b", "Bass")); // Exact duplicate rejected, no mutation (size unchanged, the collided id absent). CHECK(!book.createBank("c", "Drums")); CHECK(book.bank("c") == nullptr); CHECK(book.size() == 3); // pool + a + b only // Trimmed + case-insensitive collisions: "drums", " Drums ", "DRUMS" all collide. CHECK(!book.createBank("c", "drums")); CHECK(!book.createBank("c", " Drums ")); CHECK(!book.createBank("c", "DRUMS")); CHECK(book.bank("c") == nullptr); // The pool's reserved name "Pool" (and its variants) cannot be taken by a new bank. CHECK(!book.createBank("c", "Pool")); CHECK(!book.createBank("c", " pool ")); CHECK(book.bank("c") == nullptr); // -- renameBank uniqueness -------------------------------------------------- // Rename to a name used by ANOTHER bank is rejected (no mutation). CHECK(!book.renameBank("b", "Drums")); CHECK(book.bank("b")->displayName == "Bass"); // unchanged CHECK(!book.renameBank("b", "drums")); // case-insensitive collision too CHECK(!book.renameBank("b", " Drums ")); // trimmed collision too // Renaming a bank to its OWN current name is a no-op success (not a rejection). CHECK(book.renameBank("a", "Drums")); CHECK(book.bank("a")->displayName == "Drums"); // Re-casing/-spacing its own name is likewise allowed (it collides only with self). CHECK(book.renameBank("a", " drums ")); CHECK(book.bank("a")->displayName == " drums "); // Renaming to the pool's reserved name is rejected (pool is the "other" bank here). CHECK(!book.renameBank("b", "Pool")); CHECK(book.bank("b")->displayName == "Bass"); // A genuinely fresh unique name still renames fine. CHECK(book.renameBank("b", "Low End")); CHECK(book.bank("b")->displayName == "Low End"); // After the rejections, the previously-freed name is now reusable by a new bank. CHECK(book.createBank("c", "Bass")); CHECK(book.bank("c")->displayName == "Bass"); } static void testMoveSourceLosesDestGains() { BankBook book; CHECK(book.createBank("drums", "Drums")); CHECK(book.pool().index.add(sampleWith("kick")) == AddResult::Added); // Move kick pool -> drums: source loses it, destination gains it. CHECK(book.moveSample("id-kick", kPoolBankId, "drums") == TransferResult::Moved); CHECK(book.pool().index.query("id-kick") == nullptr); // source lost it CHECK(book.bank("drums")->index.query("id-kick") != nullptr); // dest gained it CHECK(book.pool().index.empty()); CHECK(book.bank("drums")->index.size() == 1); // Rejections: unknown bank, absent sample, same bank. CHECK(book.moveSample("id-kick", "drums", "nope") == TransferResult::RejectedUnknownBank); CHECK(book.moveSample("missing", "drums", kPoolBankId) == TransferResult::RejectedSampleAbsent); CHECK(book.moveSample("id-kick", "drums", "drums") == TransferResult::RejectedSameBank); // After the rejected ops the sample is still only in drums (state uncorrupted). CHECK(book.bank("drums")->index.query("id-kick") != nullptr); } static void testCopySourceRetainedDestGains() { BankBook book; CHECK(book.createBank("drums", "Drums")); CHECK(book.pool().index.add(sampleWith("snare")) == AddResult::Added); // Copy: source retained, destination gains it — same hash in both banks (no // cross-bank dedup: that is the point of copy). CHECK(book.copySample("id-snare", kPoolBankId, "drums") == TransferResult::Copied); CHECK(book.pool().index.query("id-snare") != nullptr); // source retained CHECK(book.bank("drums")->index.query("id-snare") != nullptr); // dest gained it CHECK(book.pool().index.findByHash("hash-snare") != nullptr); CHECK(book.bank("drums")->index.findByHash("hash-snare") != nullptr); } static void testMoveDestCollapse() { BankBook book; CHECK(book.createBank("drums", "Drums")); // Same hash already in the destination under a DIFFERENT id. Sample inPool = sampleWith("kick-a", "shared-hash"); Sample inDrums = sampleWith("kick-b", "shared-hash"); CHECK(book.pool().index.add(inPool) == AddResult::Added); CHECK(book.bank("drums")->index.add(inDrums) == AddResult::Added); // Move the pool entry into drums: destination collapses onto its existing entry, // but the source STILL loses the entry (move semantics). CHECK(book.moveSample("id-kick-a", kPoolBankId, "drums") == TransferResult::Collapsed); CHECK(book.pool().index.query("id-kick-a") == nullptr); // source lost it CHECK(book.bank("drums")->index.size() == 1); // no duplicate CHECK(book.bank("drums")->index.query("id-kick-b") != nullptr);// original kept CHECK(book.bank("drums")->index.query("id-kick-a") == nullptr);// collapsed away } static void testCopyDestCollapse() { BankBook book; CHECK(book.createBank("drums", "Drums")); Sample inPool = sampleWith("hat-a", "hat-hash"); Sample inDrums = sampleWith("hat-b", "hat-hash"); CHECK(book.pool().index.add(inPool) == AddResult::Added); CHECK(book.bank("drums")->index.add(inDrums) == AddResult::Added); // Copy into a bank already holding the hash: collapse; source retained. CHECK(book.copySample("id-hat-a", kPoolBankId, "drums") == TransferResult::Collapsed); CHECK(book.pool().index.query("id-hat-a") != nullptr); // source retained CHECK(book.bank("drums")->index.size() == 1); // collapsed, no dup CHECK(book.bank("drums")->index.query("id-hat-b") != nullptr); } static void testCrossBankSameHashCoexistence() { BankBook book; CHECK(book.createBank("drums", "Drums")); CHECK(book.createBank("hits", "Hits")); CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added); // Copy the same sample into two named banks — all three banks hold the hash. CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied); CHECK(book.copySample("id-clap", kPoolBankId, "hits") == TransferResult::Copied); CHECK(book.pool().index.findByHash("clap-hash") != nullptr); CHECK(book.bank("drums")->index.findByHash("clap-hash") != nullptr); CHECK(book.bank("hits")->index.findByHash("clap-hash") != nullptr); } static void testEvacuate() { BankBook book; CHECK(book.createBank("drums", "Drums")); CHECK(book.bank("drums")->index.add(sampleWith("k1")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("k2")) == AddResult::Added); // A hash that ALSO lives in the pool already, to exercise destination collapse // during evacuate. CHECK(book.pool().index.add(sampleWith("dup-pool", "dup-hash")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("dup-drums", "dup-hash")) == AddResult::Added); CHECK(book.evacuate("drums")); // Source emptied. CHECK(book.bank("drums")->index.empty()); // Pool gained the two unique members; the dup collapsed onto the pool's existing. CHECK(book.pool().index.query("id-k1") != nullptr); CHECK(book.pool().index.query("id-k2") != nullptr); CHECK(book.pool().index.query("id-dup-pool") != nullptr); // original kept CHECK(book.pool().index.query("id-dup-drums") == nullptr); // collapsed away CHECK(book.pool().index.size() == 3); // k1, k2, dup-pool // Evacuate an empty bank is a valid no-op success; unknown id rejected. CHECK(book.evacuate("drums")); CHECK(!book.evacuate("missing")); } static void testActiveBank() { BankBook book; CHECK(book.createBank("drums", "Drums")); // Defaults to pool. CHECK(book.activeBankId() == std::string(kPoolBankId)); // Set to a named bank; activeIndex resolves it. CHECK(book.setActiveBank("drums")); CHECK(book.activeBankId() == "drums"); CHECK(&book.activeIndex() == &book.bank("drums")->index); // Invalid id: rejected, state unchanged. CHECK(!book.setActiveBank("missing")); CHECK(book.activeBankId() == "drums"); // Deleting the active bank falls back to the pool. CHECK(book.deleteBank("drums")); CHECK(book.activeBankId() == std::string(kPoolBankId)); CHECK(&book.activeIndex() == &book.pool().index); } static void testJsonRoundTripFullBook() { BankBook book; CHECK(book.createBank("drums", "Drums \"kit\"\n")); // exercises escaping CHECK(book.createBank("hits", "One-Shots")); CHECK(book.setActiveBank("hits")); // Populate per-bank indices with distinct + shared-hash samples. CHECK(book.pool().index.add(sampleWith("p1")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("d1")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("d2")) == AddResult::Added); CHECK(book.bank("hits")->index.add(sampleWith("h1")) == AddResult::Added); std::string json = book.serialize(); auto back = BankBook::deserialize(json); CHECK(back.has_value()); CHECK(back && *back == book); // String form is idempotent too. if (back) CHECK(back->serialize() == json); // Spot-check the reconstructed structure. if (back) { CHECK(back->activeBankId() == "hits"); CHECK(back->size() == 3); CHECK(back->bank("drums") != nullptr); CHECK(back->bank("drums")->displayName == "Drums \"kit\"\n"); CHECK(back->bank("drums")->index.size() == 2); CHECK(back->bank("hits")->index.query("id-h1") != nullptr); CHECK(back->pool().displayName == std::string(kPoolBankName)); // Ordinals survived: pool 0, then named contiguously. CHECK(back->banks()[0].ordinal == 0); CHECK(back->banks()[1].ordinal == 1); CHECK(back->banks()[2].ordinal == 2); } } static void testJsonEmptyBookRoundTrip() { BankBook book; // pool only, empty index, active = pool std::string json = book.serialize(); auto back = BankBook::deserialize(json); CHECK(back.has_value()); CHECK(back && *back == book); CHECK(back && back->size() == 1); CHECK(back && back->pool().index.empty()); } static void testLegacyMigration() { // A bare legacy bank_index JSON (BankModel::serialize output — has "samples", no // "banks") must promote into the pool: a book of { pool } with zero named banks. BankModel legacy; CHECK(legacy.add(sampleWith("old1")) == AddResult::Added); CHECK(legacy.add(sampleWith("old2")) == AddResult::Added); std::string legacyJson = legacy.serialize(); auto back = BankBook::deserialize(legacyJson); CHECK(back.has_value()); if (back) { CHECK(back->size() == 1); // pool only CHECK(back->pool().id == kPoolBankId); CHECK(back->pool().displayName == std::string(kPoolBankName)); CHECK(back->activeBankId() == std::string(kPoolBankId)); CHECK(back->pool().index.size() == 2); // samples migrated CHECK(back->pool().index.query("id-old1") != nullptr); CHECK(back->pool().index.query("id-old2") != nullptr); // The migrated index equals the legacy index (lossless). CHECK(back->pool().index == legacy); } // An EMPTY legacy index ("{\"samples\":[]}" style via serialize) also migrates. BankModel emptyLegacy; auto back2 = BankBook::deserialize(emptyLegacy.serialize()); CHECK(back2.has_value()); CHECK(back2 && back2->size() == 1 && back2->pool().index.empty()); } static void testMalformedJson() { const char* bad[] = { "", "{", "not json", "{}", // neither shape marker "{\"banks\":[", // truncated array "{\"banks\":[{\"id\":\"x\"}]}", // bank missing its index "{\"banks\":[{\"index\":{\"samples\":[]}}]}", // bank missing its id "{\"banks\":[{\"id\":\"drums\",\"index\":{\"samples\":[]}}]}", // no pool "{\"activeBank\":\"pool\"}", // no banks + no samples marker "{\"banks\":[]}trailing", // trailing garbage }; for (const char* j : bad) { auto r = BankBook::deserialize(j); CHECK(!r.has_value()); } // Duplicate bank ids are malformed (ids key the registry). const char* dup = "{\"activeBank\":\"pool\",\"banks\":[" "{\"id\":\"pool\",\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"samples\":[]}}," "{\"id\":\"x\",\"displayName\":\"X\",\"ordinal\":1,\"index\":{\"samples\":[]}}," "{\"id\":\"x\",\"displayName\":\"X2\",\"ordinal\":2,\"index\":{\"samples\":[]}}]}"; CHECK(!BankBook::deserialize(dup).has_value()); } // --- B2: persist-load source-precedence decision (pure) -------------------- // loadFromPersisted picks the load source the persist shell will feed it from the // two ext-state values a project may carry: the authoritative `banks` blob and the // retired legacy `bank_index` blob. Precedence: banks > legacy > empty; a malformed // banks blob degrades to empty WITHOUT falling back to the stale legacy key. static void testLoadPrefersBanksBlob() { // A full book + a stale legacy index both present: `banks` wins, legacy ignored. BankBook src; CHECK(src.createBank("drums", "Drums")); CHECK(src.setActiveBank("drums")); CHECK(src.bank("drums")->index.add(sampleWith("new1")) == AddResult::Added); const std::string banksJson = src.serialize(); BankModel stale; CHECK(stale.add(sampleWith("stale-old")) == AddResult::Added); const std::string legacyJson = stale.serialize(); BankBook loaded = BankBook::loadFromPersisted(banksJson, legacyJson); // The book equals the source book — the legacy key had NO effect. CHECK(loaded == src); CHECK(loaded.activeBankId() == "drums"); CHECK(loaded.bank("drums") != nullptr); CHECK(loaded.bank("drums")->index.query("id-new1") != nullptr); // The stale legacy sample must NOT have leaked into the pool. CHECK(loaded.pool().index.query("id-stale-old") == nullptr); } static void testLoadMigratesLegacyWhenNoBanks() { // No `banks` key, a legacy `bank_index` present: migrate into the pool, zero named. BankModel legacy; CHECK(legacy.add(sampleWith("l1")) == AddResult::Added); CHECK(legacy.add(sampleWith("l2")) == AddResult::Added); const std::string legacyJson = legacy.serialize(); BankBook loaded = BankBook::loadFromPersisted(std::string{}, legacyJson); CHECK(loaded.size() == 1); // pool only CHECK(loaded.pool().id == kPoolBankId); CHECK(loaded.activeBankId() == std::string(kPoolBankId)); CHECK(loaded.pool().index.size() == 2); CHECK(loaded.pool().index == legacy); // lossless } static void testLoadEmptyWhenNeither() { // Both absent: a fresh empty book (pool only, empty index, active = pool). BankBook loaded = BankBook::loadFromPersisted(std::string{}, std::string{}); CHECK(loaded == BankBook{}); CHECK(loaded.size() == 1); CHECK(loaded.pool().index.empty()); CHECK(loaded.activeBankId() == std::string(kPoolBankId)); } static void testLoadMalformedBanksDegradesWithoutLegacyFallback() { // A present-but-malformed `banks` blob must degrade to an empty book and must NOT // resurrect the stale legacy key (that would revive superseded single-bank state). BankModel stale; CHECK(stale.add(sampleWith("stale")) == AddResult::Added); const std::string legacyJson = stale.serialize(); BankBook loaded = BankBook::loadFromPersisted("{\"banks\":[", legacyJson); CHECK(loaded == BankBook{}); // empty, NOT the legacy CHECK(loaded.pool().index.query("id-stale") == nullptr); // legacy did not leak } // --- B3: active-bank cycle ordering (pure free function) ------------------- // nextBankId(orderedIds, current) is the pure decision behind the "cycle active // bank" action: given the book's ordered bank ids (pool-first) + the current active // id, return the next id in ordinal order, wrapping pool -> named -> ... -> pool. static void testCycleOrderingWrapAround() { // pool -> drums -> hits -> (wrap) pool. Exercises every step + the wrap. const std::vector ids = {kPoolBankId, "drums", "hits"}; CHECK(nextBankId(ids, kPoolBankId) == "drums"); CHECK(nextBankId(ids, "drums") == "hits"); CHECK(nextBankId(ids, "hits") == std::string(kPoolBankId)); // wrap past the last } static void testCyclePoolOnlyStaysPool() { // A pool-only book (no named banks) cycles to itself — the single id wraps to // itself. The action becomes a no-op activation, which is correct. const std::vector ids = {kPoolBankId}; CHECK(nextBankId(ids, kPoolBankId) == std::string(kPoolBankId)); } static void testCycleUnknownActiveResolvesToFirst() { // A stale/unknown active id (e.g. the active bank was just deleted and the // ordered list already dropped it) resolves to the first id — a sane home to jump // to rather than "" — matching nextModeId's fallback. const std::vector ids = {kPoolBankId, "drums"}; CHECK(nextBankId(ids, "ghost") == std::string(kPoolBankId)); } static void testCycleEmptyListYieldsEmpty() { // Degenerate guard: an empty list has nothing to cycle to. (A real BankBook always // seeds the pool, so this cannot arise from the book — but the pure helper must not // index into an empty vector.) const std::vector ids; CHECK(nextBankId(ids, kPoolBankId).empty()); } static void testCycleMatchesBookOrdinalOrder() { // Integration-flavoured but still pure: drive the cycle off a real book's banks() // order and confirm one full loop lands back on the pool, activating each bank in // ordinal order. This is exactly what the action does (build ids from banks(), // call nextBankId, setActiveBank). BankBook book; CHECK(book.createBank("a", "A")); CHECK(book.createBank("b", "B")); // ordinals: pool 0, a 1, b 2 std::vector ids; for (const Bank& bk : book.banks()) ids.push_back(bk.id); std::string cur = book.activeBankId(); // pool cur = nextBankId(ids, cur); CHECK(cur == "a"); cur = nextBankId(ids, cur); CHECK(cur == "b"); cur = nextBankId(ids, cur); CHECK(cur == std::string(kPoolBankId)); // full loop } static void testActiveBankResolveAfterCorruptPersistedId() { // A book blob whose activeBank names no bank resolves to the pool (defensive). const char* json = "{\"activeBank\":\"ghost\",\"banks\":[" "{\"id\":\"pool\",\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"samples\":[]}}]}"; auto back = BankBook::deserialize(json); CHECK(back.has_value()); CHECK(back && back->activeBankId() == std::string(kPoolBankId)); } // --- B4 fold-in: deserialize coalesces duplicate folded display names -------- // // The in-model create/rename path enforces unique display names under the trimmed + // case-insensitive fold, but a hand-edited .rpp blob can carry two banks whose names // fold to the same key. deserialize must NOT reject the whole book (that would drop // the user's entire library over one collision) — it AUTO-DISAMBIGUATES the later // duplicate deterministically so the book loads intact with unique names, all banks // and samples preserved, and ids untouched. // Rewrites the first occurrence of `from` in `s` to `to` (test helper: injects a // colliding display name into a serialized blob to simulate a hand-edit). static std::string replaceFirst(std::string s, const std::string& from, const std::string& to) { const auto pos = s.find(from); if (pos != std::string::npos) s.replace(pos, from.size(), to); return s; } static void testDeserializeCoalescesDuplicateFoldedNames() { // Build a real book with two distinctly-named banks each holding a sample, then // corrupt the second bank's display name so it folds to the first's key // (" drums " folds to "drums", same as "Drums"). This is exactly what a // hand-edited blob would look like. BankBook book; CHECK(book.createBank("a", "Drums")); CHECK(book.createBank("b", "Bass")); CHECK(book.bank("a")->index.add(sampleWith("a1")) == AddResult::Added); CHECK(book.bank("b")->index.add(sampleWith("b1")) == AddResult::Added); const std::string json = book.serialize(); // Rename bank "b" from "Bass" to " drums " (folds to "drums") — a duplicate of "a". const std::string corrupted = replaceFirst(json, "\"displayName\":\"Bass\"", "\"displayName\":\" drums \""); CHECK(corrupted != json); // the substitution landed auto back = BankBook::deserialize(corrupted); CHECK(back.has_value()); if (!back) return; // The book loaded intact: pool + 2 named banks, no bank lost. CHECK(back->size() == 3); // Ids are preserved (disambiguation touches names only, never ids). CHECK(back->bank("a") != nullptr); CHECK(back->bank("b") != nullptr); // The FIRST bank to carry the folded key keeps its name; the later one is // suffixed to a unique name. CHECK(back->bank("a")->displayName == "Drums"); CHECK(back->bank("b")->displayName != back->bank("a")->displayName); // The disambiguated names are genuinely unique under the model's own fold — the // book can now round-trip through the in-model uniqueness invariant. Prove it by // re-serializing and re-parsing: idempotent, no further renames. const std::string json2 = back->serialize(); auto back2 = BankBook::deserialize(json2); CHECK(back2.has_value()); if (back2) CHECK(back2->serialize() == json2); // No sample was lost across the coalesce. CHECK(back->bank("a")->index.size() == 1); CHECK(back->bank("b")->index.size() == 1); CHECK(back->bank("a")->index.query("id-a1") != nullptr); CHECK(back->bank("b")->index.query("id-b1") != nullptr); } static void testDeserializeCoalescesMultipleCollisions() { // Three banks all folding to the same key: the first keeps its name, the next two // get distinct suffixes so all three end unique (no two disambiguate to the same). BankBook book; CHECK(book.createBank("a", "Drums")); CHECK(book.createBank("b", "Bass")); CHECK(book.createBank("c", "Keys")); std::string json = book.serialize(); json = replaceFirst(json, "\"displayName\":\"Bass\"", "\"displayName\":\"drums\""); json = replaceFirst(json, "\"displayName\":\"Keys\"", "\"displayName\":\"DRUMS\""); auto back = BankBook::deserialize(json); CHECK(back.has_value()); if (!back) return; CHECK(back->size() == 4); // pool + 3, none lost // All three named banks carry distinct folded keys after coalesce. const std::string na = back->bank("a")->displayName; const std::string nb = back->bank("b")->displayName; const std::string nc = back->bank("c")->displayName; CHECK(na != nb); CHECK(na != nc); CHECK(nb != nc); // Re-parse proves the result satisfies the round-trip (unique keys throughout). auto back2 = BankBook::deserialize(back->serialize()); CHECK(back2.has_value()); if (back2) CHECK(back2->serialize() == back->serialize()); } static void testDeserializeNamedBankCollidingWithPoolIsDisambiguated() { // A named bank whose name folds to the pool's reserved "Pool" key is renamed away // from the pool (never the reverse — the pool's name is fixed and reserved). BankBook book; CHECK(book.createBank("a", "Drums")); std::string json = book.serialize(); json = replaceFirst(json, "\"displayName\":\"Drums\"", "\"displayName\":\"pool\""); auto back = BankBook::deserialize(json); CHECK(back.has_value()); if (!back) return; CHECK(back->size() == 2); // The pool keeps its authoritative name; the named bank is disambiguated off it. CHECK(back->pool().displayName == std::string(kPoolBankName)); CHECK(back->bank("a") != nullptr); CHECK(back->bank("a")->displayName != std::string(kPoolBankName)); // And it is not any case/space variant that would re-collide with "Pool". auto back2 = BankBook::deserialize(back->serialize()); CHECK(back2.has_value()); 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); } // M10: updateSampleInPlace refreshes a sample wherever it lives, order-preserving, // no dedup, and reports no-op honestly for an absent id. static void testUpdateSampleInPlace() { BankBook book; CHECK(book.createBank("drums", "Drums")); CHECK(book.pool().index.add(sampleWith("kick")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("snare")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("hat")) == AddResult::Added); // Refresh the middle entry of the NAMED bank: new path/hash, same id + slot. Sample updated = sampleWith("snare"); updated.relativePath = "bank/snare-recaptured.wav"; updated.contentHash = "recaptured-hash"; CHECK(book.updateSampleInPlace("id-snare", updated)); CHECK(book.bank("drums")->index.all()[0].id == "id-snare"); // slot preserved CHECK(book.bank("drums")->index.query("id-snare")->relativePath == "bank/snare-recaptured.wav"); CHECK(book.bank("drums")->index.size() == 2); // no new entry // A sample in the POOL is found and updated too. Sample pk = sampleWith("kick"); pk.contentHash = "kick-recaptured"; CHECK(book.updateSampleInPlace("id-kick", pk)); CHECK(book.pool().index.query("id-kick")->contentHash == "kick-recaptured"); // An absent id is an honest no-op. CHECK(!book.updateSampleInPlace("id-nope", sampleWith("nope"))); } // =========================================================================== // L7 — SlotMap (gap-preserving display positions) + BankBook ordering/reorder/replace // =========================================================================== // --- SlotMap unit behaviour -------------------------------------------------- static void testSlotMapDenseAppend() { SlotMap m; m.append("a"); m.append("b"); m.append("c"); CHECK(m.slotOf("a") == 0); CHECK(m.slotOf("b") == 1); CHECK(m.slotOf("c") == 2); CHECK(m.maxSlot() == 2); CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); CHECK(m.idAt(1) == "b"); CHECK(m.slotOf("nope") == -1); } static void testSlotMapRemoveLeavesGap() { SlotMap m; m.append("a"); m.append("b"); m.append("c"); // 0,1,2 CHECK(m.remove("b")); // slot 1 now EMPTY (no re-pack) CHECK(m.slotOf("a") == 0); CHECK(m.slotOf("c") == 2); // c did NOT shift down CHECK(m.idAt(1).empty()); // gap preserved CHECK((m.orderedIds() == std::vector{"a", "c"})); CHECK(!m.remove("b")); // already gone } static void testSlotMapAppendAfterGapGoesToFrontier() { SlotMap m; m.append("a"); m.append("b"); m.append("c"); // 0,1,2 m.remove("a"); // slot 0 empty m.append("d"); // append goes AFTER last occupied (2) -> 3 CHECK(m.slotOf("d") == 3); // did NOT fill the slot-0 gap CHECK(m.idAt(0).empty()); } static void testSlotMapReorderIntoEmpty() { SlotMap m; m.append("a"); m.append("b"); m.append("c"); // 0,1,2 m.remove("b"); // slot 1 empty CHECK(m.reorder("c", 1)); // c -> empty slot 1; its slot 2 empties CHECK(m.slotOf("c") == 1); CHECK(m.idAt(2).empty()); CHECK(m.slotOf("a") == 0); // untouched } static void testSlotMapReorderOntoOccupiedInsertsAndShifts() { SlotMap m; m.append("a"); m.append("b"); m.append("c"); m.append("d"); // 0,1,2,3 CHECK(m.reorder("d", 1)); // d onto occupied slot 1 -> insert-before, shift b,c up CHECK(m.slotOf("a") == 0); // before the target: unchanged CHECK(m.slotOf("d") == 1); // took the target slot CHECK(m.slotOf("b") == 2); // shifted +1 CHECK(m.slotOf("c") == 3); // shifted +1 CHECK((m.orderedIds() == std::vector{"a", "d", "b", "c"})); } static void testSlotMapReorderPreservesInteriorGapAboveTarget() { SlotMap m; m.append("a"); m.append("b"); m.append("c"); // 0,1,2 m.remove("b"); // gap at 1: a@0, c@2 m.append("d"); // d@3 CHECK(m.reorder("d", 0)); // d onto occupied slot 0 -> a shifts to 1, c shifts to 3 CHECK(m.slotOf("d") == 0); CHECK(m.slotOf("a") == 1); // shifted from 0 -> 1 CHECK(m.slotOf("c") == 3); // shifted from 2 -> 3 (gap at 2 preserved as a +1 of its own) CHECK(m.idAt(2).empty()); // interior gap above the target survives } static void testSlotMapReorderUnmappedIsNoOp() { SlotMap m; m.append("a"); CHECK(!m.reorder("ghost", 0)); // not mapped -> false, no mutation CHECK(m.slotOf("a") == 0); } static void testSlotMapNegativeTargetClampsToZero() { SlotMap m; m.append("a"); m.append("b"); // 0,1 CHECK(m.reorder("b", -3)); // clamp to 0 -> insert-before a CHECK(m.slotOf("b") == 0); CHECK(m.slotOf("a") == 1); } static void testSlotMapResetDenseSkipsDupesAndEmpties() { SlotMap m; m.resetDense({"a", "", "b", "a", "c"}); // "" and the second "a" dropped CHECK((m.orderedIds() == std::vector{"a", "b", "c"})); CHECK(m.slotOf("a") == 0); CHECK(m.slotOf("c") == 2); } static void testSlotMapReconcileDropsStaleAppendsNew() { SlotMap m; m.append("a"); m.append("b"); m.append("c"); // 0,1,2 m.reconcile({"a", "c", "d"}); // b left the index (drop), d is new (append) CHECK(m.slotOf("a") == 0); // kept at its slot CHECK(m.slotOf("c") == 2); // kept at its slot (gap where b was) CHECK(m.slotOf("b") == -1); // stale marker dropped CHECK(m.slotOf("d") == 3); // appended after the frontier CHECK(m.idAt(1).empty()); // b's slot stays empty } static void testSlotMapEqualityAndFromEntries() { SlotMap a; a.append("x"); a.append("y"); SlotMap b = SlotMap::fromEntries({{"x", 0}, {"y", 1}}); CHECK(a == b); // Defensive repair: duplicate id (first wins), slot conflict (later dropped), // empty id / negative slot dropped. SlotMap c = SlotMap::fromEntries({{"x", 0}, {"x", 5}, {"y", 0}, {"", 9}, {"z", -1}, {"w", 2}}); CHECK(c.slotOf("x") == 0); // first x wins CHECK(c.slotOf("y") == -1); // slot 0 already taken -> dropped CHECK(c.slotOf("w") == 2); // valid CHECK(c.slotOf("z") == -1); // negative slot dropped } // --- BankBook L7: JSON round-trip WITH positions ----------------------------- static void testBankBookSlotsRoundTrip() { BankBook book; CHECK(book.pool().index.add(sampleWith("p1")) == AddResult::Added); CHECK(book.pool().index.add(sampleWith("p2")) == AddResult::Added); CHECK(book.pool().index.add(sampleWith("p3")) == AddResult::Added); book.reconcileSlots(); // seed dense: p1@0, p2@1, p3@2 CHECK(book.reorderSample("id-p3", kPoolBankId, 0)); // p3 -> 0, p1->1, p2->2 CHECK(book.removeSample("id-p1", kPoolBankId) == RemoveResult::Removed); // gap at 1 const std::string json = book.serialize(); auto back = BankBook::deserialize(json); CHECK(back.has_value()); CHECK(back && *back == book); // positions (incl. the gap) survive if (back) CHECK(back->serialize() == json); // idempotent if (back) { // p3 kept slot 0; p2 kept slot 2; slot 1 (where p1's shifted position was) is a gap. CHECK(back->pool().slots.slotOf("id-p3") == 0); CHECK(back->pool().slots.slotOf("id-p2") == 2); CHECK(back->pool().slots.idAt(1).empty()); } } // --- BankBook L7: migration default (pre-L7 blob, no slots) ------------------- static void testMigrationDefaultsToInsertionOrderDense() { // A pre-L7 legacy bank_index blob carries no slot data. On load -> reconcileSlots // seeds dense insertion order (no gaps), so it is visually identical. BankModel legacy; CHECK(legacy.add(sampleWith("o1")) == AddResult::Added); CHECK(legacy.add(sampleWith("o2")) == AddResult::Added); CHECK(legacy.add(sampleWith("o3")) == AddResult::Added); auto back = BankBook::deserialize(legacy.serialize()); CHECK(back.has_value()); if (back) { back->reconcileSlots(); // the persist load path calls this CHECK((back->orderedSampleIds(kPoolBankId) == std::vector{"id-o1", "id-o2", "id-o3"})); CHECK(back->pool().slots.maxSlot() == 2); // dense, no gaps } } static void testOrderedSampleIdsReconcilesLazily() { // Samples added straight to the index (capture path) without touching slots are // reconciled on the first orderedSampleIds query (dense append in insertion order). BankBook book; CHECK(book.pool().index.add(sampleWith("c1")) == AddResult::Added); CHECK(book.pool().index.add(sampleWith("c2")) == AddResult::Added); CHECK((book.orderedSampleIds(kPoolBankId) == std::vector{"id-c1", "id-c2"})); // Unknown bank -> empty. CHECK(book.orderedSampleIds("no-such-bank").empty()); } // --- BankBook L7: reorder mutator -------------------------------------------- static void testReorderSampleRejectsUnknown() { BankBook book; CHECK(book.pool().index.add(sampleWith("r1")) == AddResult::Added); book.reconcileSlots(); CHECK(!book.reorderSample("id-r1", "no-bank", 0)); // unknown bank CHECK(!book.reorderSample("id-ghost", kPoolBankId, 0)); // not a member CHECK(book.pool().slots.slotOf("id-r1") == 0); // unchanged } // Same-slot reorder is a true no-op: returns false, no undo point triggered, // JSON byte-identical before/after (the invariant that blocks spurious dirty-state). static void testReorderSampleSameSlotIsNoOp() { BankBook book; CHECK(book.pool().index.add(sampleWith("a")) == AddResult::Added); CHECK(book.pool().index.add(sampleWith("b")) == AddResult::Added); CHECK(book.pool().index.add(sampleWith("c")) == AddResult::Added); book.reconcileSlots(); // a@0, b@1, c@2 const std::string jsonBefore = book.serialize(); // Drop each card onto its own current slot — must return false every time. CHECK(!book.reorderSample("id-a", kPoolBankId, 0)); CHECK(!book.reorderSample("id-b", kPoolBankId, 1)); CHECK(!book.reorderSample("id-c", kPoolBankId, 2)); // Slots and JSON are byte-identical — no mutation occurred. CHECK(book.pool().slots.slotOf("id-a") == 0); CHECK(book.pool().slots.slotOf("id-b") == 1); CHECK(book.pool().slots.slotOf("id-c") == 2); CHECK(book.serialize() == jsonBefore); } // Reorder to a slot BEYOND the current maxSlot — the trailing-drop case. The model // places the card at the exact target slot (no shift, slot is empty), leaving the card's // old slot empty. This is the pure-layer gate for the DAW beyond-extent drop repro. static void testReorderSampleBeyondMaxSlot() { BankBook book; CHECK(book.pool().index.add(sampleWith("a")) == AddResult::Added); CHECK(book.pool().index.add(sampleWith("b")) == AddResult::Added); book.reconcileSlots(); // a@0, b@1 -> maxSlot = 1 CHECK(book.pool().slots.maxSlot() == 1); // Drag "b" to slot 5 (well beyond maxSlot=1). Should succeed: slot 5 is empty, // no insert-shift needed, b just takes slot 5. CHECK(book.reorderSample("id-b", kPoolBankId, 5)); CHECK(book.pool().slots.slotOf("id-b") == 5); CHECK(book.pool().slots.slotOf("id-a") == 0); // a untouched // maxSlot is now 5; slot 1 is empty (gap). CHECK(book.pool().slots.maxSlot() == 5); CHECK(book.pool().slots.idAt(1).empty()); // b's old slot is now a gap } // --- BankBook L7: Alt-replace mutator ---------------------------------------- static void testReplaceSampleTakesSlotAndRemovesOccupant() { BankBook book; CHECK(book.createBank("drums", "Drums")); CHECK(book.bank("drums")->index.add(sampleWith("a")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("b")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("c")) == AddResult::Added); book.reconcileSlots(); // a@0, b@1, c@2 // Drag c (the newId) onto b (the occupant/oldId) with Alt -> c takes slot 1, b removed. CHECK(book.replaceSample("id-c", "id-b", "drums")); CHECK(book.bank("drums")->index.query("id-b") == nullptr); // occupant removed from index CHECK(book.bank("drums")->index.query("id-c") != nullptr); // dragged sample survives CHECK(book.bank("drums")->slots.slotOf("id-c") == 1); // took the vacated slot CHECK(book.bank("drums")->slots.slotOf("id-a") == 0); // untouched CHECK(book.bank("drums")->slots.idAt(2).empty()); // c's old slot emptied } static void testReplaceSampleNonDestructiveFileStays() { // Replace is INDEX-ONLY: the removed occupant's FILE is never touched. We assert the // model does not mutate relativePath / does not report a disk op — the removed entry's // hash can still be referenced elsewhere (the last-reference story is unchanged). BankBook book; CHECK(book.createBank("drums", "Drums")); // Same-hash sample lives in BOTH the pool and drums (a copy). Replacing it out of drums // leaves the pool's reference intact -> hashReferencedElsewhere still true for the pool. Sample shared = sampleWith("shared", "shared-hash"); CHECK(book.pool().index.add(shared) == AddResult::Added); CHECK(book.bank("drums")->index.add(shared) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("dragged")) == AddResult::Added); book.reconcileSlots(); // drums: shared@0, dragged@1 CHECK(book.replaceSample("id-dragged", "id-shared", "drums")); CHECK(book.bank("drums")->index.query("id-shared") == nullptr); // gone from drums CHECK(book.pool().index.query("id-shared") != nullptr); // pool copy survives CHECK(book.hashReferencedElsewhere("shared-hash", "drums")); // last-ref story intact } static void testReplaceSampleRejectionsNoMutation() { BankBook book; CHECK(book.createBank("drums", "Drums")); CHECK(book.bank("drums")->index.add(sampleWith("a")) == AddResult::Added); CHECK(book.bank("drums")->index.add(sampleWith("b")) == AddResult::Added); book.reconcileSlots(); const BankBook snapshot = book; // capture full state to prove no-mutation CHECK(!book.replaceSample("id-a", "id-a", "drums")); // newId == oldId CHECK(!book.replaceSample("id-a", "id-b", "no-bank")); // unknown bank CHECK(!book.replaceSample("id-ghost", "id-b", "drums")); // newId not a member CHECK(!book.replaceSample("id-a", "id-ghost", "drums")); // oldId not a member CHECK(book == snapshot); // every rejection left the book byte-identical } static void testReplaceSampleInPoolPassesGuard() { // The pool guard: per-sample remove from the pool is permitted, so Alt-replace over a // pool occupant succeeds whenever the occupant exists (no pool-only rejection path). BankBook book; // pool only CHECK(book.pool().index.add(sampleWith("a")) == AddResult::Added); CHECK(book.pool().index.add(sampleWith("b")) == AddResult::Added); book.reconcileSlots(); // a@0, b@1 CHECK(book.replaceSample("id-b", "id-a", kPoolBankId)); // b replaces a in the pool CHECK(book.pool().index.query("id-a") == nullptr); CHECK(book.pool().slots.slotOf("id-b") == 0); // took a's slot } int main() { testPoolSeededAndDefaults(); testPoolPrivileges(); testCreateRenameReorder(); testDisplayNameUniqueness(); testMoveSourceLosesDestGains(); testCopySourceRetainedDestGains(); testMoveDestCollapse(); testCopyDestCollapse(); testCrossBankSameHashCoexistence(); testEvacuate(); testActiveBank(); testJsonRoundTripFullBook(); testJsonEmptyBookRoundTrip(); testLegacyMigration(); testMalformedJson(); testLoadPrefersBanksBlob(); testLoadMigratesLegacyWhenNoBanks(); testLoadEmptyWhenNeither(); testLoadMalformedBanksDegradesWithoutLegacyFallback(); testActiveBankResolveAfterCorruptPersistedId(); testCycleOrderingWrapAround(); testCyclePoolOnlyStaysPool(); testCycleUnknownActiveResolvesToFirst(); testCycleEmptyListYieldsEmpty(); testCycleMatchesBookOrdinalOrder(); testDeserializeCoalescesDuplicateFoldedNames(); testDeserializeCoalescesMultipleCollisions(); testDeserializeNamedBankCollidingWithPoolIsDisambiguated(); testRemoveDropsTargetEntry(); testRemoveThisBankLeavesSameHashInAnotherBank(); testRemoveFromPoolAllowedContainerPrivilegesHold(); testRemoveRejectionsNoMutation(); testHashReferencedElsewhere(); testRemoveAllBanksLatentScope(); testUpdateSampleInPlace(); // L7 — SlotMap + ordering/reorder/replace + slot round-trip/migration. testSlotMapDenseAppend(); testSlotMapRemoveLeavesGap(); testSlotMapAppendAfterGapGoesToFrontier(); testSlotMapReorderIntoEmpty(); testSlotMapReorderOntoOccupiedInsertsAndShifts(); testSlotMapReorderPreservesInteriorGapAboveTarget(); testSlotMapReorderUnmappedIsNoOp(); testSlotMapNegativeTargetClampsToZero(); testSlotMapResetDenseSkipsDupesAndEmpties(); testSlotMapReconcileDropsStaleAppendsNew(); testSlotMapEqualityAndFromEntries(); testBankBookSlotsRoundTrip(); testMigrationDefaultsToInsertionOrderDense(); testOrderedSampleIdsReconcilesLazily(); testReorderSampleRejectsUnknown(); testReorderSampleSameSlotIsNoOp(); testReorderSampleBeyondMaxSlot(); testReplaceSampleTakesSlotAndRemovesOccupant(); testReplaceSampleNonDestructiveFileStays(); testReplaceSampleRejectionsNoMutation(); testReplaceSampleInPoolPassesGuard(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }