feat(bank_panel): B4 vertical-split UI — LICE tab strip, id-keyed bank ops, move/copy drag

Pool grid on top, LICE-drawn named-banks tab strip below with overflow-scroll
(new pure tab_strip seam, unit-tested). Full-height toggles, unmistakable
active-bank readout distinct from the shown tab, tab context menu
(activate/rename/delete/evacuate/create) with rich confirm-on-non-empty-delete,
and move/copy via menu + drag with drop-highlighting. Fold-in: deserialize
auto-disambiguates duplicate folded bank names instead of rejecting the book.
This commit is contained in:
2026-07-25 14:37:57 -04:00
parent 88af39e036
commit a67a2f9479
10 changed files with 1769 additions and 457 deletions
+120
View File
@@ -521,6 +521,123 @@ static void testActiveBankResolveAfterCorruptPersistedId() {
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());
}
int main() {
testPoolSeededAndDefaults();
testPoolPrivileges();
@@ -547,6 +664,9 @@ int main() {
testCycleUnknownActiveResolvesToFirst();
testCycleEmptyListYieldsEmpty();
testCycleMatchesBookOrdinalOrder();
testDeserializeCoalescesDuplicateFoldedNames();
testDeserializeCoalescesMultipleCollisions();
testDeserializeNamedBankCollidingWithPoolIsDisambiguated();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;