Files
reasampler/tests/test_capture_paths.cpp
T
daniel affde0ef53 fix: layer GUID-primary project identity so reopened/new projects reload the bank
classifyProjectTransition now checks the stored GUID first, then the pointer,
fixing the w10 regression where a recycled ReaProject* address stopped the bank
reloading. poll()'s fork re-GUID gate is bound to !sameProjectObject. Full
transition matrix pinned in tests.
2026-07-23 04:46:36 -04:00

326 lines
16 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 (W12 combined identity fix) ----------------
//
// Signature: classifyProjectTransition(sameProjectObject, lastGuid, lastPath,
// currentGuid, currentPath).
// Identity is layered GUID-PRIMARY: the stored GUID (identity of record) leads;
// the pointer only disambiguates the same-GUID case. poll() computes
// sameProjectObject as `proj == lastProject_`. This matrix covers every branch —
// the classifier has regressed twice, so every case is pinned.
static void testTransitionRecycledPointerReopenDifferentProjectLoads() {
// THE W12 REGRESSION. REAPER recycled the previous project's ReaProject* address
// for a DIFFERENT reopened saved project, so sameProjectObject == true, but the
// reopened project carries its OWN (different, non-empty) stored GUID. The old
// pointer-primary classifier decided on path alone and returned NoOp (same path)
// or SaveAsRelocate (new path) — the bank never reloaded. GUID-first makes this
// a Load regardless of path.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidB", "/b/b.rpp")
== ProjectTransition::Load);
// Same recycled-address regression, but the reopened project happens to sit at
// the SAME path as the one we left (old code returned NoOp here). Still a Load.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidB", "/a/a.rpp")
== ProjectTransition::Load);
}
static void testTransitionOpenNewUnsavedFromSavedLoads() {
// Open a new/unsaved project from a saved one, recycled onto the same address
// (sameProjectObject == true): currentGuid empty, lastGuid non-empty -> the
// record identity differs -> Load (so the bank clears to the new empty project).
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "", "")
== ProjectTransition::Load);
}
static void testTransitionForkTabSwitchLoadsNeverRelocates() {
// THE W10 CASE — must stay fixed. 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). Same GUID -> step 1 falls through; step 2
// (!sameProjectObject) -> Load, so neither bank is ever relocated.
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) AND same GUID saved to a new .rpp
// location -> the one case that legitimately relocates the bank (step 3).
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/b/b.rpp")
== ProjectTransition::SaveAsRelocate);
}
static void testTransitionReopenSameProjectRecycledSameAddrIsNoOp() {
// Reopen the SAME project, recycled onto the same address: same object, same
// (non-empty) GUID, same path -> nothing changed -> NoOp (step 4).
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::NoOp);
}
static void testTransitionTwoDistinctSavedProjectsDistinctPointersLoad() {
// Ordinary tab-switch between two distinct (non-forked) saved projects: distinct
// pointers, different GUIDs -> step 1 (GUID differs) -> 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 testTransitionTwoUnsavedProjectsSwitchLoads() {
// Switch between two unsaved projects: both GUIDs empty (step 1 falls through:
// equal), distinct objects -> step 2 (!sameProjectObject) -> Load. Installs the
// right in-memory (empty) state for whichever unsaved project is now active.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"", "", "", "")
== ProjectTransition::Load);
// Distinct unsaved objects may even report distinct (untitled) paths -> Load.
CHECK(classifyProjectTransition(/*sameProjectObject=*/false,
"", "/untitled1", "", "/untitled2")
== ProjectTransition::Load);
}
static void testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps() {
// First save of an unsaved project: same object, both GUIDs empty (step 1 & 2
// fall through), path appears (step 3) -> SaveAsRelocate. The empty-GUID safety
// is preserved at execution: the old project dir is empty, so deriveRelocation-
// Plan makes `needed` false and NOTHING is physically relocated; poll() 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 == false);
}
static void testTransitionInPlaceSaveIsNoOp() {
// In-place save (or an idle tick): same object, same GUID, same path -> NoOp.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true,
"guidA", "/a/a.rpp", "guidA", "/a/a.rpp")
== ProjectTransition::NoOp);
// Idle unsaved project (same object, both empty GUID, same empty path) -> NoOp.
CHECK(classifyProjectTransition(/*sameProjectObject=*/true, "", "", "", "")
== ProjectTransition::NoOp);
}
int main() {
testNormalizeSlashes();
testSanitizeStem();
testDeriveRelativePathIsProjectRelative();
testDeriveAbsoluteDirJoinsProjectDir();
testDeriveHandlesTrailingSlashProjectDir();
testDeriveEmptyProjectDirIsRejected();
testDeterministicForSameInputs();
testFileStem();
testResolveBankFileAgainstProjectDir();
testResolveBankFileRejectsEmptyInputs();
testResolveIsInverseOfDerive();
testResolveAgainstNewProjectDirAfterSaveAs();
testRelocationPlanForSaveAs();
testRelocationPlanNotNeededForSaveInPlace();
testRelocationPlanEmptyInputsNoOp();
testTransitionRecycledPointerReopenDifferentProjectLoads();
testTransitionOpenNewUnsavedFromSavedLoads();
testTransitionForkTabSwitchLoadsNeverRelocates();
testTransitionGenuineSaveAsRelocates();
testTransitionReopenSameProjectRecycledSameAddrIsNoOp();
testTransitionTwoDistinctSavedProjectsDistinctPointersLoad();
testTransitionTwoUnsavedProjectsSwitchLoads();
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
testTransitionInPlaceSaveIsNoOp();
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;
}