rename view_model -> view_mode_model; add shell-expands-FX test and parse-site comments

This commit is contained in:
2026-07-22 20:37:17 -04:00
parent 33ffb45a45
commit 25cfb62d46
4 changed files with 93 additions and 49 deletions
+7 -7
View File
@@ -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})
+35 -22
View File
@@ -1,4 +1,4 @@
#include "view_model.h"
#include "view_mode_model.h"
#include <algorithm>
#include <cerrno>
@@ -6,7 +6,7 @@
#include <cstdio>
#include <cstdlib>
// 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<std::string> ViewModel::visibleTracks(const FolderTree& tree,
std::set<std::string> ViewModeModel::visibleTracks(const FolderTree& tree,
const std::string& modeId) const {
std::set<std::string> visible;
@@ -189,7 +189,7 @@ std::set<std::string> 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<std::string, TrackSnapshot>& 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> ViewModel::deserialize(const std::string& json) {
ViewModel vm;
std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string& json) {
ViewModeModel vm;
Parser p(json);
if (!p.parseModel(vm)) return std::nullopt;
return vm;
+10 -6
View File
@@ -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<TrackPlan> 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<ViewModel> deserialize(const std::string& json);
static std::optional<ViewModeModel> deserialize(const std::string& json);
private:
ModeRegistry modes_;
@@ -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 <algorithm>
#include <cstdio>
@@ -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;