Render in place: a track's output to a new sibling, source to the bench
This commit is contained in:
@@ -241,6 +241,41 @@ static void testEveryAwkwardStemStaysFilesystemLegal() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- captureTrackName -------------------------------------------------------
|
||||
|
||||
static void testCaptureTrackNamePrefixesAPlainSourceName() {
|
||||
CHECK(captureTrackName("MONEY") == "Capture MONEY");
|
||||
CHECK(captureTrackName("bass di") == "Capture bass di");
|
||||
}
|
||||
|
||||
static void testCaptureTrackNameIsIdempotent() {
|
||||
// The whole point: a second render over a result track must not stack the prefix.
|
||||
CHECK(captureTrackName("Capture MONEY") == "Capture MONEY");
|
||||
CHECK(captureTrackName(captureTrackName("MONEY")) == "Capture MONEY");
|
||||
// A fixed point on its own output for EVERY input, degenerate ones included.
|
||||
for (const char* src : {"MONEY", "", "Capture", "Capture ", "Captured drums"}) {
|
||||
const std::string once = captureTrackName(src);
|
||||
CHECK(captureTrackName(once) == once);
|
||||
}
|
||||
}
|
||||
|
||||
static void testCaptureTrackNameEmptySourceHasNoTrailingSpace() {
|
||||
// Unreachable from trackName (GetTrackName always answers "Track N"), so this is
|
||||
// the defensive case — a bare word rather than a name ending in a space.
|
||||
CHECK(captureTrackName("") == "Capture");
|
||||
}
|
||||
|
||||
static void testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN() {
|
||||
// trackName's GetTrackName fallback rides in as an ordinary name.
|
||||
CHECK(captureTrackName("Track 7") == "Capture Track 7");
|
||||
}
|
||||
|
||||
static void testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord() {
|
||||
// "Captured" begins with "Capture" but not with "Capture " — it is a different
|
||||
// name and must be prefixed like any other.
|
||||
CHECK(captureTrackName("Captured drums") == "Capture Captured drums");
|
||||
}
|
||||
|
||||
int main() {
|
||||
testStampIsZeroPaddedMonthDayHourMinute();
|
||||
testUnsetStampProducesNoDiscriminator();
|
||||
@@ -268,6 +303,12 @@ int main() {
|
||||
testOrdinalAndMultiSourceCompose();
|
||||
testEveryAwkwardStemStaysFilesystemLegal();
|
||||
|
||||
testCaptureTrackNamePrefixesAPlainSourceName();
|
||||
testCaptureTrackNameIsIdempotent();
|
||||
testCaptureTrackNameEmptySourceHasNoTrailingSpace();
|
||||
testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN();
|
||||
testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord();
|
||||
|
||||
if (g_fail == 0) std::printf("capture_name: all tests passed\n");
|
||||
else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
|
||||
@@ -362,6 +362,39 @@ static void testBankRelativeForNameMatchesDerivePathSpelling() {
|
||||
CHECK(bankRelativeForName(p.fileName) == p.relativePath);
|
||||
}
|
||||
|
||||
// --- deriveRenderPaths ------------------------------------------------------
|
||||
|
||||
static void testRenderPathsSpellTheStemExactlyAsTheBankPathDoes() {
|
||||
// The one owner claim, made checkable: for the same baseName + uniqueTag, the
|
||||
// bank path's stem and file name must BE the render path's. If these ever
|
||||
// diverge, bankRelativeForName's exact-string match against an enumerated
|
||||
// folder entry starts misfiring and prune misreads referenced files as orphans.
|
||||
const BankPaths bank = deriveBankPaths("/proj", "kick drum!", "001");
|
||||
const RenderPaths render = deriveRenderPaths("/proj/reasampler_bank",
|
||||
"kick drum!", "001");
|
||||
CHECK(render.fileStem == bank.fileStem);
|
||||
CHECK(render.fileName == bank.fileName);
|
||||
CHECK(render.absoluteDir == bank.absoluteDir);
|
||||
}
|
||||
|
||||
static void testRenderPathsTakeTheirDirectoryVerbatim() {
|
||||
// No bank subfolder is appended — a render outside the bank has none, which is
|
||||
// what makes "write into the bank folder" inexpressible through this call.
|
||||
const RenderPaths r = deriveRenderPaths("/proj/media/", "take", "");
|
||||
CHECK(r.absoluteDir == normalizeSlashes("/proj/media"));
|
||||
CHECK(r.fileName == "take.wav");
|
||||
CHECK(r.fileStem == "take");
|
||||
// Backslashes normalize and a trailing slash is stripped, same as everywhere.
|
||||
CHECK(deriveRenderPaths("C:\\proj\\media\\", "take", "").absoluteDir ==
|
||||
normalizeSlashes("C:/proj/media"));
|
||||
}
|
||||
|
||||
static void testRenderPathsEmptyDirectoryStaysEmpty() {
|
||||
// No CWD fallback: an unresolvable directory must fail at the caller's own
|
||||
// guard, never silently render next to whatever the process happened to be in.
|
||||
CHECK(deriveRenderPaths("", "take", "001").absoluteDir.empty());
|
||||
}
|
||||
|
||||
static void testBankRelativeForNameConventionAndEdge() {
|
||||
// The convention verbatim: "reasampler_bank/<name>" (the one place the spelling lives).
|
||||
CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav");
|
||||
@@ -396,6 +429,9 @@ int main() {
|
||||
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
|
||||
testTransitionInPlaceSaveIsNoOp();
|
||||
testBankRelativeForNameMatchesDerivePathSpelling();
|
||||
testRenderPathsSpellTheStemExactlyAsTheBankPathDoes();
|
||||
testRenderPathsTakeTheirDirectoryVerbatim();
|
||||
testRenderPathsEmptyDirectoryStaysEmpty();
|
||||
testBankRelativeForNameConventionAndEdge();
|
||||
|
||||
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "../src/core/capture/track_topology.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
@@ -79,6 +80,143 @@ static void testSiblingFolderAfterParentClosesIsNotIncluded() {
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||
}
|
||||
|
||||
// --- siblingPlacement -------------------------------------------------------
|
||||
//
|
||||
// Every case asserts the property that actually matters, not just the numbers: the
|
||||
// new track sits at the SOURCE's own nesting level, and the delta total is
|
||||
// unchanged so no track after the insertion moves. `levelsAfter` rebuilds the
|
||||
// post-insertion list and reads the levels straight off it.
|
||||
|
||||
static std::vector<int> depthsAfter(const std::vector<int>& depths,
|
||||
const SiblingPlacement& p) {
|
||||
std::vector<int> out = depths;
|
||||
if (p.precedingIndex >= 0) out[static_cast<std::size_t>(p.precedingIndex)] = p.precedingDepth;
|
||||
out.insert(out.begin() + p.insertIndex, p.newDepth);
|
||||
return out;
|
||||
}
|
||||
|
||||
static int sumOf(const std::vector<int>& v) {
|
||||
int s = 0;
|
||||
for (int d : v) s += d;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Absolute nesting level of track `idx` in a delta list.
|
||||
static int levelAt(const std::vector<int>& depths, int idx) {
|
||||
int level = 0;
|
||||
for (int i = 0; i < idx; ++i) level += depths[static_cast<std::size_t>(i)];
|
||||
return level;
|
||||
}
|
||||
|
||||
// The whole contract in one call: the new track is a sibling (same level as the
|
||||
// source) and nothing downstream shifted (delta total preserved).
|
||||
static void checkIsSibling(const std::vector<int>& before, int srcIdx) {
|
||||
const SiblingPlacement p = siblingPlacement(before, srcIdx);
|
||||
const std::vector<int> after = depthsAfter(before, p);
|
||||
CHECK(sumOf(after) == sumOf(before));
|
||||
CHECK(levelAt(after, p.insertIndex) == levelAt(before, srcIdx));
|
||||
}
|
||||
|
||||
static void testSiblingOfANormalTrackGoesDirectlyBelowIt() {
|
||||
// Three normal tracks at top level; the source is the middle one.
|
||||
const std::vector<int> depths{0, 0, 0};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2);
|
||||
CHECK(p.precedingIndex == 1);
|
||||
CHECK(p.precedingDepth == 0); // unchanged
|
||||
CHECK(p.newDepth == 0);
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testSiblingOfAMidFolderTrackStaysInsideTheFolder() {
|
||||
// 0: parent, 1: child (the source), 2: last child closing the folder.
|
||||
const std::vector<int> depths{1, 0, -1};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2);
|
||||
CHECK(p.precedingDepth == 0);
|
||||
CHECK(p.newDepth == 0); // still inside; track 2 still closes the folder
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta() {
|
||||
// The source carries the folder's close, so a naive insert-after would drop the
|
||||
// new track OUTSIDE the folder and bypass the folder bus entirely.
|
||||
const std::vector<int> depths{1, -1, 0};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2);
|
||||
CHECK(p.precedingDepth == 0); // the source no longer closes the folder
|
||||
CHECK(p.newDepth == -1); // the new track does
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose() {
|
||||
// 0: outer parent, 1: inner parent, 2: last in BOTH folders (the source).
|
||||
const std::vector<int> depths{1, 1, -2};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 2);
|
||||
CHECK(p.insertIndex == 3);
|
||||
CHECK(p.precedingDepth == 0);
|
||||
CHECK(p.newDepth == -2); // the -2 travels intact
|
||||
checkIsSibling(depths, 2);
|
||||
}
|
||||
|
||||
static void testSiblingOfAFolderParentLandsAfterTheWholeFolder() {
|
||||
// Inserting straight after a folder parent would make the new track its FIRST
|
||||
// CHILD, re-summing the render through the parent's FX and fader.
|
||||
const std::vector<int> depths{1, 0, -1, 0};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 0);
|
||||
CHECK(p.insertIndex == 3); // past the whole folder, not at index 1
|
||||
CHECK(p.precedingIndex == 2);
|
||||
CHECK(p.precedingDepth == -1); // unchanged — track 2 still closes the folder
|
||||
CHECK(p.newDepth == 0);
|
||||
checkIsSibling(depths, 0);
|
||||
}
|
||||
|
||||
static void testSiblingOfTheLastTrackInTheProjectAppends() {
|
||||
const std::vector<int> depths{0, 0};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2); // == count: appended
|
||||
CHECK(p.precedingDepth == 0);
|
||||
CHECK(p.newDepth == 0);
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testSiblingOfTheLastTrackInTheProjectInsideAFolder() {
|
||||
// The project's last track also closes a folder — the close must still travel.
|
||||
const std::vector<int> depths{1, -1};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2);
|
||||
CHECK(p.precedingDepth == 0);
|
||||
CHECK(p.newDepth == -1);
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testMalformedDeltaListClampsRatherThanAsserting() {
|
||||
// Deltas summing to -3: more closes than opens, which no well-formed project
|
||||
// produces. The result must still be a legal in-range placement.
|
||||
const std::vector<int> depths{0, -2, -1};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex >= 0 && p.insertIndex <= static_cast<int>(depths.size()));
|
||||
CHECK(p.precedingIndex == p.insertIndex - 1);
|
||||
// Clamped at zero rather than tracking a negative nesting level.
|
||||
CHECK(levelAt(depthsAfter(depths, p), p.insertIndex) >= 0);
|
||||
|
||||
// An unterminated folder (deltas summing to +1) is the other direction.
|
||||
const std::vector<int> open{1, 0};
|
||||
const SiblingPlacement q = siblingPlacement(open, 1);
|
||||
CHECK(q.insertIndex == 2);
|
||||
CHECK(q.newDepth <= 0); // never invents a second folder open
|
||||
}
|
||||
|
||||
static void testOutOfRangeSourceIndexClamps() {
|
||||
const std::vector<int> depths{0, 0};
|
||||
// Past the end clamps to the last track; negative clamps to the first.
|
||||
CHECK(siblingPlacement(depths, 99).insertIndex == 2);
|
||||
CHECK(siblingPlacement(depths, -5).insertIndex == 1);
|
||||
// An empty project has nothing to precede the new track.
|
||||
CHECK(siblingPlacement({}, 0).insertIndex == 0);
|
||||
CHECK(siblingPlacement({}, 0).precedingIndex == -1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFlatProjectHasNoChildren();
|
||||
testFolderParentReturnsItsDirectChildren();
|
||||
@@ -88,6 +226,16 @@ int main() {
|
||||
testUnterminatedFolderSwallowsTheRest();
|
||||
testSiblingFolderAfterParentClosesIsNotIncluded();
|
||||
|
||||
testSiblingOfANormalTrackGoesDirectlyBelowIt();
|
||||
testSiblingOfAMidFolderTrackStaysInsideTheFolder();
|
||||
testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta();
|
||||
testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose();
|
||||
testSiblingOfAFolderParentLandsAfterTheWholeFolder();
|
||||
testSiblingOfTheLastTrackInTheProjectAppends();
|
||||
testSiblingOfTheLastTrackInTheProjectInsideAFolder();
|
||||
testMalformedDeltaListClampsRatherThanAsserting();
|
||||
testOutOfRangeSourceIndexClamps();
|
||||
|
||||
if (g_fail == 0) std::printf("track_topology: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -1739,6 +1739,48 @@ static void testLaneMintingEmptyFolderNotSplit() {
|
||||
|
||||
// -- D2.6 JSON round-trip with lane index + membership -----------------------
|
||||
|
||||
// An EXPLICIT Arrange record is new: the shipped "tag selected tracks -> Arrange"
|
||||
// action untags instead, so until now Arrange was only ever represented by absence.
|
||||
// The render-in-place verb writes one, because the record — not the behaviour — is
|
||||
// what the panel's auto-tag detector defers to. It must be indistinguishable from
|
||||
// absence everywhere else.
|
||||
static void testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence() {
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{TAGGED-ARRANGE}", kArrangeModeId);
|
||||
// "{UNTAGGED}" is deliberately never tagged — the comparison partner.
|
||||
|
||||
const std::string json = vm.serialize();
|
||||
const auto back = ViewModeModel::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == vm);
|
||||
if (back) CHECK(back->serialize() == json);
|
||||
|
||||
// The record survives as a record, not collapsed away on the round-trip.
|
||||
if (back) {
|
||||
const Membership* m = back->membership().query("{TAGGED-ARRANGE}");
|
||||
CHECK(m != nullptr);
|
||||
CHECK(m && m->modeIds == std::set<std::string>{kArrangeModeId});
|
||||
CHECK(back->membership().query("{UNTAGGED}") == nullptr);
|
||||
}
|
||||
|
||||
// Membership answers identically for the record and for its absence, in BOTH
|
||||
// modes — that equivalence is what makes writing the record free of behaviour.
|
||||
const auto checkEquivalent = [](const ViewModeModel& m) {
|
||||
CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kArrangeModeId) ==
|
||||
m.leafBelongsToMode("{UNTAGGED}", kArrangeModeId));
|
||||
CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kArrangeModeId));
|
||||
CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kDesignModeId) ==
|
||||
m.leafBelongsToMode("{UNTAGGED}", kDesignModeId));
|
||||
CHECK(!m.leafBelongsToMode("{TAGGED-ARRANGE}", kDesignModeId));
|
||||
};
|
||||
checkEquivalent(vm);
|
||||
if (back) checkEquivalent(*back); // and after a save/reload round-trip
|
||||
|
||||
// untag() still returns it to absence, so the existing way out still works.
|
||||
CHECK(vm.membership().untag("{TAGGED-ARRANGE}"));
|
||||
CHECK(vm.membership().query("{TAGGED-ARRANGE}") == nullptr);
|
||||
}
|
||||
|
||||
static void testLaneJsonRoundTrip() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
||||
@@ -1922,6 +1964,7 @@ int main() {
|
||||
testLaneMintingShowBothNotForceSplit();
|
||||
testLaneMintingSingleModeLeafVisibleOnceNoSplit();
|
||||
testLaneMintingEmptyFolderNotSplit();
|
||||
testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence();
|
||||
testLaneJsonRoundTrip();
|
||||
testLaneMalformedJson();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user