feat(banks): pure bank_book registry — pool + named banks, move/copy, JSON round-trip (B1)
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
// 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 <cstdio>
|
||||
#include <string>
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user