36270e2064
M4's GUID-only classifier read a tab-switch between two Save-As forks (shared copied GUID, different paths) as a Save-As and clobbered a bank. Thread sameProjectObject into classifyProjectTransition: a different object always Loads, never relocates; a forked sibling gets re-GUID'd to diverge.
298 lines
14 KiB
C++
298 lines
14 KiB
C++
// Standalone tests for reasampler::capture_paths — no REAPER, no framework.
|
|
// The capture shell is DAW-bound and only verifiable in REAPER; this covers the
|
|
// one genuinely pure piece: the bank-folder / unique-name / project-relative
|
|
// path arithmetic that feeds BankIndex::add's relative-only invariant.
|
|
|
|
#include "../src/capture_paths.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 testNormalizeSlashes() {
|
|
CHECK(normalizeSlashes("C:\\a\\b") == "C:/a/b");
|
|
CHECK(normalizeSlashes("a/b/c") == "a/b/c");
|
|
CHECK(normalizeSlashes("a/b/") == "a/b"); // trailing slash stripped
|
|
CHECK(normalizeSlashes("a\\b\\") == "a/b"); // backslash + trailing
|
|
CHECK(normalizeSlashes("/") == "/"); // lone root preserved
|
|
CHECK(normalizeSlashes("") == ""); // empty stays empty
|
|
}
|
|
|
|
static void testSanitizeStem() {
|
|
// Safe characters survive verbatim.
|
|
CHECK(sanitizeStem("Kick_01.take-2") == "Kick_01.take-2");
|
|
// Spaces, slashes, quotes, control chars become '_'.
|
|
CHECK(sanitizeStem("my mix") == "my_mix");
|
|
CHECK(sanitizeStem("a/b\\c") == "a_b_c");
|
|
CHECK(sanitizeStem("q\"uote") == "q_uote");
|
|
CHECK(sanitizeStem(std::string("nul\0byte", 8)) == "nul_byte");
|
|
// Nothing usable -> stable default.
|
|
CHECK(sanitizeStem("") == "capture");
|
|
CHECK(sanitizeStem(" ") == "capture");
|
|
// All-separator (no alphanumeric) -> default, so the name is meaningful.
|
|
CHECK(sanitizeStem("...") == "capture");
|
|
CHECK(sanitizeStem("-_-") == "capture");
|
|
}
|
|
|
|
static void testDeriveRelativePathIsProjectRelative() {
|
|
BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "master mix", "1753080000");
|
|
// Relative path is under the fixed bank subfolder, forward-slashed, .wav.
|
|
CHECK(p.relativePath == "reasampler_bank/master_mix_1753080000.wav");
|
|
// It must NOT be absolute by any of BankIndex::add's rejection rules:
|
|
// no leading '/', no drive letter, no backslash, no UNC prefix.
|
|
CHECK(p.relativePath.find(':') == std::string::npos);
|
|
CHECK(p.relativePath.find('\\') == std::string::npos);
|
|
CHECK(!p.relativePath.empty() && p.relativePath[0] != '/');
|
|
CHECK(p.relativePath.rfind("\\\\", 0) != 0);
|
|
}
|
|
|
|
static void testDeriveAbsoluteDirJoinsProjectDir() {
|
|
BankPaths p = deriveBankPaths("C:\\Users\\d\\proj", "kick", "");
|
|
// Backslashes normalized; bank subfolder appended; no trailing slash.
|
|
CHECK(p.absoluteDir == "C:/Users/d/proj/reasampler_bank");
|
|
// No unique tag -> stem has no trailing "_".
|
|
CHECK(p.fileName == "kick.wav");
|
|
CHECK(p.relativePath == "reasampler_bank/kick.wav");
|
|
}
|
|
|
|
static void testDeriveHandlesTrailingSlashProjectDir() {
|
|
// A project dir with a trailing slash must not double up in the join.
|
|
BankPaths p = deriveBankPaths("/home/d/proj/", "mix", "7");
|
|
CHECK(p.absoluteDir == "/home/d/proj/reasampler_bank");
|
|
CHECK(p.fileName == "mix_7.wav");
|
|
}
|
|
|
|
static void testDeriveEmptyProjectDirIsRejected() {
|
|
// Precondition: deriveBankPaths requires a non-empty projectDir.
|
|
// In debug builds the assert(!dir.empty()) fires immediately and aborts
|
|
// the process — that IS the check, so we don't call into it there.
|
|
// In release/NDEBUG builds the assert is elided; we verify the fallback
|
|
// contract: absoluteDir is left empty (not a bare "reasampler_bank") so
|
|
// any caller that ignores the precondition fails loudly at the render/stat
|
|
// step rather than silently writing to CWD.
|
|
#ifdef NDEBUG
|
|
BankPaths p = deriveBankPaths("", "mix", "");
|
|
CHECK(p.absoluteDir.empty());
|
|
CHECK(p.relativePath == "reasampler_bank/mix.wav");
|
|
#endif
|
|
// Debug: assert fires on the call above — contract verified by the crash.
|
|
}
|
|
|
|
static void testDeterministicForSameInputs() {
|
|
// Same inputs -> same derived paths (feeds deterministic file naming).
|
|
BankPaths a = deriveBankPaths("C:/p", "mix", "42");
|
|
BankPaths b = deriveBankPaths("C:/p", "mix", "42");
|
|
CHECK(a.absoluteDir == b.absoluteDir);
|
|
CHECK(a.relativePath == b.relativePath);
|
|
CHECK(a.fileName == b.fileName);
|
|
}
|
|
|
|
static void testFileStem() {
|
|
// fileStem is the stem component of fileName (no extension). The capture
|
|
// backend passes fileStem directly to RENDER_PATTERN because REAPER appends
|
|
// the format extension itself — the backend must not re-derive or re-strip it.
|
|
BankPaths p = deriveBankPaths("C:/p", "master mix", "123");
|
|
CHECK(p.fileStem == "master_mix_123");
|
|
CHECK(p.fileName == "master_mix_123.wav");
|
|
// fileStem + ".wav" must equal fileName (the invariant the backend relies on).
|
|
CHECK(p.fileStem + ".wav" == p.fileName);
|
|
|
|
// No tag: stem only.
|
|
BankPaths q = deriveBankPaths("C:/p", "kick", "");
|
|
CHECK(q.fileStem == "kick");
|
|
CHECK(q.fileName == "kick.wav");
|
|
CHECK(q.fileStem + ".wav" == q.fileName);
|
|
}
|
|
|
|
// --- Persist-side path arithmetic (M4) --------------------------------------
|
|
|
|
static void testResolveBankFileAgainstProjectDir() {
|
|
// A relative index entry resolves to <projectDir>/<relativePath>, forward-
|
|
// slashed, regardless of the input slash style.
|
|
CHECK(resolveBankFile("C:\\Users\\d\\proj", "reasampler_bank/kick.wav")
|
|
== "C:/Users/d/proj/reasampler_bank/kick.wav");
|
|
CHECK(resolveBankFile("/home/d/proj", "reasampler_bank/mix.wav")
|
|
== "/home/d/proj/reasampler_bank/mix.wav");
|
|
// Trailing slash on the project dir must not double up.
|
|
CHECK(resolveBankFile("/home/d/proj/", "reasampler_bank/mix.wav")
|
|
== "/home/d/proj/reasampler_bank/mix.wav");
|
|
// Backslashes in the stored relative path are normalized on resolution.
|
|
CHECK(resolveBankFile("/p", "reasampler_bank\\a.wav")
|
|
== "/p/reasampler_bank/a.wav");
|
|
}
|
|
|
|
static void testResolveBankFileRejectsEmptyInputs() {
|
|
// No default-location fallback (CLAUDE.md invariant): empty project dir or
|
|
// empty relative path yields empty, never a bare relative resolved to CWD.
|
|
CHECK(resolveBankFile("", "reasampler_bank/kick.wav").empty());
|
|
CHECK(resolveBankFile("C:/p", "").empty());
|
|
CHECK(resolveBankFile("", "").empty());
|
|
}
|
|
|
|
static void testResolveIsInverseOfDerive() {
|
|
// The path a capture stored (relativePath) resolves back to the same file the
|
|
// capture wrote (absoluteDir/fileName) when resolved against the SAME project
|
|
// dir. This is the round-trip persist relies on.
|
|
const std::string projectDir = "C:/Users/d/proj";
|
|
BankPaths p = deriveBankPaths(projectDir, "master mix", "1753080000");
|
|
const std::string absoluteFile = p.absoluteDir + "/" + p.fileName;
|
|
CHECK(resolveBankFile(projectDir, p.relativePath) == absoluteFile);
|
|
}
|
|
|
|
static void testResolveAgainstNewProjectDirAfterSaveAs() {
|
|
// The Save-As guarantee: the SAME stored relative path, resolved against a
|
|
// NEW project dir, points into the new project's bank. The index does not
|
|
// need rewriting — resolution against the current dir does the work.
|
|
BankPaths p = deriveBankPaths("/old/proj", "kick", "7");
|
|
CHECK(resolveBankFile("/new/place/proj", p.relativePath)
|
|
== "/new/place/proj/reasampler_bank/kick_7.wav");
|
|
}
|
|
|
|
static void testRelocationPlanForSaveAs() {
|
|
// Save-As to a different directory: relocation is needed; both bank dirs are
|
|
// <projectDir>/reasampler_bank, forward-slashed, no trailing slash.
|
|
BankRelocation r = deriveRelocationPlan("C:\\old\\proj", "C:/new/proj");
|
|
CHECK(r.needed);
|
|
CHECK(r.oldBankDir == "C:/old/proj/reasampler_bank");
|
|
CHECK(r.newBankDir == "C:/new/proj/reasampler_bank");
|
|
}
|
|
|
|
static void testRelocationPlanNotNeededForSaveInPlace() {
|
|
// Save in place (same dir, any slash style) -> no relocation.
|
|
BankRelocation r = deriveRelocationPlan("/home/d/proj", "/home/d/proj/");
|
|
CHECK(!r.needed);
|
|
// Dirs still computed (harmless), but needed=false is the load-bearing bit.
|
|
CHECK(r.oldBankDir == "/home/d/proj/reasampler_bank");
|
|
CHECK(r.newBankDir == "/home/d/proj/reasampler_bank");
|
|
}
|
|
|
|
static void testRelocationPlanEmptyInputsNoOp() {
|
|
// First-ever save (no old dir) or missing new dir -> nothing to relocate.
|
|
CHECK(!deriveRelocationPlan("", "/new/proj").needed);
|
|
CHECK(!deriveRelocationPlan("/old/proj", "").needed);
|
|
CHECK(!deriveRelocationPlan("", "").needed);
|
|
}
|
|
|
|
// --- Project-identity transition (W10 forked-project fix) -------------------
|
|
//
|
|
// Signature: classifyProjectTransition(sameProjectObject, lastGuid, lastPath,
|
|
// currentGuid, currentPath).
|
|
// The first arg — did the SAME ReaProject* stay active across the two ticks —
|
|
// is the primary signal; poll() computes it as `proj == lastProject_`.
|
|
|
|
static void testTransitionForkTabSwitchLoadsNeverRelocates() {
|
|
// THE W10 BUG. proj2 and proj3 are forked siblings (Save-As copied the .rpp
|
|
// incl. our GUID), so BOTH carry the same non-empty GUID on disk but sit at
|
|
// different paths. Tab-switching between them is a DIFFERENT project object
|
|
// (sameProjectObject == false). The old GUID-only classifier read this as a
|
|
// Save-As and clobbered a bank; with the pointer back it is a Load, so neither
|
|
// bank is ever relocated. This is the regression made provable outside the DAW.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
|
|
"guidShared", "/proj2/p.rpp",
|
|
"guidShared", "/proj3/p.rpp")
|
|
== ProjectTransition::Load);
|
|
// Switching back the other way is likewise a different object -> Load.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
|
|
"guidShared", "/proj3/p.rpp",
|
|
"guidShared", "/proj2/p.rpp")
|
|
== ProjectTransition::Load);
|
|
}
|
|
|
|
static void testTransitionGenuineSaveAsRelocates() {
|
|
// The SAME project object (pointer unchanged) saved to a new .rpp location ->
|
|
// the one case that legitimately relocates the bank. Same GUID, new path.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
|
|
"guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
|
|
== ProjectTransition::SaveAsRelocate);
|
|
}
|
|
|
|
static void testTransitionPointerReuseDifferentGuidLoads() {
|
|
// Pointer *reuse* for a genuinely different project: at THIS tick the object
|
|
// differs (sameProjectObject == false) and its GUID differs too -> Load. Never
|
|
// a relocate. This is the case the M4 GUID was originally added to handle;
|
|
// the pointer check subsumes it (different object) and the GUID corroborates.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
|
|
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
|
|
== ProjectTransition::Load);
|
|
}
|
|
|
|
static void testTransitionTabSwitchDistinctProjectsLoads() {
|
|
// Ordinary tab-switch between two distinct (non-forked) saved projects:
|
|
// different object, different GUID -> Load, regardless of paths.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
|
|
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
|
|
== ProjectTransition::Load);
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
|
|
"guidB", "/b/b.rpp", "guidA", "/a/a.rpp")
|
|
== ProjectTransition::Load);
|
|
}
|
|
|
|
static void testTransitionSaveInPlaceIsNoOp() {
|
|
// Same object, same path (idle tick, or a Save that did not move the .rpp) ->
|
|
// nothing to do.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
|
|
"guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
|
|
== ProjectTransition::NoOp);
|
|
// Empty GUID (unsaved project sitting idle), same (empty) path -> NoOp too.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/true, "", "", "", "")
|
|
== ProjectTransition::NoOp);
|
|
}
|
|
|
|
static void testTransitionFirstSaveSameObjectRelocatesButPlanNoOps() {
|
|
// Same object, empty GUID, path appears (first save of an untitled project).
|
|
// The classifier says SaveAsRelocate, but the empty-GUID safety is preserved
|
|
// at execution: the old project dir is empty, so deriveRelocationPlan makes
|
|
// `needed` false and NOTHING is physically relocated; poll() just mints a GUID.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
|
|
"", "", "", "/a/a.rpp")
|
|
== ProjectTransition::SaveAsRelocate);
|
|
// Prove the safety end-to-end: the relocation plan for an empty old dir no-ops.
|
|
CHECK(!deriveRelocationPlan(/*oldProjectDir=*/"", "/a").needed);
|
|
}
|
|
|
|
static void testTransitionSameObjectSharedGuidStillLoadsWhenObjectDiffers() {
|
|
// Divergence guard's precondition, from the pure side: even if a forked sibling
|
|
// still shares our GUID, as long as the OBJECT differs the verdict is Load
|
|
// (never Save-As). The actual re-GUID that makes the siblings diverge is a
|
|
// REAPER-facing action in poll() (SetProjExtState + MarkProjectDirty) and is
|
|
// covered by the DAW procedure; here we lock the pure verdict that gates it.
|
|
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
|
|
"guidShared", "/proj2/p.rpp",
|
|
"guidShared", "/proj3/p.rpp")
|
|
== ProjectTransition::Load);
|
|
}
|
|
|
|
int main() {
|
|
testNormalizeSlashes();
|
|
testSanitizeStem();
|
|
testDeriveRelativePathIsProjectRelative();
|
|
testDeriveAbsoluteDirJoinsProjectDir();
|
|
testDeriveHandlesTrailingSlashProjectDir();
|
|
testDeriveEmptyProjectDirIsRejected();
|
|
testDeterministicForSameInputs();
|
|
testFileStem();
|
|
testResolveBankFileAgainstProjectDir();
|
|
testResolveBankFileRejectsEmptyInputs();
|
|
testResolveIsInverseOfDerive();
|
|
testResolveAgainstNewProjectDirAfterSaveAs();
|
|
testRelocationPlanForSaveAs();
|
|
testRelocationPlanNotNeededForSaveInPlace();
|
|
testRelocationPlanEmptyInputsNoOp();
|
|
testTransitionForkTabSwitchLoadsNeverRelocates();
|
|
testTransitionGenuineSaveAsRelocates();
|
|
testTransitionPointerReuseDifferentGuidLoads();
|
|
testTransitionTabSwitchDistinctProjectsLoads();
|
|
testTransitionSaveInPlaceIsNoOp();
|
|
testTransitionFirstSaveSameObjectRelocatesButPlanNoOps();
|
|
testTransitionSameObjectSharedGuidStillLoadsWhenObjectDiffers();
|
|
|
|
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
|
|
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);
|
|
return g_fail ? 1 : 0;
|
|
}
|