L7: capture-order slot model, card metadata overlay, tertiary-border selection

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.
This commit is contained in:
2026-07-27 01:49:07 -04:00
parent 7dc77e51b5
commit fdb9e38ad6
20 changed files with 1495 additions and 21 deletions
+273
View File
@@ -14,6 +14,7 @@
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler;
@@ -785,6 +786,257 @@ static void testUpdateSampleInPlace() {
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();
@@ -822,6 +1074,27 @@ int main() {
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;
}