feat(persist): owned-file manifest seam — capture records created files (B-cap)
Pure OwnedFileManifest (relative paths, dedup, JSON round-trip) persisted under sibling owned_files ext-state key; both capture commit paths record; joins the R-B undo-reload set. Phase R prune consumes it later.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// Standalone tests for reasampler::OwnedFileManifest — no REAPER, no framework.
|
||||
// The owned-file manifest seam (Phase B B-cap): a deduplicated, insertion-ordered
|
||||
// set of project-relative files the capture path created, with JSON round-trip.
|
||||
//
|
||||
// Covers (brief-named): JSON round-trip, dedup of repeated adds, the empty manifest.
|
||||
// Plus: the relative-paths-only invariant (reject empty / absolute), contains()
|
||||
// semantics, insertion-order preservation, malformed-parse -> nullopt (the persist
|
||||
// shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters.
|
||||
|
||||
#include "../src/owned_manifest.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
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)
|
||||
|
||||
// --- empty manifest ----------------------------------------------------------
|
||||
|
||||
static void testEmptyManifest() {
|
||||
OwnedFileManifest m;
|
||||
CHECK(m.empty());
|
||||
CHECK(m.size() == 0);
|
||||
CHECK(m.paths().empty());
|
||||
CHECK(!m.contains("anything.wav"));
|
||||
|
||||
// An empty manifest serializes to a well-formed shape and round-trips to empty.
|
||||
const std::string json = m.serialize();
|
||||
auto back = OwnedFileManifest::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == m);
|
||||
CHECK(back->empty());
|
||||
}
|
||||
|
||||
// --- add / contains / order --------------------------------------------------
|
||||
|
||||
static void testAddAndContains() {
|
||||
OwnedFileManifest m;
|
||||
CHECK(m.add("reasampler_bank/a.wav") == ManifestAddResult::Added);
|
||||
CHECK(m.add("reasampler_bank/b.wav") == ManifestAddResult::Added);
|
||||
|
||||
CHECK(m.size() == 2);
|
||||
CHECK(m.contains("reasampler_bank/a.wav"));
|
||||
CHECK(m.contains("reasampler_bank/b.wav"));
|
||||
CHECK(!m.contains("reasampler_bank/c.wav"));
|
||||
// Exact-string match — not a prefix / substring match.
|
||||
CHECK(!m.contains("reasampler_bank/a"));
|
||||
CHECK(!m.contains("a.wav"));
|
||||
|
||||
// Insertion order is preserved (deterministic ext-state).
|
||||
CHECK(m.paths().size() == 2);
|
||||
CHECK(m.paths()[0] == "reasampler_bank/a.wav");
|
||||
CHECK(m.paths()[1] == "reasampler_bank/b.wav");
|
||||
}
|
||||
|
||||
// --- dedup of repeated adds --------------------------------------------------
|
||||
|
||||
static void testDedupRepeatedAdds() {
|
||||
OwnedFileManifest m;
|
||||
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::Added);
|
||||
// A repeat capture of an identical request must not double-record the file.
|
||||
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
|
||||
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
|
||||
CHECK(m.size() == 1);
|
||||
CHECK(m.paths().size() == 1);
|
||||
}
|
||||
|
||||
// --- relative-paths-only invariant -------------------------------------------
|
||||
|
||||
static void testRejectsEmptyAndAbsolute() {
|
||||
OwnedFileManifest m;
|
||||
CHECK(m.add("") == ManifestAddResult::RejectedEmptyPath);
|
||||
// Every absolute form bank_model rejects, the manifest rejects too.
|
||||
CHECK(m.add("/abs/take.wav") == ManifestAddResult::RejectedAbsolutePath); // POSIX root
|
||||
CHECK(m.add("\\\\host\\share\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* UNC */
|
||||
CHECK(m.add("C:/bank/x.wav") == ManifestAddResult::RejectedAbsolutePath); // Win drive /
|
||||
CHECK(m.add("C:\\bank\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* Win drive backslash */
|
||||
CHECK(m.add("C:x.wav") == ManifestAddResult::RejectedAbsolutePath); // drive-relative
|
||||
// A rejected add never mutates.
|
||||
CHECK(m.empty());
|
||||
CHECK(!m.contains("/abs/take.wav"));
|
||||
}
|
||||
|
||||
// --- JSON round-trip ---------------------------------------------------------
|
||||
|
||||
static void testRoundTrip() {
|
||||
OwnedFileManifest m;
|
||||
m.add("reasampler_bank/one.wav");
|
||||
m.add("reasampler_bank/two.wav");
|
||||
m.add("reasampler_bank/three.wav");
|
||||
|
||||
const std::string json = m.serialize();
|
||||
auto back = OwnedFileManifest::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == m);
|
||||
// Order + membership survive.
|
||||
CHECK(back->paths().size() == 3);
|
||||
CHECK(back->paths()[0] == "reasampler_bank/one.wav");
|
||||
CHECK(back->paths()[2] == "reasampler_bank/three.wav");
|
||||
|
||||
// serialize(deserialize(serialize(x))) is stable.
|
||||
CHECK(back->serialize() == json);
|
||||
}
|
||||
|
||||
// A path carrying JSON metacharacters must survive the escape/unescape round-trip.
|
||||
static void testRoundTripEscaping() {
|
||||
OwnedFileManifest m;
|
||||
m.add("reasampler_bank/od\"d name.wav"); // embedded quote
|
||||
m.add("reasampler_bank/back\\slash.wav"); // embedded backslash
|
||||
m.add("reasampler_bank/tab\tafter.wav"); // control char
|
||||
|
||||
auto back = OwnedFileManifest::deserialize(m.serialize());
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == m);
|
||||
CHECK(back->contains("reasampler_bank/od\"d name.wav"));
|
||||
CHECK(back->contains("reasampler_bank/back\\slash.wav"));
|
||||
CHECK(back->contains("reasampler_bank/tab\tafter.wav"));
|
||||
}
|
||||
|
||||
// --- malformed / tolerant parse ----------------------------------------------
|
||||
|
||||
static void testMalformedParse() {
|
||||
// The persist shell's warn+fallback hinges on nullopt for a corrupt blob.
|
||||
CHECK(!OwnedFileManifest::deserialize("").has_value()); // empty string
|
||||
CHECK(!OwnedFileManifest::deserialize("not json").has_value());
|
||||
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[").has_value()); // unterminated array
|
||||
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[1,2]}").has_value()); // non-string element
|
||||
CHECK(!OwnedFileManifest::deserialize("{\"owned\":\"x\"}").has_value()); // wrong value type
|
||||
|
||||
// An explicit empty array parses to an empty manifest.
|
||||
auto empty = OwnedFileManifest::deserialize("{\"owned\":[]}");
|
||||
CHECK(empty.has_value());
|
||||
CHECK(empty->empty());
|
||||
|
||||
// An unknown sibling key is tolerated (forward-compat) — the owned array still loads.
|
||||
auto fwd = OwnedFileManifest::deserialize(
|
||||
"{\"future\":{\"nested\":[1,2]},\"owned\":[\"reasampler_bank/x.wav\"]}");
|
||||
CHECK(fwd.has_value());
|
||||
CHECK(fwd->size() == 1);
|
||||
CHECK(fwd->contains("reasampler_bank/x.wav"));
|
||||
|
||||
// A stored blob cannot smuggle a duplicate or absolute path past the load-time
|
||||
// invariant re-assertion (deserialize routes each element through add()).
|
||||
auto dupe = OwnedFileManifest::deserialize(
|
||||
"{\"owned\":[\"reasampler_bank/x.wav\",\"reasampler_bank/x.wav\"]}");
|
||||
CHECK(dupe.has_value());
|
||||
CHECK(dupe->size() == 1);
|
||||
|
||||
auto absolute = OwnedFileManifest::deserialize(
|
||||
"{\"owned\":[\"reasampler_bank/ok.wav\",\"/etc/evil.wav\"]}");
|
||||
CHECK(absolute.has_value());
|
||||
CHECK(absolute->size() == 1);
|
||||
CHECK(absolute->contains("reasampler_bank/ok.wav"));
|
||||
CHECK(!absolute->contains("/etc/evil.wav"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testEmptyManifest();
|
||||
testAddAndContains();
|
||||
testDedupRepeatedAdds();
|
||||
testRejectsEmptyAndAbsolute();
|
||||
testRoundTrip();
|
||||
testRoundTripEscaping();
|
||||
testMalformedParse();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user