// 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 #include // std::memcpy (for putF32cp in hashWavContent tests) #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() { #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 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. // 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); } // --- hashBytes (FNV-1a content hash) ---------------------------------------- // // The fix for the confirm-on-last-reference bug: hashBytes produces a 16-char hex // string that capture.cpp and capture_realtime.cpp store on Sample::contentHash so // BankBook::hashReferencedElsewhere can detect copies and suppress the confirm when // another bank still holds the same file. static void testHashBytesOutputFormat() { // Output is always 16 lowercase hex characters. const std::uint8_t bytes[] = {0x01, 0x02, 0x03}; const std::string h = hashBytes(bytes, 3); CHECK(h.size() == 16); for (char c : h) { CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')); } } static void testHashBytesDeterministic() { // Same input always produces the same output (bit-identical captures get // the same hash, so hashReferencedElsewhere fires correctly for copies). const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01}; CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5)); } static void testHashBytesDistinct() { // Different inputs produce different hashes (no accidental dedup of distinct // files). This covers the "one-bit-flip changes the hash" property. std::uint8_t a[] = {0x00, 0x00}; std::uint8_t b[] = {0x00, 0x01}; CHECK(hashBytes(a, 2) != hashBytes(b, 2)); std::uint8_t c[] = {0xFF, 0xFF, 0xFF}; std::uint8_t d[] = {0xFF, 0xFF, 0xFE}; CHECK(hashBytes(c, 3) != hashBytes(d, 3)); } static void testHashBytesEmptyBufferIsNonEmpty() { // An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty // sentinel) — capturing the contract that even empty inputs yield a 16-char hash. const std::string h = hashBytes(nullptr, 0); CHECK(h.size() == 16); } static void testHashBytesLargerBufferDiffersFromSmaller() { // Padding a buffer with a zero byte must change the hash (order + length // sensitivity so two differently-sized WAV files don't accidentally collide). const std::uint8_t short_buf[] = {0xAB, 0xCD}; const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00}; CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3)); } // --- hashWavContent (WAV-aware dedup hash) ----------------------------------- // // Verifies that the WAV-content hash hashes only fmt+data (skipping metadata // chunks like bext/LIST), falls back gracefully for non-WAV input, and that // different audio data yields different hashes. // Minimal synthetic WAV builder (mirrors the one in test_wav_trim.cpp). static void putU16cp(std::vector& b, std::uint16_t v) { b.push_back(static_cast(v & 0xFF)); b.push_back(static_cast((v >> 8) & 0xFF)); } static void putU32cp(std::vector& b, std::uint32_t v) { b.push_back(static_cast(v & 0xFF)); b.push_back(static_cast((v >> 8) & 0xFF)); b.push_back(static_cast((v >> 16) & 0xFF)); b.push_back(static_cast((v >> 24) & 0xFF)); } static void putTagcp(std::vector& b, const char* t) { for (int i = 0; i < 4; ++i) b.push_back(static_cast(t[i])); } static void putF32cp(std::vector& b, float f) { std::uint8_t tmp[4]; std::memcpy(tmp, &f, 4); for (int i = 0; i < 4; ++i) b.push_back(tmp[i]); } // Builds a minimal 32-bit-float RIFF/WAVE with an optional metadata chunk // inserted between "WAVE" and the fmt chunk. `metaChunkBody` and `metaTag` are // used when `insertMeta` is true. This is the shape REAPER produces: a `bext` // or `LIST` chunk before fmt with a render-time timestamp in the body. static std::vector buildTestWav( std::uint16_t channels, std::uint32_t sampleRate, const std::vector& samples, bool insertMeta = false, const char* metaTag = "bext", const std::vector& metaBody = {}) { std::vector chunks; if (insertMeta && !metaBody.empty()) { putTagcp(chunks, metaTag); putU32cp(chunks, static_cast(metaBody.size())); chunks.insert(chunks.end(), metaBody.begin(), metaBody.end()); if (metaBody.size() & 1u) chunks.push_back(0); // RIFF pad } // fmt chunk (16-byte body, IEEE-float tag 3). const std::uint32_t dataBytes = static_cast(samples.size() * 4u); putTagcp(chunks, "fmt "); putU32cp(chunks, 16); putU16cp(chunks, 3); // IEEE float putU16cp(chunks, channels); putU32cp(chunks, sampleRate); putU32cp(chunks, sampleRate * channels * 4u); // byteRate putU16cp(chunks, static_cast(channels * 4)); // blockAlign putU16cp(chunks, 32); // bitsPerSample // data chunk. putTagcp(chunks, "data"); putU32cp(chunks, dataBytes); for (float f : samples) putF32cp(chunks, f); std::vector wav; putTagcp(wav, "RIFF"); putU32cp(wav, static_cast(4 + chunks.size())); putTagcp(wav, "WAVE"); wav.insert(wav.end(), chunks.begin(), chunks.end()); return wav; } static void testHashWavContentIdenticalAudioSameHash() { // Two WAVs with the same audio but different metadata body -> same hash. // This is the core dedup regression: REAPER embeds a bext chunk with a // render-time origination timestamp; without WAV-aware hashing, two renders // of the same clip produce different file bytes -> no dedup collapse. const std::vector audio = {0.1f, -0.2f, 0.3f, -0.4f}; std::vector metaA(64, 0x00); // bext body, all zeros (e.g. epoch) std::vector metaB(64, 0x00); // Different origination timestamps: first 10 bytes of bext are ASCII date/time. metaB[0] = '2'; metaB[1] = '0'; metaB[2] = '2'; metaB[3] = '6'; // year auto wavA = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaA); auto wavB = buildTestWav(1, 44100, audio, /*meta=*/true, "bext", metaB); // Files must differ (the bext body is different) to prove the test is valid. CHECK(wavA != wavB); // But their content hashes must be equal: same fmt+data, different metadata. CHECK(hashWavContent(wavA) == hashWavContent(wavB)); } static void testHashWavContentDifferentAudioDifferentHash() { // Different PCM data -> different content hashes (no false dedup). const std::vector audioA = {0.5f, 0.5f}; const std::vector audioB = {0.5f, 0.6f}; // last sample differs auto wavA = buildTestWav(1, 44100, audioA); auto wavB = buildTestWav(1, 44100, audioB); CHECK(hashWavContent(wavA) != hashWavContent(wavB)); } static void testHashWavContentDifferentFmtDifferentHash() { // Different fmt fields (sample rate) -> different content hashes. const std::vector audio = {0.1f, 0.2f}; auto wav44 = buildTestWav(1, 44100, audio); auto wav48 = buildTestWav(1, 48000, audio); CHECK(hashWavContent(wav44) != hashWavContent(wav48)); } static void testHashWavContentNonWavFallsBackToWholeFile() { // Non-WAV bytes -> falls back to whole-file hashBytes; result is non-empty // and equals hashBytes of the same bytes directly. std::vector notWav = {0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02}; const std::string h = hashWavContent(notWav); CHECK(!h.empty()); CHECK(h.size() == 16); CHECK(h == hashBytes(notWav.data(), notWav.size())); } static void testHashWavContentEmptyFallsBackToHashBytes() { // Empty vector -> falls back to whole-file hashBytes (the FNV offset basis). std::vector empty; const std::string h = hashWavContent(empty); CHECK(!h.empty()); CHECK(h.size() == 16); CHECK(h == hashBytes(nullptr, 0)); } static void testHashWavContentListMetaSkipped() { // A LIST/INFO chunk (another common metadata chunk) is likewise skipped. const std::vector audio = {1.0f, -1.0f, 0.5f}; std::vector listBody = {'I','N','F','O', 'x','x','x','x'}; auto wavClean = buildTestWav(1, 48000, audio); auto wavList = buildTestWav(1, 48000, audio, true, "LIST", listBody); // Content hashes must match: only the LIST chunk differs. CHECK(hashWavContent(wavClean) == hashWavContent(wavList)); } static void testHashWavContentDomainSeparationFromWholeFile() { // The content hash ('W'-prefixed) must not accidentally equal the whole-file // hash of the SAME bytes. This guards against the domain-separation prefix // being dropped or zeroed out. const std::vector audio = {0.0f}; auto wav = buildTestWav(1, 44100, audio); const std::string contentHash = hashWavContent(wav); const std::string wholeHash = hashBytes(wav.data(), wav.size()); CHECK(contentHash != wholeHash); } // --- 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(); testHashBytesOutputFormat(); testHashBytesDeterministic(); testHashBytesDistinct(); testHashBytesEmptyBufferIsNonEmpty(); testHashBytesLargerBufferDiffersFromSmaller(); testHashWavContentIdenticalAudioSameHash(); testHashWavContentDifferentAudioDifferentHash(); testHashWavContentDifferentFmtDifferentHash(); testHashWavContentNonWavFallsBackToWholeFile(); testHashWavContentEmptyFallsBackToHashBytes(); testHashWavContentListMetaSkipped(); testHashWavContentDomainSeparationFromWholeFile(); 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; }