From 33ffb45a45a48cf479cf768804412dc08988a383 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 22 Jul 2026 20:21:14 -0400 Subject: [PATCH] Add pure view_model core (Phase D1): N-mode registry, GUID-keyed membership, folder-tree visibility derivation, snapshot-based park/restore planner, JSON round-trip; new view_model_tests CTest target --- CMakeLists.txt | 14 + src/view_model.cpp | 711 ++++++++++++++++++++++++++++++++++++++ src/view_model.h | 312 +++++++++++++++++ tests/test_view_model.cpp | 372 ++++++++++++++++++++ 4 files changed, 1409 insertions(+) create mode 100644 src/view_model.cpp create mode 100644 src/view_model.h create mode 100644 tests/test_view_model.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ab632a..05418e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,15 @@ target_include_directories(peaks PUBLIC src) add_library(capture_paths STATIC src/capture_paths.cpp) target_include_directories(capture_paths PUBLIC src) +# --------------------------------------------------------------------------- +# 2c) Pure view_model library — NO REAPER, NO SWELL. The Design View heart +# (Phase D1): mode registry + GUID-keyed membership index + folder-tree-aware +# visibility derivation + parking/restore planner + JSON round-trip. Mirror of +# bank_model; the folder tree is an INPUT supplied by the D2 shell. +# --------------------------------------------------------------------------- +add_library(view_model STATIC src/view_model.cpp) +target_include_directories(view_model PUBLIC src) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -52,6 +61,10 @@ add_executable(capture_paths_tests tests/test_capture_paths.cpp) target_link_libraries(capture_paths_tests PRIVATE capture_paths) add_test(NAME capture_paths_tests COMMAND capture_paths_tests) +add_executable(view_model_tests tests/test_view_model.cpp) +target_link_libraries(view_model_tests PRIVATE view_model) +add_test(NAME view_model_tests COMMAND view_model_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -59,6 +72,7 @@ add_library(reaper_reasampler MODULE src/main.cpp src/capture.cpp src/persist.cpp + src/view_model.cpp ) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/view_model.cpp b/src/view_model.cpp new file mode 100644 index 0000000..0d90b83 --- /dev/null +++ b/src/view_model.cpp @@ -0,0 +1,711 @@ +#include "view_model.h" + +#include +#include +#include +#include +#include + +// view_model implementation. +// +// JSON is hand-rolled and self-contained, mirroring bank_model's approach (brief: +// keep the pure core dependency-free — no third-party JSON lib). A compact writer +// plus a recursive-descent parser covers the field set: the mode registry, the +// GUID-keyed membership map, per-track snapshots (with a variable-length per-FX +// offline vector), and the active mode. Ints are emitted plainly; strings are +// escaped identically to bank_model so control chars and unicode survive. + +namespace reasampler { + +// --------------------------------------------------------------------------- +// equality +// --------------------------------------------------------------------------- + +bool Mode::operator==(const Mode& o) const { + return id == o.id && displayName == o.displayName && ordinal == o.ordinal; +} + +// --------------------------------------------------------------------------- +// ModeRegistry +// --------------------------------------------------------------------------- + +ModeRegistry::ModeRegistry() { + modes_.push_back(Mode{kArrangeModeId, "Arrange", 0}); + modes_.push_back(Mode{kDesignModeId, "Design", 1}); +} + +bool ModeRegistry::add(const Mode& mode) { + if (mode.id.empty()) return false; + if (query(mode.id) != nullptr) return false; // ids are unique + modes_.push_back(mode); + // Keep ordinal order stable; std::stable_sort so equal ordinals keep insertion + // order (the tie-break documented in the header). + std::stable_sort(modes_.begin(), modes_.end(), + [](const Mode& a, const Mode& b) { return a.ordinal < b.ordinal; }); + return true; +} + +const Mode* ModeRegistry::query(const std::string& id) const { + for (const auto& m : modes_) + if (m.id == id) return &m; + return nullptr; +} + +// --------------------------------------------------------------------------- +// MembershipIndex +// --------------------------------------------------------------------------- + +bool MembershipIndex::tag(const std::string& guid, const std::string& modeId) { + if (guid.empty() || modeId.empty()) return false; + Membership& m = entries_[guid]; + m.modeIds.clear(); // a leaf lives in exactly one mode (show-both aside) + m.modeIds.insert(modeId); + return true; +} + +bool MembershipIndex::untag(const std::string& guid) { + return entries_.erase(guid) > 0; +} + +bool MembershipIndex::setShowBoth(const std::string& guid, bool showBoth) { + if (guid.empty()) return false; + entries_[guid].showBoth = showBoth; // creates an Arrange-default entry if new + return true; +} + +bool MembershipIndex::restore(const std::string& guid, const Membership& membership) { + if (guid.empty()) return false; + entries_[guid] = membership; + return true; +} + +const Membership* MembershipIndex::query(const std::string& guid) const { + auto it = entries_.find(guid); + return it == entries_.end() ? nullptr : &it->second; +} + +std::set MembershipIndex::modesOf(const std::string& guid) const { + const Membership* m = query(guid); + return m ? m->modeIds : std::set{}; +} + +// --------------------------------------------------------------------------- +// planner helpers +// --------------------------------------------------------------------------- + +TrackPlan makeParkPlan(const std::string& guid, int fxCount) { + // Parking contract: hide both panels, out of the mix, FX bypassed, every FX + // offline. All fixed zeros — park never consults a snapshot. + TrackPlan p; + p.flags = { + {guid, Flag::ShowInTcp, 0}, + {guid, Flag::ShowInMixer, 0}, + {guid, Flag::MainSend, 0}, + {guid, Flag::FxEnable, 0}, + }; + for (int i = 0; i < fxCount; ++i) + p.fxOffline.push_back({guid, i, true}); + return p; +} + +TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) { + // Restore contract: every driven flag returns to its SNAPSHOTTED value — never + // a hardcoded "on"/default. A flag captured at 0 restores to 0. + TrackPlan p; + p.flags = { + {guid, Flag::ShowInTcp, snap.showInTcp}, + {guid, Flag::ShowInMixer, snap.showInMixer}, + {guid, Flag::MainSend, snap.mainSend}, + {guid, Flag::FxEnable, snap.fxEnable}, + }; + for (std::size_t i = 0; i < snap.fxOffline.size(); ++i) + p.fxOffline.push_back({guid, static_cast(i), snap.fxOffline[i] != 0}); + return p; +} + +// --------------------------------------------------------------------------- +// ViewModel +// --------------------------------------------------------------------------- + +ViewModel::ViewModel() : activeModeId_(kArrangeModeId) {} + +bool ViewModel::setActiveMode(const std::string& modeId) { + if (!modes_.contains(modeId)) return false; + activeModeId_ = modeId; + return true; +} + +void ViewModel::storeSnapshot(const std::string& guid, const TrackSnapshot& snap) { + snapshots_[guid] = snap; +} + +void ViewModel::clearSnapshot(const std::string& guid) { + snapshots_.erase(guid); +} + +const TrackSnapshot* ViewModel::snapshot(const std::string& guid) const { + auto it = snapshots_.find(guid); + return it == snapshots_.end() ? nullptr : &it->second; +} + +bool ViewModel::leafBelongsToMode(const std::string& guid, const std::string& modeId) const { + const Membership* m = membership_.query(guid); + if (!m) return modeId == kArrangeModeId; // untagged ⇒ Arrange default + if (m->showBoth) return true; // show-both ⇒ every mode + if (m->modeIds.empty()) return modeId == kArrangeModeId; // show-both-cleared, no mode + return m->modeIds.count(modeId) > 0; +} + +std::set ViewModel::visibleTracks(const FolderTree& tree, + const std::string& modeId) const { + std::set visible; + + // Pass 1: every leaf that belongs to the mode is visible. + for (const auto& node : tree.nodes) { + if (node.isParent) continue; // parents derived in pass 2 + if (leafBelongsToMode(node.guid, modeId)) + visible.insert(node.guid); + } + + // Pass 2: a parent is visible if any descendant leaf is visible. Walk each + // visible leaf up its parent chain and mark ancestors. Parent chains are read + // from the supplied tree only (no REAPER access). A cycle-guard bounds the walk + // in case a malformed tree links a node to itself. + std::map parentOf; + for (const auto& node : tree.nodes) parentOf[node.guid] = node.parentGuid; + + // Snapshot the leaf-visible set so we don't re-walk parents we add mid-loop. + const std::vector seeds(visible.begin(), visible.end()); + for (const auto& leaf : seeds) { + auto it = parentOf.find(leaf); + std::size_t guard = 0; + while (it != parentOf.end() && !it->second.empty() && guard++ < parentOf.size()) { + const std::string& parent = it->second; + if (!visible.insert(parent).second) break; // already marked ⇒ chain done + it = parentOf.find(parent); + } + } + + return visible; +} + +TogglePlan ViewModel::planToggle(const FolderTree& tree, const std::string& targetMode) const { + TogglePlan plan; + + // Index the tree so we can classify each tagged GUID (leaf vs parent vs stale). + std::map byGuid; + for (const auto& node : tree.nodes) byGuid[node.guid] = &node; + + for (const auto& [guid, m] : membership_.all()) { + auto it = byGuid.find(guid); + if (it == byGuid.end()) continue; // stale GUID: prune-safe, ignore + if (it->second->isParent) continue; // parents are derived, never parked + if (m.showBoth) continue; // show-both leaves are never parked + + const bool active = leafBelongsToMode(guid, targetMode); + if (active) { + // Returning to visibility: restore from snapshot if we have one. No + // snapshot ⇒ the track was never parked, nothing to restore. + if (const TrackSnapshot* snap = snapshot(guid)) + plan.restore.push_back(makeRestorePlan(guid, *snap)); + } else { + // Inactive tagged leaf ⇒ park. FX count is unknown to the pure model; + // the shell expands per-FX offline from TrackFX_GetCount. We emit the + // scalar flags and leave fxOffline to the shell for the count (park + // offlines ALL, so no per-slot value decision is needed here). + plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0)); + } + } + + return plan; +} + +bool ViewModel::operator==(const ViewModel& o) const { + return modes_ == o.modes_ && membership_ == o.membership_ && + activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_; +} + +// =========================================================================== +// 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; +} + +void writeIntArray(std::string& out, const std::vector& v) { + out += '['; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i) out += ','; + out += intToStr(v[i]); + } + out += ']'; +} + +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 ViewModel::serialize() const { + std::string out; + { + ObjWriter root(out); + root.keyRaw("version", intToStr(1)); + root.keyStr("activeMode", activeModeId_); + + // modes + root.keyBegin("modes"); + out += '['; + { + const auto& all = modes_.all(); + for (std::size_t i = 0; i < all.size(); ++i) { + if (i) out += ','; + ObjWriter m(out); + m.keyStr("id", all[i].id); + m.keyStr("displayName", all[i].displayName); + m.keyRaw("ordinal", intToStr(all[i].ordinal)); + } + } + out += ']'; + + // membership: array of { guid, modes[], showBoth } + root.keyBegin("membership"); + out += '['; + { + bool first = true; + for (const auto& [guid, mem] : membership_.all()) { + if (!first) out += ','; first = false; + ObjWriter e(out); + e.keyStr("guid", guid); + e.keyBegin("modes"); + out += '['; + { + bool mf = true; + for (const auto& id : mem.modeIds) { + if (!mf) out += ','; mf = false; + writeEscaped(out, id); + } + } + out += ']'; + e.keyRaw("showBoth", mem.showBoth ? "true" : "false"); + } + } + out += ']'; + + // snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, fxOffline[] } + root.keyBegin("snapshots"); + out += '['; + { + bool first = true; + for (const auto& [guid, snap] : snapshots_) { + if (!first) out += ','; first = false; + ObjWriter e(out); + e.keyStr("guid", guid); + e.keyRaw("showInTcp", intToStr(snap.showInTcp)); + e.keyRaw("showInMixer", intToStr(snap.showInMixer)); + e.keyRaw("mainSend", intToStr(snap.mainSend)); + e.keyRaw("fxEnable", intToStr(snap.fxEnable)); + e.keyBegin("fxOffline"); + writeIntArray(out, snap.fxOffline); + } + } + out += ']'; + } // root closes here (see bank_model note on NRVO + deferred close) + return out; +} + +// =========================================================================== +// JSON — parser (recursive descent; false on any malformed input, never UB) +// =========================================================================== + +namespace { + +class Parser { +public: + explicit Parser(const std::string& s) : s_(s) {} + bool parseModel(ViewModel& out); + +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 parseRawScalar(std::string& out); + bool parseInt(int& out); + bool parseBool(bool& out); + bool parseKey(std::string& key); + bool skipValue(); + + bool parseModes(ModeRegistry& reg); + bool parseMembership(MembershipIndex& idx); + bool parseSnapshots(std::map& snaps); + bool parseIntArray(std::vector& 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; + } + 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::parseRawScalar(std::string& out) { + skipWs(); + std::size_t start = pos_; + while (!eof()) { + char c = s_[pos_]; + if (c == ',' || c == '}' || c == ']' || c == ' ' || c == '\t' || + c == '\n' || c == '\r') + break; + ++pos_; + } + if (pos_ == start) return false; + out.assign(s_, start, pos_ - start); + return true; +} + +bool Parser::parseInt(int& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + const char* b = tok.c_str(); + char* end = nullptr; + errno = 0; + long long v = std::strtoll(b, &end, 10); + if (end != b + tok.size()) return false; + if (errno == ERANGE) return false; + if (v < INT_MIN || v > INT_MAX) return false; + out = static_cast(v); + return true; +} + +bool Parser::parseBool(bool& out) { + std::string tok; + if (!parseRawScalar(tok)) return false; + if (tok == "true") { out = true; return true; } + if (tok == "false") { out = false; return true; } + return false; +} + +bool Parser::parseKey(std::string& key) { + if (!parseString(key)) return false; + return consume(':'); +} + +bool Parser::skipValue() { + skipWs(); + if (eof()) return false; + char c = s_[pos_]; + if (c == '"') { std::string tmp; return parseString(tmp); } + 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; continue; } + if (d == open) ++depth; + else if (d == close) --depth; + ++pos_; + } + return depth == 0; + } + std::string tmp; + return parseRawScalar(tmp); +} + +bool Parser::parseIntArray(std::vector& out) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; + do { + int v = 0; + if (!parseInt(v)) return false; + out.push_back(v); + } while (consume(',')); + return consume(']'); +} + +// The registry starts seeded (Arrange + Design). Deserialization must reproduce the +// serialized set exactly, so we replace the seeded contents with the parsed ones — +// add() dedups by id, so a serialized Arrange/Design would otherwise be rejected as +// duplicates and the ordinals/names would not round-trip. We therefore parse into a +// fresh vector and swap. `reg` is passed empty (see parseModel). +bool Parser::parseModes(ModeRegistry& reg) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; // empty array (unusual, but valid) + do { + if (!consume('{')) return false; + Mode m; + bool haveId = false; + do { + std::string k; + if (!parseKey(k)) return false; + if (k == "id") { if (!parseString(m.id)) return false; haveId = true; } + else if (k == "displayName") { if (!parseString(m.displayName)) return false; } + else if (k == "ordinal") { if (!parseInt(m.ordinal)) return false; } + else if (!skipValue()) return false; + } while (consume(',')); + if (!consume('}')) return false; + if (!haveId || !reg.add(m)) return false; // malformed / duplicate id + } while (consume(',')); + return consume(']'); +} + +bool Parser::parseMembership(MembershipIndex& idx) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; + do { + if (!consume('{')) return false; + std::string guid; + Membership mem; + bool haveGuid = false; + do { + std::string k; + if (!parseKey(k)) return false; + if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } + else if (k == "modes") { + if (!consume('[')) return false; + skipWs(); + if (!consume(']')) { + do { + std::string id; + if (!parseString(id)) return false; + mem.modeIds.insert(id); + } while (consume(',')); + if (!consume(']')) return false; + } + } + else if (k == "showBoth") { if (!parseBool(mem.showBoth)) return false; } + else if (!skipValue()) return false; + } while (consume(',')); + if (!consume('}')) return false; + if (!haveGuid || guid.empty()) return false; + // Install the entry verbatim (tag() would clear a multi-mode set and drop + // show-both). A serialized entry is trusted to already satisfy the model's + // invariants. + if (!idx.restore(guid, mem)) return false; + } while (consume(',')); + return consume(']'); +} + +bool Parser::parseSnapshots(std::map& snaps) { + if (!consume('[')) return false; + skipWs(); + if (consume(']')) return true; + do { + if (!consume('{')) return false; + std::string guid; + TrackSnapshot snap; + bool haveGuid = false; + do { + std::string k; + if (!parseKey(k)) return false; + if (k == "guid") { if (!parseString(guid)) return false; haveGuid = true; } + else if (k == "showInTcp") { if (!parseInt(snap.showInTcp)) return false; } + else if (k == "showInMixer") { if (!parseInt(snap.showInMixer)) return false; } + else if (k == "mainSend") { if (!parseInt(snap.mainSend)) return false; } + else if (k == "fxEnable") { if (!parseInt(snap.fxEnable)) return false; } + else if (k == "fxOffline") { if (!parseIntArray(snap.fxOffline)) return false; } + else if (!skipValue()) return false; + } while (consume(',')); + if (!consume('}')) return false; + if (!haveGuid || guid.empty()) return false; + snaps[guid] = snap; + } while (consume(',')); + return consume(']'); +} + +bool Parser::parseModel(ViewModel& out) { + if (!consume('{')) return false; + skipWs(); + if (consume('}')) return true; // lenient empty root ⇒ default-seeded model + + ModeRegistry reg; // seeded default; REPLACED if a modes array is present + bool haveModes = false; + std::string activeMode; + bool haveActive = false; + MembershipIndex membership; + std::map snaps; + + do { + std::string key; + if (!parseKey(key)) return false; + if (key == "activeMode") { + if (!parseString(activeMode)) return false; + haveActive = true; + } else if (key == "modes") { + ModeRegistry fresh = ModeRegistry::makeEmpty(); // parse into empty, then own + if (!parseModes(fresh)) return false; + reg = fresh; + haveModes = true; + } else if (key == "membership") { + if (!parseMembership(membership)) return false; + } else if (key == "snapshots") { + if (!parseSnapshots(snaps)) return false; + } else { + if (!skipValue()) return false; // version, unknown keys + } + } while (consume(',')); + + if (!consume('}')) return false; + skipWs(); + if (!eof()) return false; // trailing garbage + + if (haveModes) out.modes() = reg; + out.membership() = membership; + for (const auto& [guid, snap] : snaps) out.storeSnapshot(guid, snap); + if (haveActive) { + if (!out.setActiveMode(activeMode)) return false; // active mode must exist + } + return true; +} + +} // namespace + +std::optional ViewModel::deserialize(const std::string& json) { + ViewModel vm; + Parser p(json); + if (!p.parseModel(vm)) return std::nullopt; + return vm; +} + +} // namespace reasampler diff --git a/src/view_model.h b/src/view_model.h new file mode 100644 index 0000000..c3f1a10 --- /dev/null +++ b/src/view_model.h @@ -0,0 +1,312 @@ +#pragma once +// view_model — the pure core of the Design View feature, deliberately free of any +// REAPER type so it compiles and unit-tests OUTSIDE the DAW. It is the mirror of +// bank_model: it owns the mode registry, the GUID-keyed membership index, the +// folder-tree-aware visibility derivation, the parking/restore planner, and the +// JSON round-trip of all of it. +// +// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO +// vendor/ includes. Standard library only. The folder structure is an INPUT +// supplied by the D2 shell (which reads REAPER's I_FOLDERDEPTH); this model never +// fetches or stores REAPER's live tree — folder structure is REAPER's truth and +// changes underneath us, so it is passed in per query, not held. +// +// -- Representation decisions (design latitude exercised; invariants below) ----- +// +// * A mode is (stable string id, display name, ordinal). Arrange (id "arrange", +// ordinal 0) and Design (id "design", ordinal 1) are seeded. Arrange is the +// fallback home for every untagged leaf; structurally it is just another mode. +// +// * Membership is GUID -> { mode ids } (a set, not a bool) plus a per-track +// show-both flag. Normally a leaf is in exactly one mode; multiple only via the +// parent-derivation rule (computed, not stored) or the show-both escape hatch. +// An untagged GUID is NOT in the index and belongs to Arrange by default. +// +// * The planner drives exactly four scalar flags (showInTcp, showInMixer, +// mainSend, fxEnable) plus a per-FX offline list. Park values are fixed zeros +// (defined by the parking contract), so PARK ops need no snapshot. RESTORE ops +// come entirely FROM a TrackSnapshot captured before parking — never a hardcoded +// default. This is where the restore-contract invariant lives and is tested. +// +// * The snapshot stores the full prior per-FX offline vector so a save-while-parked +// project round-trips and restores each FX to its exact prior offline state. The +// pure model does NOT need REAPER FX counts to plan a park (park offlines all N, +// which the shell expands from TrackFX_GetCount); it only needs them to restore, +// and it gets them from the snapshot it captured. + +#include +#include +#include +#include +#include +#include + +namespace reasampler { + +// Stable seed-mode ids. Arrange is the default home for untagged leaves. +inline constexpr const char* kArrangeModeId = "arrange"; +inline constexpr const char* kDesignModeId = "design"; + +// A display "stance" the user adopts. Modes are ordered by `ordinal` for tab order. +struct Mode { + std::string id; // stable, persisted; never reused for a different mode + std::string displayName; + int ordinal = 0; // tab order + + bool operator==(const Mode& o) const; +}; + +// Ordered registry of modes. Arrange + Design are seeded on construction. Add more +// to prove the model is N-mode, not boolean. Ids are unique; adding a duplicate id +// is rejected. +class ModeRegistry { +public: + ModeRegistry(); // seeds Arrange (ordinal 0) + Design (ordinal 1) + + // Adds a mode. Rejects (returns false, no mutation) an empty or duplicate id. + bool add(const Mode& mode); + + // Returns the mode with `id`, or nullptr. Invalidated by any mutating call. + const Mode* query(const std::string& id) const; + + bool contains(const std::string& id) const { return query(id) != nullptr; } + + // All modes in ordinal order (ties broken by insertion order). + const std::vector& all() const { return modes_; } + + std::size_t size() const { return modes_.size(); } + + bool operator==(const ModeRegistry& o) const { return modes_ == o.modes_; } + + // An empty registry (no seed modes). Deserialization parses the persisted mode + // set into this and then owns it; the default ctor's seed would otherwise make + // the serialized Arrange/Design collide on add() and fail to round-trip. + static ModeRegistry makeEmpty() { return ModeRegistry(EmptyTag{}); } + +private: + struct EmptyTag {}; + explicit ModeRegistry(EmptyTag) {} // no seed + + std::vector modes_; // kept sorted by ordinal, then insertion +}; + +// The membership record for one tagged leaf track, keyed externally by GUID. +struct Membership { + std::set modeIds; // the mode(s) this leaf opted into + bool showBoth = false; // pinned visible + running in every mode + + bool operator==(const Membership& o) const { + return modeIds == o.modeIds && showBoth == o.showBoth; + } +}; + +// GUID-keyed membership index. Untagged GUIDs are absent and belong to Arrange. +// Keyed by track GUID string, never index (reorder-safe). +class MembershipIndex { +public: + // Tags `guid` into `modeId`, replacing any prior mode set (a leaf lives in one + // mode; use showBoth for the cross-mode case). No-op-safe on repeated calls. + // Returns false if guid or modeId is empty. + bool tag(const std::string& guid, const std::string& modeId); + + // Removes `guid` from the index entirely (returns it to the Arrange default). + // Returns true if it was present. + bool untag(const std::string& guid); + + // Sets the show-both flag for `guid`. Tags the guid into no new mode; if the + // guid is untagged it is created with an empty mode set (Arrange default) so + // show-both alone is representable. Returns false if guid is empty. + bool setShowBoth(const std::string& guid, bool showBoth); + + // Installs a complete membership record verbatim (multi-mode set + show-both), + // replacing any existing entry for `guid`. Used by deserialization to rebuild a + // trusted, already-valid entry without tag()'s single-mode clobbering. Returns + // false if guid is empty. + bool restore(const std::string& guid, const Membership& membership); + + // Returns the membership for `guid`, or nullptr if untagged. Invalidated by any + // mutating call. + const Membership* query(const std::string& guid) const; + + bool isShowBoth(const std::string& guid) const { + const Membership* m = query(guid); + return m && m->showBoth; + } + + // The mode ids `guid` belongs to. Empty for an untagged guid (⇒ Arrange). + std::set modesOf(const std::string& guid) const; + + const std::map& all() const { return entries_; } + + std::size_t size() const { return entries_.size(); } + bool empty() const { return entries_.empty(); } + + bool operator==(const MembershipIndex& o) const { return entries_ == o.entries_; } + +private: + std::map entries_; // guid -> membership +}; + +// -- Folder tree (INPUT, not stored) ---------------------------------------- +// +// The shell builds this from I_FOLDERDEPTH each time and passes it to a visibility +// query. A node is a leaf or a parent; a parent is visible in every mode any of +// its descendant leaves belongs to, and is never parked. The master track is +// modeled implicitly (always visible, never touched) and is NOT a node here. +struct FolderNode { + std::string guid; + std::string parentGuid; // empty ⇒ top-level (child of master / project root) + bool isParent = false; // true if this node has descendant tracks (a folder) +}; + +// A flat parent↔child description of the current track tree. Order is arrange-view +// order; parentGuid links each node to its immediate parent folder. +struct FolderTree { + std::vector nodes; +}; + +// -- Snapshot + planner ------------------------------------------------------ + +// The prior value of every tool-driven flag on one track, captured BEFORE parking. +// Restore uses these values verbatim — the restore contract's source of truth. +// Flags mirror REAPER's numeric representation (0/1 for the bools) so the shell +// applies them without translation; ints, not bools, so a snapshot faithfully +// round-trips whatever REAPER reported (defensive against non-0/1 values). +struct TrackSnapshot { + int showInTcp = 0; // B_SHOWINTCP prior value + int showInMixer = 0; // B_SHOWINMIXER prior value + int mainSend = 0; // B_MAINSEND prior value + int fxEnable = 0; // I_FXEN prior value + + // Prior per-FX offline state, index = fx slot. Lets restore return each FX to + // exactly its captured offline value rather than a blanket "online". + std::vector fxOffline; + + bool operator==(const TrackSnapshot& o) const { + return showInTcp == o.showInTcp && showInMixer == o.showInMixer && + mainSend == o.mainSend && fxEnable == o.fxEnable && + fxOffline == o.fxOffline; + } +}; + +// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it is +// per-slot, variable length), see TrackParkPlan::fxOffline. +enum class Flag { + ShowInTcp, // B_SHOWINTCP + ShowInMixer, // B_SHOWINMIXER + MainSend, // B_MAINSEND + FxEnable, // I_FXEN +}; + +// One scalar-flag write the shell must apply: SetMediaTrackInfo_Value(guid, flag, value). +struct TrackFlagOp { + std::string guid; + Flag flag = Flag::ShowInTcp; + int value = 0; + + bool operator==(const TrackFlagOp& o) const { + return guid == o.guid && flag == o.flag && value == o.value; + } +}; + +// One per-FX offline write: TrackFX_SetOffline(guid, fxIndex, offline). +struct FxOfflineOp { + std::string guid; + int fxIndex = 0; + bool offline = false; + + bool operator==(const FxOfflineOp& o) const { + return guid == o.guid && fxIndex == o.fxIndex && offline == o.offline; + } +}; + +// The complete set of operations to park one inactive leaf, or restore one leaf. +// Park uses fixed zeros (parking contract); restore uses a snapshot's values. +// fxOffline is emitted per known FX slot: on park, from the snapshot's slot count +// (all -> offline); on restore, each slot back to its captured value. +struct TrackPlan { + std::vector flags; + std::vector fxOffline; +}; + +// The plan for a whole toggle to a target mode: which tracks to park, and which to +// restore from their snapshots. Parents and show-both leaves never appear here — +// they are derived-visible and never parked (visibility is answered separately by +// visibleTracks). Untagged tracks never appear either (the tool owns only what it +// tagged). +struct TogglePlan { + std::vector park; // inactive leaves -> parked (fixed zeros) + std::vector restore; // active leaves returning -> snapshot values +}; + +// -- The view model ---------------------------------------------------------- +// +// Owns the mode registry, the membership index, the active mode, and the durable +// per-track snapshots (kept for tracks currently parked so a save-while-parked +// project restores correctly). Visibility and the toggle plan are computed against +// a supplied FolderTree — the tree is never stored. +class ViewModel { +public: + ViewModel(); // Arrange + Design seeded; active mode = Arrange + + ModeRegistry& modes() { return modes_; } + const ModeRegistry& modes() const { return modes_; } + MembershipIndex& membership() { return membership_; } + const MembershipIndex& membership() const { return membership_; } + + const std::string& activeModeId() const { return activeModeId_; } + // Sets the active mode. Returns false (no change) if the id is not registered. + bool setActiveMode(const std::string& modeId); + + // Records / clears the pre-park snapshot for a track. The shell calls store + // before it parks a track; the model persists it so restore survives a save. + void storeSnapshot(const std::string& guid, const TrackSnapshot& snap); + void clearSnapshot(const std::string& guid); + const TrackSnapshot* snapshot(const std::string& guid) const; + const std::map& snapshots() const { return snapshots_; } + + // Does `guid` belong to `modeId`? A leaf belongs if it is tagged into modeId, + // is show-both (belongs everywhere), or is untagged and modeId is Arrange (the + // default). Parent derivation is NOT applied here — this is the LEAF rule; use + // visibleTracks for the tree-aware answer. + bool leafBelongsToMode(const std::string& guid, const std::string& modeId) const; + + // The set of track GUIDs visible in `modeId`, tree-aware: active leaves, + // show-both leaves, and every parent with at least one descendant leaf in the + // mode. Untagged leaves count as Arrange. Stale GUIDs in the tree are tolerated. + // The master is not represented (always visible; the shell never touches it). + std::set visibleTracks(const FolderTree& tree, + const std::string& modeId) const; + + // Plans a toggle to `targetMode` against the current tree. Inactive tagged + // leaves (not show-both, not derived-visible-only) are parked with fixed zeros; + // leaves that become active AND have a stored snapshot are restored from it. + // Parents, show-both leaves, the master, and untagged tracks are never parked. + // Unknown/stale membership GUIDs absent from the tree are ignored (prune-safe). + TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const; + + bool operator==(const ViewModel& o) const; + + std::string serialize() const; + + // Parses a JSON string produced by serialize(). std::nullopt on malformed + // input. On success deserialize(serialize(x)) == x. + static std::optional deserialize(const std::string& json); + +private: + ModeRegistry modes_; + MembershipIndex membership_; + std::string activeModeId_; // always a registered id + std::map snapshots_; // guid -> pre-park snapshot +}; + +// Builds the fixed-zero park plan for one leaf. Offlines `fxCount` slots. Exposed +// for the shell and for direct testing of the parking contract. +TrackPlan makeParkPlan(const std::string& guid, int fxCount); + +// Builds the restore plan for one leaf from its snapshot — every flag set to its +// captured value, never a default. Exposed for the shell and for testing the +// restore-contract invariant directly. +TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap); + +} // namespace reasampler diff --git a/tests/test_view_model.cpp b/tests/test_view_model.cpp new file mode 100644 index 0000000..988bf61 --- /dev/null +++ b/tests/test_view_model.cpp @@ -0,0 +1,372 @@ +// Standalone tests for reasampler::view_model — no REAPER, no test framework. +// Mirror of test_bank_model: iterate the hard logic outside the DAW. +// +// Covers (PLAN.md D1 test cases): +// 1. N-mode proven — >=3 modes, membership + derivation still correct. +// 2. Parent derivation — a folder with descendant leaves in different modes is +// visible in each of those modes. +// 3. Restore round-trip — snapshot -> park -> restore returns every driven flag to +// its captured value; includes the "flag already at 0 stays 0" (no default). +// 4. show-both leaf never appears in a park op-list and is visible in all modes. +// 5. Unknown/stale GUID tolerated (ignore-and-prune, no crash). +// 6. JSON round-trip lossless: modes + membership + show-both + snapshots + active. + +#include "../src/view_model.h" + +#include +#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) + +// -- helpers ----------------------------------------------------------------- + +static bool visibleHas(const std::set& v, const std::string& g) { + return v.count(g) > 0; +} + +// Does any park TrackPlan in the plan target `guid`? +static bool parkTargets(const TogglePlan& plan, const std::string& guid) { + for (const auto& p : plan.park) + for (const auto& f : p.flags) + if (f.guid == guid) return true; + return false; +} + +// Find the single restore plan for `guid`, or nullptr. +static const TrackPlan* restoreFor(const TogglePlan& plan, const std::string& guid) { + for (const auto& p : plan.restore) + if (!p.flags.empty() && p.flags.front().guid == guid) return &p; + return nullptr; +} + +static int flagValue(const TrackPlan& p, Flag f) { + for (const auto& op : p.flags) + if (op.flag == f) return op.value; + return -999; // sentinel: flag absent +} + +// -- 1. N-mode proven -------------------------------------------------------- + +static void testNModeRegistryAndMembership() { + ViewModel vm; + // Seeded: Arrange + Design. + CHECK(vm.modes().size() == 2); + CHECK(vm.modes().contains(kArrangeModeId)); + CHECK(vm.modes().contains(kDesignModeId)); + CHECK(vm.activeModeId() == kArrangeModeId); + + // Add a third mode — proves N-mode, not boolean. + CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2})); + CHECK(vm.modes().size() == 3); + CHECK(vm.modes().contains("mixdown")); + + // Duplicate id and empty id are rejected without mutation. + CHECK(!vm.modes().add(Mode{"mixdown", "Dup", 5})); + CHECK(!vm.modes().add(Mode{"", "Empty", 6})); + CHECK(vm.modes().size() == 3); + + // Membership across three modes. + CHECK(vm.membership().tag("{A}", kArrangeModeId)); + CHECK(vm.membership().tag("{D}", kDesignModeId)); + CHECK(vm.membership().tag("{M}", "mixdown")); + + // Leaf-belongs rule holds in each mode. + CHECK(vm.leafBelongsToMode("{D}", kDesignModeId)); + CHECK(!vm.leafBelongsToMode("{D}", kArrangeModeId)); + CHECK(vm.leafBelongsToMode("{M}", "mixdown")); + CHECK(!vm.leafBelongsToMode("{M}", kDesignModeId)); + + // Untagged leaf defaults to Arrange, and only Arrange. + CHECK(vm.leafBelongsToMode("{UNTAGGED}", kArrangeModeId)); + CHECK(!vm.leafBelongsToMode("{UNTAGGED}", kDesignModeId)); + + // Retag moves the leaf (single-mode semantics). + CHECK(vm.membership().tag("{D}", "mixdown")); + CHECK(vm.leafBelongsToMode("{D}", "mixdown")); + CHECK(!vm.leafBelongsToMode("{D}", kDesignModeId)); + + // Untag returns to the Arrange default. + CHECK(vm.membership().untag("{D}")); + CHECK(vm.leafBelongsToMode("{D}", kArrangeModeId)); + CHECK(!vm.membership().untag("{D}")); // second untag is a no-op + + // setActiveMode rejects an unregistered id, accepts a registered one. + CHECK(!vm.setActiveMode("nope")); + CHECK(vm.activeModeId() == kArrangeModeId); + CHECK(vm.setActiveMode("mixdown")); + CHECK(vm.activeModeId() == "mixdown"); +} + +// -- 2. Parent derivation ---------------------------------------------------- + +static void testParentDerivationMultiMode() { + ViewModel vm; + // Folder {F} holds two leaves: {L1} in Arrange (untagged default), {L2} in Design. + FolderTree tree; + tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true}); + tree.nodes.push_back(FolderNode{"{L1}", "{F}", false}); + tree.nodes.push_back(FolderNode{"{L2}", "{F}", false}); + vm.membership().tag("{L2}", kDesignModeId); + // {L1} stays untagged ⇒ Arrange. + + auto arrange = vm.visibleTracks(tree, kArrangeModeId); + auto design = vm.visibleTracks(tree, kDesignModeId); + + // The parent is visible in BOTH modes because it has a descendant in each. + CHECK(visibleHas(arrange, "{F}")); + CHECK(visibleHas(design, "{F}")); + + // Leaves appear only in their own mode. + CHECK(visibleHas(arrange, "{L1}") && !visibleHas(arrange, "{L2}")); + CHECK(visibleHas(design, "{L2}") && !visibleHas(design, "{L1}")); + + // Nested folder chain: grandparent {G} > parent {F2} > leaf {L3} (Design). + // The whole chain up to the root must be visible in Design. + FolderTree nested; + nested.nodes.push_back(FolderNode{"{G}", "", true}); + nested.nodes.push_back(FolderNode{"{F2}", "{G}", true}); + nested.nodes.push_back(FolderNode{"{L3}", "{F2}", false}); + ViewModel vm2; + vm2.membership().tag("{L3}", kDesignModeId); + auto d2 = vm2.visibleTracks(nested, kDesignModeId); + CHECK(visibleHas(d2, "{L3}")); + CHECK(visibleHas(d2, "{F2}")); + CHECK(visibleHas(d2, "{G}")); + // In Arrange, none of the chain is visible (no Arrange leaf under it). + auto a2 = vm2.visibleTracks(nested, kArrangeModeId); + CHECK(!visibleHas(a2, "{L3}") && !visibleHas(a2, "{F2}") && !visibleHas(a2, "{G}")); + + // A parent is NEVER parked, in either mode. + auto planD = vm.planToggle(tree, kDesignModeId); + auto planA = vm.planToggle(tree, kArrangeModeId); + CHECK(!parkTargets(planD, "{F}")); + CHECK(!parkTargets(planA, "{F}")); +} + +// -- 3. Restore round-trip (the trust anchor) -------------------------------- + +static void testRestoreRoundTripSnapshotValues() { + // Direct planner check: park is fixed zeros; restore is snapshot verbatim. + TrackSnapshot snap; + snap.showInTcp = 1; + snap.showInMixer = 1; + snap.mainSend = 0; // user had it OUT of the mix for their own reason + snap.fxEnable = 1; + snap.fxOffline = {0, 1, 0}; // slot 1 was already offline before parking + + TrackPlan park = makeParkPlan("{T}", /*fxCount=*/3); + CHECK(flagValue(park, Flag::ShowInTcp) == 0); + CHECK(flagValue(park, Flag::ShowInMixer) == 0); + CHECK(flagValue(park, Flag::MainSend) == 0); + CHECK(flagValue(park, Flag::FxEnable) == 0); + CHECK(park.fxOffline.size() == 3); + for (const auto& op : park.fxOffline) CHECK(op.offline == true); + + TrackPlan restore = makeRestorePlan("{T}", snap); + // Every flag returns to its CAPTURED value — not a hardcoded "on". + CHECK(flagValue(restore, Flag::ShowInTcp) == 1); + CHECK(flagValue(restore, Flag::ShowInMixer) == 1); + CHECK(flagValue(restore, Flag::MainSend) == 0); // the "already at 0 stays 0" case + CHECK(flagValue(restore, Flag::FxEnable) == 1); + CHECK(restore.fxOffline.size() == 3); + CHECK(restore.fxOffline[0].offline == false); + CHECK(restore.fxOffline[1].offline == true); // was offline pre-park ⇒ stays offline + CHECK(restore.fxOffline[2].offline == false); + + // A snapshot entirely at 0 must restore entirely to 0 (no default leaks in). + TrackSnapshot zero; // all zeros, empty fxOffline + TrackPlan rz = makeRestorePlan("{Z}", zero); + CHECK(flagValue(rz, Flag::ShowInTcp) == 0); + CHECK(flagValue(rz, Flag::ShowInMixer) == 0); + CHECK(flagValue(rz, Flag::MainSend) == 0); + CHECK(flagValue(rz, Flag::FxEnable) == 0); + CHECK(rz.fxOffline.empty()); + + // End-to-end via planToggle: a leaf tagged Design, snapshotted, parked while in + // Arrange, then restored when we toggle back to Design. + ViewModel vm; + FolderTree tree; + tree.nodes.push_back(FolderNode{"{DES}", "", false}); + vm.membership().tag("{DES}", kDesignModeId); + vm.storeSnapshot("{DES}", snap); + + // Toggle to Arrange: {DES} is inactive ⇒ parked. + auto toArrange = vm.planToggle(tree, kArrangeModeId); + CHECK(parkTargets(toArrange, "{DES}")); + CHECK(restoreFor(toArrange, "{DES}") == nullptr); // not restored while inactive + + // Toggle to Design: {DES} is active AND has a snapshot ⇒ restored from it. + auto toDesign = vm.planToggle(tree, kDesignModeId); + CHECK(!parkTargets(toDesign, "{DES}")); + const TrackPlan* r = restoreFor(toDesign, "{DES}"); + CHECK(r != nullptr); + if (r) { + CHECK(flagValue(*r, Flag::MainSend) == 0); // captured 0 comes back 0 + CHECK(flagValue(*r, Flag::ShowInTcp) == 1); + } +} + +// -- 4. show-both leaf -------------------------------------------------------- + +static void testShowBothNeverParkedVisibleEverywhere() { + ViewModel vm; + CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2})); + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{SB}", "", false}); + // Tag into Design, then pin show-both. + vm.membership().tag("{SB}", kDesignModeId); + CHECK(vm.membership().setShowBoth("{SB}", true)); + CHECK(vm.membership().isShowBoth("{SB}")); + + // Visible in EVERY mode. + CHECK(visibleHas(vm.visibleTracks(tree, kArrangeModeId), "{SB}")); + CHECK(visibleHas(vm.visibleTracks(tree, kDesignModeId), "{SB}")); + CHECK(visibleHas(vm.visibleTracks(tree, "mixdown"), "{SB}")); + + // Never parked, in any mode — even a mode it isn't tagged into. + CHECK(!parkTargets(vm.planToggle(tree, kArrangeModeId), "{SB}")); + CHECK(!parkTargets(vm.planToggle(tree, kDesignModeId), "{SB}")); + CHECK(!parkTargets(vm.planToggle(tree, "mixdown"), "{SB}")); + + // Clearing show-both restores normal one-mode parking: now in Arrange it parks. + CHECK(vm.membership().setShowBoth("{SB}", false)); + CHECK(parkTargets(vm.planToggle(tree, kArrangeModeId), "{SB}")); + CHECK(!parkTargets(vm.planToggle(tree, kDesignModeId), "{SB}")); +} + +// -- 5. Unknown/stale GUID tolerated ----------------------------------------- + +static void testStaleGuidTolerated() { + ViewModel vm; + // Tag two leaves, but the tree only knows one — the other GUID is stale (its + // track was deleted / restructured while parked). + vm.membership().tag("{LIVE}", kDesignModeId); + vm.membership().tag("{GHOST}", kDesignModeId); + vm.storeSnapshot("{GHOST}", TrackSnapshot{}); // stale snapshot too + + FolderTree tree; + tree.nodes.push_back(FolderNode{"{LIVE}", "", false}); + // {GHOST} absent from the tree. + + // No crash; the stale GUID is simply ignored (prune-safe). + auto plan = vm.planToggle(tree, kArrangeModeId); + CHECK(parkTargets(plan, "{LIVE}")); // live leaf still planned + CHECK(!parkTargets(plan, "{GHOST}")); // stale leaf never emitted + + // Visibility derivation also ignores the stale GUID without incident. + auto vis = vm.visibleTracks(tree, kDesignModeId); + CHECK(visibleHas(vis, "{LIVE}")); + CHECK(!visibleHas(vis, "{GHOST}")); + + // An empty tree with tagged members: nothing planned, no crash. + FolderTree empty; + auto emptyPlan = vm.planToggle(empty, kDesignModeId); + CHECK(emptyPlan.park.empty() && emptyPlan.restore.empty()); +} + +// -- 6. JSON round-trip lossless --------------------------------------------- + +static void testJsonRoundTrip() { + ViewModel vm; + // Modes: seeded pair + a third; also an out-of-order ordinal to prove sorting + // survives round-trip. + CHECK(vm.modes().add(Mode{"print", "Print \"stem\"\n", 5})); + CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2})); + + // Membership: a plain Design leaf, a show-both leaf, an Arrange leaf, and a + // leaf carrying multiple modes (representable via restore; exercises the set). + vm.membership().tag("{A}", kArrangeModeId); + vm.membership().tag("{D}", kDesignModeId); + vm.membership().tag("{SB}", "mixdown"); + vm.membership().setShowBoth("{SB}", true); + Membership multi; + multi.modeIds = {kDesignModeId, "mixdown"}; + multi.showBoth = false; + CHECK(vm.membership().restore("{MULTI}", multi)); + + // Snapshots: one full, one with a per-FX vector, including the tricky 0-values. + TrackSnapshot s1; s1.showInTcp = 1; s1.showInMixer = 0; s1.mainSend = 1; + s1.fxEnable = 0; s1.fxOffline = {1, 0, 1, 1}; + vm.storeSnapshot("{D}", s1); + TrackSnapshot s2; // all zeros, empty fx vector + vm.storeSnapshot("{A}", s2); + + // Active mode set to a non-default. + CHECK(vm.setActiveMode("mixdown")); + + std::string json = vm.serialize(); + auto back = ViewModel::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == vm); + // String form is stable across a second round-trip. + if (back) CHECK(back->serialize() == json); + + // Spot-check the load-bearing bits survived. + if (back) { + CHECK(back->activeModeId() == "mixdown"); + CHECK(back->modes().size() == 4); + const Mode* print = back->modes().query("print"); + CHECK(print && print->displayName == "Print \"stem\"\n" && print->ordinal == 5); + CHECK(back->membership().isShowBoth("{SB}")); + const Membership* mm = back->membership().query("{MULTI}"); + CHECK(mm && mm->modeIds.size() == 2 && mm->modeIds.count("mixdown")); + const TrackSnapshot* snap = back->snapshot("{D}"); + CHECK(snap && snap->mainSend == 1 && snap->fxEnable == 0); + CHECK(snap && snap->fxOffline.size() == 4 && snap->fxOffline[1] == 0); + } +} + +static void testEmptyModelRoundTrip() { + ViewModel vm; // default: Arrange + Design seeded, active = Arrange, no members + std::string json = vm.serialize(); + auto back = ViewModel::deserialize(json); + CHECK(back.has_value()); + CHECK(back && *back == vm); + + // Lenient empty root ⇒ a default-seeded model. + auto empty = ViewModel::deserialize("{}"); + CHECK(empty.has_value()); + CHECK(empty && empty->modes().size() == 2); + CHECK(empty && empty->activeModeId() == kArrangeModeId); + CHECK(empty && empty->membership().empty()); +} + +static void testMalformedJson() { + const char* bad[] = { + "", + "{", + "not json", + "{\"modes\":[", + "{\"modes\":[{\"id\":\"x\"", // truncated mode + "{\"activeMode\":\"ghost\"}", // active mode not registered + "{\"modes\":[{\"id\":\"a\",\"ordinal\":0},{\"id\":\"a\",\"ordinal\":1}]}", // dup id + "{\"membership\":[{\"guid\":\"\"}]}", // empty guid + "{\"snapshots\":[{\"showInTcp\":1}]}", // snapshot without guid + "{\"snapshots\":[{\"guid\":\"x\",\"fxOffline\":[1,notanumber]}]}", + "{\"modes\":[]}trailing", // trailing garbage + }; + for (const char* j : bad) { + auto r = ViewModel::deserialize(j); + CHECK(!r.has_value()); + } +} + +int main() { + testNModeRegistryAndMembership(); + testParentDerivationMultiMode(); + testRestoreRoundTripSnapshotValues(); + testShowBothNeverParkedVisibleEverywhere(); + testStaleGuidTolerated(); + testJsonRoundTrip(); + testEmptyModelRoundTrip(); + testMalformedJson(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}