// 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 (M4 defect fix) ---------------------------- static void testTransitionRecycledPointerLoadsNotRelocates() { // THE ORIGINAL BUG. Project A (guidA, /a/a.rpp) is closed; REAPER makes a // different saved project B active with a recycled pointer. B carries its own // GUID and a different path. With identity keyed on the GUID this is a Load, // NOT a Save-As — so the bank is never clobbered. CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/b/b.rpp") == ProjectTransition::Load); // Even the adversarial shape — recycled pointer AND B happens to sit at a // path the old check would misread — resolves to Load purely on the GUID. CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/a/other.rpp") == ProjectTransition::Load); } static void testTransitionGenuineSaveAsRelocates() { // Same project (same non-empty GUID) saved to a new .rpp location -> the one // case that legitimately relocates the bank. CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidA", "/b/b.rpp") == ProjectTransition::SaveAsRelocate); } static void testTransitionTabSwitchLoads() { // Switching to another open project (different GUID) -> Load, never relocate, // regardless of whether the paths differ. CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidB", "/b/b.rpp") == ProjectTransition::Load); // Switching back also loads (its GUID differs from the one just seen). CHECK(classifyProjectTransition("guidB", "/b/b.rpp", "guidA", "/a/a.rpp") == ProjectTransition::Load); } static void testTransitionSaveInPlaceIsNoOp() { // Same GUID, same path (idle tick, or a Save that did not move the .rpp) -> // nothing to do. CHECK(classifyProjectTransition("guidA", "/a/a.rpp", "guidA", "/a/a.rpp") == ProjectTransition::NoOp); } static void testTransitionEmptyGuidPathChangeLoadsNeverRelocates() { // No GUID on either side (unsaved projects / first-save) means we CANNOT // prove the two ticks saw the same project. A path change is then a Load // (first save, or a switch between unsaved projects) — never a relocate, // which is the safe direction (no destructive copy-over without proof). CHECK(classifyProjectTransition("", "", "", "/a/a.rpp") == ProjectTransition::Load); CHECK(classifyProjectTransition("", "/tmp/untitled.rpp", "", "/a/a.rpp") == ProjectTransition::Load); // Both empty, same path -> idle unsaved project -> NoOp. CHECK(classifyProjectTransition("", "", "", "") == ProjectTransition::NoOp); } static void testTransitionGuidAppearingLoads() { // A project that had no stored GUID (last seen empty) now reports one (we just // minted+wrote it, or an older project gained one). The GUID changed, so it // classifies as Load — harmless: loadFromProject re-reads the same ext state. CHECK(classifyProjectTransition("", "/a/a.rpp", "guidA", "/a/a.rpp") == ProjectTransition::Load); } int main() { testNormalizeSlashes(); testSanitizeStem(); testDeriveRelativePathIsProjectRelative(); testDeriveAbsoluteDirJoinsProjectDir(); testDeriveHandlesTrailingSlashProjectDir(); testDeriveEmptyProjectDirIsRejected(); testDeterministicForSameInputs(); testFileStem(); testResolveBankFileAgainstProjectDir(); testResolveBankFileRejectsEmptyInputs(); testResolveIsInverseOfDerive(); testResolveAgainstNewProjectDirAfterSaveAs(); testRelocationPlanForSaveAs(); testRelocationPlanNotNeededForSaveInPlace(); testRelocationPlanEmptyInputsNoOp(); testTransitionRecycledPointerLoadsNotRelocates(); testTransitionGenuineSaveAsRelocates(); testTransitionTabSwitchLoads(); testTransitionSaveInPlaceIsNoOp(); testTransitionEmptyGuidPathChangeLoadsNeverRelocates(); testTransitionGuidAppearingLoads(); 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; }