feat(persist): persist BankBook under banks key, retire legacy bank_index, route capture to active bank (B2)

This commit is contained in:
2026-07-23 20:26:15 -04:00
parent 5b19525ec0
commit 85993ebc3e
6 changed files with 218 additions and 55 deletions
+68
View File
@@ -341,6 +341,70 @@ static void testMalformedJson() {
CHECK(!BankBook::deserialize(dup).has_value());
}
// --- B2: persist-load source-precedence decision (pure) --------------------
// loadFromPersisted picks the load source the persist shell will feed it from the
// two ext-state values a project may carry: the authoritative `banks` blob and the
// retired legacy `bank_index` blob. Precedence: banks > legacy > empty; a malformed
// banks blob degrades to empty WITHOUT falling back to the stale legacy key.
static void testLoadPrefersBanksBlob() {
// A full book + a stale legacy index both present: `banks` wins, legacy ignored.
BankBook src;
CHECK(src.createBank("drums", "Drums"));
CHECK(src.setActiveBank("drums"));
CHECK(src.bank("drums")->index.add(sampleWith("new1")) == AddResult::Added);
const std::string banksJson = src.serialize();
BankIndex stale;
CHECK(stale.add(sampleWith("stale-old")) == AddResult::Added);
const std::string legacyJson = stale.serialize();
BankBook loaded = BankBook::loadFromPersisted(banksJson, legacyJson);
// The book equals the source book — the legacy key had NO effect.
CHECK(loaded == src);
CHECK(loaded.activeBankId() == "drums");
CHECK(loaded.bank("drums") != nullptr);
CHECK(loaded.bank("drums")->index.query("id-new1") != nullptr);
// The stale legacy sample must NOT have leaked into the pool.
CHECK(loaded.pool().index.query("id-stale-old") == nullptr);
}
static void testLoadMigratesLegacyWhenNoBanks() {
// No `banks` key, a legacy `bank_index` present: migrate into the pool, zero named.
BankIndex legacy;
CHECK(legacy.add(sampleWith("l1")) == AddResult::Added);
CHECK(legacy.add(sampleWith("l2")) == AddResult::Added);
const std::string legacyJson = legacy.serialize();
BankBook loaded = BankBook::loadFromPersisted(std::string{}, legacyJson);
CHECK(loaded.size() == 1); // pool only
CHECK(loaded.pool().id == kPoolBankId);
CHECK(loaded.activeBankId() == std::string(kPoolBankId));
CHECK(loaded.pool().index.size() == 2);
CHECK(loaded.pool().index == legacy); // lossless
}
static void testLoadEmptyWhenNeither() {
// Both absent: a fresh empty book (pool only, empty index, active = pool).
BankBook loaded = BankBook::loadFromPersisted(std::string{}, std::string{});
CHECK(loaded == BankBook{});
CHECK(loaded.size() == 1);
CHECK(loaded.pool().index.empty());
CHECK(loaded.activeBankId() == std::string(kPoolBankId));
}
static void testLoadMalformedBanksDegradesWithoutLegacyFallback() {
// A present-but-malformed `banks` blob must degrade to an empty book and must NOT
// resurrect the stale legacy key (that would revive superseded single-bank state).
BankIndex stale;
CHECK(stale.add(sampleWith("stale")) == AddResult::Added);
const std::string legacyJson = stale.serialize();
BankBook loaded = BankBook::loadFromPersisted("{\"banks\":[", legacyJson);
CHECK(loaded == BankBook{}); // empty, NOT the legacy
CHECK(loaded.pool().index.query("id-stale") == nullptr); // legacy did not leak
}
static void testActiveBankResolveAfterCorruptPersistedId() {
// A book blob whose activeBank names no bank resolves to the pool (defensive).
const char* json =
@@ -366,6 +430,10 @@ int main() {
testJsonEmptyBookRoundTrip();
testLegacyMigration();
testMalformedJson();
testLoadPrefersBanksBlob();
testLoadMigratesLegacyWhenNoBanks();
testLoadEmptyWhenNeither();
testLoadMalformedBanksDegradesWithoutLegacyFallback();
testActiveBankResolveAfterCorruptPersistedId();
if (g_fail == 0) std::printf("All tests passed.\n");