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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ static Sample fullSample(const std::string& seed) {
|
||||
s.lengthSeconds = 3.141592653589793;
|
||||
s.lengthBeats = 4.0;
|
||||
s.captureTempo = 128.5;
|
||||
s.captureTimeSigNum = 6; // L7 F1 meter stamp (non-4/4 to prove it round-trips)
|
||||
s.captureTimeSigDenom = 8;
|
||||
s.key = "F#m";
|
||||
s.levels = {-0.3, -12.7, -14.2};
|
||||
s.clipped = true;
|
||||
@@ -77,6 +79,11 @@ static void testFullFieldRoundTrip() {
|
||||
if (back) {
|
||||
const Sample* full = back->query("id-a");
|
||||
CHECK(full && full->key.has_value() && *full->key == "F#m");
|
||||
// L7 F1 meter stamp survived exactly.
|
||||
CHECK(full && full->captureTimeSigNum == 6 && full->captureTimeSigDenom == 8);
|
||||
// The minimal sample never stamped a meter -> 0/0 (the unstamped default).
|
||||
const Sample* minMeter = back->query("min-b");
|
||||
CHECK(minMeter && minMeter->captureTimeSigNum == 0 && minMeter->captureTimeSigDenom == 0);
|
||||
CHECK(full && full->provenance.has_value());
|
||||
CHECK(full && full->provenance->fxChainSnapshot == "<FXCHAIN\n BYPASS 0 0 0\n>");
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// Standalone tests for reasampler::card_drag — no REAPER, no test framework. Asserts the
|
||||
// L7 in-grid reorder drag decision logic + sparse-aware slot layout/hit-test.
|
||||
//
|
||||
// Covers: gesture precedence (no-drag/empty -> None; leave-client -> OsDragOut wins first;
|
||||
// other-bank -> Move/Copy on Ctrl; same-bank grid empty vs occupied+no-mod -> Reorder;
|
||||
// same-bank occupied+Alt -> Replace; Alt over EMPTY slot -> Reorder not Replace; dead space
|
||||
// -> None); cursor-cue mapping (incl. Replace only for Replace); slot rects include empties
|
||||
// (gap layout), dense layout matches a plain grid, slot hit-test returns slot index + miss.
|
||||
|
||||
#include "../src/card_drag.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
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 200x200 client at origin. A pointer at (10,10) is inside; (-5,10) / (250,10) are outside.
|
||||
static const PanelClientRect kClient{0, 0, 200, 200};
|
||||
static const DragState kLiveDrag{/*dragging=*/true, /*hasArmedSamples=*/true};
|
||||
|
||||
static DragModifiers mods(DropRegion region, int slot, bool occupied, bool ctrl, bool alt) {
|
||||
DragModifiers m;
|
||||
m.region = region;
|
||||
m.targetSlot = slot;
|
||||
m.slotOccupied = occupied;
|
||||
m.ctrl = ctrl;
|
||||
m.alt = alt;
|
||||
return m;
|
||||
}
|
||||
|
||||
// --- Gesture: guard cases ----------------------------------------------------
|
||||
|
||||
static void testNoDragIsNone() {
|
||||
const DragState idle{/*dragging=*/false, /*hasArmedSamples=*/true};
|
||||
CHECK(decideCardGesture(10, 10, kClient, idle,
|
||||
mods(DropRegion::SameBankGrid, 0, true, false, false)) ==
|
||||
CardGesture::None);
|
||||
}
|
||||
|
||||
static void testEmptyPayloadIsNone() {
|
||||
const DragState noSamples{/*dragging=*/true, /*hasArmedSamples=*/false};
|
||||
CHECK(decideCardGesture(10, 10, kClient, noSamples,
|
||||
mods(DropRegion::SameBankGrid, 0, true, false, false)) ==
|
||||
CardGesture::None);
|
||||
}
|
||||
|
||||
// --- Gesture: precedence 1 — leave client wins first -------------------------
|
||||
|
||||
static void testLeaveClientIsOsDragOut() {
|
||||
// Pointer outside the client -> OsDragOut EVEN when the region verdict says same-bank
|
||||
// and Alt is held (leave-client wins first — invariant #4 boundary).
|
||||
CHECK(decideCardGesture(-5, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::SameBankGrid, 0, true, /*ctrl=*/true, /*alt=*/true)) ==
|
||||
CardGesture::OsDragOut);
|
||||
CHECK(decideCardGesture(250, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::OtherBankOrTab, -1, false, false, false)) ==
|
||||
CardGesture::OsDragOut);
|
||||
}
|
||||
|
||||
// --- Gesture: precedence 2 — other bank/tab = move/copy ----------------------
|
||||
|
||||
static void testOtherBankIsMove() {
|
||||
CHECK(decideCardGesture(10, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::OtherBankOrTab, -1, false, /*ctrl=*/false, false)) ==
|
||||
CardGesture::Move);
|
||||
}
|
||||
|
||||
static void testOtherBankCtrlIsCopy() {
|
||||
CHECK(decideCardGesture(10, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::OtherBankOrTab, -1, false, /*ctrl=*/true, false)) ==
|
||||
CardGesture::Copy);
|
||||
}
|
||||
|
||||
// --- Gesture: precedence 3 — same-bank grid reorder / replace ----------------
|
||||
|
||||
static void testSameBankEmptySlotIsReorder() {
|
||||
CHECK(decideCardGesture(10, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::SameBankGrid, 3, /*occupied=*/false, false, false)) ==
|
||||
CardGesture::Reorder);
|
||||
}
|
||||
|
||||
static void testSameBankOccupiedNoModIsReorder() {
|
||||
// Occupied + no modifier = insert-before-and-shift, which is still Reorder.
|
||||
CHECK(decideCardGesture(10, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::SameBankGrid, 2, /*occupied=*/true, false, false)) ==
|
||||
CardGesture::Reorder);
|
||||
}
|
||||
|
||||
static void testSameBankOccupiedAltIsReplace() {
|
||||
CHECK(decideCardGesture(10, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::SameBankGrid, 2, /*occupied=*/true, false, /*alt=*/true)) ==
|
||||
CardGesture::Replace);
|
||||
}
|
||||
|
||||
static void testAltOverEmptySlotIsReorderNotReplace() {
|
||||
// Alt over an EMPTY slot must NOT be Replace (replace needs an occupant).
|
||||
CHECK(decideCardGesture(10, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::SameBankGrid, 5, /*occupied=*/false, false, /*alt=*/true)) ==
|
||||
CardGesture::Reorder);
|
||||
}
|
||||
|
||||
static void testDeadSpaceIsNone() {
|
||||
CHECK(decideCardGesture(10, 10, kClient, kLiveDrag,
|
||||
mods(DropRegion::DeadSpace, -1, false, false, true)) ==
|
||||
CardGesture::None);
|
||||
}
|
||||
|
||||
// --- Cursor cue mapping ------------------------------------------------------
|
||||
|
||||
static void testCursorCueMapping() {
|
||||
CHECK(cursorForGesture(CardGesture::None) == CursorCue::Default);
|
||||
CHECK(cursorForGesture(CardGesture::OsDragOut) == CursorCue::OsDragOut);
|
||||
CHECK(cursorForGesture(CardGesture::Move) == CursorCue::Move);
|
||||
CHECK(cursorForGesture(CardGesture::Copy) == CursorCue::Copy);
|
||||
CHECK(cursorForGesture(CardGesture::Reorder) == CursorCue::Reorder);
|
||||
CHECK(cursorForGesture(CardGesture::Replace) == CursorCue::Replace);
|
||||
}
|
||||
|
||||
static void testReplaceCueOnlyFromReplace() {
|
||||
// The Replace cue is produced by NO gesture other than Replace (which itself requires
|
||||
// Alt-over-occupied) — the "replace cursor only while Alt over occupied" guarantee.
|
||||
CHECK(cursorForGesture(CardGesture::Reorder) != CursorCue::Replace);
|
||||
CHECK(cursorForGesture(CardGesture::Move) != CursorCue::Replace);
|
||||
CHECK(cursorForGesture(CardGesture::Copy) != CursorCue::Replace);
|
||||
CHECK(cursorForGesture(CardGesture::None) != CursorCue::Replace);
|
||||
}
|
||||
|
||||
// --- Sparse slot layout + hit-test -------------------------------------------
|
||||
|
||||
// Spec: cell 100x40, gap 10. In a 340-wide panel: usable = 340-10 = 330; cell+gap = 110;
|
||||
// cols = 330/110 = 3.
|
||||
static const GridSpec kSpec{/*cellWidth=*/100, /*cellHeight=*/40, /*gap=*/10};
|
||||
|
||||
static void testSlotRectsIncludeEmpties() {
|
||||
// maxSlot = 4 -> 5 rects, slots 0..4, three per row.
|
||||
const std::vector<SlotCellRect> r = computeSlotRects(4, 340, kSpec);
|
||||
CHECK(r.size() == 5);
|
||||
CHECK(r[0].slot == 0);
|
||||
CHECK(r[4].slot == 4);
|
||||
// Slot 0: x = gap = 10, y = gap = 10.
|
||||
CHECK((r[0] == SlotCellRect{0, 10, 10, 100, 40}));
|
||||
// Slot 3 wraps to row 1, col 0: y = gap + 1*(40+10) = 60.
|
||||
CHECK((r[3] == SlotCellRect{3, 10, 60, 100, 40}));
|
||||
}
|
||||
|
||||
static void testSlotRectsEmptyWhenNoOccupied() {
|
||||
CHECK(computeSlotRects(-1, 340, kSpec).empty());
|
||||
}
|
||||
|
||||
static void testSlotRectsDenseMatchesGrid() {
|
||||
// A dense slot layout (slots 0..N-1) lays out identically to bank_grid's item tiling.
|
||||
const std::vector<SlotCellRect> s = computeSlotRects(2, 340, kSpec);
|
||||
const std::vector<CellRect> g = computeCellRects(3, 340, kSpec);
|
||||
CHECK(s.size() == g.size());
|
||||
for (std::size_t i = 0; i < s.size(); ++i) {
|
||||
CHECK(s[i].x == g[i].x && s[i].y == g[i].y);
|
||||
CHECK(s[i].width == g[i].width && s[i].height == g[i].height);
|
||||
}
|
||||
}
|
||||
|
||||
static void testHitTestSlotReturnsSlotIndex() {
|
||||
const std::vector<SlotCellRect> r = computeSlotRects(4, 340, kSpec);
|
||||
// A point inside slot 3's rect (x=10,y=60) returns slot 3, not vector index 3 (they
|
||||
// coincide here, but the point maps by geometry).
|
||||
CHECK(hitTestSlot(15, 65, r) == 3);
|
||||
// Inside slot 1 (x = gap + 1*(100+10) = 120).
|
||||
CHECK(hitTestSlot(125, 15, r) == 1);
|
||||
}
|
||||
|
||||
static void testHitTestSlotMiss() {
|
||||
const std::vector<SlotCellRect> r = computeSlotRects(4, 340, kSpec);
|
||||
CHECK(hitTestSlot(0, 0, r) == -1); // top-left margin (gap) is a miss
|
||||
CHECK(hitTestSlot(115, 15, r) == -1); // inter-cell gap between slot 0 (ends x=110) and slot 1 (starts x=120)
|
||||
}
|
||||
|
||||
int main() {
|
||||
testNoDragIsNone();
|
||||
testEmptyPayloadIsNone();
|
||||
testLeaveClientIsOsDragOut();
|
||||
testOtherBankIsMove();
|
||||
testOtherBankCtrlIsCopy();
|
||||
testSameBankEmptySlotIsReorder();
|
||||
testSameBankOccupiedNoModIsReorder();
|
||||
testSameBankOccupiedAltIsReplace();
|
||||
testAltOverEmptySlotIsReorderNotReplace();
|
||||
testDeadSpaceIsNone();
|
||||
testCursorCueMapping();
|
||||
testReplaceCueOnlyFromReplace();
|
||||
testSlotRectsIncludeEmpties();
|
||||
testSlotRectsEmptyWhenNoOccupied();
|
||||
testSlotRectsDenseMatchesGrid();
|
||||
testHitTestSlotReturnsSlotIndex();
|
||||
testHitTestSlotMiss();
|
||||
|
||||
if (g_fail == 0) std::printf("card_drag: all tests passed\n");
|
||||
else std::printf("card_drag: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Standalone tests for reasampler::card_meta — no REAPER, no test framework. Asserts the
|
||||
// L7 decorative overlay formatters: bars.beats.subdivisions (F1 tempo+meter stamp) and
|
||||
// seconds.milliseconds.
|
||||
//
|
||||
// Covers: bar-1 origin (zero length), sub-bar, exact bar boundary rollover, multi-bar,
|
||||
// long capture, non-4/4 meters (3/4 and 6/8), unstamped meter -> blank, unknown tempo ->
|
||||
// blank (s.ms still derivable); s.ms zero / sub-second / multi-second / ms carry / negative.
|
||||
|
||||
#include "../src/card_meta.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
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)
|
||||
|
||||
// Beat length in 4/4 at 120 BPM: (60/120)*(4/4) = 0.5 s/beat, so a 4/4 bar = 2.0 s.
|
||||
|
||||
// --- bars.beats.subdivisions -------------------------------------------------
|
||||
|
||||
static void testZeroLengthIsBarOneOrigin() {
|
||||
// Zero length -> the musical origin, "1.1.00".
|
||||
CHECK(formatBarsBeats(MusicalLength{0.0, 120.0, 4, 4}) == "1.1.00");
|
||||
}
|
||||
|
||||
static void testSubBeat() {
|
||||
// 0.25 s at 120 BPM 4/4 = 0.5 beats -> bar 1, beat 1, .50.
|
||||
CHECK(formatBarsBeats(MusicalLength{0.25, 120.0, 4, 4}) == "1.1.50");
|
||||
}
|
||||
|
||||
static void testWholeBeatWithinBar() {
|
||||
// 0.5 s = exactly 1 beat -> bar 1, beat 2, .00.
|
||||
CHECK(formatBarsBeats(MusicalLength{0.5, 120.0, 4, 4}) == "1.2.00");
|
||||
}
|
||||
|
||||
static void testExactBarBoundaryRollsOver() {
|
||||
// 2.0 s = exactly 4 beats = 1 bar in 4/4 -> rolls to bar 2, beat 1 (NOT "1.5.00").
|
||||
CHECK(formatBarsBeats(MusicalLength{2.0, 120.0, 4, 4}) == "2.1.00");
|
||||
}
|
||||
|
||||
static void testMultiBar() {
|
||||
// 5.0 s at 120 4/4 = 10 beats = 2 bars + 2 beats -> "3.3.00".
|
||||
CHECK(formatBarsBeats(MusicalLength{5.0, 120.0, 4, 4}) == "3.3.00");
|
||||
}
|
||||
|
||||
static void testLongCaptureNoBarCap() {
|
||||
// 256 s at 120 4/4 = 512 beats = 128 bars exactly -> bar 129, beat 1.
|
||||
CHECK(formatBarsBeats(MusicalLength{256.0, 120.0, 4, 4}) == "129.1.00");
|
||||
}
|
||||
|
||||
static void testThreeFourMeter() {
|
||||
// 3/4 at 120 BPM: beat = (60/120)*(4/4)=0.5 s, bar = 3 beats = 1.5 s. 1.5 s -> bar 2,1.
|
||||
CHECK(formatBarsBeats(MusicalLength{1.5, 120.0, 3, 4}) == "2.1.00");
|
||||
// 1.0 s = 2 beats -> bar 1, beat 3.
|
||||
CHECK(formatBarsBeats(MusicalLength{1.0, 120.0, 3, 4}) == "1.3.00");
|
||||
}
|
||||
|
||||
static void testSixEightMeter() {
|
||||
// 6/8 at 120 BPM: an eighth-beat = (60/120)*(4/8) = 0.25 s; bar = 6 beats = 1.5 s.
|
||||
// 1.5 s -> bar 2, beat 1.
|
||||
CHECK(formatBarsBeats(MusicalLength{1.5, 120.0, 6, 8}) == "2.1.00");
|
||||
// 0.25 s = exactly 1 eighth-beat -> bar 1, beat 2.
|
||||
CHECK(formatBarsBeats(MusicalLength{0.25, 120.0, 6, 8}) == "1.2.00");
|
||||
}
|
||||
|
||||
static void testUnstampedMeterIsBlank() {
|
||||
// 0/0 (pre-L7 sample) -> no musical read-out.
|
||||
CHECK(formatBarsBeats(MusicalLength{3.0, 120.0, 0, 0}).empty());
|
||||
CHECK(formatBarsBeats(MusicalLength{3.0, 120.0, 4, 0}).empty()); // partial stamp also blank
|
||||
CHECK(formatBarsBeats(MusicalLength{3.0, 120.0, 0, 4}).empty());
|
||||
}
|
||||
|
||||
static void testUnknownTempoIsBlank() {
|
||||
// Tempo 0 -> no musical read-out even with a meter (cannot derive beats).
|
||||
CHECK(formatBarsBeats(MusicalLength{3.0, 0.0, 4, 4}).empty());
|
||||
}
|
||||
|
||||
static void testNegativeLengthClampsToOrigin() {
|
||||
// Defensive: a negative length reads as the origin, not garbage.
|
||||
CHECK(formatBarsBeats(MusicalLength{-5.0, 120.0, 4, 4}) == "1.1.00");
|
||||
}
|
||||
|
||||
// --- seconds.milliseconds ----------------------------------------------------
|
||||
|
||||
static void testSecondsMsZero() {
|
||||
CHECK(formatSecondsMs(0.0) == "0.000");
|
||||
}
|
||||
|
||||
static void testSecondsMsSubSecond() {
|
||||
CHECK(formatSecondsMs(0.5) == "0.500");
|
||||
}
|
||||
|
||||
static void testSecondsMsMultiSecond() {
|
||||
CHECK(formatSecondsMs(1.5) == "1.500");
|
||||
CHECK(formatSecondsMs(62.037) == "62.037");
|
||||
}
|
||||
|
||||
static void testSecondsMsNegativeClamps() {
|
||||
CHECK(formatSecondsMs(-1.0) == "0.000");
|
||||
}
|
||||
|
||||
static void testSecondsMsNearWholeSecondCarry() {
|
||||
// 0.9999 s: floor to ms could hit 999 or 1000; the carry keeps it well-formed.
|
||||
const std::string r = formatSecondsMs(0.9999);
|
||||
CHECK(r == "0.999" || r == "1.000");
|
||||
}
|
||||
|
||||
int main() {
|
||||
testZeroLengthIsBarOneOrigin();
|
||||
testSubBeat();
|
||||
testWholeBeatWithinBar();
|
||||
testExactBarBoundaryRollsOver();
|
||||
testMultiBar();
|
||||
testLongCaptureNoBarCap();
|
||||
testThreeFourMeter();
|
||||
testSixEightMeter();
|
||||
testUnstampedMeterIsBlank();
|
||||
testUnknownTempoIsBlank();
|
||||
testNegativeLengthClampsToOrigin();
|
||||
testSecondsMsZero();
|
||||
testSecondsMsSubSecond();
|
||||
testSecondsMsMultiSecond();
|
||||
testSecondsMsNegativeClamps();
|
||||
testSecondsMsNearWholeSecondCarry();
|
||||
|
||||
if (g_fail == 0) std::printf("card_meta: all tests passed\n");
|
||||
else std::printf("card_meta: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user