Merge Phase D2: view shell — apply mode toggle to live project
This commit is contained in:
@@ -54,6 +54,17 @@ target_include_directories(bank_grid PUBLIC src)
|
|||||||
add_library(view_mode_model STATIC src/view_mode_model.cpp)
|
add_library(view_mode_model STATIC src/view_mode_model.cpp)
|
||||||
target_include_directories(view_mode_model PUBLIC src)
|
target_include_directories(view_mode_model PUBLIC src)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2d) Pure view_tree library — NO REAPER, NO SWELL. The one testable-outside-DAW
|
||||||
|
# piece of the D2 view shell: turning REAPER's linear I_FOLDERDEPTH stream into
|
||||||
|
# the parent<->child FolderTree the model consumes. The REAPER reads stay in
|
||||||
|
# view.cpp; this fiddly folder-depth walk is unit-tested here (mirrors
|
||||||
|
# capture_paths splitting the path math out of the capture shell).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
add_library(view_tree STATIC src/view_tree.cpp)
|
||||||
|
target_include_directories(view_tree PUBLIC src)
|
||||||
|
target_link_libraries(view_tree PUBLIC view_mode_model)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
# 3) Standalone tests for the pure modules (run without launching REAPER).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -78,6 +89,10 @@ add_executable(view_mode_model_tests tests/test_view_mode_model.cpp)
|
|||||||
target_link_libraries(view_mode_model_tests PRIVATE view_mode_model)
|
target_link_libraries(view_mode_model_tests PRIVATE view_mode_model)
|
||||||
add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests)
|
add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests)
|
||||||
|
|
||||||
|
add_executable(view_tree_tests tests/test_view_tree.cpp)
|
||||||
|
target_link_libraries(view_tree_tests PRIVATE view_tree)
|
||||||
|
add_test(NAME view_tree_tests COMMAND view_tree_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).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -98,6 +113,9 @@ add_library(reaper_reasampler MODULE
|
|||||||
src/persist.cpp
|
src/persist.cpp
|
||||||
src/bank_panel.cpp
|
src/bank_panel.cpp
|
||||||
${LICE_SRC}
|
${LICE_SRC}
|
||||||
|
src/view_mode_model.cpp
|
||||||
|
src/view_tree.cpp
|
||||||
|
src/view.cpp
|
||||||
)
|
)
|
||||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model)
|
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model)
|
||||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
|
|||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
// view.cpp — REAPER-facing Design View shell (Phase D2). See view.h.
|
||||||
|
//
|
||||||
|
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
|
||||||
|
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API
|
||||||
|
// pointers; here they are extern (CLAUDE.md §contract).
|
||||||
|
//
|
||||||
|
// The tree arithmetic (I_FOLDERDEPTH -> FolderTree) lives in the pure view_tree
|
||||||
|
// module so it is unit-tested outside the DAW; this file owns only the REAPER
|
||||||
|
// reads/writes and the snapshot-before-park ordering.
|
||||||
|
|
||||||
|
#include "view.h"
|
||||||
|
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "view_tree.h"
|
||||||
|
|
||||||
|
#define REAPERAPI_MINIMAL
|
||||||
|
#define REAPERAPI_WANT_CountTracks
|
||||||
|
#define REAPERAPI_WANT_GetTrack
|
||||||
|
#define REAPERAPI_WANT_GetTrackGUID
|
||||||
|
#define REAPERAPI_WANT_guidToString
|
||||||
|
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
||||||
|
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
|
||||||
|
#define REAPERAPI_WANT_TrackFX_GetCount
|
||||||
|
#define REAPERAPI_WANT_TrackFX_GetOffline
|
||||||
|
#define REAPERAPI_WANT_TrackFX_SetOffline
|
||||||
|
#define REAPERAPI_WANT_Undo_BeginBlock2
|
||||||
|
#define REAPERAPI_WANT_Undo_EndBlock2
|
||||||
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// REAPER's GUID -> string form. guidToString needs a 64-byte destination (SDK
|
||||||
|
// contract); the canonical "{XXXXXXXX-...}" string is the membership-index key the
|
||||||
|
// actions layer tags with, so the tree keys and the model keys align exactly.
|
||||||
|
std::string trackGuidString(MediaTrack* tr) {
|
||||||
|
GUID* g = GetTrackGUID(tr);
|
||||||
|
if (!g) return {};
|
||||||
|
char buf[64] = {0};
|
||||||
|
guidToString(g, buf);
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parmname for each planner Flag. All four are documented bool*/int* track
|
||||||
|
// info params driven through the double-valued Get/SetMediaTrackInfo_Value API.
|
||||||
|
const char* flagParm(Flag f) {
|
||||||
|
switch (f) {
|
||||||
|
case Flag::ShowInTcp: return "B_SHOWINTCP";
|
||||||
|
case Flag::ShowInMixer: return "B_SHOWINMIXER";
|
||||||
|
case Flag::MainSend: return "B_MAINSEND";
|
||||||
|
case Flag::FxEnable: return "I_FXEN";
|
||||||
|
}
|
||||||
|
return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the arrange-ordered track list and their I_FOLDERDEPTH, keyed by GUID.
|
||||||
|
// The master track is NOT enumerated by GetTrack (index space is the non-master
|
||||||
|
// tracks), so it can never enter the tree — the master-untouched invariant holds
|
||||||
|
// by construction. Also caches the MediaTrack* per GUID so later apply steps
|
||||||
|
// resolve a GUID back to its handle without a second linear scan.
|
||||||
|
std::vector<TrackFolderEntry> readFolderEntries(
|
||||||
|
ReaProject* proj,
|
||||||
|
std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid) {
|
||||||
|
std::vector<TrackFolderEntry> entries;
|
||||||
|
int count = CountTracks(proj);
|
||||||
|
entries.reserve(static_cast<std::size_t>(count));
|
||||||
|
handleByGuid.reserve(static_cast<std::size_t>(count));
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
MediaTrack* tr = GetTrack(proj, i);
|
||||||
|
if (!tr) continue;
|
||||||
|
std::string guid = trackGuidString(tr);
|
||||||
|
if (guid.empty()) continue;
|
||||||
|
int depth = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"));
|
||||||
|
entries.push_back(TrackFolderEntry{guid, depth});
|
||||||
|
handleByGuid.emplace_back(guid, tr);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& handleByGuid,
|
||||||
|
const std::string& guid) {
|
||||||
|
for (const auto& kv : handleByGuid) {
|
||||||
|
if (kv.first == guid) return kv.second;
|
||||||
|
}
|
||||||
|
return nullptr; // stale/deleted GUID — pruned by being skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captures a track's prior driven-flag state BEFORE it is parked. Reads only the
|
||||||
|
// four owned flags + per-FX offline; never B_MUTE/I_SOLO, never the master (not
|
||||||
|
// reachable here). ints preserve whatever REAPER reported (defensive per D1's
|
||||||
|
// TrackSnapshot contract).
|
||||||
|
TrackSnapshot snapshotTrack(MediaTrack* tr) {
|
||||||
|
TrackSnapshot snap;
|
||||||
|
snap.showInTcp = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINTCP"));
|
||||||
|
snap.showInMixer = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_SHOWINMIXER"));
|
||||||
|
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
|
||||||
|
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
|
||||||
|
|
||||||
|
int fxCount = TrackFX_GetCount(tr);
|
||||||
|
snap.fxOffline.reserve(static_cast<std::size_t>(fxCount));
|
||||||
|
for (int fx = 0; fx < fxCount; ++fx) {
|
||||||
|
snap.fxOffline.push_back(TrackFX_GetOffline(tr, fx) ? 1 : 0);
|
||||||
|
}
|
||||||
|
return snap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies the planner's scalar-flag writes. B_* are bool* params, I_FXEN is int*,
|
||||||
|
// all driven through the double API — marshal the plan's int value to double.
|
||||||
|
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) {
|
||||||
|
for (const TrackFlagOp& op : flags) {
|
||||||
|
SetMediaTrackInfo_Value(tr, flagParm(op.flag), static_cast<double>(op.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parks a track's FX offline: the pure park plan leaves fxOffline empty by design;
|
||||||
|
// the shell expands it from the live FX count and offlines every slot.
|
||||||
|
void parkFxOffline(MediaTrack* tr) {
|
||||||
|
int fxCount = TrackFX_GetCount(tr);
|
||||||
|
for (int fx = 0; fx < fxCount; ++fx) {
|
||||||
|
TrackFX_SetOffline(tr, fx, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores per-FX offline from the snapshot verbatim — each slot back to its
|
||||||
|
// captured value, never a blanket "online". Bounds-checked against the live FX
|
||||||
|
// count in case the plugin chain changed while parked (prune-safe).
|
||||||
|
//
|
||||||
|
// HAZARD (deferred, PLAN "reconcile on delete/restructure"): the remap is by
|
||||||
|
// slot INDEX, not plugin identity. If the FX chain changed while the track was
|
||||||
|
// parked, snapshot slot k is restored onto whatever plugin now occupies slot k —
|
||||||
|
// the bounds-check guards against out-of-range, not against a reshuffled chain.
|
||||||
|
// Acceptable for D2; full identity-based reconciliation is future hardening.
|
||||||
|
void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
|
||||||
|
int fxCount = TrackFX_GetCount(tr);
|
||||||
|
for (const FxOfflineOp& op : fxOffline) {
|
||||||
|
if (op.fxIndex < 0 || op.fxIndex >= fxCount) continue;
|
||||||
|
TrackFX_SetOffline(tr, op.fxIndex, op.offline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) {
|
||||||
|
// Reject an unregistered target before touching the project (no partial apply).
|
||||||
|
if (!model.modes().contains(targetModeId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<std::string, MediaTrack*>> handleByGuid;
|
||||||
|
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid);
|
||||||
|
FolderTree tree = buildFolderTree(entries);
|
||||||
|
|
||||||
|
TogglePlan plan = model.planToggle(tree, targetModeId);
|
||||||
|
|
||||||
|
Undo_BeginBlock2(proj);
|
||||||
|
|
||||||
|
// PARK: snapshot BEFORE mutating, store into the model (so restore survives a
|
||||||
|
// save-while-parked), then apply the park writes + expand the FX-offline loop.
|
||||||
|
for (const TrackPlan& tp : plan.park) {
|
||||||
|
// Every op in a TrackPlan targets the same track; take the guid from the
|
||||||
|
// first flag op (the pure park plan always emits the four flag ops).
|
||||||
|
if (tp.flags.empty()) continue;
|
||||||
|
const std::string& guid = tp.flags.front().guid;
|
||||||
|
MediaTrack* tr = resolve(handleByGuid, guid);
|
||||||
|
if (!tr) continue; // stale GUID — prune
|
||||||
|
|
||||||
|
model.storeSnapshot(guid, snapshotTrack(tr));
|
||||||
|
applyFlags(tr, tp.flags);
|
||||||
|
parkFxOffline(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESTORE: apply the snapshot-sourced flag + per-FX offline writes verbatim,
|
||||||
|
// then drop the now-consumed snapshot so a re-park recaptures fresh state.
|
||||||
|
for (const TrackPlan& tp : plan.restore) {
|
||||||
|
if (tp.flags.empty()) continue;
|
||||||
|
const std::string& guid = tp.flags.front().guid;
|
||||||
|
MediaTrack* tr = resolve(handleByGuid, guid);
|
||||||
|
if (!tr) continue; // stale GUID — prune
|
||||||
|
|
||||||
|
applyFlags(tr, tp.flags);
|
||||||
|
restoreFxOffline(tr, tp.fxOffline);
|
||||||
|
model.clearSnapshot(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PARENT VISIBILITY (derived, never parked): a folder is visible iff at least
|
||||||
|
// one of its descendant leaves is visible in the target mode. That derivation is
|
||||||
|
// pure (membership + active mode), so it is recomputed every toggle rather than
|
||||||
|
// snapshotted — the four leaf-park flags don't apply to parents. Drive only the
|
||||||
|
// two visibility flags; never touch B_MAINSEND/I_FXEN/FX-offline on a parent.
|
||||||
|
std::set<std::string> visible = model.visibleTracks(tree, targetModeId);
|
||||||
|
for (const FolderNode& node : tree.nodes) {
|
||||||
|
if (!node.isParent) continue;
|
||||||
|
MediaTrack* tr = resolve(handleByGuid, node.guid);
|
||||||
|
if (!tr) continue; // stale GUID — prune
|
||||||
|
double show = visible.count(node.guid) ? 1.0 : 0.0;
|
||||||
|
SetMediaTrackInfo_Value(tr, "B_SHOWINTCP", show);
|
||||||
|
SetMediaTrackInfo_Value(tr, "B_SHOWINMIXER", show);
|
||||||
|
}
|
||||||
|
|
||||||
|
model.setActiveMode(targetModeId);
|
||||||
|
|
||||||
|
Undo_EndBlock2(proj, "ReaSampler: apply Design View mode", -1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#pragma once
|
||||||
|
// view — the REAPER-facing shell of the Design View feature (Phase D2). It is the
|
||||||
|
// mirror of the capture shell: the ViewModeModel (pure, D1) holds the mode/
|
||||||
|
// membership/snapshot state and emits the toggle plan; this shell reads the live
|
||||||
|
// project's folder tree, snapshots the tracks it is about to park, runs the model's
|
||||||
|
// planner, and applies the resulting flag + per-FX-offline writes to REAPER.
|
||||||
|
//
|
||||||
|
// It includes view_mode_model (pure) but NO REAPER headers — the .cpp is the one
|
||||||
|
// REAPER-facing translation unit (CLAUDE.md §contract: only main.cpp defines the
|
||||||
|
// API pointers; every other .cpp gets them extern). Callers (persist, actions)
|
||||||
|
// depend on this seam without dragging the SDK into their include sites.
|
||||||
|
//
|
||||||
|
// Hard invariants this shell enforces (CONTEXT.md §Design View, precision
|
||||||
|
// invariants) — verified in self-review, never crossed:
|
||||||
|
// * Never touches the master track's visibility (SDK forbids B_SHOWINTCP/
|
||||||
|
// B_SHOWINMIXER on master); the master is never a node in the tree.
|
||||||
|
// * Never reads or writes B_MUTE / I_SOLO on any track.
|
||||||
|
// * Acts only on tracks the model owns (tagged leaves the planner names) plus
|
||||||
|
// derived parents' visibility — never an untagged track's owned flags.
|
||||||
|
// * Snapshots every to-be-parked track's prior flags BEFORE parking, storing
|
||||||
|
// them into the model so restore is faithful and survives a save-while-parked.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "view_mode_model.h"
|
||||||
|
|
||||||
|
// REAPER's opaque project handle. Forward-declared to keep this header SDK-free;
|
||||||
|
// the .cpp includes reaper_plugin_functions.h and sees the real class.
|
||||||
|
class ReaProject;
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// Applies `targetModeId` to the live project `proj`:
|
||||||
|
// 1. Reads the arrange-ordered track list, builds the FolderTree from
|
||||||
|
// I_FOLDERDEPTH (via the pure buildFolderTree helper).
|
||||||
|
// 2. Runs model.planToggle(tree, targetModeId).
|
||||||
|
// 3. For each track about to be PARKED: snapshots its current B_SHOWINTCP /
|
||||||
|
// B_SHOWINMIXER / B_MAINSEND / I_FXEN and per-FX offline state, stores the
|
||||||
|
// snapshot into the model, THEN applies the park writes (expanding the
|
||||||
|
// per-FX offline loop from TrackFX_GetCount, which the pure plan leaves empty).
|
||||||
|
// 4. For each track to RESTORE: applies the plan's snapshot-sourced flag + per-FX
|
||||||
|
// offline writes verbatim.
|
||||||
|
// 5. For each PARENT (folder) node: drives B_SHOWINTCP / B_SHOWINMIXER to 1 if the
|
||||||
|
// parent is in model.visibleTracks(tree, targetModeId), else 0 — derived from
|
||||||
|
// membership, never parked/snapshotted. Only the two visibility flags.
|
||||||
|
// 6. Sets the model's active mode to `targetModeId`.
|
||||||
|
// All track mutations are wrapped in Undo_BeginBlock2 / Undo_EndBlock2.
|
||||||
|
//
|
||||||
|
// Returns false (no mutation, active mode unchanged) if `targetModeId` is not a
|
||||||
|
// registered mode. `proj` may be nullptr to mean REAPER's current project.
|
||||||
|
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// view_tree — pure folder-depth walk. See view_tree.h.
|
||||||
|
|
||||||
|
#include "view_tree.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
FolderTree buildFolderTree(const std::vector<TrackFolderEntry>& entries) {
|
||||||
|
FolderTree tree;
|
||||||
|
tree.nodes.reserve(entries.size());
|
||||||
|
|
||||||
|
// Stack of currently-open folder-parent GUIDs. The top is the immediate parent
|
||||||
|
// of the next track. A folder-parent track opens its folder AFTER contributing
|
||||||
|
// its own node (its own parent is the enclosing folder), so the push trails the
|
||||||
|
// assignment. A closing track belongs to the folder it closes, so the pop also
|
||||||
|
// trails the assignment.
|
||||||
|
std::vector<std::string> open;
|
||||||
|
|
||||||
|
for (const TrackFolderEntry& e : entries) {
|
||||||
|
FolderNode node;
|
||||||
|
node.guid = e.guid;
|
||||||
|
node.parentGuid = open.empty() ? std::string{} : open.back();
|
||||||
|
node.isParent = e.folderDepth == 1;
|
||||||
|
tree.nodes.push_back(node);
|
||||||
|
|
||||||
|
if (e.folderDepth == 1) {
|
||||||
|
open.push_back(e.guid); // this track's folder opens for what follows
|
||||||
|
} else if (e.folderDepth < 0) {
|
||||||
|
// Closes |folderDepth| levels after this (already-assigned) track.
|
||||||
|
// Clamp to the stack size so a malformed/stale depth stream can't
|
||||||
|
// underflow — the walk stays total.
|
||||||
|
int levels = -e.folderDepth;
|
||||||
|
while (levels-- > 0 && !open.empty()) {
|
||||||
|
open.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
// view_tree — the ONE genuinely pure piece of the D2 view shell: turning REAPER's
|
||||||
|
// linear I_FOLDERDEPTH stream into the parent<->child FolderTree the pure model
|
||||||
|
// consumes. The REAPER reads (GetTrack / GetTrackGUID / I_FOLDERDEPTH) stay in
|
||||||
|
// view.cpp; this tree arithmetic is REAPER-free so the fiddly folder-depth walk is
|
||||||
|
// unit-tested outside the DAW (mirrors capture_paths splitting the path math out).
|
||||||
|
//
|
||||||
|
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||||
|
// vendor/ includes. Standard library + view_mode_model.h (for FolderTree) only.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "view_mode_model.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// One track's contribution to the folder walk, read from REAPER in arrange order.
|
||||||
|
// folderDepth is I_FOLDERDEPTH verbatim: 0 = normal, 1 = folder parent (opens a
|
||||||
|
// folder after this track), <0 = closes |folderDepth| folder levels after this
|
||||||
|
// track (-1 last in innermost, -2 last in innermost + next-innermost, ...).
|
||||||
|
struct TrackFolderEntry {
|
||||||
|
std::string guid;
|
||||||
|
int folderDepth = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walks the ordered entries, tracking the open-folder stack, and assigns each
|
||||||
|
// node its immediate parentGuid (empty = top level) and isParent (opens a folder).
|
||||||
|
// Pure and total: tolerates malformed depth streams (a close deeper than the stack
|
||||||
|
// is clamped to empty) so a corrupt/stale project can never fault the shell.
|
||||||
|
FolderTree buildFolderTree(const std::vector<TrackFolderEntry>& entries);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Standalone tests for reasampler::buildFolderTree — no REAPER, no framework.
|
||||||
|
// The D2 view shell is DAW-bound and only verifiable in REAPER; this covers the
|
||||||
|
// one genuinely pure piece: the I_FOLDERDEPTH walk that turns REAPER's linear
|
||||||
|
// track stream into the parent<->child FolderTree the pure model consumes.
|
||||||
|
|
||||||
|
#include "../src/view_tree.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)
|
||||||
|
|
||||||
|
// Find a node by guid; returns nullptr if absent.
|
||||||
|
static const FolderNode* nodeOf(const FolderTree& t, const std::string& guid) {
|
||||||
|
for (const auto& n : t.nodes)
|
||||||
|
if (n.guid == guid) return &n;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All top-level leaves: three depth-0 tracks, no folders. Every parentGuid empty,
|
||||||
|
// nothing is a parent, order preserved.
|
||||||
|
static void testFlatLeaves() {
|
||||||
|
FolderTree t = buildFolderTree({{"a", 0}, {"b", 0}, {"c", 0}});
|
||||||
|
CHECK(t.nodes.size() == 3);
|
||||||
|
CHECK(t.nodes[0].guid == "a" && t.nodes[1].guid == "b" && t.nodes[2].guid == "c");
|
||||||
|
for (const auto& n : t.nodes) {
|
||||||
|
CHECK(n.parentGuid.empty());
|
||||||
|
CHECK(!n.isParent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One folder: parent P (depth 1) contains child C, which closes the folder (-1).
|
||||||
|
// P is a parent and is itself top-level; C's parent is P.
|
||||||
|
static void testSingleFolder() {
|
||||||
|
FolderTree t = buildFolderTree({{"P", 1}, {"C", -1}});
|
||||||
|
const FolderNode* p = nodeOf(t, "P");
|
||||||
|
const FolderNode* c = nodeOf(t, "C");
|
||||||
|
CHECK(p && c);
|
||||||
|
CHECK(p->isParent);
|
||||||
|
CHECK(p->parentGuid.empty()); // the folder parent is itself top-level
|
||||||
|
CHECK(!c->isParent);
|
||||||
|
CHECK(c->parentGuid == "P"); // child sits under the folder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder with two children; a following top-level leaf must land back at the root,
|
||||||
|
// proving the close (-1) popped the folder off the stack.
|
||||||
|
static void testFolderThenSibling() {
|
||||||
|
FolderTree t = buildFolderTree({{"P", 1}, {"C1", 0}, {"C2", -1}, {"S", 0}});
|
||||||
|
CHECK(nodeOf(t, "C1")->parentGuid == "P");
|
||||||
|
CHECK(nodeOf(t, "C2")->parentGuid == "P");
|
||||||
|
CHECK(nodeOf(t, "S")->parentGuid.empty()); // popped back to root
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested folders: P > Q > leaf. The inner leaf closes BOTH folders at once (-2).
|
||||||
|
// A sibling after it returns to the root.
|
||||||
|
static void testNestedDeepClose() {
|
||||||
|
FolderTree t = buildFolderTree({{"P", 1}, {"Q", 1}, {"L", -2}, {"S", 0}});
|
||||||
|
CHECK(nodeOf(t, "P")->isParent && nodeOf(t, "P")->parentGuid.empty());
|
||||||
|
CHECK(nodeOf(t, "Q")->isParent && nodeOf(t, "Q")->parentGuid == "P");
|
||||||
|
CHECK(nodeOf(t, "L")->parentGuid == "Q"); // deepest leaf under Q
|
||||||
|
CHECK(!nodeOf(t, "L")->isParent);
|
||||||
|
CHECK(nodeOf(t, "S")->parentGuid.empty()); // both levels popped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested with a stepwise close: inner folder closes (-1), then a sibling at the
|
||||||
|
// OUTER level, then that folder closes (-1). Proves partial pops track correctly.
|
||||||
|
static void testNestedStepwiseClose() {
|
||||||
|
FolderTree t = buildFolderTree(
|
||||||
|
{{"P", 1}, {"Q", 1}, {"L", -1}, {"M", -1}});
|
||||||
|
CHECK(nodeOf(t, "Q")->parentGuid == "P");
|
||||||
|
CHECK(nodeOf(t, "L")->parentGuid == "Q"); // L inside inner folder Q
|
||||||
|
CHECK(nodeOf(t, "M")->parentGuid == "P"); // after Q closed, M is under P
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed stream: a close deeper than the open stack must clamp to empty, never
|
||||||
|
// underflow. A following leaf is still top-level and the walk stays total.
|
||||||
|
static void testMalformedCloseClamps() {
|
||||||
|
FolderTree t = buildFolderTree({{"P", 1}, {"C", -5}, {"S", 0}});
|
||||||
|
CHECK(nodeOf(t, "C")->parentGuid == "P");
|
||||||
|
CHECK(nodeOf(t, "S")->parentGuid.empty()); // clamped, not underflowed
|
||||||
|
CHECK(t.nodes.size() == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty input yields an empty tree (no fault).
|
||||||
|
static void testEmpty() {
|
||||||
|
FolderTree t = buildFolderTree({});
|
||||||
|
CHECK(t.nodes.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty folder (parent immediately closes itself, depth accounting P=1 then a
|
||||||
|
// child that both belongs to and closes it). Here P opens and the sole child
|
||||||
|
// closes; verifies a folder with exactly one leaf child.
|
||||||
|
static void testSingleChildFolder() {
|
||||||
|
FolderTree t = buildFolderTree({{"P", 1}, {"only", -1}});
|
||||||
|
CHECK(nodeOf(t, "only")->parentGuid == "P");
|
||||||
|
CHECK(nodeOf(t, "P")->isParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testFlatLeaves();
|
||||||
|
testSingleFolder();
|
||||||
|
testFolderThenSibling();
|
||||||
|
testNestedDeepClose();
|
||||||
|
testNestedStepwiseClose();
|
||||||
|
testMalformedCloseClamps();
|
||||||
|
testEmpty();
|
||||||
|
testSingleChildFolder();
|
||||||
|
|
||||||
|
if (g_fail == 0) {
|
||||||
|
std::printf("view_tree: all tests passed\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("view_tree: %d checks FAILED\n", g_fail);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user