feat(persist): M4 bank persistence + Save-As relocation
Serialize BankIndex to project ext state ('reasampler'), reload on project
load, resolve paths project-relative. Save-As copies the bank to the new .rpp;
identity keyed off a minted GUID (not the recycled ReaProject*) so project
switches don't clobber banks. Pure classifyProjectTransition tested.
This commit is contained in:
@@ -109,6 +109,136 @@ static void testFileStem() {
|
||||
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.
|
||||
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
|
||||
// <projectDir>/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();
|
||||
@@ -118,6 +248,19 @@ int main() {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user