// 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 #include 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 /, 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 // /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; }