// 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/bank_book.h" #include #include using namespace reasampler; 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")); // 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 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 (BankIndex::serialize output — has "samples", no // "banks") must promote into the pool: a book of { pool } with zero named banks. BankIndex 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. BankIndex 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()); } 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)); } int main() { testPoolSeededAndDefaults(); testPoolPrivileges(); testCreateRenameReorder(); testMoveSourceLosesDestGains(); testCopySourceRetainedDestGains(); testMoveDestCollapse(); testCopyDestCollapse(); testCrossBankSameHashCoexistence(); testEvacuate(); testActiveBank(); testJsonRoundTripFullBook(); testJsonEmptyBookRoundTrip(); testLegacyMigration(); testMalformedJson(); testActiveBankResolveAfterCorruptPersistedId(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }