Implement M1 bank_model: Sample + BankIndex, hash-dedup, tiers, JSON round-trip
Pure dependency-free core with self-contained JSON writer/parser. Absolute paths (incl. drive-relative) rejected at the add boundary; \uXXXX decoded to UTF-8 with surrogate pairs; malformed input returns nullopt.
This commit is contained in:
+370
-6
@@ -2,12 +2,14 @@
|
||||
// This is the point of splitting the model out: iterate the hard logic here with
|
||||
// a fast build/run loop instead of restarting REAPER.
|
||||
//
|
||||
// Milestone 0 placeholder: a single assertion proving the pure lib links and
|
||||
// runs under CTest. The real round-trip / dedup / tier / relative-path tests
|
||||
// arrive with the Milestone 1 data model (see TODO.md, PLAN.md).
|
||||
// Covers (PLAN.md M1 test cases): full-field round-trip lossless (optionals
|
||||
// present AND absent), dedup-by-hash collapse, tier filter + tier move,
|
||||
// relative-path invariant, empty-index round-trip, malformed/truncated JSON.
|
||||
|
||||
#include "../src/bank_model.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
@@ -15,9 +17,371 @@ static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
int main()
|
||||
{
|
||||
CHECK(bankModelVersion() == 1);
|
||||
// A fully-populated sample with every optional PRESENT. `seed` disambiguates
|
||||
// id/hash so multiple can coexist in one index.
|
||||
static Sample fullSample(const std::string& seed) {
|
||||
Sample s;
|
||||
s.id = "id-" + seed;
|
||||
s.displayName = "Kick \"punchy\"\n\t/ take " + seed; // exercises escaping
|
||||
s.relativePath = "bank/" + seed + "/kick.wav";
|
||||
s.sourceMode = SourceMode::SelectedItems;
|
||||
s.sourceRange = {12.3456789012345, 98.7654321098765, 1234.5, 9876.5};
|
||||
s.trackGuids = {"{GUID-A}", "{GUID-B}"};
|
||||
s.wetDry = 0.6180339887498949;
|
||||
s.channelCount = 2;
|
||||
s.sampleRate = 48000;
|
||||
s.lengthSeconds = 3.141592653589793;
|
||||
s.lengthBeats = 4.0;
|
||||
s.captureTempo = 128.5;
|
||||
s.key = "F#m";
|
||||
s.levels = {-0.3, -12.7, -14.2};
|
||||
s.clipped = true;
|
||||
s.tier = Tier::Archive;
|
||||
s.contentHash = "hash-" + seed;
|
||||
Provenance p;
|
||||
p.parentSampleId = "parent-" + seed;
|
||||
p.fxChainSnapshot = "<FXCHAIN\n BYPASS 0 0 0\n>";
|
||||
s.provenance = p;
|
||||
s.createdTimestamp = 1753080000LL;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Same but with every optional ABSENT and empty collections.
|
||||
static Sample minimalSample(const std::string& seed) {
|
||||
Sample s;
|
||||
s.id = "min-" + seed;
|
||||
s.relativePath = "bank/min.wav";
|
||||
s.sourceMode = SourceMode::MasterMix;
|
||||
s.channelCount = 1;
|
||||
s.sampleRate = 44100;
|
||||
s.tier = Tier::Scratch;
|
||||
s.contentHash = "minhash-" + seed;
|
||||
// key absent, provenance absent, trackGuids empty, displayName empty.
|
||||
return s;
|
||||
}
|
||||
|
||||
static void testFullFieldRoundTrip() {
|
||||
BankIndex idx;
|
||||
CHECK(idx.add(fullSample("a")) == AddResult::Added);
|
||||
CHECK(idx.add(minimalSample("b")) == AddResult::Added);
|
||||
|
||||
std::string json = idx.serialize();
|
||||
auto back = BankIndex::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == idx);
|
||||
|
||||
// Round-trip is idempotent on the string form too.
|
||||
if (back) CHECK(back->serialize() == json);
|
||||
|
||||
// Spot-check optionals survived exactly.
|
||||
if (back) {
|
||||
const Sample* full = back->query("id-a");
|
||||
CHECK(full && full->key.has_value() && *full->key == "F#m");
|
||||
CHECK(full && full->provenance.has_value());
|
||||
CHECK(full && full->provenance->fxChainSnapshot == "<FXCHAIN\n BYPASS 0 0 0\n>");
|
||||
|
||||
const Sample* min = back->query("min-b");
|
||||
CHECK(min && !min->key.has_value());
|
||||
CHECK(min && !min->provenance.has_value());
|
||||
CHECK(min && min->trackGuids.empty());
|
||||
}
|
||||
}
|
||||
|
||||
static void testDedupByHash() {
|
||||
BankIndex idx;
|
||||
Sample a = fullSample("x");
|
||||
CHECK(idx.add(a) == AddResult::Added);
|
||||
|
||||
// Same content hash, different id/name — must collapse, not duplicate.
|
||||
Sample dup = minimalSample("y");
|
||||
dup.contentHash = a.contentHash;
|
||||
CHECK(idx.add(dup) == AddResult::Collapsed);
|
||||
CHECK(idx.size() == 1);
|
||||
// Original entry is the one kept.
|
||||
CHECK(idx.query("id-x") != nullptr);
|
||||
CHECK(idx.query("min-y") == nullptr);
|
||||
CHECK(idx.findByHash(a.contentHash) != nullptr);
|
||||
|
||||
// Empty hashes do NOT participate in dedup (two empty-hash adds coexist).
|
||||
Sample e1 = minimalSample("e1"); e1.contentHash.clear();
|
||||
Sample e2 = minimalSample("e2"); e2.contentHash.clear();
|
||||
CHECK(idx.add(e1) == AddResult::Added);
|
||||
CHECK(idx.add(e2) == AddResult::Added);
|
||||
CHECK(idx.size() == 3);
|
||||
CHECK(idx.findByHash("") == nullptr);
|
||||
}
|
||||
|
||||
static void testTierFilterAndMove() {
|
||||
BankIndex idx;
|
||||
Sample scratch = minimalSample("s"); scratch.tier = Tier::Scratch;
|
||||
Sample archive = fullSample("a"); archive.tier = Tier::Archive;
|
||||
CHECK(idx.add(scratch) == AddResult::Added);
|
||||
CHECK(idx.add(archive) == AddResult::Added);
|
||||
|
||||
CHECK(idx.byTier(Tier::Scratch).size() == 1);
|
||||
CHECK(idx.byTier(Tier::Archive).size() == 1);
|
||||
CHECK(idx.byTier(Tier::Scratch)[0].id == "min-s");
|
||||
|
||||
// scratch is auto-prunable, archive is not.
|
||||
CHECK(idx.query("min-s")->isAutoPrunable());
|
||||
CHECK(!idx.query("id-a")->isAutoPrunable());
|
||||
|
||||
// Move scratch -> archive relocates it.
|
||||
CHECK(idx.moveTier("min-s", Tier::Archive));
|
||||
CHECK(idx.byTier(Tier::Scratch).empty());
|
||||
CHECK(idx.byTier(Tier::Archive).size() == 2);
|
||||
CHECK(!idx.query("min-s")->isAutoPrunable());
|
||||
|
||||
// Moving a nonexistent id fails.
|
||||
CHECK(!idx.moveTier("nope", Tier::Scratch));
|
||||
}
|
||||
|
||||
static void testRelativePathInvariant() {
|
||||
BankIndex idx;
|
||||
|
||||
// POSIX absolute, Windows drive, Windows backslash, UNC — all rejected.
|
||||
const char* absolutes[] = {
|
||||
"/etc/passwd.wav",
|
||||
"C:/Users/x/kick.wav",
|
||||
"C:\\Users\\x\\kick.wav",
|
||||
"\\\\host\\share\\kick.wav",
|
||||
};
|
||||
for (const char* abs : absolutes) {
|
||||
Sample s = minimalSample(abs);
|
||||
s.relativePath = abs;
|
||||
CHECK(idx.add(s) == AddResult::RejectedAbsolutePath);
|
||||
}
|
||||
CHECK(idx.empty()); // nothing absolute was ever stored
|
||||
|
||||
// A relative path is accepted.
|
||||
Sample ok = minimalSample("ok");
|
||||
ok.relativePath = "bank/sub/kick.wav";
|
||||
CHECK(idx.add(ok) == AddResult::Added);
|
||||
|
||||
// Empty id is rejected (collection is id-keyed).
|
||||
Sample noId = minimalSample("noid");
|
||||
noId.id.clear();
|
||||
CHECK(idx.add(noId) == AddResult::RejectedEmptyId);
|
||||
}
|
||||
|
||||
static void testEmptyIndexRoundTrip() {
|
||||
BankIndex idx;
|
||||
CHECK(idx.empty());
|
||||
std::string json = idx.serialize();
|
||||
auto back = BankIndex::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && back->empty());
|
||||
CHECK(back && *back == idx);
|
||||
}
|
||||
|
||||
static void testMalformedJson() {
|
||||
const char* bad[] = {
|
||||
"",
|
||||
"{",
|
||||
"not json at all",
|
||||
"{\"samples\":[",
|
||||
"{\"samples\":[{\"id\":\"x\"", // truncated sample object
|
||||
"{\"samples\":[{\"id\":\"x\",}]}", // dangling comma -> bad key
|
||||
"{\"samples\":[{]}", // garbage inside array
|
||||
"{\"samples\":{}}", // samples not an array
|
||||
"{\"samples\":[]}trailing", // trailing garbage
|
||||
"{\"samples\":[{\"createdTimestamp\":notanumber}]}",
|
||||
};
|
||||
for (const char* j : bad) {
|
||||
auto r = BankIndex::deserialize(j);
|
||||
CHECK(!r.has_value()); // signaled as nullopt, no crash / UB
|
||||
}
|
||||
|
||||
// A well-formed empty object deserializes to an empty index (lenient root).
|
||||
auto ok = BankIndex::deserialize("{}");
|
||||
CHECK(ok.has_value() && ok->empty());
|
||||
}
|
||||
|
||||
static void testRemoveAndQuery() {
|
||||
BankIndex idx;
|
||||
CHECK(idx.add(fullSample("1")) == AddResult::Added);
|
||||
CHECK(idx.add(fullSample("2")) == AddResult::Added);
|
||||
CHECK(idx.query("id-1") != nullptr);
|
||||
CHECK(idx.query("missing") == nullptr);
|
||||
CHECK(idx.remove("id-1"));
|
||||
CHECK(idx.query("id-1") == nullptr);
|
||||
CHECK(!idx.remove("id-1")); // second remove is a no-op
|
||||
CHECK(idx.size() == 1);
|
||||
}
|
||||
|
||||
// Fix 1: drive-relative and bare-drive forms must be rejected by add().
|
||||
static void testAbsolutePathDriveRelative() {
|
||||
BankIndex idx;
|
||||
|
||||
// Drive-relative: resolves against the drive's CWD, not the project root.
|
||||
Sample dr = minimalSample("dr");
|
||||
dr.contentHash = "hash-dr";
|
||||
dr.relativePath = "C:foo.wav";
|
||||
CHECK(idx.add(dr) == AddResult::RejectedAbsolutePath);
|
||||
|
||||
// Bare drive letter + colon: also drive-relative / ambiguous.
|
||||
Sample bare = minimalSample("bare");
|
||||
bare.contentHash = "hash-bare";
|
||||
bare.relativePath = "C:";
|
||||
CHECK(idx.add(bare) == AddResult::RejectedAbsolutePath);
|
||||
|
||||
// UNC path — belt-and-suspenders alongside the existing test.
|
||||
Sample unc = minimalSample("unc");
|
||||
unc.contentHash = "hash-unc";
|
||||
unc.relativePath = "\\\\server\\share\\kick.wav";
|
||||
CHECK(idx.add(unc) == AddResult::RejectedAbsolutePath);
|
||||
|
||||
// Nothing was stored.
|
||||
CHECK(idx.empty());
|
||||
}
|
||||
|
||||
// Fix 2: \uXXXX escape sequences decode to correct UTF-8 bytes.
|
||||
static void testUnicodeEscapeDecoding() {
|
||||
// é = U+00E9 → 2-byte UTF-8: 0xC3 0xA9
|
||||
// JSON: "é"
|
||||
auto r1 = BankIndex::deserialize(
|
||||
"{\"samples\":[{\"id\":\"u1\",\"relativePath\":\"bank/u.wav\","
|
||||
"\"displayName\":\"\\u00e9\","
|
||||
"\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
|
||||
"\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
|
||||
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u1\","
|
||||
"\"provenance\":null,\"createdTimestamp\":0}]}");
|
||||
CHECK(r1.has_value());
|
||||
if (r1) {
|
||||
const Sample* s = r1->query("u1");
|
||||
CHECK(s != nullptr);
|
||||
if (s) {
|
||||
// UTF-8 for U+00E9: 0xC3 0xA9 (2 bytes)
|
||||
CHECK(s->displayName.size() == 2);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[0]) == 0xC3);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[1]) == 0xA9);
|
||||
}
|
||||
}
|
||||
|
||||
// 中 = U+4E2D → 3-byte UTF-8: 0xE4 0xB8 0xAD
|
||||
// JSON: "中"
|
||||
auto r2 = BankIndex::deserialize(
|
||||
"{\"samples\":[{\"id\":\"u2\",\"relativePath\":\"bank/u.wav\","
|
||||
"\"displayName\":\"\\u4e2d\","
|
||||
"\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
|
||||
"\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
|
||||
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u2\","
|
||||
"\"provenance\":null,\"createdTimestamp\":0}]}");
|
||||
CHECK(r2.has_value());
|
||||
if (r2) {
|
||||
const Sample* s = r2->query("u2");
|
||||
CHECK(s != nullptr);
|
||||
if (s) {
|
||||
// UTF-8 for U+4E2D: 0xE4 0xB8 0xAD (3 bytes)
|
||||
CHECK(s->displayName.size() == 3);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[0]) == 0xE4);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[1]) == 0xB8);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[2]) == 0xAD);
|
||||
}
|
||||
}
|
||||
|
||||
// 😀 = U+1F600 → surrogate pair 😀 → 4-byte UTF-8: 0xF0 0x9F 0x98 0x80
|
||||
auto r3 = BankIndex::deserialize(
|
||||
"{\"samples\":[{\"id\":\"u3\",\"relativePath\":\"bank/u.wav\","
|
||||
"\"displayName\":\"\\uD83D\\uDE00\","
|
||||
"\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
|
||||
"\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
|
||||
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u3\","
|
||||
"\"provenance\":null,\"createdTimestamp\":0}]}");
|
||||
CHECK(r3.has_value());
|
||||
if (r3) {
|
||||
const Sample* s = r3->query("u3");
|
||||
CHECK(s != nullptr);
|
||||
if (s) {
|
||||
// UTF-8 for U+1F600: 0xF0 0x9F 0x98 0x80 (4 bytes)
|
||||
CHECK(s->displayName.size() == 4);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[0]) == 0xF0);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[1]) == 0x9F);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[2]) == 0x98);
|
||||
CHECK(static_cast<unsigned char>(s->displayName[3]) == 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
// Unpaired high surrogate (no following \uDCxx) → nullopt.
|
||||
auto r4 = BankIndex::deserialize(
|
||||
"{\"samples\":[{\"id\":\"u4\",\"relativePath\":\"bank/u.wav\","
|
||||
"\"displayName\":\"\\uD83D\","
|
||||
"\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
|
||||
"\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
|
||||
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-u4\","
|
||||
"\"provenance\":null,\"createdTimestamp\":0}]}");
|
||||
CHECK(!r4.has_value());
|
||||
}
|
||||
|
||||
// Fix 3: strtoll overflow must reject the value, not clamp it silently.
|
||||
static void testIntegerOverflow() {
|
||||
// A timestamp value that overflows int64_t (> 9223372036854775807).
|
||||
auto r = BankIndex::deserialize(
|
||||
"{\"samples\":[{\"id\":\"ov1\",\"relativePath\":\"bank/ov.wav\","
|
||||
"\"displayName\":\"\","
|
||||
"\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
|
||||
"\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
|
||||
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-ov1\","
|
||||
"\"provenance\":null,\"createdTimestamp\":99999999999999999999}]}");
|
||||
CHECK(!r.has_value());
|
||||
}
|
||||
|
||||
// Fix 4: out-of-range enum values must reject the sample, not produce invalid enum.
|
||||
static void testEnumRangeValidation() {
|
||||
// tier: 99 is not a valid Tier enumerator.
|
||||
auto r1 = BankIndex::deserialize(
|
||||
"{\"samples\":[{\"id\":\"en1\",\"relativePath\":\"bank/en.wav\","
|
||||
"\"displayName\":\"\","
|
||||
"\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
|
||||
"\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
|
||||
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":99,\"contentHash\":\"h-en1\","
|
||||
"\"provenance\":null,\"createdTimestamp\":0}]}");
|
||||
CHECK(!r1.has_value());
|
||||
|
||||
// sourceMode: 99 is not a valid SourceMode enumerator.
|
||||
auto r2 = BankIndex::deserialize(
|
||||
"{\"samples\":[{\"id\":\"en2\",\"relativePath\":\"bank/en.wav\","
|
||||
"\"displayName\":\"\","
|
||||
"\"sourceMode\":99,\"sourceRange\":{\"startSeconds\":0.0,\"endSeconds\":0.0,"
|
||||
"\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[],"
|
||||
"\"wetDry\":1.0,\"channelCount\":1,\"sampleRate\":44100,"
|
||||
"\"lengthSeconds\":0.0,\"lengthBeats\":0.0,\"captureTempo\":0.0,"
|
||||
"\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0},"
|
||||
"\"clipped\":false,\"tier\":0,\"contentHash\":\"h-en2\","
|
||||
"\"provenance\":null,\"createdTimestamp\":0}]}");
|
||||
CHECK(!r2.has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFullFieldRoundTrip();
|
||||
testDedupByHash();
|
||||
testTierFilterAndMove();
|
||||
testRelativePathInvariant();
|
||||
testEmptyIndexRoundTrip();
|
||||
testMalformedJson();
|
||||
testRemoveAndQuery();
|
||||
testAbsolutePathDriveRelative();
|
||||
testUnicodeEscapeDecoding();
|
||||
testIntegerOverflow();
|
||||
testEnumRangeValidation();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
|
||||
Reference in New Issue
Block a user