Merge dev into phase-b-multibank (integrate parallel M7/8 + Phase D work before dev promotion)

# Conflicts:
#	CLAUDE.md
#	CMakeLists.txt
#	src/actions.cpp
#	src/bank_panel.cpp
#	src/persist.h
This commit is contained in:
2026-07-25 23:30:44 -04:00
42 changed files with 5289 additions and 140 deletions
+53
View File
@@ -15,6 +15,7 @@
#include "../src/bank_grid.h"
#include <cmath>
#include <cstdio>
#include <string>
#include <vector>
@@ -322,6 +323,52 @@ static void testNavDegenerate() {
CHECK(selEq(navigate(Selection{{0}, 0, 0}, NavKey::Down, 0, 4, false), {1}, 1, 1));
}
// --- compressAmplitudeForDisplay ----------------------------------------------
// Full scale: magnitude 1.0 must reach the full display fraction exactly.
static void testCompressFullScale() {
CHECK(compressAmplitudeForDisplay(1.0f) == 1.0f);
CHECK(compressAmplitudeForDisplay(-1.0f) == -1.0f);
}
// Exact zero must stay on the midline (no log of zero; guards the singularity).
static void testCompressZeroIsMidline() {
CHECK(compressAmplitudeForDisplay(0.0f) == 0.0f);
}
// -20 dB (0.1 linear) and -40 dB (0.01 linear) must both produce clearly visible
// (non-zero) fractions, with -20 dB > -40 dB (monotonic), and both well above
// the midline (arbitrary threshold of 0.15 chosen conservatively — at a -60 dB
// floor, -20 dB normalizes to 2/3 and -40 dB to 1/3).
static void testCompressMidValuesVisible() {
const float f20 = compressAmplitudeForDisplay(0.1f); // -20 dBFS
const float f40 = compressAmplitudeForDisplay(0.01f); // -40 dBFS
CHECK(f20 > 0.15f); // clearly non-zero
CHECK(f40 > 0.15f); // clearly non-zero
CHECK(f20 > f40); // monotonic: louder -> taller bar
}
// At and below the floor (-60 dB = 0.001 linear) the result is ~0 (silence).
// We test at exactly the floor magnitude and well below it.
static void testCompressAtAndBelowFloor() {
// 0.001 == 10^(-60/20) is the floor ratio. Magnitude at or below it -> 0.
const float floorMag = std::pow(10.0f, kDisplayFloorDb / 20.0f); // ~0.001
CHECK(compressAmplitudeForDisplay(floorMag) == 0.0f);
CHECK(compressAmplitudeForDisplay(floorMag * 0.5f) == 0.0f);
CHECK(compressAmplitudeForDisplay(0.0001f) == 0.0f);
}
// Sign is preserved: negative input produces a negative fraction of the same
// magnitude as its positive counterpart.
static void testCompressSignPreserved() {
const float pos = compressAmplitudeForDisplay(0.1f);
const float neg = compressAmplitudeForDisplay(-0.1f);
CHECK(neg < 0.0f);
// Magnitudes must be equal (sign-symmetric).
const float diff = pos + neg; // pos - |neg|
CHECK(diff > -0.001f && diff < 0.001f);
}
int main() {
testColumnsForWidth();
testTooNarrowClampsToOneColumn();
@@ -355,6 +402,12 @@ int main() {
testNavFromEmptyFocusesFirst();
testNavDegenerate();
testCompressFullScale();
testCompressZeroIsMidline();
testCompressMidValuesVisible();
testCompressAtAndBelowFloor();
testCompressSignPreserved();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+194
View File
@@ -0,0 +1,194 @@
// Standalone tests for reasampler::newGuids + GuidBaseline — no REAPER, no test
// framework. Mirror of test_view_mode_model: iterate the hard logic outside the DAW.
//
// Covers (D2 Wave-2 new-content detection):
// 1. newGuids: current \ previous, empty-GUID filtering, determinism.
// 2. GuidBaseline first-poll guard: the first observe() after open reports NOTHING
// new (pre-existing content stays Arrange) and establishes the baseline.
// 3. Incremental detection: only GUIDs added since the prior observe() are returned.
// 4. Deletion drops from the baseline so a reused GUID is re-detected.
// 5. reset() (project switch) re-arms the first-poll guard: the next observe()
// re-baselines and reports nothing new — never diffs across projects.
#include "../src/guid_diff.h"
#include <cstdio>
#include <set>
#include <string>
#include <vector>
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)
static bool has(const std::vector<std::string>& v, const std::string& g) {
for (const auto& e : v) if (e == g) return true;
return false;
}
// -- 1. newGuids set difference ----------------------------------------------
static void testNewGuidsDifference() {
std::set<std::string> prev{"{A}", "{B}"};
std::set<std::string> cur{"{A}", "{B}", "{C}", "{D}"};
auto added = newGuids(prev, cur);
CHECK(added.size() == 2);
CHECK(has(added, "{C}"));
CHECK(has(added, "{D}"));
CHECK(!has(added, "{A}")); // pre-existing, not new
CHECK(!has(added, "{B}"));
// No change ⇒ nothing new.
CHECK(newGuids(cur, cur).empty());
// A removed GUID is not "new" (it is absent from current).
std::set<std::string> shrunk{"{A}"};
CHECK(newGuids(prev, shrunk).empty());
// Determinism: ascending set order.
std::set<std::string> p2;
std::set<std::string> c2{"{Z}", "{A}", "{M}"};
auto ordered = newGuids(p2, c2);
CHECK(ordered.size() == 3);
CHECK(ordered[0] == "{A}" && ordered[1] == "{M}" && ordered[2] == "{Z}");
}
static void testNewGuidsIgnoresEmpty() {
std::set<std::string> prev{"{A}"};
std::set<std::string> cur{"", "{A}", "{B}"}; // empty ⇒ a GUID-read failure
auto added = newGuids(prev, cur);
CHECK(added.size() == 1);
CHECK(has(added, "{B}"));
CHECK(!has(added, "")); // never tag an empty GUID
}
// -- 2. First-poll guard -----------------------------------------------------
static void testBaselineFirstPollReportsNothing() {
GuidBaseline b;
CHECK(!b.primed());
// First observe after open: pre-existing content must NOT be tagged.
auto first = b.observe({"{A}", "{B}", "{C}"});
CHECK(first.empty()); // nothing new at open
CHECK(b.primed());
}
// -- 3. Incremental detection ------------------------------------------------
static void testBaselineIncremental() {
GuidBaseline b;
b.observe({"{A}", "{B}"}); // baseline
auto t1 = b.observe({"{A}", "{B}", "{C}"});
CHECK(t1.size() == 1 && has(t1, "{C}")); // only the newly-added GUID
// Next tick with a further addition — earlier-added {C} is now baseline.
auto t2 = b.observe({"{A}", "{B}", "{C}", "{D}"});
CHECK(t2.size() == 1 && has(t2, "{D}"));
CHECK(!has(t2, "{C}"));
// A steady state reports nothing new.
CHECK(b.observe({"{A}", "{B}", "{C}", "{D}"}).empty());
}
// -- 4. Deletion drops from baseline; reused GUID re-detected ----------------
static void testBaselineDeletionReDetect() {
GuidBaseline b;
b.observe({"{A}", "{B}"});
// Delete {B}: not "new", and drops out of the baseline.
CHECK(b.observe({"{A}"}).empty());
// {B} reappears (REAPER reused the GUID or the user re-added) ⇒ detected again.
auto again = b.observe({"{A}", "{B}"});
CHECK(again.size() == 1 && has(again, "{B}"));
}
// -- 5. reset() re-arms the first-poll guard (project switch) -----------------
static void testResetReBaselines() {
GuidBaseline b;
b.observe({"{A}"}); // project 1 baseline
b.observe({"{A}", "{B}"}); // {B} detected in project 1
b.reset();
CHECK(!b.primed());
// Switching to project 2: its pre-existing content must NOT be mass-tagged even
// though those GUIDs were never seen before reset.
auto afterSwitch = b.observe({"{X}", "{Y}", "{Z}"});
CHECK(afterSwitch.empty()); // re-baselined, nothing new
CHECK(b.primed());
// Content created in project 2 after the switch IS detected.
auto p2new = b.observe({"{X}", "{Y}", "{Z}", "{W}"});
CHECK(p2new.size() == 1 && has(p2new, "{W}"));
}
// -- 6. Reload-mis-tag regression: a project LOAD must re-baseline before the first
// post-load observe, so the newly-loaded project's PRE-EXISTING content is never
// reported as new. This locks the exact failure behind the reload-mis-tag bug:
// the detector used to re-arm on a `proj != lastProject` pointer compare, which a
// recycled ReaProject* address defeats; the previous project's stale baseline then
// reported the whole just-loaded project as new content and it got mass-tagged into
// the active mode. The fix routes the re-arm through persist's authoritative load
// signal (bankPanelNotifyProjectLoaded -> reset()), modeled here as: on a load,
// reset() runs BEFORE the first observe of the new project's set.
//
// The seam under test is GuidBaseline; the shell wiring (main.cpp notify ->
// bank_panel reset()) is DAW-verified, but the load-then-observe DECISION lives
// here and is what the bug got wrong.
static void testReloadReBaselinesBeforeFirstObserve() {
// Project A is open and settled: its content is the baseline, steady state reports
// nothing new. This is the "extension already running against project A" precondition
// the bug needs (a NON-empty stale baseline to mis-diff the next project against).
GuidBaseline b;
b.observe({"{A1}", "{A2}"}); // A baseline (first-poll guard)
CHECK(b.observe({"{A1}", "{A2}"}).empty()); // steady: nothing new
CHECK(b.primed());
// Daniel opens project B (saved in Design). B's pre-existing tracks are an ENTIRELY
// different GUID set from A. persist raises its load signal; the fix calls reset()
// (via bankPanelNotifyProjectLoaded) BEFORE the first post-load observe.
b.reset();
auto afterLoad = b.observe({"{B1}", "{B2}", "{B3}"});
// The load must tag NOTHING: B's pre-existing content is the baseline, not "new".
// Untagged/Arrange leaves stay Arrange; nothing is mass-tagged into Design.
CHECK(afterLoad.empty());
// And genuine post-load creation in B is still detected (the fix must not deafen the
// detector — only suppress the pre-existing set at the load boundary).
auto createdInB = b.observe({"{B1}", "{B2}", "{B3}", "{B4}"});
CHECK(createdInB.size() == 1 && has(createdInB, "{B4}"));
}
// -- 6b. Negative control: WITHOUT the load re-baseline (the old pointer-miss path where
// reset() never fired), the just-loaded project's pre-existing content IS reported
// as new — i.e. it would be mass-tagged. This proves the assertion in test 6 is
// load-bearing (the reset() is what prevents the mis-tag), not self-affirming.
static void testMissingReBaselineWouldMisTag() {
GuidBaseline b;
b.observe({"{A1}", "{A2}"}); // A baseline
b.observe({"{A1}", "{A2}"}); // settled against A
// Simulate the BUG: no reset() on the load (the pointer compare missed a recycled
// ReaProject*). The next observe diffs B's set against A's stale baseline.
auto misdetected = b.observe({"{B1}", "{B2}", "{B3}"});
// Every one of B's pre-existing tracks looks "new" — exactly the mass-tag that
// parked the Arrange tracks into Design on open. This is the failure the fix removes.
CHECK(misdetected.size() == 3);
CHECK(has(misdetected, "{B1}") && has(misdetected, "{B2}") && has(misdetected, "{B3}"));
}
int main() {
testNewGuidsDifference();
testNewGuidsIgnoresEmpty();
testBaselineFirstPollReportsNothing();
testBaselineIncremental();
testBaselineDeletionReDetect();
testResetReBaselines();
testReloadReBaselinesBeforeFirstObserve();
testMissingReBaselineWouldMisTag();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+113
View File
@@ -0,0 +1,113 @@
// Standalone tests for reasampler::lane_keys — no REAPER, no test framework. The pure
// managed/manual lane-name heuristic that resolves D2 design points #1 (auto-tag
// exemption) and #2 (durable lane identity vs ordinal renumber).
//
// Covers:
// 1. isManagedLaneName: only the "reasampler:" prefix is managed; everything else
// (empty, user comp names, near-miss prefixes) is manual.
// 2. managedLaneKey: managed name -> its durable key; manual/unnamed -> nullopt.
// 3. Round-trip: managedLaneKey(laneNameForMode(m)) == "reasampler:" + m, so the
// Wave-3 minting path and the read path cannot drift.
#include "../src/lane_keys.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)
static void testIsManagedLaneName() {
// Tool-minted managed names.
CHECK(isManagedLaneName("reasampler:design"));
CHECK(isManagedLaneName("reasampler:arrange"));
CHECK(isManagedLaneName("reasampler:")); // prefix alone still ours (odd but managed)
// Manual / user lanes are never managed.
CHECK(!isManagedLaneName("")); // unnamed lane ⇒ manual
CHECK(!isManagedLaneName("Comp 1")); // user comp lane
CHECK(!isManagedLaneName("Lead vocal"));
CHECK(!isManagedLaneName("reasample")); // near-miss, no colon ⇒ not ours
CHECK(!isManagedLaneName("Reasampler:design")); // case-sensitive prefix
CHECK(!isManagedLaneName(" reasampler:x")); // leading space ⇒ not a prefix match
}
static void testManagedLaneKey() {
// Managed lane: the durable name IS the key.
auto k = managedLaneKey("reasampler:design");
CHECK(k.has_value() && *k == "reasampler:design");
// Manual / unnamed lanes have no managed key (⇒ treated as manual, never driven).
CHECK(!managedLaneKey("").has_value());
CHECK(!managedLaneKey("Comp 1").has_value());
CHECK(!managedLaneKey("guitar-double").has_value());
}
static void testIsOnManualLane() {
// Non-fixed-lane track: concept does not apply regardless of name.
CHECK(!isOnManualLane(false, "")); // normal track, unnamed ⇒ not manual
CHECK(!isOnManualLane(false, "Comp 1")); // normal track, user name ⇒ not manual
CHECK(!isOnManualLane(false, "reasampler:design")); // normal track, managed name ⇒ not manual
// Fixed-lane track: managed lane (tool-prefixed) ⇒ NOT manual (tool drives it).
CHECK(!isOnManualLane(true, "reasampler:design"));
CHECK(!isOnManualLane(true, "reasampler:arrange"));
CHECK(!isOnManualLane(true, "reasampler:")); // prefix-only: still managed
// Fixed-lane track: unnamed lane (empty P_LANENAME) ⇒ manual.
// REAPER starts fixed lanes unnamed; an item on an unnamed fixed lane is a user
// comp lane and must be exempt from auto-tag.
CHECK(isOnManualLane(true, ""));
// Fixed-lane track: user-named but non-managed ⇒ manual.
CHECK(isOnManualLane(true, "Comp 1"));
CHECK(isOnManualLane(true, "Lead vocal"));
CHECK(isOnManualLane(true, "reasample")); // near-miss, no colon ⇒ manual
CHECK(isOnManualLane(true, "Reasampler:x")); // wrong case ⇒ manual
}
static void testRoundTrip() {
// Minting then reading must agree: managedLaneKey(laneNameForMode(m)) recovers the
// prefixed name for every mode id.
for (const std::string mode : {std::string("arrange"), std::string("design"),
std::string("mixdown")}) {
const std::string name = laneNameForMode(mode);
CHECK(name == "reasampler:" + mode);
CHECK(isManagedLaneName(name));
auto key = managedLaneKey(name);
CHECK(key.has_value() && *key == "reasampler:" + mode);
}
}
static void testModeIdFromLaneName() {
// The exact inverse of laneNameForMode: recover the owning mode from a managed name.
// Used by the Wave-3 load-time reconcile to rebuild ownership from durable names.
for (const std::string mode : {std::string("arrange"), std::string("design"),
std::string("mixdown"), std::string("mode:with:colons")}) {
auto recovered = modeIdFromLaneName(laneNameForMode(mode));
CHECK(recovered.has_value() && *recovered == mode); // modeIdFromLaneName∘laneNameForMode == id
}
// Manual / unnamed lanes carry no mode (⇒ left off the ownership index on reconcile).
CHECK(!modeIdFromLaneName("").has_value());
CHECK(!modeIdFromLaneName("Comp 1").has_value());
CHECK(!modeIdFromLaneName("Reasampler:design").has_value()); // wrong case ⇒ manual
// Prefix-only with no mode suffix is illegal for a managed lane ⇒ no mode recovered
// (defensive: reconcile skips it rather than recording an empty-mode ownership).
CHECK(!modeIdFromLaneName("reasampler:").has_value());
}
int main() {
testIsManagedLaneName();
testManagedLaneKey();
testIsOnManualLane();
testRoundTrip();
testModeIdFromLaneName();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+75
View File
@@ -278,6 +278,76 @@ static void testLargeBinCountOverflowGuard() {
CHECK(env[0][7].min == 0.0f && env[0][7].max == 0.0f);
}
// --- lastFrameAboveThreshold: the realtime tail's decay-scan boundary primitive --
// A mono decaying ramp: frame i has amplitude that falls linearly to zero. With a
// threshold set between two frames' levels, the last frame above it is deterministic.
static void testLastFrameDecayingRamp() {
// 10 mono frames, amplitude 1.0 - i*0.1: frame0=1.0 ... frame9=0.1.
std::vector<AudioSample> buf(10);
for (std::size_t i = 0; i < 10; ++i) buf[i] = 1.0f - 0.1f * (float)i;
// Threshold 0.35: frames 0..6 (levels 1.0..0.4) exceed it; frame 6 is the last
// (level 0.4 > 0.35), frame 7 (0.3) does not. Strict > semantics.
CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.35f) == 6);
// Threshold just under frame 9's level (0.1): the very last frame stays.
CHECK(lastFrameAboveThreshold(buf, 1, 10, 0.05f) == 9);
// Threshold above the loudest frame: nothing survives.
CHECK(lastFrameAboveThreshold(buf, 1, 10, 1.5f) == kNoFrameAboveThreshold);
}
// Pure silence at or below the threshold -> sentinel (the "trim back to end" case:
// no frame in the tail window exceeds -72 dB).
static void testLastFrameSilence() {
std::vector<AudioSample> zeros(20, 0.0f);
CHECK(lastFrameAboveThreshold(zeros, 2, 10, 0.001f) == kNoFrameAboveThreshold);
// A DC level exactly AT the threshold does not count (strict >).
std::vector<AudioSample> atThresh(8, 0.25f);
CHECK(lastFrameAboveThreshold(atThresh, 1, 8, 0.25f) == kNoFrameAboveThreshold);
}
// Every frame above the threshold (a non-decaying source): the last frame is the
// boundary — the caller keeps the whole window (the 8 s cap did its job).
static void testLastFrameAllAbove() {
std::vector<AudioSample> loud(12, 0.8f); // 6 stereo frames
CHECK(lastFrameAboveThreshold(loud, 2, 6, 0.1f) == 5);
}
// Per-frame peak is the MAX abs across channels (no fold): a frame with one loud
// channel and one silent channel is "above" on the strength of the loud one, and a
// negative sample is compared by magnitude.
static void testLastFramePerChannelMaxAbs() {
// 3 stereo frames. Frame0: (0.9, 0.0) loud L. Frame1: (0.0, -0.9) loud R (negative
// -> abs). Frame2: (0.05, -0.05) both quiet.
std::vector<AudioSample> buf = {0.9f, 0.0f, 0.0f, -0.9f, 0.05f, -0.05f};
// Threshold 0.5: frame2 is below (peak 0.05), frame1 is above (|-0.9|=0.9).
CHECK(lastFrameAboveThreshold(buf, 2, 3, 0.5f) == 1);
// If both channels of the last frame mattered independently, a fold-average
// (0.9+0.0)/2 = 0.45 on frame0 would fall below 0.5 — but frame0's L alone (0.9)
// is above, proving max-abs, not average. Lower the threshold to isolate frame0.
std::vector<AudioSample> f0 = {0.9f, 0.0f};
CHECK(lastFrameAboveThreshold(f0, 2, 1, 0.5f) == 0);
}
// Degenerate: zero channels, zero frames, and a frameCount that overstates the
// buffer (must clamp to available frames, no OOB read).
static void testLastFrameDegenerate() {
std::vector<AudioSample> buf = {0.5f, 0.5f, 0.5f, 0.5f}; // 2 stereo frames
CHECK(lastFrameAboveThreshold(buf, 0, 2, 0.1f) == kNoFrameAboveThreshold);
CHECK(lastFrameAboveThreshold(buf, 2, 0, 0.1f) == kNoFrameAboveThreshold);
std::vector<AudioSample> empty;
CHECK(lastFrameAboveThreshold(empty, 2, 10, 0.1f) == kNoFrameAboveThreshold);
// frameCount=100 but only 2 real stereo frames: clamps to frame 1 (the last real
// frame), which is above -> index 1, no read past the buffer.
CHECK(lastFrameAboveThreshold(buf, 2, 100, 0.1f) == 1);
}
int main() {
testSineEnvelope();
testRampMonotonic();
@@ -289,6 +359,11 @@ int main() {
testSingleBinWholeBuffer();
testDegenerateInputs();
testLargeBinCountOverflowGuard();
testLastFrameDecayingRamp();
testLastFrameSilence();
testLastFrameAllAbove();
testLastFramePerChannelMaxAbs();
testLastFrameDegenerate();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
+28
View File
@@ -126,6 +126,31 @@ static void testTailManualClampsToCap() {
CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0);
}
// --- realtimeRecordWindowEnd: the T2 record-window extension -----------------
static void testRealtimeWindowNoneIsExact() {
// None -> the exact range end, no extra recording (byte-identical to today).
CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 2000.0) == 12.5);
// manualTailMs is ignored for None.
CHECK(realtimeRecordWindowEnd(TailMode::None, 12.5, 0.0) == 12.5);
}
static void testRealtimeWindowAutoAddsCap() {
// Auto -> range end + the 8 s runaway cap (trimmed later by the decay scan).
CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 0.0) == 10.0 + kMaxTailSeconds);
// manualTailMs is ignored for Auto (the cap is fixed).
CHECK(realtimeRecordWindowEnd(TailMode::Auto, 10.0, 3000.0) == 10.0 + kMaxTailSeconds);
}
static void testRealtimeWindowManualAddsClampedLength() {
// Manual -> range end + the set length in seconds (fixed, no trim).
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 2000.0) == 5.0 + 2.0);
// Clamped to the 8 s cap: > 8000 ms -> +8 s.
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, 9000.0) == 5.0 + kMaxTailSeconds);
// Negative floors to 0 -> no extra window (never records before the range end).
CHECK(realtimeRecordWindowEnd(TailMode::Manual, 5.0, -100.0) == 5.0);
}
// --- parseRazorEdits: P_RAZOREDITS string -> ranges --------------------------
static void testParseSingleTrackAudioArea() {
@@ -267,6 +292,9 @@ int main() {
testAutoTrimRatioDerivesFromDb();
testTailManualFixedNoTrim();
testTailManualClampsToCap();
testRealtimeWindowNoneIsExact();
testRealtimeWindowAutoAddsCap();
testRealtimeWindowManualAddsClampedLength();
testParseSingleTrackAudioArea();
testParseMultipleAreas();
testParseSkipsEnvelopeLaneAreas();
+92 -2
View File
@@ -49,15 +49,53 @@ static void testManualClampCapsAtEightSeconds() {
CHECK(clampManualMs(-100.0) == 0.0);
}
// --- adjustManualMs: the scroll-wheel fine-adjust arithmetic ------------------
static void testAdjustUpAndDownBySteps() {
// Positive notches lengthen, negative shorten, in whole kManualStepMs increments.
CHECK(adjustManualMs(2000.0, 1, kManualStepMs) == 2250.0);
CHECK(adjustManualMs(2000.0, -1, kManualStepMs) == 1750.0);
CHECK(adjustManualMs(2000.0, 4, kManualStepMs) == 3000.0); // 4 * 250
CHECK(adjustManualMs(2000.0, 0, kManualStepMs) == 2000.0); // no notch, no move
}
static void testAdjustClampsAtUpperBound() {
// Scrolling up past the 8 s cap saturates AT the cap, never beyond.
CHECK(adjustManualMs(kMaxTailMs, 1, kManualStepMs) == kMaxTailMs);
CHECK(adjustManualMs(kMaxTailMs - 100.0, 10, kManualStepMs) == kMaxTailMs);
}
static void testAdjustClampsAtLowerBound() {
// Scrolling down past 0 floors at 0, never negative.
CHECK(adjustManualMs(0.0, -1, kManualStepMs) == 0.0);
CHECK(adjustManualMs(100.0, -10, kManualStepMs) == 0.0);
}
// --- tailToggleLabel: the exact strings the panel draws -----------------------
static void testLabelStringsPerMode() {
TailSetting off; off.mode = TailMode::None;
TailSetting autoM; autoM.mode = TailMode::Auto;
TailSetting man; man.mode = TailMode::Manual;
// Off/Auto carry NO length regardless of manualMs.
off.manualMs = 5000.0;
autoM.manualMs = 5000.0;
CHECK(tailToggleLabel(off) == "Tail: Off");
CHECK(tailToggleLabel(autoM) == "Tail: Auto");
CHECK(tailToggleLabel(man) == "Tail: Manual");
}
static void testManualLabelRendersLengthInSeconds() {
// Manual appends the length in seconds to one decimal — pin the format and the
// boundary values (0.0s, the 2 s default, the 8 s cap).
TailSetting man; man.mode = TailMode::Manual;
man.manualMs = 0.0;
CHECK(tailToggleLabel(man) == "Tail: Manual 0.0s");
man.manualMs = kDefaultManualTailMs; // 2000 ms
CHECK(tailToggleLabel(man) == "Tail: Manual 2.0s");
man.manualMs = kMaxTailMs; // 8000 ms
CHECK(tailToggleLabel(man) == "Tail: Manual 8.0s");
// An over-cap stored value renders at the CLAMPED length, never past the cap.
man.manualMs = kMaxTailMs + 3000.0;
CHECK(tailToggleLabel(man) == "Tail: Manual 8.0s");
}
static void testDefaultSettingIsOff() {
@@ -69,13 +107,65 @@ static void testDefaultSettingIsOff() {
CHECK(tailToggleLabel(s) == "Tail: Off");
}
// --- serialize/deserialize: per-project persistence round-trip ----------------
static bool settingsEqual(const TailSetting& a, const TailSetting& b) {
return a.mode == b.mode && a.manualMs == b.manualMs;
}
static void testRoundTripNoneDefault() {
TailSetting s; // None + 2 s default
auto back = deserializeTailSetting(serializeTailSetting(s));
CHECK(back.has_value());
CHECK(back && settingsEqual(*back, s));
}
static void testRoundTripManualArbitraryMs() {
// A non-round manual length must round-trip bit-for-bit (17-sig-digit emit).
TailSetting s; s.mode = TailMode::Manual; s.manualMs = 3141.592653589793;
auto back = deserializeTailSetting(serializeTailSetting(s));
CHECK(back.has_value());
CHECK(back && settingsEqual(*back, s));
}
static void testRoundTripAuto() {
TailSetting s; s.mode = TailMode::Auto; s.manualMs = 500.0;
auto back = deserializeTailSetting(serializeTailSetting(s));
CHECK(back.has_value());
CHECK(back && settingsEqual(*back, s));
}
static void testDeserializeEmptyIsDefault() {
// An absent/empty stored value (older project) -> nullopt, so the caller falls
// back to the default. This is the graceful-old-project path the brief requires.
CHECK(!deserializeTailSetting("").has_value());
}
static void testDeserializeMalformedIsDefault() {
// Garbage, a missing key, or an unknown mode enumerant -> nullopt (no crash).
CHECK(!deserializeTailSetting("not json at all").has_value());
CHECK(!deserializeTailSetting("{\"mode\":1}").has_value()); // manualMs missing
CHECK(!deserializeTailSetting("{\"manualMs\":2000}").has_value()); // mode missing
CHECK(!deserializeTailSetting("{\"mode\":9,\"manualMs\":2000}").has_value()); // bad enum
CHECK(!deserializeTailSetting("{\"mode\":x,\"manualMs\":2000}").has_value()); // non-numeric
}
int main() {
testCycleOrderIsNoneAutoManualNone();
testCycleThreeStepsReturnsToStart();
testManualClampInRangeIsUnchanged();
testManualClampCapsAtEightSeconds();
testAdjustUpAndDownBySteps();
testAdjustClampsAtUpperBound();
testAdjustClampsAtLowerBound();
testLabelStringsPerMode();
testManualLabelRendersLengthInSeconds();
testDefaultSettingIsOff();
testRoundTripNoneDefault();
testRoundTripManualArbitraryMs();
testRoundTripAuto();
testDeserializeEmptyIsDefault();
testDeserializeMalformedIsDefault();
if (g_fail == 0) std::printf("tail_control: all tests passed\n");
else std::printf("tail_control: %d CHECK(s) FAILED\n", g_fail);
+553
View File
@@ -16,6 +16,7 @@
// guards the in-DAW "all leaves hidden after toggling twice" regression.
#include "../src/view_mode_model.h"
#include "../src/lane_keys.h" // laneNameForMode — assert the minting plan's durable keys
#include <algorithm>
#include <cstdio>
@@ -940,6 +941,40 @@ static void testLaneOwnershipIndex() {
CHECK(!idx.remove("{T}", "l0"));
}
// -- D2.2b Last-writer-wins ownership replace (round-trip) --------------------
//
// setManual then setManaged on the SAME (guid, laneKey) must leave EXACTLY ONE
// managed entry — the ownership record is replaced, not accumulated. Guards the
// "retag a lane the tool now owns" contract and its persistence: the replace must
// survive a serialize/deserialize round-trip with no stray manual duplicate.
static void testLaneOwnershipLastWriterWins() {
ViewModeModel vm;
// Manual first, then managed on the same lane — the managed write replaces.
CHECK(vm.lanes().setManual("{T}", "l0"));
CHECK(vm.lanes().setManaged("{T}", "l0", kDesignModeId));
CHECK(vm.lanes().size() == 1); // one entry, not two
const LaneOwnership* o = vm.lanes().query("{T}", "l0");
CHECK(o && o->isManaged() && *o->managedMode == kDesignModeId);
// The reverse also replaces: managed -> manual leaves exactly one manual entry.
CHECK(vm.lanes().setManual("{T}", "l0"));
CHECK(vm.lanes().size() == 1);
const LaneOwnership* m = vm.lanes().query("{T}", "l0");
CHECK(m && m->isManual());
// Back to managed, then round-trip: exactly one managed entry survives, no stray
// manual duplicate resurrected by (de)serialization.
CHECK(vm.lanes().setManaged("{T}", "l0", kArrangeModeId));
auto back = ViewModeModel::deserialize(vm.serialize());
CHECK(back.has_value());
if (back) {
CHECK(back->lanes().size() == 1);
const LaneOwnership* r = back->lanes().query("{T}", "l0");
CHECK(r && r->isManaged() && *r->managedMode == kArrangeModeId);
}
}
// -- D2.3/D2.4 Managed-only: planner + query never emit a manual lane --------
//
// Required case: a track with a manual lane + managed mode lanes — neither the planner
@@ -1039,6 +1074,510 @@ static void testAutoTagDecision() {
}
}
// -- D2 W3-B item-level mode-move decision -----------------------------------
//
// planItemRetag: the pure decision behind the three item actions. A non-empty target
// tags each eligible selected item into it; an EMPTY target untags (→ Arrange default).
// Manual-lane items are EXEMPT (no op) and empty-GUID items are skipped.
// Find the single op for `guid`, or nullptr.
static const ItemRetagOp* retagOpFor(const std::vector<ItemRetagOp>& ops,
const std::string& guid) {
for (const auto& o : ops)
if (o.guid == guid) return &o;
return nullptr;
}
static void testPlanItemRetag() {
// Move -> Design: a managed-lane / normal item is tagged into Design (untag=false).
{
std::vector<RetagItem> sel{
RetagItem{"{A}", /*onManualLane=*/false},
RetagItem{"{B}", false},
};
auto ops = planItemRetag(sel, kDesignModeId);
CHECK(ops.size() == 2);
const ItemRetagOp* a = retagOpFor(ops, "{A}");
CHECK(a != nullptr);
CHECK(a && !a->untag); // a tag, not an untag
CHECK(a && a->modeId == kDesignModeId); // into Design specifically
const ItemRetagOp* b = retagOpFor(ops, "{B}");
CHECK(b && !b->untag && b->modeId == kDesignModeId);
}
// Empty target ⇒ UNTAG each item (Move -> Arrange / Untag items collapse to this).
// untag must be true and modeId empty — NOT a tag into "arrange".
{
std::vector<RetagItem> sel{ RetagItem{"{A}", false} };
auto ops = planItemRetag(sel, std::string{});
CHECK(ops.size() == 1);
const ItemRetagOp* a = retagOpFor(ops, "{A}");
CHECK(a != nullptr);
CHECK(a && a->untag); // an untag
CHECK(a && a->modeId.empty()); // no target mode carried on an untag
}
// Manual-lane exemption: a manual-lane item yields NO op — not for Move nor for Untag.
{
std::vector<RetagItem> sel{
RetagItem{"{NORMAL}", false},
RetagItem{"{MANUAL}", true}, // on a hand-managed lane ⇒ EXEMPT
};
auto design = planItemRetag(sel, kDesignModeId);
CHECK(design.size() == 1);
CHECK(retagOpFor(design, "{NORMAL}") != nullptr);
CHECK(retagOpFor(design, "{MANUAL}") == nullptr); // exempt — never retagged
auto untag = planItemRetag(sel, std::string{});
CHECK(untag.size() == 1);
CHECK(retagOpFor(untag, "{NORMAL}") != nullptr);
CHECK(retagOpFor(untag, "{MANUAL}") == nullptr); // exempt — never untagged
}
// Empty-GUID items are skipped defensively; empty selection ⇒ no ops.
{
std::vector<RetagItem> sel{ RetagItem{"", false}, RetagItem{"{A}", false} };
auto ops = planItemRetag(sel, kDesignModeId);
CHECK(ops.size() == 1);
CHECK(retagOpFor(ops, "{A}") != nullptr);
CHECK(planItemRetag({}, kDesignModeId).empty());
CHECK(planItemRetag({}, std::string{}).empty());
}
}
// -- D2 W3-B reconcile unregistered-mode guard (pure decision) ---------------
//
// reconcileManagedLanes (shell) recovers a lane's managed ownership from its durable
// name, but must NOT record ownership for a mode the registry no longer knows — a lane
// keyed to an unregistered mode can never become the active mode's lane and would stay
// silenced+hidden forever, orphaning its items. The guard's pure decision is exactly
// modeIdFromLaneName(name) ∈ modes(): this locks that composition so the shell's guard
// (which calls model.modes().contains(*mode)) cannot silently drift.
static void testReconcileUnregisteredModeGuardDecision() {
ViewModeModel vm; // seeds Arrange + Design only
// A managed lane naming a REGISTERED mode: mode decodes and IS contained ⇒ record.
{
const std::string name = laneNameForMode(kDesignModeId);
auto mode = modeIdFromLaneName(name);
CHECK(mode.has_value());
CHECK(vm.modes().contains(*mode)); // guard passes ⇒ shell records ownership
}
// A managed lane naming an UNREGISTERED mode: mode decodes but is NOT contained ⇒
// the guard rejects it and the shell leaves the lane off the index (manual-by-default).
{
const std::string name = laneNameForMode("removed_mode");
auto mode = modeIdFromLaneName(name);
CHECK(mode.has_value());
CHECK(*mode == "removed_mode");
CHECK(!vm.modes().contains(*mode)); // guard fails ⇒ shell must skip
}
}
// -- D2.7 Lane minting decision (Wave 3) -------------------------------------
//
// planLaneMinting: a track with content of only ONE mode is NOT split (D1 unchanged);
// a track that holds >1 mode's content mints one managed lane per mode and assigns EVERY
// managed-eligible item (incl. pre-existing) to its mode's lane; manual-lane items are
// exempt (never counted, never reassigned, their lane never minted-over).
static bool hasMint(const LaneMintPlan& p, const std::string& track,
const std::string& mode) {
for (const auto& m : p.mints)
if (m.trackGuid == track && m.modeId == mode &&
m.laneKey == laneNameForMode(mode))
return true;
return false;
}
static bool hasAssign(const LaneMintPlan& p, const std::string& item,
const std::string& track, const std::string& mode) {
for (const auto& a : p.assigns)
if (a.itemGuid == item && a.trackGuid == track &&
a.laneKey == laneNameForMode(mode))
return true;
return false;
}
static int splitLaneCount(const LaneMintPlan& p, const std::string& track) {
for (const auto& s : p.splits)
if (s.trackGuid == track) return s.laneCount;
return -1; // no split for this track
}
// A plain LEAF track (not a folder) carrying its own items, with no tree derivation:
// an empty model + empty tree means visibleTracks contributes nothing, so the ONLY
// trigger is the track's own-item mode span — exactly the W3-A behavior. These helpers
// keep the W3-A leaf tests reading against a neutral model/tree.
static const ViewModeModel& bareModel() { static ViewModeModel m; return m; }
static const FolderTree& emptyTree() { static FolderTree t; return t; }
static void testLaneMintingSingleModeNoSplit() {
// A track whose items all belong to ONE mode is NOT lane-split — D1 whole-track
// parking still separates the stances. No split, no mint, no assignment.
std::vector<LaneTrack> tracks{
LaneTrack{"{T}", {
LaneItem{"{i1}", kArrangeModeId, false},
LaneItem{"{i2}", kArrangeModeId, false},
}},
};
const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks);
CHECK(plan.empty());
CHECK(splitLaneCount(plan, "{T}") == -1);
// An empty track (no items) is likewise never split.
CHECK(planLaneMinting(bareModel(), emptyTree(), {LaneTrack{"{E}", {}}}).empty());
}
static void testLaneMintingMultiModeMintsAndAssignsAll() {
// A track that gained a second mode's item: it now holds Arrange + Design content.
// Both modes get a managed lane; ALL managed-eligible items are assigned — including
// the pre-existing Arrange item (retroactive lane assignment), not only the new one.
std::vector<LaneTrack> tracks{
LaneTrack{"{T}", {
LaneItem{"{arr1}", kArrangeModeId, false}, // pre-existing single-mode item
LaneItem{"{arr2}", kArrangeModeId, false}, // pre-existing single-mode item
LaneItem{"{des1}", kDesignModeId, false}, // the newly-added 2nd-mode item
}},
};
const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks);
CHECK(!plan.empty());
// One split with two managed lanes (one per involved mode).
CHECK(splitLaneCount(plan, "{T}") == 2);
CHECK(plan.mints.size() == 2);
CHECK(hasMint(plan, "{T}", kArrangeModeId));
CHECK(hasMint(plan, "{T}", kDesignModeId));
// EVERY managed-eligible item assigned to its mode's lane — pre-existing included.
CHECK(plan.assigns.size() == 3);
CHECK(hasAssign(plan, "{arr1}", "{T}", kArrangeModeId)); // retroactive
CHECK(hasAssign(plan, "{arr2}", "{T}", kArrangeModeId)); // retroactive
CHECK(hasAssign(plan, "{des1}", "{T}", kDesignModeId)); // the new item
}
static void testLaneMintingManualLaneExempt() {
// A track with Arrange + Design managed-eligible content AND an item the user placed
// on a manual lane: the manual item is EXEMPT — it is not counted, not assigned, and
// its lane is never minted-over. The managed split proceeds around it.
std::vector<LaneTrack> tracks{
LaneTrack{"{T}", {
LaneItem{"{arr}", kArrangeModeId, false},
LaneItem{"{des}", kDesignModeId, false},
LaneItem{"{comp}", kDesignModeId, /*onManualLane=*/true}, // user's comp take
}},
};
const LaneMintPlan plan = planLaneMinting(bareModel(), emptyTree(), tracks);
// Split for the two managed modes; the manual item never appears in assigns.
CHECK(splitLaneCount(plan, "{T}") == 2);
CHECK(plan.assigns.size() == 2);
CHECK(hasAssign(plan, "{arr}", "{T}", kArrangeModeId));
CHECK(hasAssign(plan, "{des}", "{T}", kDesignModeId));
for (const auto& a : plan.assigns)
CHECK(a.itemGuid != "{comp}"); // manual-lane item NEVER reassigned
// Manual-lane exemption can also SUPPRESS a split: if the ONLY second mode is
// supplied by a manual-lane item, the managed-eligible items are single-mode ⇒ NO
// split (the user's manual lane is not a mode the tool separates).
std::vector<LaneTrack> t2{
LaneTrack{"{U}", {
LaneItem{"{a}", kArrangeModeId, false},
LaneItem{"{d}", kDesignModeId, /*onManualLane=*/true}, // only 2nd mode, exempt
}},
};
// managed-eligible content is single-mode ⇒ no split (leaf, no tree derivation).
CHECK(planLaneMinting(bareModel(), emptyTree(), t2).empty());
}
static void testLaneMintingThreeModesAndOwnershipKeys() {
// N-mode proof + the ownership writes the shell will apply: three modes on one track
// mint three managed lanes, each keyed by its durable name (== laneNameForMode), each
// owning the right mode. Applying the mints to a real ownership index reproduces the
// managed classification the toggle planner then gates on.
ViewModeModel vm;
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
std::vector<LaneTrack> tracks{
LaneTrack{"{T}", {
LaneItem{"{a}", kArrangeModeId, false},
LaneItem{"{d}", kDesignModeId, false},
LaneItem{"{m}", "mixdown", false},
}},
};
// Own items span three modes (leaf; empty tree ⇒ own-item-span is the sole trigger).
const LaneMintPlan plan = planLaneMinting(vm, FolderTree{}, tracks);
CHECK(splitLaneCount(plan, "{T}") == 3);
CHECK(plan.mints.size() == 3);
// Apply the mints exactly as the shell does — record managed ownership — then assert
// the ownership index classifies each lane managed-for-its-mode and the toggle
// planner would drive exactly these three lanes (managed-only invariant intact).
for (const auto& m : plan.mints)
CHECK(vm.lanes().setManaged(m.trackGuid, m.laneKey, m.modeId));
CHECK(vm.lanes().size() == 3);
CHECK(vm.lanes().isManaged("{T}", laneNameForMode(kArrangeModeId)));
CHECK(vm.lanes().isManaged("{T}", laneNameForMode(kDesignModeId)));
CHECK(vm.lanes().isManaged("{T}", laneNameForMode("mixdown")));
CHECK(vm.lanesTouchedByToggle().size() == 3);
// Persist round-trip of the just-minted lane-split project: the ownership index (and
// the whole model) survives serialize/deserialize unchanged, so a saved lane-split
// project restores its managed classification without re-minting.
auto back = ViewModeModel::deserialize(vm.serialize());
CHECK(back.has_value());
CHECK(back && *back == vm);
if (back) CHECK(back->lanes().size() == 3);
}
// -- Fix: content-bearing folder derived-visible in >1 mode splits its own media ----
//
// The exact failing case. A folder {F} has descendant leaves in BOTH modes ({LD} Design,
// {LA} Arrange) and carries ONE OWN item ({own}) tagged Design. W3-A's own-item-span test
// alone would NOT split {F} (its own content is single-mode Design), so the item leaked
// into every mode the folder was derived-visible in. The visibility-aware decision splits
// {F} and lanes {own} onto the Design lane — so it hides+silences whenever Arrange is
// active. LAZY-MINT: {F} mints ONLY the Design lane (holding the item), NOT an empty
// reserved Arrange lane — confinement holds via C_LANEPLAYS=0 on the lone Design lane when
// Arrange is active. This is the load-bearing fix; assert it hard.
static void testLaneMintingFolderDerivedVisibleSplitsOwnMedia() {
ViewModeModel vm;
vm.membership().tag("{LD}", kDesignModeId); // a Design leaf under the folder
// {LA} left untagged ⇒ Arrange member; both stances thus live under {F}.
vm.membership().tag("{own}", kDesignModeId); // the folder's OWN dropped item (Design)
FolderTree tree;
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
// Sanity: the folder really is derived-visible in BOTH modes (the precondition the
// W3-A trigger ignored). If this ever stops holding, the fix's premise is gone.
CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{F}") == 1);
CHECK(vm.visibleTracks(tree, kDesignModeId).count("{F}") == 1);
// The folder track {F} carries its own single Design item; its child leaves are the
// separate leaf tracks (not reported as items on {F}).
std::vector<LaneTrack> tracks{
LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}},
};
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
// {F} MUST split even though its own item is single-mode: it is visible in 2 modes.
CHECK(!plan.empty());
// LAZY-MINT: ONE lane only — the Design lane that holds the item. No empty reserved
// Arrange lane is minted, even though {F} is derived-visible in Arrange. The Arrange
// lane appears on demand when an Arrange item first lands on {F}.
CHECK(splitLaneCount(plan, "{F}") == 1); // Design lane only — no reserved lane
CHECK(plan.mints.size() == 1);
CHECK(hasMint(plan, "{F}", kDesignModeId)); // Design lane (holds the item)
CHECK(!hasMint(plan, "{F}", kArrangeModeId)); // NO empty reserved Arrange lane
// The own item is confined to its tagged (Design) lane — the exact hide-in-Arrange fix.
// With only the Design lane present, toggling to Arrange sets its C_LANEPLAYS to 0, so
// the item hides+silences and the track reads as an empty normal track (no leak).
CHECK(plan.assigns.size() == 1);
CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId));
for (const auto& a : plan.assigns)
CHECK(!(a.itemGuid == "{own}" && a.laneKey == laneNameForMode(kArrangeModeId)));
}
// LAZY-MINT confinement proof. Same single-own-mode / dual-visibility folder, but instead
// of asserting the mint COUNT we prove the FUNCTIONAL confinement the lazy split preserves:
// apply the minted lane's ownership to a live model, then drive the toggle planner and show
// the lone Design lane SILENCES when Arrange is active (C_LANEPLAYS = 0). That is the whole
// point — a single managed lane still hides its item in every other mode, so removing the
// empty reserved Arrange lane costs nothing functionally. Fails if the split ever leaves the
// Design item audible in Arrange (the leak the D2 fix closed) or mints a spurious lane.
static void testLaneMintingLazySingleLaneStillConfines() {
ViewModeModel vm;
vm.membership().tag("{LD}", kDesignModeId); // Design leaf ⇒ folder visible in Design
// {LA} untagged ⇒ Arrange member ⇒ folder ALSO derived-visible in Arrange.
vm.membership().tag("{own}", kDesignModeId); // the folder's one own item (Design)
FolderTree tree;
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
std::vector<LaneTrack> tracks{
LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}},
};
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
// Exactly one lane minted (the Design lane) — no empty reserved Arrange lane.
CHECK(plan.mints.size() == 1);
CHECK(hasMint(plan, "{F}", kDesignModeId));
// Apply the mint's ownership exactly as the shell does, then drive the toggle planner.
for (const auto& m : plan.mints)
CHECK(vm.lanes().setManaged(m.trackGuid, m.laneKey, m.modeId));
CHECK(vm.lanes().size() == 1); // one managed lane on {F}, not two
const std::string designLane = laneNameForMode(kDesignModeId);
// Active = Design: the lone Design lane PLAYS (item visible+audible in its own mode).
const auto design = vm.planToggle(tree, kDesignModeId);
CHECK(lanePlaysFor(design, "{F}", designLane) == kLanePlaysExclusive);
// Active = Arrange: the lone Design lane SILENCES — with no lane playing, the track
// reads as an empty normal track and the Design item does NOT leak. This is the
// confinement guarantee that lets us drop the reserved Arrange lane.
const auto arrange = vm.planToggle(tree, kArrangeModeId);
CHECK(lanePlaysFor(arrange, "{F}", designLane) == kLaneSilent);
}
// A folder carrying its OWN items that already span both modes → still split (the two
// triggers OR: own-item span AND derived visibility both point the same way here). Both
// own items separate to their tagged lanes.
static void testLaneMintingFolderOwnItemsSpanBothModes() {
ViewModeModel vm;
vm.membership().tag("{LD}", kDesignModeId);
vm.membership().tag("{d}", kDesignModeId);
// {a} untagged ⇒ Arrange.
FolderTree tree;
tree.nodes.push_back(FolderNode{"{F}", "", true});
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false}); // untagged ⇒ Arrange
std::vector<LaneTrack> tracks{
LaneTrack{"{F}", {
LaneItem{"{a}", kArrangeModeId, false},
LaneItem{"{d}", kDesignModeId, false},
}},
};
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
CHECK(splitLaneCount(plan, "{F}") == 2);
CHECK(plan.assigns.size() == 2);
CHECK(hasAssign(plan, "{a}", "{F}", kArrangeModeId));
CHECK(hasAssign(plan, "{d}", "{F}", kDesignModeId));
}
// -- Fix (pd2): item inserted onto an ALREADY-split folder still yields a non-empty plan --
//
// Regression guard for the "inserted item invisible until a manual toggle" bug. When a new
// item lands (via insert/capture) on a folder that is ALREADY lane-split and derived-visible
// in >1 mode, and REAPER placed it on the active mode's currently-playing lane, the shell's
// assignItemToLane sees I_FIXEDLANE unchanged and writes nothing — applyMintPlan reports
// changed==false. The shell must STILL treat this tick as "content landed on a managed track"
// and refresh the arrange (so the item draws immediately, no toggle). The pure signal the
// shell keys on is: planLaneMinting returns a NON-EMPTY plan carrying an assign for the new
// item. This test locks that signal; if planLaneMinting ever went empty here, the shell would
// have nothing to refresh on and the bug would return.
static void testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan() {
ViewModeModel vm;
vm.membership().tag("{LD}", kDesignModeId); // Design leaf ⇒ folder derived-visible Design
// {LA} untagged ⇒ Arrange ⇒ folder ALSO derived-visible in Arrange (dual-visible).
vm.membership().tag("{own}", kDesignModeId); // the pre-existing own Design item
vm.membership().tag("{new}", kDesignModeId); // the JUST-INSERTED item (auto-tagged Design)
FolderTree tree;
tree.nodes.push_back(FolderNode{"{F}", "", /*isParent=*/true});
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
// The folder is already split for Design (its lane exists + is owned). This mirrors the
// live "already auto-split" track the bug reproduces on.
CHECK(vm.lanes().setManaged("{F}", laneNameForMode(kDesignModeId), kDesignModeId));
// {F} now carries its original own item PLUS the freshly-inserted one, both Design.
std::vector<LaneTrack> tracks{
LaneTrack{"{F}", {
LaneItem{"{own}", kDesignModeId, false},
LaneItem{"{new}", kDesignModeId, false},
}},
};
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
// The plan is NON-EMPTY (folder is dual-visible ⇒ splits) and carries an assign for the
// new item onto the Design lane. In the shell this is the exact branch that must force a
// redraw even when the assign is an idempotent no-op (item already on the playing lane).
CHECK(!plan.empty());
CHECK(hasAssign(plan, "{new}", "{F}", kDesignModeId));
CHECK(hasAssign(plan, "{own}", "{F}", kDesignModeId));
}
// SHOW-BOTH escape hatch: a show-both track carrying its own items is visible in every
// mode ON PURPOSE and must NOT be force-split — its content stays cross-mode-visible.
// Even with own items that would otherwise span modes, the decision skips it entirely.
static void testLaneMintingShowBothNotForceSplit() {
ViewModeModel vm;
vm.membership().setShowBoth("{SB}", true);
// A show-both track whose OWN items even span two modes — the W3-A own-span trigger
// would fire, but show-both must override it (its items are meant to play everywhere).
std::vector<LaneTrack> tracks{
LaneTrack{"{SB}", {
LaneItem{"{a}", kArrangeModeId, false},
LaneItem{"{d}", kDesignModeId, false},
}},
};
const LaneMintPlan plan = planLaneMinting(vm, FolderTree{}, tracks);
CHECK(plan.empty()); // NOT split — the escape hatch holds
CHECK(splitLaneCount(plan, "{SB}") == -1);
// And a show-both FOLDER derived-visible in both modes carrying an own item: still not
// split. Visibility is the deliberate point of show-both.
ViewModeModel vm2;
vm2.membership().setShowBoth("{F}", true);
vm2.membership().tag("{LD}", kDesignModeId);
vm2.membership().tag("{own}", kDesignModeId);
FolderTree tree;
tree.nodes.push_back(FolderNode{"{F}", "", true});
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
std::vector<LaneTrack> t2{LaneTrack{"{F}", {LaneItem{"{own}", kDesignModeId, false}}}};
CHECK(planLaneMinting(vm2, tree, t2).empty());
}
// A single-mode LEAF visible in exactly one mode is still never split — the D1 whole-track
// parking case. A leaf under a folder, tagged Design, whose sibling is also Design: the
// leaf is visible in one mode only, carries its own Design item, and must NOT lane-split.
static void testLaneMintingSingleModeLeafVisibleOnceNoSplit() {
ViewModeModel vm;
vm.membership().tag("{L}", kDesignModeId);
vm.membership().tag("{own}", kDesignModeId);
FolderTree tree;
tree.nodes.push_back(FolderNode{"{F}", "", true});
tree.nodes.push_back(FolderNode{"{L}", "{F}", false}); // the leaf under test
// The leaf {L} is visible only in Design (its one tagged mode).
CHECK(vm.visibleTracks(tree, kDesignModeId).count("{L}") == 1);
CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{L}") == 0);
std::vector<LaneTrack> tracks{
LaneTrack{"{L}", {LaneItem{"{own}", kDesignModeId, false}}},
};
const LaneMintPlan plan = planLaneMinting(vm, tree, tracks);
CHECK(plan.empty()); // single-mode, visible once ⇒ D1 whole-track parking, no split
}
// A content-EMPTY folder derived-visible in many modes carries NO own media, so there is
// nothing to lane-separate: it stays visibility-only (D1 parent handling), never split.
static void testLaneMintingEmptyFolderNotSplit() {
ViewModeModel vm;
vm.membership().tag("{LD}", kDesignModeId);
// {LA} untagged ⇒ Arrange; folder derived-visible in both modes but holds no own item.
FolderTree tree;
tree.nodes.push_back(FolderNode{"{F}", "", true});
tree.nodes.push_back(FolderNode{"{LD}", "{F}", false});
tree.nodes.push_back(FolderNode{"{LA}", "{F}", false});
CHECK(vm.visibleTracks(tree, kArrangeModeId).count("{F}") == 1);
CHECK(vm.visibleTracks(tree, kDesignModeId).count("{F}") == 1);
std::vector<LaneTrack> tracks{LaneTrack{"{F}", {}}}; // no own media
CHECK(planLaneMinting(vm, tree, tracks).empty());
}
// -- D2.6 JSON round-trip with lane index + membership -----------------------
static void testLaneJsonRoundTrip() {
@@ -1124,8 +1663,22 @@ int main() {
// D2 two-canvas lane extension
testLaneModeStateAndPlayValues();
testLaneOwnershipIndex();
testLaneOwnershipLastWriterWins();
testManagedOnlyPlannerAndQuery();
testAutoTagDecision();
testPlanItemRetag();
testReconcileUnregisteredModeGuardDecision();
testLaneMintingSingleModeNoSplit();
testLaneMintingMultiModeMintsAndAssignsAll();
testLaneMintingManualLaneExempt();
testLaneMintingThreeModesAndOwnershipKeys();
testLaneMintingFolderDerivedVisibleSplitsOwnMedia();
testLaneMintingLazySingleLaneStillConfines();
testLaneMintingFolderOwnItemsSpanBothModes();
testLaneMintingNewItemOnAlreadySplitFolderYieldsPlan();
testLaneMintingShowBothNotForceSplit();
testLaneMintingSingleModeLeafVisibleOnceNoSplit();
testLaneMintingEmptyFolderNotSplit();
testLaneJsonRoundTrip();
testLaneMalformedJson();
+372
View File
@@ -0,0 +1,372 @@
// Standalone tests for reasampler::wav_trim — no REAPER, no test framework.
// Builds synthetic 32-bit-float WAV byte buffers, asserts the parse geometry, the
// float extraction, and the truncate-plan arithmetic (the header size-field patch).
//
// Covers: canonical stereo/mono 32-bit-float parse; a leading unknown chunk skipped;
// format rejection (16-bit PCM, non-WAV, data-before-fmt, truncated data); frame
// extraction (whole / tail window / clamp / out-of-range); truncate plan (kept<all,
// no-op keep-all, kept==0, grow rejected) with exact size-field values.
#include "../src/wav_trim.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
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)
// --- Synthetic WAV builder ---------------------------------------------------
static void putU16(std::vector<std::uint8_t>& b, std::uint16_t v) {
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
}
static void putU32(std::vector<std::uint8_t>& b, std::uint32_t v) {
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
static void putTag(std::vector<std::uint8_t>& b, const char* t) {
for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
}
static void putFloat(std::vector<std::uint8_t>& b, float f) {
std::uint8_t tmp[4];
std::memcpy(tmp, &f, 4);
for (int i = 0; i < 4; ++i) b.push_back(tmp[i]);
}
// A canonical 32-bit-float WAV: RIFF/WAVE, fmt (tag 3, 16-byte body), data holding
// `frames` interleaved frames of `channels`. `leadingJunk` optionally inserts an
// unknown chunk before fmt to exercise the chunk walk. Samples: frame f, channel c
// = value(f,c).
template <typename Fn>
static std::vector<std::uint8_t> buildFloatWav(std::uint16_t channels,
std::uint32_t sampleRate,
std::size_t frames,
Fn value,
bool leadingJunk = false,
std::uint16_t fmtTag = 3,
std::uint16_t bits = 32) {
const std::uint32_t dataBytes =
static_cast<std::uint32_t>(frames * channels * (bits / 8));
std::vector<std::uint8_t> chunks; // everything after "WAVE"
if (leadingJunk) {
putTag(chunks, "LIST");
putU32(chunks, 4);
putTag(chunks, "INFO"); // 4-byte body, even -> no pad
}
// fmt chunk (16-byte body).
putTag(chunks, "fmt ");
putU32(chunks, 16);
putU16(chunks, fmtTag); // format tag
putU16(chunks, channels);
putU32(chunks, sampleRate);
const std::uint32_t byteRate = sampleRate * channels * (bits / 8);
putU32(chunks, byteRate);
putU16(chunks, static_cast<std::uint16_t>(channels * (bits / 8))); // block align
putU16(chunks, bits);
// data chunk.
putTag(chunks, "data");
putU32(chunks, dataBytes);
for (std::size_t f = 0; f < frames; ++f)
for (std::uint16_t c = 0; c < channels; ++c)
putFloat(chunks, value(f, c));
std::vector<std::uint8_t> wav;
putTag(wav, "RIFF");
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size())); // "WAVE" + chunks
putTag(wav, "WAVE");
wav.insert(wav.end(), chunks.begin(), chunks.end());
return wav;
}
// Builds a WAVE_FORMAT_EXTENSIBLE (0xFFFE) WAV with a 40-byte fmt body.
// `subFormatTag` is the 2-byte leading tag embedded in the SubFormat GUID:
// 0x0003 = IEEE float, 0x0001 = PCM integer (and any other value to exercise rejection).
// bitsPerSample and the PCM data are always 32-bit float bytes regardless of subFormatTag
// (we're testing that the parser correctly rejects/accepts based on the GUID, not the data).
template <typename Fn>
static std::vector<std::uint8_t> buildExtensibleWav(std::uint16_t channels,
std::uint32_t sampleRate,
std::size_t frames,
Fn value,
std::uint16_t subFormatTag) {
const std::uint32_t dataBytes =
static_cast<std::uint32_t>(frames * channels * 4u);
// WAVEFORMATEXTENSIBLE fmt body (40 bytes):
// [0..1] wFormatTag = 0xFFFE
// [2..3] nChannels
// [4..7] nSamplesPerSec
// [8..11] nAvgBytesPerSec
// [12..13] nBlockAlign
// [14..15] wBitsPerSample = 32
// [16..17] cbSize = 22 (extension size beyond the 18-byte WAVEFORMATEX)
// [18..19] wValidBitsPerSample = 32
// [20..23] dwChannelMask = 0
// [24..39] SubFormat GUID: first 2 bytes = subFormatTag (LE), rest = standard
// KSDATAFORMAT_SUBTYPE base GUID {00000000-0000-0010-8000-00aa00389b71}
std::vector<std::uint8_t> fmt;
putU16(fmt, 0xFFFE); // wFormatTag
putU16(fmt, channels); // nChannels
putU32(fmt, sampleRate); // nSamplesPerSec
putU32(fmt, sampleRate * channels * 4u); // nAvgBytesPerSec
putU16(fmt, static_cast<std::uint16_t>(channels * 4)); // nBlockAlign
putU16(fmt, 32); // wBitsPerSample
putU16(fmt, 22); // cbSize
putU16(fmt, 32); // wValidBitsPerSample
putU32(fmt, 0); // dwChannelMask
// SubFormat GUID (16 bytes): [subFormatTag, 0x0000, 0x00, 0x00, 0x10, 0x00,
// 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71]
putU16(fmt, subFormatTag); // bytes [24..25]: the effective format tag
putU16(fmt, 0x0000); // bytes [26..27]
fmt.push_back(0x00); fmt.push_back(0x00); // bytes [28..29]
fmt.push_back(0x10); fmt.push_back(0x00); // bytes [30..31]
fmt.push_back(0x80); fmt.push_back(0x00); // bytes [32..33]
fmt.push_back(0x00); fmt.push_back(0xaa); // bytes [34..35]
fmt.push_back(0x00); fmt.push_back(0x38); // bytes [36..37]
fmt.push_back(0x9b); fmt.push_back(0x71); // bytes [38..39]
std::vector<std::uint8_t> chunks;
putTag(chunks, "fmt ");
putU32(chunks, static_cast<std::uint32_t>(fmt.size())); // 40
chunks.insert(chunks.end(), fmt.begin(), fmt.end());
putTag(chunks, "data");
putU32(chunks, dataBytes);
for (std::size_t f = 0; f < frames; ++f)
for (std::uint16_t c = 0; c < channels; ++c)
putFloat(chunks, value(f, c));
std::vector<std::uint8_t> wav;
putTag(wav, "RIFF");
putU32(wav, static_cast<std::uint32_t>(4 + chunks.size()));
putTag(wav, "WAVE");
wav.insert(wav.end(), chunks.begin(), chunks.end());
return wav;
}
// --- Parse tests -------------------------------------------------------------
static void testParseCanonicalStereo() {
auto wav = buildFloatWav(2, 48000, 5,
[](std::size_t f, std::uint16_t c) {
return static_cast<float>(f) + 0.1f * c;
});
WavLayout L = parseWavLayout(wav);
CHECK(L.valid);
CHECK(L.channelCount == 2);
CHECK(L.sampleRate == 48000);
CHECK(L.dataByteLength == 5 * 2 * 4);
CHECK(L.frameCount() == 5);
// data body sits after RIFF(12) + fmt(8 header + 16 body) + data(8 header) = 44.
CHECK(L.dataByteOffset == 44);
CHECK(L.dataSizeFieldOffset == 40); // the 4 bytes before dataByteOffset
CHECK(L.riffSizeFieldOffset == 4);
}
static void testParseMonoAndLeadingChunk() {
// A leading LIST/INFO chunk before fmt must be skipped by the walk.
auto wav = buildFloatWav(1, 44100, 3,
[](std::size_t f, std::uint16_t) {
return static_cast<float>(f);
},
/*leadingJunk=*/true);
WavLayout L = parseWavLayout(wav);
CHECK(L.valid);
CHECK(L.channelCount == 1);
CHECK(L.frameCount() == 3);
// Data still parses correctly despite the leading chunk shifting its offset.
auto pcm = extractFloatFrames(wav, L, 0, 3);
CHECK(pcm.size() == 3);
CHECK(pcm[0] == 0.0f && pcm[1] == 1.0f && pcm[2] == 2.0f);
}
static void testParseRejectsNon32BitAndNonWav() {
// 16-bit PCM (tag 1, bits 16) -> rejected.
auto pcm16 = buildFloatWav(2, 48000, 4,
[](std::size_t, std::uint16_t) { return 0.0f; },
false, /*fmtTag=*/1, /*bits=*/16);
CHECK(!parseWavLayout(pcm16).valid);
// Not a RIFF file.
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
CHECK(!parseWavLayout(junk).valid);
// Too short to hold even the RIFF header.
std::vector<std::uint8_t> tiny = {'R','I','F','F'};
CHECK(!parseWavLayout(tiny).valid);
}
static void testParseRejectsLyingDataLength() {
// Build a valid WAV, then inflate the `data` size field so it claims more bytes
// than the buffer holds -> must be rejected (no OOB trust).
auto wav = buildFloatWav(2, 48000, 4,
[](std::size_t, std::uint16_t) { return 1.0f; });
WavLayout good = parseWavLayout(wav);
CHECK(good.valid);
// Overwrite the data size field with a huge value.
wav[good.dataSizeFieldOffset + 0] = 0xFF;
wav[good.dataSizeFieldOffset + 1] = 0xFF;
wav[good.dataSizeFieldOffset + 2] = 0xFF;
wav[good.dataSizeFieldOffset + 3] = 0x7F;
CHECK(!parseWavLayout(wav).valid);
}
// --- Extraction tests --------------------------------------------------------
static void testExtractTailWindow() {
// Stereo, 10 frames. Sample value encodes frame+channel so a mis-index is caught.
auto wav = buildFloatWav(2, 48000, 10,
[](std::size_t f, std::uint16_t c) {
return static_cast<float>(f) * 10.0f + c;
});
WavLayout L = parseWavLayout(wav);
CHECK(L.valid);
// The "tail region" the realtime trim scans: frames 6..9 (start at frame 6).
auto tail = extractFloatFrames(wav, L, 6, 100 /*clamps*/);
CHECK(tail.size() == 4 * 2); // frames 6,7,8,9, 2 channels each
CHECK(tail[0] == 60.0f && tail[1] == 61.0f); // frame 6: L=60,R=61
CHECK(tail[6] == 90.0f && tail[7] == 91.0f); // frame 9: L=90,R=91
// Out-of-range start -> empty.
CHECK(extractFloatFrames(wav, L, 10, 4).empty());
CHECK(extractFloatFrames(wav, L, 99, 4).empty());
}
// --- Truncate-plan tests -----------------------------------------------------
static void testTruncatePlanKeepFewer() {
auto wav = buildFloatWav(2, 48000, 10,
[](std::size_t, std::uint16_t) { return 0.0f; });
WavLayout L = parseWavLayout(wav);
CHECK(L.valid);
// Keep 4 of 10 frames.
WavTruncatePlan p = planWavTruncate(L, 4);
CHECK(p.valid);
const std::size_t bpf = 2 * 4; // channels * 4 bytes
CHECK(p.newDataSize == 4 * bpf); // 32 bytes of PCM kept
CHECK(p.newFileByteLength == L.dataByteOffset + 4 * bpf); // 44 + 32 = 76
CHECK(p.newRiffSize == p.newFileByteLength - 8);
CHECK(p.dataSizeFieldOffset == L.dataSizeFieldOffset);
CHECK(p.riffSizeFieldOffset == 4);
// Applying the plan yields a buffer that re-parses to exactly 4 frames.
std::vector<std::uint8_t> trimmed(wav.begin(),
wav.begin() + p.newFileByteLength);
// Patch the two size fields (what the shell does before truncating on disk).
auto writeU32 = [](std::vector<std::uint8_t>& b, std::size_t off, std::uint32_t v) {
b[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
b[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
b[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
b[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
};
writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize);
writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
WavLayout L2 = parseWavLayout(trimmed);
CHECK(L2.valid);
CHECK(L2.frameCount() == 4);
CHECK(L2.dataByteLength == 4 * bpf);
}
static void testTruncatePlanKeepAllIsNoOp() {
auto wav = buildFloatWav(1, 48000, 6,
[](std::size_t, std::uint16_t) { return 0.0f; });
WavLayout L = parseWavLayout(wav);
WavTruncatePlan p = planWavTruncate(L, 6); // keep all
CHECK(p.valid);
CHECK(p.newFileByteLength == wav.size()); // unchanged
CHECK(p.newDataSize == L.dataByteLength);
}
// --- Extensible format tests -------------------------------------------------
// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0001 (PCM integer) and
// bitsPerSample==32 must be REJECTED — it is 32-bit integer, not 32-bit float.
static void testExtensiblePcmIntegerRejected() {
auto wav = buildExtensibleWav(2, 48000, 4,
[](std::size_t, std::uint16_t) { return 0.0f; },
/*subFormatTag=*/0x0001); // PCM integer
CHECK(!parseWavLayout(wav).valid);
}
// A WAVE_FORMAT_EXTENSIBLE fmt with SubFormat tag 0x0003 (IEEE float) and
// bitsPerSample==32 must be ACCEPTED and parse + trim correctly.
static void testExtensibleFloatAccepted() {
auto wav = buildExtensibleWav(2, 48000, 5,
[](std::size_t f, std::uint16_t c) {
return static_cast<float>(f) + 0.1f * c;
},
/*subFormatTag=*/0x0003); // IEEE float
WavLayout L = parseWavLayout(wav);
CHECK(L.valid);
CHECK(L.channelCount == 2);
CHECK(L.sampleRate == 48000);
CHECK(L.frameCount() == 5);
// Frame extraction works correctly.
auto pcm = extractFloatFrames(wav, L, 0, 2);
CHECK(pcm.size() == 4);
CHECK(pcm[0] == 0.0f); // frame 0, channel 0
CHECK(pcm[1] == 0.1f); // frame 0, channel 1
// Truncate plan is valid and re-parses cleanly.
WavTruncatePlan p = planWavTruncate(L, 3);
CHECK(p.valid);
CHECK(p.newDataSize == 3 * 2 * 4u);
std::vector<std::uint8_t> trimmed(wav.begin(), wav.begin() + p.newFileByteLength);
auto writeU32 = [](std::vector<std::uint8_t>& b, std::size_t off, std::uint32_t v) {
b[off + 0] = static_cast<std::uint8_t>(v & 0xFF);
b[off + 1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
b[off + 2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
b[off + 3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
};
writeU32(trimmed, p.dataSizeFieldOffset, p.newDataSize);
writeU32(trimmed, p.riffSizeFieldOffset, p.newRiffSize);
WavLayout L2 = parseWavLayout(trimmed);
CHECK(L2.valid);
CHECK(L2.frameCount() == 3);
}
static void testTruncatePlanKeepZeroAndGrowRejected() {
auto wav = buildFloatWav(2, 48000, 5,
[](std::size_t, std::uint16_t) { return 0.0f; });
WavLayout L = parseWavLayout(wav);
WavTruncatePlan zero = planWavTruncate(L, 0);
CHECK(zero.valid);
CHECK(zero.newDataSize == 0);
CHECK(zero.newFileByteLength == L.dataByteOffset); // header only
// keptFrames > total -> refused (never grow a file).
CHECK(!planWavTruncate(L, 6).valid);
// Invalid layout -> invalid plan.
WavLayout bad;
CHECK(!planWavTruncate(bad, 0).valid);
}
int main() {
testParseCanonicalStereo();
testParseMonoAndLeadingChunk();
testParseRejectsNon32BitAndNonWav();
testParseRejectsLyingDataLength();
testExtractTailWindow();
testTruncatePlanKeepFewer();
testTruncatePlanKeepAllIsNoOp();
testTruncatePlanKeepZeroAndGrowRejected();
testExtensiblePcmIntegerRejected();
testExtensibleFloatAccepted();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}