diff --git a/CMakeLists.txt b/CMakeLists.txt index 05418e3..fd9f15d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,13 +37,13 @@ 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 +# 2c) Pure view_mode_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) +add_library(view_mode_model STATIC src/view_mode_model.cpp) +target_include_directories(view_mode_model PUBLIC src) # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). @@ -61,9 +61,9 @@ 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) +add_executable(view_mode_model_tests tests/test_view_mode_model.cpp) +target_link_libraries(view_mode_model_tests PRIVATE view_mode_model) +add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests) # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). @@ -72,7 +72,7 @@ add_library(reaper_reasampler MODULE src/main.cpp src/capture.cpp src/persist.cpp - src/view_model.cpp + src/view_mode_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_mode_model.cpp similarity index 91% rename from src/view_model.cpp rename to src/view_mode_model.cpp index 0d90b83..71dc284 100644 --- a/src/view_model.cpp +++ b/src/view_mode_model.cpp @@ -1,4 +1,4 @@ -#include "view_model.h" +#include "view_mode_model.h" #include #include @@ -6,7 +6,7 @@ #include #include -// view_model implementation. +// view_mode_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 @@ -124,31 +124,31 @@ TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) { } // --------------------------------------------------------------------------- -// ViewModel +// ViewModeModel // --------------------------------------------------------------------------- -ViewModel::ViewModel() : activeModeId_(kArrangeModeId) {} +ViewModeModel::ViewModeModel() : activeModeId_(kArrangeModeId) {} -bool ViewModel::setActiveMode(const std::string& modeId) { +bool ViewModeModel::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) { +void ViewModeModel::storeSnapshot(const std::string& guid, const TrackSnapshot& snap) { snapshots_[guid] = snap; } -void ViewModel::clearSnapshot(const std::string& guid) { +void ViewModeModel::clearSnapshot(const std::string& guid) { snapshots_.erase(guid); } -const TrackSnapshot* ViewModel::snapshot(const std::string& guid) const { +const TrackSnapshot* ViewModeModel::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 { +bool ViewModeModel::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 @@ -156,7 +156,7 @@ bool ViewModel::leafBelongsToMode(const std::string& guid, const std::string& mo return m->modeIds.count(modeId) > 0; } -std::set ViewModel::visibleTracks(const FolderTree& tree, +std::set ViewModeModel::visibleTracks(const FolderTree& tree, const std::string& modeId) const { std::set visible; @@ -189,7 +189,7 @@ std::set ViewModel::visibleTracks(const FolderTree& tree, return visible; } -TogglePlan ViewModel::planToggle(const FolderTree& tree, const std::string& targetMode) const { +TogglePlan ViewModeModel::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). @@ -209,10 +209,10 @@ TogglePlan ViewModel::planToggle(const FolderTree& tree, const std::string& targ 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). + // Inactive tagged leaf ⇒ park. fxOffline is intentionally empty here: + // the D2 shell expands per-FX offline writes using TrackFX_GetCount. + // The pure model has no access to REAPER FX counts at plan time; + // makeParkPlan(guid, 0) emits only the scalar flags as a result. plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0)); } } @@ -220,7 +220,7 @@ TogglePlan ViewModel::planToggle(const FolderTree& tree, const std::string& targ return plan; } -bool ViewModel::operator==(const ViewModel& o) const { +bool ViewModeModel::operator==(const ViewModeModel& o) const { return modes_ == o.modes_ && membership_ == o.membership_ && activeModeId_ == o.activeModeId_ && snapshots_ == o.snapshots_; } @@ -301,7 +301,7 @@ private: } // namespace -std::string ViewModel::serialize() const { +std::string ViewModeModel::serialize() const { std::string out; { ObjWriter root(out); @@ -378,7 +378,7 @@ namespace { class Parser { public: explicit Parser(const std::string& s) : s_(s) {} - bool parseModel(ViewModel& out); + bool parseModel(ViewModeModel& out); private: const std::string& s_; @@ -622,6 +622,15 @@ bool Parser::parseMembership(MembershipIndex& idx) { // 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. + // + // Deliberate tolerance: we do NOT validate that membership modeIds reference + // registered modes, and we do not validate snapshot GUIDs against the index. + // Stale-GUID and stale-mode tolerance is a stated invariant of this model — + // a deserialized entry is treated as trusted data, not as live cross-checked + // state. Rejecting stale entries here would violate that invariant. The one + // exception is activeMode (validated below in parseModel): a persisted active + // mode that no longer exists has an immediate behavioral consequence, so it + // is caught and the parse is rejected. if (!idx.restore(guid, mem)) return false; } while (consume(',')); return consume(']'); @@ -654,7 +663,7 @@ bool Parser::parseSnapshots(std::map& snaps) { return consume(']'); } -bool Parser::parseModel(ViewModel& out) { +bool Parser::parseModel(ViewModeModel& out) { if (!consume('{')) return false; skipWs(); if (consume('}')) return true; // lenient empty root ⇒ default-seeded model @@ -682,7 +691,11 @@ bool Parser::parseModel(ViewModel& out) { } else if (key == "snapshots") { if (!parseSnapshots(snaps)) return false; } else { - if (!skipValue()) return false; // version, unknown keys + // Unknown keys and the "version" field are skipped here. + // "version" is serialized as a forward-compat placeholder — there is no + // active version gate yet; all persisted data is parsed the same way + // regardless of the value. A future gate would add a version branch here. + if (!skipValue()) return false; } } while (consume(',')); @@ -701,8 +714,8 @@ bool Parser::parseModel(ViewModel& out) { } // namespace -std::optional ViewModel::deserialize(const std::string& json) { - ViewModel vm; +std::optional ViewModeModel::deserialize(const std::string& json) { + ViewModeModel vm; Parser p(json); if (!p.parseModel(vm)) return std::nullopt; return vm; diff --git a/src/view_model.h b/src/view_mode_model.h similarity index 95% rename from src/view_model.h rename to src/view_mode_model.h index c3f1a10..1414948 100644 --- a/src/view_model.h +++ b/src/view_mode_model.h @@ -1,5 +1,5 @@ #pragma once -// view_model — the pure core of the Design View feature, deliberately free of any +// view_mode_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 @@ -239,15 +239,15 @@ struct TogglePlan { std::vector restore; // active leaves returning -> snapshot values }; -// -- The view model ---------------------------------------------------------- +// -- The view mode 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 { +class ViewModeModel { public: - ViewModel(); // Arrange + Design seeded; active mode = Arrange + ViewModeModel(); // Arrange + Design seeded; active mode = Arrange ModeRegistry& modes() { return modes_; } const ModeRegistry& modes() const { return modes_; } @@ -283,15 +283,19 @@ public: // 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). + // + // Note: park plans emitted here have an empty fxOffline vector. The D2 shell + // expands per-FX offline writes using TrackFX_GetCount — the pure model has no + // access to REAPER FX counts at plan time. TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const; - bool operator==(const ViewModel& o) const; + bool operator==(const ViewModeModel& 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); + static std::optional deserialize(const std::string& json); private: ModeRegistry modes_; diff --git a/tests/test_view_model.cpp b/tests/test_view_mode_model.cpp similarity index 89% rename from tests/test_view_model.cpp rename to tests/test_view_mode_model.cpp index 988bf61..e712955 100644 --- a/tests/test_view_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -1,4 +1,4 @@ -// Standalone tests for reasampler::view_model — no REAPER, no test framework. +// Standalone tests for reasampler::ViewModeModel — no REAPER, no test framework. // Mirror of test_bank_model: iterate the hard logic outside the DAW. // // Covers (PLAN.md D1 test cases): @@ -10,8 +10,9 @@ // 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. +// 7. planToggle park path: fxOffline is empty (shell-expands-FX contract). -#include "../src/view_model.h" +#include "../src/view_mode_model.h" #include #include @@ -53,7 +54,7 @@ static int flagValue(const TrackPlan& p, Flag f) { // -- 1. N-mode proven -------------------------------------------------------- static void testNModeRegistryAndMembership() { - ViewModel vm; + ViewModeModel vm; // Seeded: Arrange + Design. CHECK(vm.modes().size() == 2); CHECK(vm.modes().contains(kArrangeModeId)); @@ -105,7 +106,7 @@ static void testNModeRegistryAndMembership() { // -- 2. Parent derivation ---------------------------------------------------- static void testParentDerivationMultiMode() { - ViewModel vm; + ViewModeModel vm; // Folder {F} holds two leaves: {L1} in Arrange (untagged default), {L2} in Design. FolderTree tree; tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true}); @@ -131,7 +132,7 @@ static void testParentDerivationMultiMode() { 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; + ViewModeModel vm2; vm2.membership().tag("{L3}", kDesignModeId); auto d2 = vm2.visibleTracks(nested, kDesignModeId); CHECK(visibleHas(d2, "{L3}")); @@ -189,7 +190,7 @@ static void testRestoreRoundTripSnapshotValues() { // End-to-end via planToggle: a leaf tagged Design, snapshotted, parked while in // Arrange, then restored when we toggle back to Design. - ViewModel vm; + ViewModeModel vm; FolderTree tree; tree.nodes.push_back(FolderNode{"{DES}", "", false}); vm.membership().tag("{DES}", kDesignModeId); @@ -214,7 +215,7 @@ static void testRestoreRoundTripSnapshotValues() { // -- 4. show-both leaf -------------------------------------------------------- static void testShowBothNeverParkedVisibleEverywhere() { - ViewModel vm; + ViewModeModel vm; CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2})); FolderTree tree; @@ -243,7 +244,7 @@ static void testShowBothNeverParkedVisibleEverywhere() { // -- 5. Unknown/stale GUID tolerated ----------------------------------------- static void testStaleGuidTolerated() { - ViewModel vm; + ViewModeModel 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); @@ -273,7 +274,7 @@ static void testStaleGuidTolerated() { // -- 6. JSON round-trip lossless --------------------------------------------- static void testJsonRoundTrip() { - ViewModel vm; + ViewModeModel 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})); @@ -301,7 +302,7 @@ static void testJsonRoundTrip() { CHECK(vm.setActiveMode("mixdown")); std::string json = vm.serialize(); - auto back = ViewModel::deserialize(json); + auto back = ViewModeModel::deserialize(json); CHECK(back.has_value()); CHECK(back && *back == vm); // String form is stable across a second round-trip. @@ -323,14 +324,14 @@ static void testJsonRoundTrip() { } static void testEmptyModelRoundTrip() { - ViewModel vm; // default: Arrange + Design seeded, active = Arrange, no members + ViewModeModel vm; // default: Arrange + Design seeded, active = Arrange, no members std::string json = vm.serialize(); - auto back = ViewModel::deserialize(json); + auto back = ViewModeModel::deserialize(json); CHECK(back.has_value()); CHECK(back && *back == vm); // Lenient empty root ⇒ a default-seeded model. - auto empty = ViewModel::deserialize("{}"); + auto empty = ViewModeModel::deserialize("{}"); CHECK(empty.has_value()); CHECK(empty && empty->modes().size() == 2); CHECK(empty && empty->activeModeId() == kArrangeModeId); @@ -352,11 +353,36 @@ static void testMalformedJson() { "{\"modes\":[]}trailing", // trailing garbage }; for (const char* j : bad) { - auto r = ViewModel::deserialize(j); + auto r = ViewModeModel::deserialize(j); CHECK(!r.has_value()); } } +// -- 7. planToggle park path: fxOffline is empty (shell-expands-FX contract) -- +// +// planToggle calls makeParkPlan(guid, /*fxCount=*/0) for each inactive leaf. +// The D2 shell is responsible for expanding per-FX offline ops using +// TrackFX_GetCount — the pure model has no access to REAPER FX counts at plan +// time. This test pins that contract so a regression that passes a non-zero +// count (and emits FX ops prematurely) is caught immediately. +// The direct makeParkPlan(guid, 3) path (non-zero fxCount) is covered by +// testRestoreRoundTripSnapshotValues above. + +static void testPlanToggleParkHasEmptyFxOffline() { + ViewModeModel vm; + FolderTree tree; + tree.nodes.push_back(FolderNode{"{LEAF}", "", false}); + vm.membership().tag("{LEAF}", kDesignModeId); + + // Toggle to Arrange: {LEAF} is inactive ⇒ park plan emitted. + auto plan = vm.planToggle(tree, kArrangeModeId); + CHECK(plan.park.size() == 1); + // The park plan must have an empty fxOffline — the shell expands FX ops. + CHECK(plan.park[0].fxOffline.empty()); + // Scalar flags must still be present (the four park zeros). + CHECK(plan.park[0].flags.size() == 4); +} + int main() { testNModeRegistryAndMembership(); testParentDerivationMultiMode(); @@ -366,6 +392,7 @@ int main() { testJsonRoundTrip(); testEmptyModelRoundTrip(); testMalformedJson(); + testPlanToggleParkHasEmptyFxOffline(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0;