M10: provenance populate + re-capture from source (bank-only)
Pure provenance core (recipe fingerprint, FX-chain identity, parent detection) + shell reads; capture stamps provenance on resample-from-sample; re-capture regenerates a provenanced sample from its source, never touching the timeline. Adds BankIndex/BankBook in-place update. CTest-covered.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
// Standalone tests for the pure provenance core (M10) — no REAPER, no framework.
|
||||
//
|
||||
// Covers (brief-named test categories):
|
||||
// * fingerprint build -> encode -> parse round-trip (lossless).
|
||||
// * identical inputs -> equal fingerprints (byte-identical string).
|
||||
// * any single component change (scope, sourceMode, range, tail, rate, channels,
|
||||
// track GUIDs, FX-chain identity) -> a MISMATCH (different string / recipe).
|
||||
// * fxChainIdentity fold: order-sensitive, field-injection-proof, empty-stable.
|
||||
// * parse of malformed / wrong-version / truncated input -> nullopt (graceful).
|
||||
// * parent-detection decision: positive, negative, ambiguous, empty, and the
|
||||
// edge where a source file is not in the bank (missing-from-bank).
|
||||
//
|
||||
// The Sample-JSON round-trip of the fingerprint (leveraging M1's existing provenance
|
||||
// round-trip) is exercised in test_bank_model.cpp — see the fingerprint case there.
|
||||
|
||||
#include "../src/provenance.h"
|
||||
|
||||
#include "../src/bank_model.h" // recipe-through-Sample-JSON round-trip (M1 seam)
|
||||
|
||||
#include <cstdio>
|
||||
#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)
|
||||
|
||||
// A representative, fully-populated recipe used as the baseline for mutation tests.
|
||||
static CaptureRecipe baseRecipe() {
|
||||
CaptureRecipe r;
|
||||
r.scope = ProvenanceScope::Track;
|
||||
r.sourceMode = 1; // SelectedTracks
|
||||
r.startSeconds = 12.3456789012345; // non-trivial doubles to exercise %.17g
|
||||
r.endSeconds = 45.6789012345678;
|
||||
r.tailMode = 2; // Manual
|
||||
r.tailMs = 1234.5;
|
||||
r.sampleRate = 48000;
|
||||
r.channelCount = 2;
|
||||
r.trackGuids = {"{11111111-1111-1111-1111-111111111111}",
|
||||
"{22222222-2222-2222-2222-222222222222}"};
|
||||
r.fxChainIdentity = fxChainIdentity({
|
||||
{"ReaEQ", "{AAAA-1}", true},
|
||||
{"ReaComp", "{BBBB-2}", false},
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
// --- fingerprint round-trip --------------------------------------------------
|
||||
|
||||
static void testFingerprintRoundTrip() {
|
||||
const CaptureRecipe r = baseRecipe();
|
||||
const std::string fp = buildFingerprint(r);
|
||||
auto back = parseFingerprint(fp);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == r);
|
||||
// Re-encode is byte-stable.
|
||||
CHECK(buildFingerprint(*back) == fp);
|
||||
}
|
||||
|
||||
// A recipe with empty GUID list + empty FX identity (a no-FX, no-track-guid capture)
|
||||
// still round-trips — the degenerate case must not corrupt the parse.
|
||||
static void testFingerprintRoundTripEmptyFields() {
|
||||
CaptureRecipe r;
|
||||
r.scope = ProvenanceScope::Item;
|
||||
r.trackGuids.clear();
|
||||
r.fxChainIdentity = fxChainIdentity({});
|
||||
const std::string fp = buildFingerprint(r);
|
||||
auto back = parseFingerprint(fp);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == r);
|
||||
CHECK(back->trackGuids.empty());
|
||||
}
|
||||
|
||||
// A GUID or FX-name carrying the field separators (':' and digits) must survive —
|
||||
// length-prefixing makes the encoding injection-proof.
|
||||
static void testFingerprintRoundTripHostileStrings() {
|
||||
CaptureRecipe r = baseRecipe();
|
||||
r.trackGuids = {"7:not-a-real-guid", "12:another:evil:one"};
|
||||
r.fxChainIdentity = fxChainIdentity({
|
||||
{"FX with 3:colons: and stuff", "{gu:id}", true},
|
||||
});
|
||||
auto back = parseFingerprint(buildFingerprint(r));
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == r);
|
||||
}
|
||||
|
||||
// --- identical inputs -> equal fingerprints ----------------------------------
|
||||
|
||||
static void testIdenticalInputsEqualFingerprints() {
|
||||
CHECK(buildFingerprint(baseRecipe()) == buildFingerprint(baseRecipe()));
|
||||
CHECK(baseRecipe() == baseRecipe());
|
||||
}
|
||||
|
||||
// --- any single component change -> mismatch ---------------------------------
|
||||
|
||||
static void testSingleComponentChangesMismatch() {
|
||||
const std::string base = buildFingerprint(baseRecipe());
|
||||
|
||||
{ auto r = baseRecipe(); r.scope = ProvenanceScope::Item;
|
||||
CHECK(buildFingerprint(r) != base); CHECK(r != baseRecipe()); }
|
||||
{ auto r = baseRecipe(); r.sourceMode = 3;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.startSeconds += 0.0000001;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.endSeconds += 0.0000001;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.tailMode = 0;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.tailMs += 1.0;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.sampleRate = 44100;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.channelCount = 1;
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.trackGuids.pop_back();
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
{ auto r = baseRecipe(); r.trackGuids[0] = "{99999999-9999-9999-9999-999999999999}";
|
||||
CHECK(buildFingerprint(r) != base); }
|
||||
// The drift component: a changed FX chain identity mismatches (this is exactly
|
||||
// the "source changed since capture" signal re-capture reports).
|
||||
{ auto r = baseRecipe();
|
||||
r.fxChainIdentity = fxChainIdentity({{"ReaEQ", "{AAAA-1}", true}});
|
||||
CHECK(buildFingerprint(r) != base); CHECK(r != baseRecipe()); }
|
||||
}
|
||||
|
||||
// --- fxChainIdentity fold ----------------------------------------------------
|
||||
|
||||
static void testFxChainIdentityOrderSensitive() {
|
||||
const std::string a = fxChainIdentity({
|
||||
{"ReaEQ", "{A}", true}, {"ReaComp", "{B}", true}});
|
||||
const std::string b = fxChainIdentity({
|
||||
{"ReaComp", "{B}", true}, {"ReaEQ", "{A}", true}});
|
||||
CHECK(a != b); // chain order is part of identity
|
||||
}
|
||||
|
||||
static void testFxChainIdentityFieldsMatter() {
|
||||
const std::string base = fxChainIdentity({{"ReaEQ", "{A}", true}});
|
||||
CHECK(fxChainIdentity({{"ReaEQ2", "{A}", true}}) != base); // name
|
||||
CHECK(fxChainIdentity({{"ReaEQ", "{B}", true}}) != base); // guid (instance)
|
||||
CHECK(fxChainIdentity({{"ReaEQ", "{A}", false}}) != base); // enabled flag
|
||||
}
|
||||
|
||||
static void testFxChainIdentityEmptyStable() {
|
||||
CHECK(fxChainIdentity({}) == fxChainIdentity({}));
|
||||
// Empty chain differs from a one-FX chain.
|
||||
CHECK(fxChainIdentity({}) != fxChainIdentity({{"X", "{Y}", true}}));
|
||||
}
|
||||
|
||||
// Concatenation cannot forge equality: {"AB",""} vs {"A","B"} must differ despite
|
||||
// sharing raw bytes — length-prefixing keeps boundaries honest.
|
||||
static void testFxChainIdentityInjectionProof() {
|
||||
const std::string x = fxChainIdentity({{"AB", "", true}});
|
||||
const std::string y = fxChainIdentity({{"A", "B", true}});
|
||||
CHECK(x != y);
|
||||
}
|
||||
|
||||
// --- combineChainIdentities (multi-track Track-scope fold) -------------------
|
||||
|
||||
static void testCombineChainIdentities() {
|
||||
const std::string idA = fxChainIdentity({{"ReaEQ", "{A}", true}});
|
||||
const std::string idB = fxChainIdentity({{"ReaComp", "{B}", true}});
|
||||
|
||||
// Order of tracks matters, and distinct partitions cannot collide by concatenation.
|
||||
CHECK(combineChainIdentities({idA, idB}) != combineChainIdentities({idB, idA}));
|
||||
CHECK(combineChainIdentities({idA, ""}) != combineChainIdentities({"", idA}));
|
||||
// Empty vs single-track vs two-track are all distinct.
|
||||
CHECK(combineChainIdentities({}) != combineChainIdentities({idA}));
|
||||
CHECK(combineChainIdentities({idA}) != combineChainIdentities({idA, idB}));
|
||||
// Deterministic.
|
||||
CHECK(combineChainIdentities({idA, idB}) == combineChainIdentities({idA, idB}));
|
||||
}
|
||||
|
||||
// --- malformed parse ---------------------------------------------------------
|
||||
|
||||
static void testMalformedFingerprint() {
|
||||
CHECK(!parseFingerprint("").has_value()); // empty
|
||||
CHECK(!parseFingerprint("garbage").has_value()); // wrong magic
|
||||
CHECK(!parseFingerprint("rsprov0...").has_value()); // wrong version tag
|
||||
// Right magic, truncated body (no fields).
|
||||
CHECK(!parseFingerprint("rsprov1").has_value());
|
||||
// A length prefix that runs past the end.
|
||||
CHECK(!parseFingerprint("rsprov199:short").has_value());
|
||||
// A valid fingerprint with trailing garbage appended is rejected.
|
||||
const std::string good = buildFingerprint(baseRecipe());
|
||||
CHECK(!parseFingerprint(good + "TRAILING").has_value());
|
||||
// An out-of-range scope value is rejected.
|
||||
CHECK(!parseFingerprint("rsprov11:9" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0" "1:0"
|
||||
"1:0" "0:").has_value());
|
||||
}
|
||||
|
||||
// --- recorded-recipe model round-trips through the Sample JSON ----------------
|
||||
// The fingerprint rides in Provenance.fxChainSnapshot (one string), which M1's
|
||||
// BankIndex JSON already round-trips. Prove a real recipe survives that path intact.
|
||||
|
||||
static void testRecipeThroughSampleJson() {
|
||||
const CaptureRecipe r = baseRecipe();
|
||||
|
||||
Sample s;
|
||||
s.id = "child-1";
|
||||
s.relativePath = "reasampler_bank/child.wav";
|
||||
s.contentHash = "hash-child";
|
||||
Provenance prov;
|
||||
prov.parentSampleId = "sample-A";
|
||||
prov.fxChainSnapshot = buildFingerprint(r);
|
||||
s.provenance = prov;
|
||||
|
||||
BankIndex idx;
|
||||
CHECK(idx.add(s) == AddResult::Added);
|
||||
|
||||
auto back = BankIndex::deserialize(idx.serialize());
|
||||
CHECK(back.has_value());
|
||||
const Sample* child = back ? back->query("child-1") : nullptr;
|
||||
CHECK(child != nullptr);
|
||||
CHECK(child && child->provenance.has_value());
|
||||
CHECK(child && child->provenance->parentSampleId == "sample-A");
|
||||
|
||||
// The fingerprint string survived byte-for-byte AND re-parses to the recipe.
|
||||
if (child && child->provenance) {
|
||||
auto recovered = parseFingerprint(child->provenance->fxChainSnapshot);
|
||||
CHECK(recovered.has_value());
|
||||
CHECK(recovered && *recovered == r);
|
||||
}
|
||||
}
|
||||
|
||||
// --- parent detection --------------------------------------------------------
|
||||
|
||||
static std::vector<BankFileRef> bank() {
|
||||
return {
|
||||
{"sample-A", "c:/proj/reasampler_bank/a.wav"},
|
||||
{"sample-B", "c:/proj/reasampler_bank/b.wav"},
|
||||
};
|
||||
}
|
||||
|
||||
static void testDetectParentPositive() {
|
||||
// A single source item resolving to a bank file -> that sample is the parent.
|
||||
auto p = detectParent({"c:/proj/reasampler_bank/a.wav"}, bank());
|
||||
CHECK(p.has_value());
|
||||
CHECK(*p == "sample-A");
|
||||
}
|
||||
|
||||
static void testDetectParentMultipleSameParent() {
|
||||
// Two source items both from the SAME bank sample -> still that parent (a track
|
||||
// capture whose items all came from one bank file).
|
||||
auto p = detectParent(
|
||||
{"c:/proj/reasampler_bank/b.wav", "c:/proj/reasampler_bank/b.wav"}, bank());
|
||||
CHECK(p.has_value());
|
||||
CHECK(*p == "sample-B");
|
||||
}
|
||||
|
||||
static void testDetectParentNegativeNotInBank() {
|
||||
// A source file that is not a bank file -> no parent (a fresh, non-resample capture).
|
||||
auto p = detectParent({"c:/proj/audio/live-recording.wav"}, bank());
|
||||
CHECK(!p.has_value());
|
||||
}
|
||||
|
||||
static void testDetectParentAmbiguous() {
|
||||
// Sources spanning two DIFFERENT bank samples -> ambiguous, record no parent
|
||||
// (honest: we will not guess which one is "the" parent).
|
||||
auto p = detectParent(
|
||||
{"c:/proj/reasampler_bank/a.wav", "c:/proj/reasampler_bank/b.wav"}, bank());
|
||||
CHECK(!p.has_value());
|
||||
}
|
||||
|
||||
static void testDetectParentMixedBankAndNonBank() {
|
||||
// One source is a bank file, another is not -> not a clean resample -> no parent.
|
||||
auto p = detectParent(
|
||||
{"c:/proj/reasampler_bank/a.wav", "c:/proj/audio/other.wav"}, bank());
|
||||
CHECK(!p.has_value());
|
||||
}
|
||||
|
||||
static void testDetectParentEmptySources() {
|
||||
CHECK(!detectParent({}, bank()).has_value());
|
||||
}
|
||||
|
||||
static void testDetectParentEmptyBank() {
|
||||
// Edge: the bank has no files (e.g. the sample's file record is missing / bank
|
||||
// empty) -> nothing matches -> no parent.
|
||||
CHECK(!detectParent({"c:/proj/reasampler_bank/a.wav"}, {}).has_value());
|
||||
// A bank ref with an empty path never matches (guards against a null resolve).
|
||||
std::vector<BankFileRef> holey = {{"sample-X", ""}};
|
||||
CHECK(!detectParent({""}, holey).has_value());
|
||||
CHECK(!detectParent({"c:/proj/reasampler_bank/a.wav"}, holey).has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFingerprintRoundTrip();
|
||||
testFingerprintRoundTripEmptyFields();
|
||||
testFingerprintRoundTripHostileStrings();
|
||||
testIdenticalInputsEqualFingerprints();
|
||||
testSingleComponentChangesMismatch();
|
||||
testFxChainIdentityOrderSensitive();
|
||||
testFxChainIdentityFieldsMatter();
|
||||
testFxChainIdentityEmptyStable();
|
||||
testFxChainIdentityInjectionProof();
|
||||
testCombineChainIdentities();
|
||||
testMalformedFingerprint();
|
||||
testRecipeThroughSampleJson();
|
||||
testDetectParentPositive();
|
||||
testDetectParentMultipleSameParent();
|
||||
testDetectParentNegativeNotInBank();
|
||||
testDetectParentAmbiguous();
|
||||
testDetectParentMixedBankAndNonBank();
|
||||
testDetectParentEmptySources();
|
||||
testDetectParentEmptyBank();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user