// 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 BankModel::add's relative-only invariant. #include "../src/core/capture/capture_paths.h" #include #include using namespace reasampler; using namespace reasampler::capture; 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() { #ifdef _WIN32 // On Windows paths are lowercased for case-insensitive comparison. 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 #else CHECK(normalizeSlashes("C:\\a\\b") == "C:/a/b"); CHECK(normalizeSlashes("a/b/c") == "a/b/c"); CHECK(normalizeSlashes("a/b/") == "a/b"); CHECK(normalizeSlashes("a\\b\\") == "a/b"); CHECK(normalizeSlashes("/") == "/"); CHECK(normalizeSlashes("") == ""); #endif } // Windows case-folding: paths differing only in casing must compare equal after // normalizeSlashes, since Windows paths are case-insensitive. On non-Windows the // function is case-preserving (filesystem is case-sensitive). static void testNormalizeSlashesCaseFolding() { #ifdef _WIN32 // Drive letter and component casing differences are neutralized. CHECK(normalizeSlashes("C:/Foo/BAR.wav") == normalizeSlashes("c:/foo/bar.wav")); CHECK(normalizeSlashes("C:/Foo/BAR.wav") == "c:/foo/bar.wav"); // Mixed-case input produces consistently lowercase output. CHECK(normalizeSlashes("C:\\Users\\Daniel\\Proj\\File.WAV") == "c:/users/daniel/proj/file.wav"); #else // Non-Windows: case is preserved exactly (case-sensitive filesystem). CHECK(normalizeSlashes("C:/Foo/BAR.wav") != normalizeSlashes("c:/foo/bar.wav")); CHECK(normalizeSlashes("C:/Foo/BAR.wav") == "C:/Foo/BAR.wav"); #endif } 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 BankModel::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. // On Windows the drive-letter + components are lowercased by normalizeSlashes. #ifdef _WIN32 CHECK(p.absoluteDir == "c:/users/d/proj/reasampler_bank"); #else CHECK(p.absoluteDir == "C:/Users/d/proj/reasampler_bank"); #endif // 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. On Windows the result is also // lowercased (Windows paths are case-insensitive; normalizeSlashes folds them). #ifdef _WIN32 CHECK(resolveBankFile("C:\\Users\\d\\proj", "reasampler_bank/kick.wav") == "c:/users/d/proj/reasampler_bank/kick.wav"); // Backslashes in the stored relative path are normalized on resolution. CHECK(resolveBankFile("C:/p", "reasampler_bank\\a.wav") == "c:/p/reasampler_bank/a.wav"); #else 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"); #endif } 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. // On Windows the drive-letter and path components are lowercased. BankRelocation r = deriveRelocationPlan("C:\\old\\proj", "C:/new/proj"); CHECK(r.needed); #ifdef _WIN32 CHECK(r.oldBankDir == "c:/old/proj/reasampler_bank"); CHECK(r.newBankDir == "c:/new/proj/reasampler_bank"); #else CHECK(r.oldBankDir == "C:/old/proj/reasampler_bank"); CHECK(r.newBankDir == "C:/new/proj/reasampler_bank"); #endif } 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); } // NOTE (Q-W3, audit §4e): the hashBytes / hashWavContent tests moved to // tests/test_wav_codec.cpp with the implementations — capture_paths is now path // arithmetic only, with no content-hash / RIFF knowledge. // --- bankRelativeForName spelling consistency (Phase R, R2) ----------------- // // The safety-critical property: the relative spelling the prune shell derives for an // ENUMERATED folder entry (bankRelativeForName) must be byte-identical to the spelling // the capture path stored in the index (deriveBankPaths().relativePath) for the same // file name. A divergence here could make a referenced file look like an orphan. static void testBankRelativeForNameMatchesDerivePathSpelling() { // For a file the capture path created, deriveBankPaths produced relativePath; // a directory listing yields the bare file name. bankRelativeForName(name) must // reproduce the SAME string, or the pure core's exact-string match misfires. const BankPaths p = deriveBankPaths("/proj", "kick", "001"); // p.fileName is the on-disk entry name a folder enumeration would return. CHECK(bankRelativeForName(p.fileName) == p.relativePath); } static void testBankRelativeForNameConventionAndEdge() { // The convention verbatim: "reasampler_bank/" (the one place the spelling lives). CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav"); // Empty in -> empty out (a defensive guard; a real enumeration never yields ""). CHECK(bankRelativeForName("").empty()); } int main() { testNormalizeSlashes(); testNormalizeSlashesCaseFolding(); testSanitizeStem(); testDeriveRelativePathIsProjectRelative(); testDeriveAbsoluteDirJoinsProjectDir(); testDeriveHandlesTrailingSlashProjectDir(); testDeriveEmptyProjectDirIsRejected(); testDeterministicForSameInputs(); testFileStem(); testResolveBankFileAgainstProjectDir(); testResolveBankFileRejectsEmptyInputs(); testResolveIsInverseOfDerive(); testResolveAgainstNewProjectDirAfterSaveAs(); testRelocationPlanForSaveAs(); testRelocationPlanNotNeededForSaveInPlace(); testRelocationPlanEmptyInputsNoOp(); testTransitionRecycledPointerReopenDifferentProjectLoads(); testTransitionOpenNewUnsavedFromSavedLoads(); testTransitionForkTabSwitchLoadsNeverRelocates(); testTransitionGenuineSaveAsRelocates(); testTransitionReopenSameProjectRecycledSameAddrIsNoOp(); testTransitionTwoDistinctSavedProjectsDistinctPointersLoad(); testTransitionTwoUnsavedProjectsSwitchLoads(); testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps(); testTransitionInPlaceSaveIsNoOp(); testBankRelativeForNameMatchesDerivePathSpelling(); testBankRelativeForNameConventionAndEdge(); 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; }