fdb9e38ad6
Add gap-preserving per-bank SlotMap (reorder + Alt-replace mutators, JSON round-trip, insertion-order migration) to bank_book; stamp captureTimeSig on Sample; pure card_meta formatters + card_drag gesture/slot module; card metadata overlay + purple selection border in the panel. Shell drop/cursor wiring deferred. Fixes: removeSample syncs SlotMap; card_drag gap-probe coordinate.
1101 lines
50 KiB
C++
1101 lines
50 KiB
C++
// 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>
|
|
#include <vector>
|
|
|
|
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"));
|
|
// 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 (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());
|
|
}
|
|
|
|
// --- 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();
|
|
|
|
BankIndex 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.
|
|
BankIndex 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).
|
|
BankIndex 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<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string> 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<std::string>{"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<std::string>{"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<std::string>{"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<std::string>{"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.
|
|
BankIndex 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<std::string>{"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<std::string>{"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
|
|
}
|
|
|
|
// --- 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();
|
|
testReplaceSampleTakesSlotAndRemovesOccupant();
|
|
testReplaceSampleNonDestructiveFileStays();
|
|
testReplaceSampleRejectionsNoMutation();
|
|
testReplaceSampleInPoolPassesGuard();
|
|
|
|
if (g_fail == 0) std::printf("All tests passed.\n");
|
|
return g_fail ? 1 : 0;
|
|
}
|