Refuse every multi-track selected-tracks render, both scopes; make a failed mono collapse observable

This commit is contained in:
2026-08-01 22:40:54 -04:00
parent 6e6f4a6a15
commit f69c4bf6bf
14 changed files with 366 additions and 114 deletions
+48 -29
View File
@@ -243,40 +243,58 @@ static void testRangedItemScopeRendersTimeBounded() {
CHECK(fxBypassPlanFor(CaptureScope::Item).bypassSelfFx);
}
static void testMultiTrackRangedItemRenderIsNamedForRefusal() {
// The one shape that cannot land: a ranged item capture (item scope re-sourced to
// the selected-tracks render) whose items span more than one track. That source
// is INFERRED to render one file per track with no single-file bit available
// (unverified; see src/shell/capture/CLAUDE.md §Gotchas for what that inference
// rests on), so N stems would collapse onto one render pattern and one track's
// audio would land as a successful capture.
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedTracks, 2));
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedTracks, 7));
static void testMultiTrackStemRenderIsNamedForRefusal() {
// The one shape that cannot land: a selected-tracks render over more than one
// track. That source is INFERRED to render one file per track with no single-file
// bit available (unverified; see src/shell/capture/CLAUDE.md §Gotchas for what that
// inference rests on), so N stems would collapse onto one render pattern and one
// track's audio would land as a successful capture.
CHECK(isMultiTrackStemRender(SourceMode::SelectedTracks, 2));
CHECK(isMultiTrackStemRender(SourceMode::SelectedTracks, 7));
// One track is the whole point of the re-source — it must still render.
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedTracks, 1));
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedTracks, 0));
// One track is the common case for BOTH scopes — it must still render. This is the
// regression floor for the plain single-track track capture.
CHECK(!isMultiTrackStemRender(SourceMode::SelectedTracks, 1));
CHECK(!isMultiTrackStemRender(SourceMode::SelectedTracks, 0));
// A full-extent item capture keeps the selected-items source, whose single-file
// bit already sums a multi-track item selection into one file.
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
SourceMode::SelectedItems, 3));
CHECK(!isMultiTrackStemRender(SourceMode::SelectedItems, 3));
// Razor's single-file bit does the same; master mix is one file by definition.
CHECK(!isMultiTrackStemRender(SourceMode::RazorArea, 3));
CHECK(!isMultiTrackStemRender(SourceMode::MasterMix, 3));
// Realtime never reaches the offline render at all (sends sum into one temp track).
CHECK(!isMultiTrackStemRender(SourceMode::Realtime, 3));
// Track scope renders through the same source and is deliberately untouched here
// — that per-track output predates the item re-source and is filed in docs/TODO.md.
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Track,
SourceMode::SelectedTracks, 3));
// The predicate is reachable from the mappings it guards: BOTH the ranged item
// capture's source mode and the track scope's resolve to the one it names, while a
// full-extent item capture does not.
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Item, false), 2));
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Track, false), 2));
CHECK(isMultiTrackStemRender(sourceModeForScope(CaptureScope::Track, true), 2));
CHECK(!isMultiTrackStemRender(sourceModeForScope(CaptureScope::Item, true), 2));
}
// The predicate is reachable from the mapping it guards: the ranged item capture's
// own source mode is the one it names.
CHECK(isMultiTrackRangedItemRender(CaptureScope::Item,
sourceModeForScope(CaptureScope::Item, false), 2));
CHECK(!isMultiTrackRangedItemRender(CaptureScope::Item,
sourceModeForScope(CaptureScope::Item, true), 2));
static void testRefusalMessagesAreSiblingsWithDistinctExits() {
const std::string item = multiTrackRefusalMessage(CaptureScope::Item);
const std::string track = multiTrackRefusalMessage(CaptureScope::Track);
// Both name the same reason — a shape that cannot land as one file — so a user who
// hits the mistake in either scope reads one story, not two.
CHECK(item.find("single file") != std::string::npos);
CHECK(track.find("single file") != std::string::npos);
// And both name a way out. The shared one is "one track at a time"; each scope then
// adds the exit only it has (widen the range / capture the folder).
CHECK(item.find("one track's items at a time") != std::string::npos);
CHECK(item.find("range match the items' extent") != std::string::npos);
CHECK(track.find("one track at a time") != std::string::npos);
CHECK(track.find("folder") != std::string::npos);
// Distinct texts: the track message must not be the item message's wording about
// items and ranges, which would misdescribe what the user actually did.
CHECK(item != track);
CHECK(track.find("selected items") == std::string::npos);
}
// --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
@@ -376,7 +394,8 @@ int main() {
testRazorUnionBounds();
testScopeSourceModes();
testRangedItemScopeRendersTimeBounded();
testMultiTrackRangedItemRenderIsNamedForRefusal();
testMultiTrackStemRenderIsNamedForRefusal();
testRefusalMessagesAreSiblingsWithDistinctExits();
testRangeInference();
testItemScopeBypassesEverythingButTake();
testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
+19
View File
@@ -775,6 +775,24 @@ static void testCollapsePreservesQuietNaNBitPattern() {
if (!pcm.empty()) CHECK(bitsFromFloat(pcm[0]) == kQuietNaNBits);
}
// The file-side collapse has three outcomes, and a genuine I/O failure once reported
// identically to "the channels differ" — a stereo file landing under a plain Ok. The
// report is where they must part: Declined stays silent (a capture with nothing to
// collapse reads as it always did), and the other two say different things.
static void testCollapseOutcomesReportDistinctly() {
const std::string declined = monoCollapseSuffix(MonoCollapseOutcome::Declined);
const std::string collapsed = monoCollapseSuffix(MonoCollapseOutcome::Collapsed);
const std::string failed = monoCollapseSuffix(MonoCollapseOutcome::Failed);
CHECK(declined.empty());
CHECK(!collapsed.empty());
CHECK(!failed.empty());
CHECK(collapsed != failed);
// The failure must read as a failure, not as a quieter success.
CHECK(failed.find("failed") != std::string::npos);
CHECK(collapsed.find("failed") == std::string::npos);
}
int main() {
testParseCanonicalStereo();
testParseMonoAndLeadingChunk();
@@ -810,6 +828,7 @@ int main() {
testCollapseDeclinesOnUnparseableBytes();
testCollapseChangesContentHash();
testCollapsePreservesQuietNaNBitPattern();
testCollapseOutcomesReportDistinctly();
if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);