diff --git a/CLAUDE.md b/CLAUDE.md index f11d23e..d9da0bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ Vendors two submodules (see `.gitmodules`): cmake --build build ctest --test-dir build -Seven targets: +Eight targets: | Target | Kind | Purpose | |---|---|---| @@ -32,6 +32,7 @@ Seven targets: | `view_mode_model_tests` | executable | Pure unit tests for `view_mode_model` — no REAPER, no DAW. | | `view_tree_tests` | executable | Pure unit tests for `view_tree` — no REAPER, no DAW. | | `mode_switch_tests` | executable | Pure unit tests for `mode_switch` — no REAPER, no DAW. | +| `bank_book_tests` | executable | Pure unit tests for `bank_book` — no REAPER, no DAW. | | `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). | ### macOS / Linux: SWELL dialog resources @@ -54,6 +55,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde - `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, JSON round-trip. Mirror of `bank_model` for the Design View phase. - `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell; no REAPER types at the boundary. - `mode_switch` — REAPER-free segment layout + hit-test math for the bank_panel's Design View mode switch; divides a header rectangle into N equal segments and hit-tests a point to a segment. Mirror of `bank_grid`. +- `bank_book` — multi-bank registry (Phase B): an ordered set of banks (pool seeded as bank-zero + named banks), each wrapping a `BankIndex`. Owns create/rename/reorder/delete of named banks, pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model, active-bank id, index-only move/copy of a sample between banks, JSON round-trip + legacy-`bank_index`→pool migration. Wraps `BankIndex` (bank_model untouched; no `bankId` on `Sample`). **REAPER-facing shells:** - `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 76b76d6..fb7a61d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,18 @@ add_library(tail_control STATIC src/tail_control.cpp) target_include_directories(tail_control PUBLIC src) target_link_libraries(tail_control PUBLIC render_settings) +# --------------------------------------------------------------------------- +# 2g') Pure bank_book library — NO REAPER, NO SWELL. The multi-bank phase heart +# (Phase B1): an ordered registry of banks (pool seeded as bank-zero + named +# banks), each wrapping a BankIndex; create/rename/reorder/delete named banks, +# pool privileges enforced in-model, active-bank id, index-only move/copy of a +# sample between banks, JSON round-trip + legacy-bank_index→pool migration. +# Mirror of bank_model / view_mode_model; wraps BankIndex (bank_model untouched). +# --------------------------------------------------------------------------- +add_library(bank_book STATIC src/bank_book.cpp) +target_include_directories(bank_book PUBLIC src) +target_link_libraries(bank_book PUBLIC bank_model) + # --------------------------------------------------------------------------- # 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record # logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values, @@ -168,6 +180,10 @@ add_executable(realtime_record_tests tests/test_realtime_record.cpp) target_link_libraries(realtime_record_tests PRIVATE realtime_record) add_test(NAME realtime_record_tests COMMAND realtime_record_tests) +add_executable(bank_book_tests tests/test_bank_book.cpp) +target_link_libraries(bank_book_tests PRIVATE bank_book) +add_test(NAME bank_book_tests COMMAND bank_book_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -198,8 +214,9 @@ add_library(reaper_reasampler MODULE src/view.cpp src/track_guid.cpp src/actions.cpp + src/bank_book.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch view_mode_model insert_plan render_settings tail_control realtime_record bank_book) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") diff --git a/src/bank_book.cpp b/src/bank_book.cpp new file mode 100644 index 0000000..9d6df3c --- /dev/null +++ b/src/bank_book.cpp @@ -0,0 +1,671 @@ +#include "bank_book.h" + +#include +#include + +// bank_book implementation. +// +// JSON is hand-rolled and self-contained, matching the house style of bank_model +// and view_mode_model (brief: keep the pure core dependency-free — no third-party +// JSON lib). The book blob nests one bank object per bank, each carrying that +// bank's BankIndex serialized by bank_model's OWN writer (BankIndex::serialize), +// so per-bank sample serialization stays owned by bank_model and is not duplicated +// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a +// raw "index" member whose value is the BankIndex blob verbatim; the parser splits +// the book envelope, then hands each nested index blob straight to +// BankIndex::deserialize. Ints use %d; strings are escaped by writeEscaped. + +namespace reasampler { + +// --------------------------------------------------------------------------- +// BankBook — construction + bank lookup +// --------------------------------------------------------------------------- + +BankBook::BankBook() { + Bank pool; + pool.id = kPoolBankId; + pool.displayName = kPoolBankName; + pool.ordinal = 0; + banks_.push_back(std::move(pool)); + activeBankId_ = kPoolBankId; +} + +Bank* BankBook::bank(const std::string& id) { + for (auto& b : banks_) + if (b.id == id) return &b; + return nullptr; +} + +const Bank* BankBook::bank(const std::string& id) const { + for (const auto& b : banks_) + if (b.id == id) return &b; + return nullptr; +} + +BankIndex* BankBook::index(const std::string& id) { + Bank* b = bank(id); + return b ? &b->index : nullptr; +} + +const BankIndex* BankBook::index(const std::string& id) const { + const Bank* b = bank(id); + return b ? &b->index : nullptr; +} + +Bank& BankBook::pool() { + // The pool is seeded on construction and is un-deletable, so it always exists. + return *bank(kPoolBankId); +} + +const Bank& BankBook::pool() const { + return *bank(kPoolBankId); +} + +// --------------------------------------------------------------------------- +// Ordinal normalization +// --------------------------------------------------------------------------- + +void BankBook::normalizeOrdinals() { + // Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a + // contiguous 0..N-1. Stability preserves the caller's relative order among banks + // that share (or, after a reorder shuffle, tie on) an ordinal. + std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) { + if (a.isPool() != b.isPool()) return a.isPool(); // pool always first + return a.ordinal < b.ordinal; + }); + for (std::size_t i = 0; i < banks_.size(); ++i) + banks_[i].ordinal = static_cast(i); +} + +// --------------------------------------------------------------------------- +// Bank lifecycle +// --------------------------------------------------------------------------- + +bool BankBook::createBank(const std::string& id, const std::string& displayName) { + if (id.empty()) return false; // ids key the registry + if (id == kPoolBankId) return false; // reserved pool id + if (bank(id) != nullptr) return false; // duplicate + + Bank b; + b.id = id; + b.displayName = displayName; + b.ordinal = static_cast(banks_.size()); // append; normalize compacts it + banks_.push_back(std::move(b)); + normalizeOrdinals(); + return true; +} + +bool BankBook::renameBank(const std::string& id, const std::string& displayName) { + if (id == kPoolBankId) return false; // pool is un-renamable + Bank* b = bank(id); + if (b == nullptr) return false; + b->displayName = displayName; + return true; +} + +bool BankBook::deleteBank(const std::string& id) { + if (id == kPoolBankId) return false; // pool is un-deletable + auto it = std::find_if(banks_.begin(), banks_.end(), + [&](const Bank& b) { return b.id == id; }); + if (it == banks_.end()) return false; + + banks_.erase(it); + // If the active bank was the one deleted, fall back to the pool (invariant: the + // active id always names a live bank). + if (activeBankId_ == id) activeBankId_ = kPoolBankId; + normalizeOrdinals(); + return true; +} + +bool BankBook::reorderBank(const std::string& id, int newOrdinal) { + if (id == kPoolBankId) return false; // pool is pinned at ordinal 0 + if (bank(id) == nullptr) return false; + + // Work on the named banks as an ordered list (banks_ is already ordinal-sorted + // with the pool first, so named banks are banks_[1..]). Pull the target out and + // re-insert it at the requested position, clamped into the named-bank range + // [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct. + std::vector named; + named.reserve(banks_.size()); + for (auto& b : banks_) + if (!b.isPool()) named.push_back(std::move(b)); + + auto it = std::find_if(named.begin(), named.end(), + [&](const Bank& b) { return b.id == id; }); + Bank moved = std::move(*it); + named.erase(it); + + // Named ordinals are 1..N; convert to a 0-based insertion index into `named`. + const int hi = static_cast(named.size()); // insert-at range is [0..size] + int insertAt = std::max(0, std::min(newOrdinal - 1, hi)); + named.insert(named.begin() + insertAt, std::move(moved)); + + // Rebuild banks_: pool first, then the reordered named banks. Assign ordinals + // directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys + // on the (now stale) ordinals and would undo the reinsertion order. + std::vector rebuilt; + rebuilt.reserve(named.size() + 1); + rebuilt.push_back(std::move(pool())); + for (auto& b : named) rebuilt.push_back(std::move(b)); + banks_ = std::move(rebuilt); + for (std::size_t i = 0; i < banks_.size(); ++i) + banks_[i].ordinal = static_cast(i); + return true; +} + +bool BankBook::evacuate(const std::string& id) { + if (id == kPoolBankId) return false; // pool is un-evacuable (it is the target) + Bank* src = bank(id); + if (src == nullptr) return false; + + // Move every member into the pool, index-only, observing destination collapse. + // Snapshot the members first, then clear the source — BankIndex has no bulk move, + // and adding into the pool must not alias the vector we are draining. + BankIndex& poolIndex = pool().index; + const std::vector members = src->index.all(); // copy + for (const auto& s : members) + poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash + src->index = BankIndex{}; // leave the evacuated bank empty + return true; +} + +// --------------------------------------------------------------------------- +// Active bank +// --------------------------------------------------------------------------- + +bool BankBook::setActiveBank(const std::string& id) { + if (bank(id) == nullptr) return false; // unknown id never corrupts state + activeBankId_ = id; + return true; +} + +BankIndex& BankBook::activeIndex() { + // activeBankId_ always names a live bank; it falls back to the pool on delete. + return bank(activeBankId_)->index; +} + +const BankIndex& BankBook::activeIndex() const { + return bank(activeBankId_)->index; +} + +// --------------------------------------------------------------------------- +// Sample movement (index-only) +// --------------------------------------------------------------------------- + +namespace { + +// Adds `s` to `dest` and maps the BankIndex outcome onto the transfer outcome for +// the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes +// (absolute path / empty id) cannot occur here: the sample already passed add() on +// the source side, so its path and id are already valid. +TransferResult applyDestAdd(BankIndex& dest, const Sample& s, TransferResult gained) { + return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained; +} + +} // namespace + +TransferResult BankBook::moveSample(const std::string& sampleId, + const std::string& fromBankId, + const std::string& toBankId) { + Bank* from = bank(fromBankId); + Bank* to = bank(toBankId); + if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; + if (fromBankId == toBankId) return TransferResult::RejectedSameBank; + + const Sample* s = from->index.query(sampleId); + if (s == nullptr) return TransferResult::RejectedSampleAbsent; + + // Copy the sample out before removing it: query returns a pointer into the + // source vector that remove() invalidates. + const Sample moved = *s; + from->index.remove(sampleId); // source loses the entry unconditionally on a move + return applyDestAdd(to->index, moved, TransferResult::Moved); +} + +TransferResult BankBook::copySample(const std::string& sampleId, + const std::string& fromBankId, + const std::string& toBankId) { + Bank* from = bank(fromBankId); + Bank* to = bank(toBankId); + if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; + if (fromBankId == toBankId) return TransferResult::RejectedSameBank; + + const Sample* s = from->index.query(sampleId); + if (s == nullptr) return TransferResult::RejectedSampleAbsent; + + const Sample copy = *s; // source entry is left intact + return applyDestAdd(to->index, copy, TransferResult::Copied); +} + +// =========================================================================== +// JSON — writer +// =========================================================================== + +namespace { + +void writeEscaped(std::string& out, const std::string& s) { + out += '"'; + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + out += buf; + } else { + out += c; + } + } + } + out += '"'; +} + +std::string intToStr(int v) { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%d", v); + return buf; +} + +class ObjWriter { +public: + explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } + ~ObjWriter() { out_ += '}'; } + + void keyRaw(const char* key, const std::string& rawValue) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + out_ += rawValue; + } + void keyStr(const char* key, const std::string& value) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + writeEscaped(out_, value); + } + void keyBegin(const char* key) { + sep(); + writeEscaped(out_, key); + out_ += ':'; + } + +private: + void sep() { if (first_) first_ = false; else out_ += ','; } + std::string& out_; + bool first_ = true; +}; + +} // namespace + +std::string BankBook::serialize() const { + std::string out; + { + ObjWriter root(out); + root.keyRaw("version", intToStr(1)); + root.keyStr("activeBank", activeBankId_); + + // banks: array of { id, displayName, ordinal, index: }. + // The pool rides in as bank-zero, persisted identically to any named bank. + root.keyBegin("banks"); + out += '['; + for (std::size_t i = 0; i < banks_.size(); ++i) { + if (i) out += ','; + ObjWriter b(out); + b.keyStr("id", banks_[i].id); + b.keyStr("displayName", banks_[i].displayName); + b.keyRaw("ordinal", intToStr(banks_[i].ordinal)); + // The nested index is bank_model's own JSON, emitted verbatim so the + // per-sample shape stays owned by BankIndex::serialize (not duplicated). + b.keyRaw("index", banks_[i].index.serialize()); + } + out += ']'; + } // root closes here (see bank_model note on NRVO + deferred close) + return out; +} + +// =========================================================================== +// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB) +// =========================================================================== + +namespace { + +class Parser { +public: + explicit Parser(const std::string& s) : s_(s) {} + + // Parses a book blob into a bank set + active id. On success fills the out-params + // and returns true. Distinguishes the legacy shape (a bare bank_index object: has + // "samples", no "banks") from the book shape (has "banks"): a legacy blob yields a + // single pool bank carrying the migrated index and an empty active id (⇒ pool). The + // member deserialize() adopts the result (ordinal normalize + active resolve). + bool parseBook(std::vector& banks, std::string& activeBank); + +private: + const std::string& s_; + std::size_t pos_ = 0; + + bool eof() const { return pos_ >= s_.size(); } + + void skipWs() { + while (!eof()) { + char c = s_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; + else break; + } + } + + bool consume(char c) { + skipWs(); + if (eof() || s_[pos_] != c) return false; + ++pos_; + return true; + } + + bool parseString(std::string& out); + bool parseInt(int& out); + bool parseKey(std::string& key); + bool skipValue(); + // Captures the raw source text of one JSON value (object / array / string / + // scalar) verbatim, so a nested BankIndex blob can be handed to its own parser. + bool captureValue(std::string& raw); + + bool parseBank(Bank& out); +}; + +bool Parser::parseString(std::string& out) { + skipWs(); + if (eof() || s_[pos_] != '"') return false; + ++pos_; + out.clear(); + while (!eof()) { + char c = s_[pos_++]; + if (c == '"') return true; + if (c == '\\') { + if (eof()) return false; + char e = s_[pos_++]; + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + auto readHex4 = [&](unsigned int& cp) -> bool { + if (pos_ + 4 > s_.size()) return false; + cp = 0; + for (int i = 0; i < 4; ++i) { + char h = s_[pos_++]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else return false; + } + return true; + }; + unsigned int hi = 0; + if (!readHex4(hi)) return false; + unsigned int codePoint = hi; + if (hi >= 0xD800 && hi <= 0xDBFF) { + if (pos_ + 6 > s_.size()) return false; + if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; + pos_ += 2; + unsigned int lo = 0; + if (!readHex4(lo)) return false; + if (lo < 0xDC00 || lo > 0xDFFF) return false; + codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + } else if (hi >= 0xDC00 && hi <= 0xDFFF) { + return false; // unpaired low surrogate + } + if (codePoint <= 0x7F) { + out += static_cast(codePoint); + } else if (codePoint <= 0x7FF) { + out += static_cast(0xC0 | (codePoint >> 6)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else if (codePoint <= 0xFFFF) { + out += static_cast(0xE0 | (codePoint >> 12)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } else { + out += static_cast(0xF0 | (codePoint >> 18)); + out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); + out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); + out += static_cast(0x80 | (codePoint & 0x3F)); + } + break; + } + default: return false; + } + } else { + out += c; + } + } + return false; // unterminated +} + +bool Parser::parseInt(int& out) { + skipWs(); + std::size_t start = pos_; + if (!eof() && (s_[pos_] == '-' || s_[pos_] == '+')) ++pos_; + std::size_t digitsStart = pos_; + while (!eof() && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; + if (pos_ == digitsStart) return false; // no digits + long v = 0; + try { + v = std::stol(s_.substr(start, pos_ - start)); + } catch (...) { + return false; // out of long range → malformed + } + if (v < INT_MIN || v > INT_MAX) return false; + out = static_cast(v); + return true; +} + +bool Parser::parseKey(std::string& key) { + if (!parseString(key)) return false; + if (!consume(':')) return false; + return true; +} + +bool Parser::skipValue() { + std::string raw; + return captureValue(raw); +} + +// Records the raw source span of one JSON value starting at the current position +// (after whitespace) so it can be re-parsed by a nested parser. Handles nested +// objects/arrays with string-aware brace matching (braces inside strings ignored). +bool Parser::captureValue(std::string& raw) { + skipWs(); + if (eof()) return false; + std::size_t start = pos_; + char c = s_[pos_]; + if (c == '"') { + std::string tmp; + if (!parseString(tmp)) return false; + raw.assign(s_, start, pos_ - start); + return true; + } + if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + ++pos_; + int depth = 1; + while (!eof() && depth > 0) { + char d = s_[pos_]; + if (d == '"') { + std::string tmp; + if (!parseString(tmp)) return false; // advances past the string + continue; + } + if (d == open) ++depth; + else if (d == close) --depth; + ++pos_; + } + if (depth != 0) return false; + raw.assign(s_, start, pos_ - start); + return true; + } + // bare scalar (number / true / false / null) + while (!eof()) { + char d = s_[pos_]; + if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || + d == '\n' || d == '\r') + break; + ++pos_; + } + if (pos_ == start) return false; + raw.assign(s_, start, pos_ - start); + return true; +} + +bool Parser::parseBank(Bank& b) { + if (!consume('{')) return false; + skipWs(); + if (consume('}')) return false; // a bank object must at least carry an id + + bool haveId = false; + bool haveIndex = false; + do { + std::string key; + if (!parseKey(key)) return false; + + if (key == "id") { + if (!parseString(b.id)) return false; + haveId = true; + } else if (key == "displayName") { + if (!parseString(b.displayName)) return false; + } else if (key == "ordinal") { + if (!parseInt(b.ordinal)) return false; + } else if (key == "index") { + std::string raw; + if (!captureValue(raw)) return false; + auto idx = BankIndex::deserialize(raw); + if (!idx) return false; // a malformed nested index fails the whole parse + b.index = std::move(*idx); + haveIndex = true; + } else { + if (!skipValue()) return false; // forward-compat unknown keys + } + } while (consume(',')); + + if (!consume('}')) return false; + if (!haveId || b.id.empty()) return false; // id keys the registry + if (!haveIndex) return false; // every bank persists its index + return true; +} + +bool Parser::parseBook(std::vector& banks, std::string& activeBank) { + banks.clear(); + activeBank.clear(); + if (!consume('{')) return false; + skipWs(); + if (consume('}')) return false; // an empty object is neither shape → malformed + + // Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a + // "samples" key with no "banks" ⇒ legacy shape (promote into the pool). + std::vector parsedBanks; + bool sawBanks = false; + bool sawSamples = false; + + do { + std::string key; + if (!parseKey(key)) return false; + + if (key == "banks") { + sawBanks = true; + if (!consume('[')) return false; + skipWs(); + if (!consume(']')) { + do { + Bank b; + if (!parseBank(b)) return false; + parsedBanks.push_back(std::move(b)); + } while (consume(',')); + if (!consume(']')) return false; + } + } else if (key == "activeBank") { + if (!parseString(activeBank)) return false; + } else if (key == "samples") { + // Legacy marker. The legacy index is re-parsed from the whole input below + // (BankIndex::deserialize owns that shape); here we only skip the value to + // keep the scan well-formed and note that we saw it. + sawSamples = true; + if (!skipValue()) return false; + } else { + if (!skipValue()) return false; // version, or unknown + } + } while (consume(',')); + + if (!consume('}')) return false; + skipWs(); + if (!eof()) return false; // trailing garbage + + // --- Legacy migration: a bare bank_index (samples, no banks) → pool. --- + if (!sawBanks) { + if (!sawSamples) return false; // neither shape's marker → malformed + auto legacy = BankIndex::deserialize(s_); + if (!legacy) return false; + Bank pool; + pool.id = kPoolBankId; + pool.displayName = kPoolBankName; + pool.ordinal = 0; + pool.index = std::move(*legacy); + banks.push_back(std::move(pool)); // { pool } with zero named banks + activeBank.clear(); // ⇒ pool (default) after adoption + return true; + } + + // --- Book shape: the parsed banks ARE the book (pool folded in). --- + // The pool must be present as bank-zero (serialize always emits it). Reject a + // book blob that omits it rather than silently re-seeding — a book without its + // pool is malformed, not a legacy blob. + bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(), + [](const Bank& b) { return b.isPool(); }); + if (!hasPool) return false; + + // Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup). + for (std::size_t i = 0; i < parsedBanks.size(); ++i) + for (std::size_t j = i + 1; j < parsedBanks.size(); ++j) + if (parsedBanks[i].id == parsedBanks[j].id) return false; + + // Force the pool's fixed display name — it is not user-mutable, so we do not + // trust a persisted override for it (keeps kPoolBankName authoritative). + for (auto& b : parsedBanks) + if (b.isPool()) b.displayName = kPoolBankName; + + banks = std::move(parsedBanks); + return true; +} + +} // namespace + +void BankBook::adoptBanks(std::vector&& banks, const std::string& activeBank) { + banks_ = std::move(banks); + normalizeOrdinals(); + // Resolve the active bank defensively: fall back to the pool if the persisted id + // names no bank, so a corrupt active id never leaves a dangling capture target. + activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId); +} + +std::optional BankBook::deserialize(const std::string& json) { + std::vector banks; + std::string activeBank; + Parser p(json); + if (!p.parseBook(banks, activeBank)) return std::nullopt; + + BankBook book; + book.adoptBanks(std::move(banks), activeBank); + return book; +} + +} // namespace reasampler diff --git a/src/bank_book.h b/src/bank_book.h new file mode 100644 index 0000000..e4f1ed7 --- /dev/null +++ b/src/bank_book.h @@ -0,0 +1,208 @@ +#pragma once +// bank_book — the pure core of the multi-bank phase (Phase B), deliberately free +// of any REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the +// third instance of the same "pure registry + JSON round-trip, unit-tested outside +// the DAW" pattern as bank_model and view_mode_model. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes. Standard library only. +// +// -- What it is -------------------------------------------------------------- +// +// An ordered registry of banks. Each bank = { stable id, display name, ordinal, +// BankIndex }. The book WRAPS N BankIndex instances — bank_model / BankIndex are +// UNTOUCHED (additive: no bankId on Sample). Movement of samples between banks is +// index-only (remove from source's BankIndex, add to destination's); files never +// relocate — banks are logical groupings over one shared file pool. +// +// -- The pool (privileged, not special-cased) -------------------------------- +// +// Structurally the pool is bank-zero — one Bank among many, seeded on construction +// with a fixed id (kPoolBankId) and fixed display name (kPoolBankName), ordinal 0. +// Semantically it is privileged, and the privileges are enforced HERE in the pure +// rules layer (CONTEXT.md §Multi-bank guardrail — not deferred to a shell): +// * always exists (seeded on construction; the book never reaches zero banks) +// * un-deletable (deleteBank rejects the pool) +// * un-renamable (renameBank rejects the pool) +// * un-evacuable (evacuate rejects the pool — the pool is evacuation's +// destination, not a source) +// +// -- Id minting is the CALLER'S job (design decision) ------------------------ +// +// createBank takes a caller-supplied stable id, mirroring bank_model's "id +// assigned by the caller" and view_mode_model's mode ids. The pure core has no +// REAPER genGuid / RNG and deliberately introduces none: a fake in-model id source +// would not be a real GUID anyway, and keeping ids caller-supplied lets the B2 +// shell mint a genuine REAPER GUID while the model stays pure and deterministically +// testable. The model still enforces the invariants: non-empty, unique, not the +// reserved pool id. + +#include +#include +#include + +#include "bank_model.h" + +namespace reasampler { + +// The pool's fixed identity. The id is reserved: createBank rejects it, and the +// pool is always bank-zero. The name is fixed: renameBank rejects the pool. +inline constexpr const char* kPoolBankId = "pool"; +inline constexpr const char* kPoolBankName = "Pool"; + +// One bank: a stable id, a display name, an ordinal (tab/display order), and its +// own BankIndex. The pool is the bank whose id == kPoolBankId. +struct Bank { + std::string id; // stable, persisted; the pool's is kPoolBankId + std::string displayName; // mutable for named banks; fixed "Pool" for the pool + int ordinal = 0; // display order; pool is 0, named banks 1..N + BankIndex index; // this bank's samples + + bool isPool() const { return id == kPoolBankId; } + + bool operator==(const Bank& o) const { + return id == o.id && displayName == o.displayName && + ordinal == o.ordinal && index == o.index; + } +}; + +// Outcome of a cross-bank sample move/copy. Mirrors AddResult's honesty: the op +// reports what happened rather than silently mutating on a bad request. +// - Moved / Copied: the sample was transferred to the destination as a new entry. +// - Collapsed: the destination already held the hash; it collapsed onto the +// existing entry (a no-op add on the destination side). For a +// MOVE the source entry is STILL removed; for a COPY the source +// entry is (as always) retained. +// - RejectedUnknownBank: a source or destination id named no bank. +// - RejectedSampleAbsent: the sample id was not in the source bank. +// - RejectedSameBank: source and destination were the same bank (no-op). +enum class TransferResult { + Moved, + Copied, + Collapsed, + RejectedUnknownBank, + RejectedSampleAbsent, + RejectedSameBank, +}; + +// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample +// indices, an active-bank pointer, and lossless JSON round-trip. The heart of the +// multi-bank phase — mirror of bank_model / view_mode_model. +class BankBook { +public: + BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0); + // active bank = pool; zero named banks. + + // -- Bank lifecycle ------------------------------------------------------ + + // Creates a named bank with the caller-supplied stable id and display name, + // assigning the next ordinal. Rejects (returns false, no mutation) an empty id, + // a duplicate id, or the reserved pool id. Display name is not required unique. + bool createBank(const std::string& id, const std::string& displayName); + + // Renames a named bank. Rejects (false, no mutation) an unknown id or the pool. + bool renameBank(const std::string& id, const std::string& displayName); + + // Deletes a NAMED bank, removing it (and its member index entries) from the + // registry. Files are a shell/prune concern and are NOT touched here. Rejects + // (false, no mutation) an unknown id or the pool. Remaining banks' ordinals are + // compacted so the pool stays 0 and named banks stay contiguous 1..N. If the + // deleted bank was active, the active bank falls back to the pool. + bool deleteBank(const std::string& id); + + // Reorders a NAMED bank to `newOrdinal` (clamped into the named-bank range), + // shifting the others to keep ordinals contiguous. The pool is pinned at 0 and + // cannot be reordered. Rejects (false, no mutation) an unknown id or the pool. + bool reorderBank(const std::string& id, int newOrdinal); + + // Moves EVERY member of a named bank into the pool (index-only, observing the + // same destination-collapse-by-hash as a move), leaving the bank empty. Rejects + // (false, no mutation) an unknown id or the pool (the pool is the destination, + // never a source). Returns true on success even if the bank was already empty. + bool evacuate(const std::string& id); + + // -- Active bank --------------------------------------------------------- + + // The active bank's id (the capture target). Defaults to the pool. + const std::string& activeBankId() const { return activeBankId_; } + + // Sets the active bank. Rejects (returns false, no change) an id that names no + // bank — an invalid set never corrupts state. + bool setActiveBank(const std::string& id); + + // The active bank's BankIndex — the index the capture layer adds to. Always + // valid (the active id always names a live bank; it falls back to the pool). + BankIndex& activeIndex(); + const BankIndex& activeIndex() const; + + // -- Sample movement (index-only; files never relocate) ------------------ + + // Moves a sample by id from `fromBankId` to `toBankId`: removes it from the + // source index and adds it to the destination (observing destination + // collapse-by-hash). See TransferResult for the full outcome set. + TransferResult moveSample(const std::string& sampleId, + const std::string& fromBankId, + const std::string& toBankId); + + // Copies a sample by id from `fromBankId` to `toBankId`: the source entry is + // retained, the destination gains it (observing destination collapse-by-hash). + // Same hash may then live in both banks — cross-bank dedup is NOT enforced. + TransferResult copySample(const std::string& sampleId, + const std::string& fromBankId, + const std::string& toBankId); + + // -- Query --------------------------------------------------------------- + + // The bank with `id`, or nullptr. Pointer invalidated by any mutating call. + Bank* bank(const std::string& id); + const Bank* bank(const std::string& id) const; + + // The bank's BankIndex by id, or nullptr. Convenience over bank()->index. + BankIndex* index(const std::string& id); + const BankIndex* index(const std::string& id) const; + + // The pool (always present). Never null. + Bank& pool(); + const Bank& pool() const; + + // All banks in ordinal order (pool first). The pool is always banks()[0]. + const std::vector& banks() const { return banks_; } + + std::size_t size() const { return banks_.size(); } // >= 1 (the pool) + + bool operator==(const BankBook& o) const { + return banks_ == o.banks_ && activeBankId_ == o.activeBankId_; + } + + // -- Persistence --------------------------------------------------------- + + // Serializes the whole book to a JSON string (lossless round-trip): the pool + // folded in as bank-zero + named banks + per-bank indices + ordinals + active + // id. deserialize(serialize(x)) == x. + std::string serialize() const; + + // Parses a book JSON produced by serialize(). std::nullopt on malformed input. + // + // LEGACY MIGRATION: a bare legacy bank_index JSON (the pre-multi-bank shape, an + // object with a "samples" array and no "banks" key) is promoted into the pool's + // index, yielding a book of { pool } with zero named banks — one-way, lossless. + // After migration the book blob is authoritative (the caller persists the book + // shape going forward; the legacy key is retired by the B2 shell). + static std::optional deserialize(const std::string& json); + +private: + std::vector banks_; // ordinal order; banks_[0] is always the pool + std::string activeBankId_; // always names a live bank; defaults to pool + + // Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a + // contiguous 0..N-1 so the pool is 0 and named banks are 1..N. Called after any + // structural change (create / delete / reorder). + void normalizeOrdinals(); + + // Replaces the book's banks with a parsed set, normalizes ordinals, and resolves + // the active bank (falling back to the pool if the id names no bank). Used only + // by deserialize; kept private so the public surface stays create/rename/etc. + void adoptBanks(std::vector&& banks, const std::string& activeBank); +}; + +} // namespace reasampler diff --git a/tests/test_bank_book.cpp b/tests/test_bank_book.cpp new file mode 100644 index 0000000..d0e7f3e --- /dev/null +++ b/tests/test_bank_book.cpp @@ -0,0 +1,373 @@ +// Standalone tests for reasampler::bank_book — no REAPER, no test framework. +// The heart of the multi-bank phase (Phase B1); the third instance of the pure +// "registry + JSON round-trip, unit-tested outside the DAW" pattern. +// +// Covers (PLAN.md B1 test cases): pool privileges (delete/rename/evacuate rejected, +// never zero banks); create / rename / reorder named banks; move source-loses / +// dest-gains; copy source-retained / dest-gains; evacuate empties source into pool +// with dest collapse; cross-bank same-hash coexistence; destination collapse on +// move/copy into a bank already holding the hash; active-bank get/set (defaults to +// pool, set named, invalid id); JSON round-trip lossless (full book); legacy +// bank_index → pool migration. + +#include "../src/bank_book.h" + +#include +#include + +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 minimal, valid sample. `seed` disambiguates id + hash; `hash` overrides the +// content hash so tests can force collapses. relativePath is always relative. +static Sample sampleWith(const std::string& seed, const std::string& hash) { + Sample s; + s.id = "id-" + seed; + s.displayName = "sample " + seed; + s.relativePath = "bank/" + seed + ".wav"; + s.sourceMode = SourceMode::MasterMix; + s.channelCount = 2; + s.sampleRate = 48000; + s.tier = Tier::Scratch; + s.contentHash = hash; + s.createdTimestamp = 1753080000LL; + return s; +} +static Sample sampleWith(const std::string& seed) { return sampleWith(seed, "hash-" + seed); } + +// --------------------------------------------------------------------------- + +static void testPoolSeededAndDefaults() { + BankBook book; + // Pool present as bank-zero with fixed id + name + ordinal 0. + CHECK(book.size() == 1); + CHECK(book.banks()[0].id == kPoolBankId); + CHECK(book.banks()[0].displayName == std::string(kPoolBankName)); + CHECK(book.banks()[0].ordinal == 0); + CHECK(book.pool().id == kPoolBankId); + // Active bank defaults to the pool and resolves the pool's index. + CHECK(book.activeBankId() == std::string(kPoolBankId)); + CHECK(&book.activeIndex() == &book.pool().index); +} + +static void testPoolPrivileges() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + + // Delete-pool rejected; rename-pool rejected; evacuate-pool rejected. + CHECK(!book.deleteBank(kPoolBankId)); + CHECK(!book.renameBank(kPoolBankId, "NotPool")); + CHECK(!book.evacuate(kPoolBankId)); + CHECK(book.pool().displayName == std::string(kPoolBankName)); // unchanged + + // Reserved pool id cannot be minted as a named bank. + CHECK(!book.createBank(kPoolBankId, "Imposter")); + + // Deleting the only named bank still leaves the pool — never zero banks. + CHECK(book.deleteBank("drums")); + CHECK(book.size() == 1); + CHECK(book.pool().id == kPoolBankId); +} + +static void testCreateRenameReorder() { + BankBook book; + CHECK(book.createBank("a", "Alpha")); + CHECK(book.createBank("b", "Beta")); + CHECK(book.createBank("c", "Gamma")); + CHECK(book.size() == 4); // pool + 3 + + // Duplicate id rejected; empty id rejected. + CHECK(!book.createBank("a", "dup")); + CHECK(!book.createBank("", "empty")); + + // Ordinals: pool 0, named 1..3 in creation order. + CHECK(book.bank("a")->ordinal == 1); + CHECK(book.bank("b")->ordinal == 2); + CHECK(book.bank("c")->ordinal == 3); + + // Rename a named bank; pool rename still rejected. + CHECK(book.renameBank("b", "Beta-renamed")); + CHECK(book.bank("b")->displayName == "Beta-renamed"); + CHECK(!book.renameBank("missing", "x")); + + // Reorder: move "c" to the front of the named region (ordinal 1). + CHECK(book.reorderBank("c", 1)); + CHECK(book.pool().ordinal == 0); + CHECK(book.bank("c")->ordinal == 1); + CHECK(book.bank("a")->ordinal == 2); + CHECK(book.bank("b")->ordinal == 3); + // banks() is ordinal order, pool first. + CHECK(book.banks()[0].id == kPoolBankId); + CHECK(book.banks()[1].id == "c"); + CHECK(book.banks()[2].id == "a"); + CHECK(book.banks()[3].id == "b"); + + // Reorder past the end clamps to the last named slot. + CHECK(book.reorderBank("c", 999)); + CHECK(book.banks()[3].id == "c"); + // Reorder the pool is rejected; unknown id rejected. + CHECK(!book.reorderBank(kPoolBankId, 2)); + CHECK(!book.reorderBank("missing", 1)); +} + +static void testMoveSourceLosesDestGains() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + CHECK(book.pool().index.add(sampleWith("kick")) == AddResult::Added); + + // Move kick pool -> drums: source loses it, destination gains it. + CHECK(book.moveSample("id-kick", kPoolBankId, "drums") == TransferResult::Moved); + CHECK(book.pool().index.query("id-kick") == nullptr); // source lost it + CHECK(book.bank("drums")->index.query("id-kick") != nullptr); // dest gained it + CHECK(book.pool().index.empty()); + CHECK(book.bank("drums")->index.size() == 1); + + // Rejections: unknown bank, absent sample, same bank. + CHECK(book.moveSample("id-kick", "drums", "nope") == TransferResult::RejectedUnknownBank); + CHECK(book.moveSample("missing", "drums", kPoolBankId) == TransferResult::RejectedSampleAbsent); + CHECK(book.moveSample("id-kick", "drums", "drums") == TransferResult::RejectedSameBank); + // After the rejected ops the sample is still only in drums (state uncorrupted). + CHECK(book.bank("drums")->index.query("id-kick") != nullptr); +} + +static void testCopySourceRetainedDestGains() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + CHECK(book.pool().index.add(sampleWith("snare")) == AddResult::Added); + + // Copy: source retained, destination gains it — same hash in both banks (no + // cross-bank dedup: that is the point of copy). + CHECK(book.copySample("id-snare", kPoolBankId, "drums") == TransferResult::Copied); + CHECK(book.pool().index.query("id-snare") != nullptr); // source retained + CHECK(book.bank("drums")->index.query("id-snare") != nullptr); // dest gained it + CHECK(book.pool().index.findByHash("hash-snare") != nullptr); + CHECK(book.bank("drums")->index.findByHash("hash-snare") != nullptr); +} + +static void testMoveDestCollapse() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + // Same hash already in the destination under a DIFFERENT id. + Sample inPool = sampleWith("kick-a", "shared-hash"); + Sample inDrums = sampleWith("kick-b", "shared-hash"); + CHECK(book.pool().index.add(inPool) == AddResult::Added); + CHECK(book.bank("drums")->index.add(inDrums) == AddResult::Added); + + // Move the pool entry into drums: destination collapses onto its existing entry, + // but the source STILL loses the entry (move semantics). + CHECK(book.moveSample("id-kick-a", kPoolBankId, "drums") == TransferResult::Collapsed); + CHECK(book.pool().index.query("id-kick-a") == nullptr); // source lost it + CHECK(book.bank("drums")->index.size() == 1); // no duplicate + CHECK(book.bank("drums")->index.query("id-kick-b") != nullptr);// original kept + CHECK(book.bank("drums")->index.query("id-kick-a") == nullptr);// collapsed away +} + +static void testCopyDestCollapse() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + Sample inPool = sampleWith("hat-a", "hat-hash"); + Sample inDrums = sampleWith("hat-b", "hat-hash"); + CHECK(book.pool().index.add(inPool) == AddResult::Added); + CHECK(book.bank("drums")->index.add(inDrums) == AddResult::Added); + + // Copy into a bank already holding the hash: collapse; source retained. + CHECK(book.copySample("id-hat-a", kPoolBankId, "drums") == TransferResult::Collapsed); + CHECK(book.pool().index.query("id-hat-a") != nullptr); // source retained + CHECK(book.bank("drums")->index.size() == 1); // collapsed, no dup + CHECK(book.bank("drums")->index.query("id-hat-b") != nullptr); +} + +static void testCrossBankSameHashCoexistence() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + CHECK(book.createBank("hits", "Hits")); + CHECK(book.pool().index.add(sampleWith("clap", "clap-hash")) == AddResult::Added); + + // Copy the same sample into two named banks — all three banks hold the hash. + CHECK(book.copySample("id-clap", kPoolBankId, "drums") == TransferResult::Copied); + CHECK(book.copySample("id-clap", kPoolBankId, "hits") == TransferResult::Copied); + CHECK(book.pool().index.findByHash("clap-hash") != nullptr); + CHECK(book.bank("drums")->index.findByHash("clap-hash") != nullptr); + CHECK(book.bank("hits")->index.findByHash("clap-hash") != nullptr); +} + +static void testEvacuate() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + CHECK(book.bank("drums")->index.add(sampleWith("k1")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("k2")) == AddResult::Added); + // A hash that ALSO lives in the pool already, to exercise destination collapse + // during evacuate. + CHECK(book.pool().index.add(sampleWith("dup-pool", "dup-hash")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("dup-drums", "dup-hash")) == AddResult::Added); + + CHECK(book.evacuate("drums")); + // Source emptied. + CHECK(book.bank("drums")->index.empty()); + // Pool gained the two unique members; the dup collapsed onto the pool's existing. + CHECK(book.pool().index.query("id-k1") != nullptr); + CHECK(book.pool().index.query("id-k2") != nullptr); + CHECK(book.pool().index.query("id-dup-pool") != nullptr); // original kept + CHECK(book.pool().index.query("id-dup-drums") == nullptr); // collapsed away + CHECK(book.pool().index.size() == 3); // k1, k2, dup-pool + + // Evacuate an empty bank is a valid no-op success; unknown id rejected. + CHECK(book.evacuate("drums")); + CHECK(!book.evacuate("missing")); +} + +static void testActiveBank() { + BankBook book; + CHECK(book.createBank("drums", "Drums")); + // Defaults to pool. + CHECK(book.activeBankId() == std::string(kPoolBankId)); + + // Set to a named bank; activeIndex resolves it. + CHECK(book.setActiveBank("drums")); + CHECK(book.activeBankId() == "drums"); + CHECK(&book.activeIndex() == &book.bank("drums")->index); + + // Invalid id: rejected, state unchanged. + CHECK(!book.setActiveBank("missing")); + CHECK(book.activeBankId() == "drums"); + + // Deleting the active bank falls back to the pool. + CHECK(book.deleteBank("drums")); + CHECK(book.activeBankId() == std::string(kPoolBankId)); + CHECK(&book.activeIndex() == &book.pool().index); +} + +static void testJsonRoundTripFullBook() { + BankBook book; + CHECK(book.createBank("drums", "Drums \"kit\"\n")); // exercises escaping + CHECK(book.createBank("hits", "One-Shots")); + CHECK(book.setActiveBank("hits")); + + // Populate per-bank indices with distinct + shared-hash samples. + CHECK(book.pool().index.add(sampleWith("p1")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("d1")) == AddResult::Added); + CHECK(book.bank("drums")->index.add(sampleWith("d2")) == AddResult::Added); + CHECK(book.bank("hits")->index.add(sampleWith("h1")) == AddResult::Added); + + std::string json = book.serialize(); + auto back = BankBook::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == book); + // String form is idempotent too. + if (back) CHECK(back->serialize() == json); + + // Spot-check the reconstructed structure. + if (back) { + CHECK(back->activeBankId() == "hits"); + CHECK(back->size() == 3); + CHECK(back->bank("drums") != nullptr); + CHECK(back->bank("drums")->displayName == "Drums \"kit\"\n"); + CHECK(back->bank("drums")->index.size() == 2); + CHECK(back->bank("hits")->index.query("id-h1") != nullptr); + CHECK(back->pool().displayName == std::string(kPoolBankName)); + // Ordinals survived: pool 0, then named contiguously. + CHECK(back->banks()[0].ordinal == 0); + CHECK(back->banks()[1].ordinal == 1); + CHECK(back->banks()[2].ordinal == 2); + } +} + +static void testJsonEmptyBookRoundTrip() { + BankBook book; // pool only, empty index, active = pool + std::string json = book.serialize(); + auto back = BankBook::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == book); + CHECK(back && back->size() == 1); + CHECK(back && back->pool().index.empty()); +} + +static void testLegacyMigration() { + // A bare legacy bank_index JSON (BankIndex::serialize output — has "samples", no + // "banks") must promote into the pool: a book of { pool } with zero named banks. + BankIndex legacy; + CHECK(legacy.add(sampleWith("old1")) == AddResult::Added); + CHECK(legacy.add(sampleWith("old2")) == AddResult::Added); + std::string legacyJson = legacy.serialize(); + + auto back = BankBook::deserialize(legacyJson); + CHECK(back.has_value()); + if (back) { + CHECK(back->size() == 1); // pool only + CHECK(back->pool().id == kPoolBankId); + CHECK(back->pool().displayName == std::string(kPoolBankName)); + CHECK(back->activeBankId() == std::string(kPoolBankId)); + CHECK(back->pool().index.size() == 2); // samples migrated + CHECK(back->pool().index.query("id-old1") != nullptr); + CHECK(back->pool().index.query("id-old2") != nullptr); + // The migrated index equals the legacy index (lossless). + CHECK(back->pool().index == legacy); + } + + // An EMPTY legacy index ("{\"samples\":[]}" style via serialize) also migrates. + BankIndex emptyLegacy; + auto back2 = BankBook::deserialize(emptyLegacy.serialize()); + CHECK(back2.has_value()); + CHECK(back2 && back2->size() == 1 && back2->pool().index.empty()); +} + +static void testMalformedJson() { + const char* bad[] = { + "", + "{", + "not json", + "{}", // neither shape marker + "{\"banks\":[", // truncated array + "{\"banks\":[{\"id\":\"x\"}]}", // bank missing its index + "{\"banks\":[{\"index\":{\"samples\":[]}}]}", // bank missing its id + "{\"banks\":[{\"id\":\"drums\",\"index\":{\"samples\":[]}}]}", // no pool + "{\"activeBank\":\"pool\"}", // no banks + no samples marker + "{\"banks\":[]}trailing", // trailing garbage + }; + for (const char* j : bad) { + auto r = BankBook::deserialize(j); + CHECK(!r.has_value()); + } + + // Duplicate bank ids are malformed (ids key the registry). + const char* dup = + "{\"activeBank\":\"pool\",\"banks\":[" + "{\"id\":\"pool\",\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"samples\":[]}}," + "{\"id\":\"x\",\"displayName\":\"X\",\"ordinal\":1,\"index\":{\"samples\":[]}}," + "{\"id\":\"x\",\"displayName\":\"X2\",\"ordinal\":2,\"index\":{\"samples\":[]}}]}"; + CHECK(!BankBook::deserialize(dup).has_value()); +} + +static void testActiveBankResolveAfterCorruptPersistedId() { + // A book blob whose activeBank names no bank resolves to the pool (defensive). + const char* json = + "{\"activeBank\":\"ghost\",\"banks\":[" + "{\"id\":\"pool\",\"displayName\":\"Pool\",\"ordinal\":0,\"index\":{\"samples\":[]}}]}"; + auto back = BankBook::deserialize(json); + CHECK(back.has_value()); + CHECK(back && back->activeBankId() == std::string(kPoolBankId)); +} + +int main() { + testPoolSeededAndDefaults(); + testPoolPrivileges(); + testCreateRenameReorder(); + testMoveSourceLosesDestGains(); + testCopySourceRetainedDestGains(); + testMoveDestCollapse(); + testCopyDestCollapse(); + testCrossBankSameHashCoexistence(); + testEvacuate(); + testActiveBank(); + testJsonRoundTripFullBook(); + testJsonEmptyBookRoundTrip(); + testLegacyMigration(); + testMalformedJson(); + testActiveBankResolveAfterCorruptPersistedId(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}