Merge B1: pure bank_book registry (pool + named banks, move/copy, JSON round-trip)
This commit is contained in:
@@ -22,7 +22,7 @@ Vendors two submodules (see `.gitmodules`):
|
|||||||
cmake --build build
|
cmake --build build
|
||||||
ctest --test-dir build
|
ctest --test-dir build
|
||||||
|
|
||||||
Seven targets:
|
Eight targets:
|
||||||
|
|
||||||
| Target | Kind | Purpose |
|
| 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_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. |
|
| `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. |
|
| `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`). |
|
| `reaper_reasampler` | loadable module | The actual extension binary (`.dll` / `.dylib` / `.so`). |
|
||||||
|
|
||||||
### macOS / Linux: SWELL dialog resources
|
### 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_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.
|
- `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`.
|
- `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:**
|
**REAPER-facing shells:**
|
||||||
- `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
- `capture` — `ICaptureBackend` interface; `OfflineRenderBackend` (deterministic default) and `RealtimeRecordBackend`. Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
|
||||||
|
|||||||
+18
-1
@@ -108,6 +108,18 @@ add_library(tail_control STATIC src/tail_control.cpp)
|
|||||||
target_include_directories(tail_control PUBLIC src)
|
target_include_directories(tail_control PUBLIC src)
|
||||||
target_link_libraries(tail_control PUBLIC render_settings)
|
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
|
# 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,
|
# 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)
|
target_link_libraries(realtime_record_tests PRIVATE realtime_record)
|
||||||
add_test(NAME realtime_record_tests COMMAND realtime_record_tests)
|
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).
|
# 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/view.cpp
|
||||||
src/track_guid.cpp
|
src/track_guid.cpp
|
||||||
src/actions.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})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,671 @@
|
|||||||
|
#include "bank_book.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <climits>
|
||||||
|
|
||||||
|
// 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<int>(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<int>(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<Bank> 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<int>(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<Bank> 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<int>(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<Sample> 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<unsigned char>(c) < 0x20) {
|
||||||
|
char buf[8];
|
||||||
|
std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(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: <BankIndex blob> }.
|
||||||
|
// 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<Bank>& 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<unsigned>(h - '0');
|
||||||
|
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
|
||||||
|
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(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<char>(codePoint);
|
||||||
|
} else if (codePoint <= 0x7FF) {
|
||||||
|
out += static_cast<char>(0xC0 | (codePoint >> 6));
|
||||||
|
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||||
|
} else if (codePoint <= 0xFFFF) {
|
||||||
|
out += static_cast<char>(0xE0 | (codePoint >> 12));
|
||||||
|
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||||
|
} else {
|
||||||
|
out += static_cast<char>(0xF0 | (codePoint >> 18));
|
||||||
|
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||||||
|
out += static_cast<char>(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<int>(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<Bank>& 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<Bank> 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<Bank>&& 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> BankBook::deserialize(const std::string& json) {
|
||||||
|
std::vector<Bank> 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
|
||||||
+208
@@ -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 <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<Bank>& 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<BankBook> deserialize(const std::string& json);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<Bank> 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<Bank>&& banks, const std::string& activeBank);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -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 <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)
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user