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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user