feat(banks): bindable multi-bank action family — create/rename/delete/evacuate/activate/move/copy + full-height toggles (B3)

This commit is contained in:
2026-07-24 05:35:16 -04:00
parent 288c1e44e4
commit cbfc13c4e0
8 changed files with 546 additions and 1 deletions
+59
View File
@@ -405,6 +405,60 @@ static void testLoadMalformedBanksDegradesWithoutLegacyFallback() {
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 =
@@ -435,6 +489,11 @@ int main() {
testLoadEmptyWhenNeither();
testLoadMalformedBanksDegradesWithoutLegacyFallback();
testActiveBankResolveAfterCorruptPersistedId();
testCycleOrderingWrapAround();
testCyclePoolOnlyStaysPool();
testCycleUnknownActiveResolvesToFirst();
testCycleEmptyListYieldsEmpty();
testCycleMatchesBookOrdinalOrder();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;