// Standalone tests for reasampler::model::SlotMap — no REAPER, no test framework. // SlotMap is the L7 gap-preserving display-position carrier for one bank, extracted // from bank_book (Q-W1, T4-05). These are the pure SlotMap-only assertions that // previously lived inline in test_bank_book.cpp (the L7 "SlotMap unit behaviour" // block); test_bank_book.cpp keeps its BankBook-level integration coverage // (reorderSample / reconcileSlots / JSON round-trip WITH a full book), this file // owns the module's own contract: add/remove/query, reorder gap-preservation, // resetDense/reconcile, equality/fromEntries, and the serialize wire shape. #include "../src/core/model/slot_map.h" #include #include #include #include #include "../src/core/json/json.h" using namespace reasampler::model; namespace json = reasampler::json; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // --- 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{"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{"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{"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{"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 } // --- serialize: golden byte-literal + round-trip ----------------------------- // Pins the exact wire shape (an array of {"id":..,"slot":..} objects, ascending // slot, no whitespace) so a future format drift is caught here rather than only // as a downstream bank_book diff. Mirrors the pre-extraction bank_book writer // byte-for-byte (core/json emit helpers are shared, not reimplemented). static void testSlotMapSerializeGoldenLiteral() { SlotMap empty; CHECK(empty.serialize() == "[]"); SlotMap m; m.append("a"); m.append("b"); CHECK(m.serialize() == "[{\"id\":\"a\",\"slot\":0},{\"id\":\"b\",\"slot\":1}]"); } // A local mirror of bank_book's private parseSlots (the "slots" array grammar): // [{id, slot}, ...]. slot_map.cpp itself only emits — JSON parsing is a consumer // concern (see slot_map.h) — so the round-trip proof below parses the emitted // text back into pairs the same way bank_book does, then rebuilds via // SlotMap::fromEntries and checks equality against the original. static bool parseSlotsArray(json::Reader& r, std::vector>& out) { out.clear(); if (!r.consume('[')) return false; r.skipWs(); if (r.consume(']')) return true; // empty array do { if (!r.consume('{')) return false; std::string id; int slot = 0; bool haveId = false, haveSlot = false; do { std::string k; if (!r.parseKey(k)) return false; if (k == "id") { if (!r.parseString(id)) return false; haveId = true; } else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; } else { if (!r.skipValue()) return false; } } while (r.consume(',')); if (!r.consume('}')) return false; if (!haveId || !haveSlot) return false; out.emplace_back(std::move(id), slot); } while (r.consume(',')); return r.consume(']'); } static void testSlotMapSerializeRoundTrip() { SlotMap m; m.append("a"); m.append("b"); m.append("c"); m.remove("b"); // leave a gap: a@0, c@2 m.append("d"); // d@3 const std::string blob = m.serialize(); json::Reader r(blob); std::vector> pairs; CHECK(parseSlotsArray(r, pairs)); SlotMap round = SlotMap::fromEntries(pairs); CHECK(round == m); CHECK(round.slotOf("a") == 0); CHECK(round.idAt(1).empty()); // gap survives the round trip CHECK(round.slotOf("c") == 2); CHECK(round.slotOf("d") == 3); } int main() { testSlotMapDenseAppend(); testSlotMapRemoveLeavesGap(); testSlotMapAppendAfterGapGoesToFrontier(); testSlotMapReorderIntoEmpty(); testSlotMapReorderOntoOccupiedInsertsAndShifts(); testSlotMapReorderPreservesInteriorGapAboveTarget(); testSlotMapReorderUnmappedIsNoOp(); testSlotMapNegativeTargetClampsToZero(); testSlotMapResetDenseSkipsDupesAndEmpties(); testSlotMapReconcileDropsStaleAppendsNew(); testSlotMapEqualityAndFromEntries(); testSlotMapSerializeGoldenLiteral(); testSlotMapSerializeRoundTrip(); if (g_fail == 0) { std::printf("slot_map_tests: all passed\n"); return 0; } std::printf("slot_map_tests: %d failure(s)\n", g_fail); return 1; }