c1473c42e1
Add ReaSamplerSession::pruneDryRun enumerating the resolved current bank folder, feeding the R1 core, and returning a PruneReport (count/bytes/list). New pure bankRelativeForName + buildPruneReport keep spelling and tally testable. Register forever-stable BANK_PRUNE_FOLDER action, report-only. No deletion.
618 lines
29 KiB
C++
618 lines
29 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 <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring> // std::memcpy (for putF32cp in hashWavContent tests)
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
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 <projectDir>/<relativePath>, 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
|
|
// <projectDir>/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<std::uint8_t>& b, std::uint16_t v) {
|
|
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
|
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
|
}
|
|
static void putU32cp(std::vector<std::uint8_t>& b, std::uint32_t v) {
|
|
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
|
b.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
|
b.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
|
b.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
|
}
|
|
static void putTagcp(std::vector<std::uint8_t>& b, const char* t) {
|
|
for (int i = 0; i < 4; ++i) b.push_back(static_cast<std::uint8_t>(t[i]));
|
|
}
|
|
static void putF32cp(std::vector<std::uint8_t>& 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<std::uint8_t> buildTestWav(
|
|
std::uint16_t channels, std::uint32_t sampleRate,
|
|
const std::vector<float>& samples,
|
|
bool insertMeta = false,
|
|
const char* metaTag = "bext",
|
|
const std::vector<std::uint8_t>& metaBody = {}) {
|
|
|
|
std::vector<std::uint8_t> chunks;
|
|
|
|
if (insertMeta && !metaBody.empty()) {
|
|
putTagcp(chunks, metaTag);
|
|
putU32cp(chunks, static_cast<std::uint32_t>(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<std::uint32_t>(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<std::uint16_t>(channels * 4)); // blockAlign
|
|
putU16cp(chunks, 32); // bitsPerSample
|
|
|
|
// data chunk.
|
|
putTagcp(chunks, "data");
|
|
putU32cp(chunks, dataBytes);
|
|
for (float f : samples) putF32cp(chunks, f);
|
|
|
|
std::vector<std::uint8_t> wav;
|
|
putTagcp(wav, "RIFF");
|
|
putU32cp(wav, static_cast<std::uint32_t>(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<float> audio = {0.1f, -0.2f, 0.3f, -0.4f};
|
|
std::vector<std::uint8_t> metaA(64, 0x00); // bext body, all zeros (e.g. epoch)
|
|
std::vector<std::uint8_t> 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<float> audioA = {0.5f, 0.5f};
|
|
const std::vector<float> 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<float> 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<std::uint8_t> 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<std::uint8_t> 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<float> audio = {1.0f, -1.0f, 0.5f};
|
|
std::vector<std::uint8_t> 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<float> 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/<name>" (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;
|
|
}
|