// Standalone tests for reasampler::bank_model — no REAPER, no test framework. // This is the point of splitting the model out: iterate the hard logic here with // a fast build/run loop instead of restarting REAPER. // // 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 #include 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 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.captureTimeSigNum = 6; // L7 F1 meter stamp (non-4/4 to prove it round-trips) s.captureTimeSigDenom = 8; 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 = ""; 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"); // L7 F1 meter stamp survived exactly. CHECK(full && full->captureTimeSigNum == 6 && full->captureTimeSigDenom == 8); // The minimal sample never stamped a meter -> 0/0 (the unstamped default). const Sample* minMeter = back->query("min-b"); CHECK(minMeter && minMeter->captureTimeSigNum == 0 && minMeter->captureTimeSigDenom == 0); CHECK(full && full->provenance.has_value()); CHECK(full && full->provenance->fxChainSnapshot == ""); 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); } // M10: updateInPlace refreshes an entry while preserving its slot + identity, and // bypasses dedup (an in-place refresh is not a new insert). The relative-paths-only // invariant still guards the replacement. static void testUpdateInPlace() { BankIndex idx; CHECK(idx.add(minimalSample("a")) == AddResult::Added); // id "min-a" CHECK(idx.add(minimalSample("b")) == AddResult::Added); // id "min-b" CHECK(idx.add(minimalSample("c")) == AddResult::Added); // id "min-c" // Refresh the MIDDLE entry: new file/hash/name, same id — position must be kept. Sample updated = minimalSample("b"); updated.relativePath = "reasampler_bank/regenerated.wav"; updated.contentHash = "new-hash-b"; updated.displayName = "regenerated"; CHECK(idx.updateInPlace("min-b", updated)); CHECK(idx.size() == 3); // no new entry, no removal CHECK(idx.all()[1].id == "min-b"); // slot preserved (still middle) CHECK(idx.all()[1].relativePath == "reasampler_bank/regenerated.wav"); CHECK(idx.all()[1].contentHash == "new-hash-b"); CHECK(idx.query("min-b")->displayName == "regenerated"); // An updated hash colliding with ANOTHER entry does NOT collapse (updateInPlace // is not an insert): the refreshed entry keeps its slot even sharing a hash. Sample collide = minimalSample("b"); collide.contentHash = idx.query("min-a")->contentHash; // same as entry "min-a" CHECK(idx.updateInPlace("min-b", collide)); CHECK(idx.size() == 3); // still three; no collapse // Updating an absent id fails without mutation. CHECK(!idx.updateInPlace("nope", minimalSample("x"))); CHECK(idx.size() == 3); // An absolute replacement path is rejected (invariant preserved). Sample bad = minimalSample("b"); bad.relativePath = "C:/evil.wav"; CHECK(!idx.updateInPlace("min-b", bad)); CHECK(idx.query("min-b")->relativePath != "C:/evil.wav"); } 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(s->displayName[0]) == 0xC3); CHECK(static_cast(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(s->displayName[0]) == 0xE4); CHECK(static_cast(s->displayName[1]) == 0xB8); CHECK(static_cast(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(s->displayName[0]) == 0xF0); CHECK(static_cast(s->displayName[1]) == 0x9F); CHECK(static_cast(s->displayName[2]) == 0x98); CHECK(static_cast(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(); testUpdateInPlace(); testEmptyIndexRoundTrip(); testMalformedJson(); testRemoveAndQuery(); testAbsolutePathDriveRelative(); testUnicodeEscapeDecoding(); testIntegerOverflow(); testEnumRangeValidation(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }