20018c1df2
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.
311 lines
12 KiB
C++
311 lines
12 KiB
C++
// 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;
|
|
}
|