// 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/core/model/bank_model.h" #include #include using namespace reasampler; using namespace reasampler::model; 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.rootNote = 60; // Phase S seam field (present) s.loop = LoopPoints{4096, 65536}; // Phase S seam field (present) 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() { BankModel idx; CHECK(idx.add(fullSample("a")) == AddResult::Added); CHECK(idx.add(minimalSample("b")) == AddResult::Added); std::string json = idx.serialize(); auto back = BankModel::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 == ""); // Phase S seam fields survive round-trip exactly. CHECK(full && full->rootNote.has_value() && *full->rootNote == 60); CHECK(full && full->loop.has_value()); CHECK(full && full->loop && full->loop->start == 4096 && full->loop->end == 65536); const Sample* min = back->query("min-b"); CHECK(min && !min->key.has_value()); CHECK(min && !min->provenance.has_value()); CHECK(min && min->trackGuids.empty()); // Seam fields absent on the minimal sample and stay absent. CHECK(min && !min->rootNote.has_value()); CHECK(min && !min->loop.has_value()); } } static void testDedupByHash() { BankModel 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() { BankModel 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() { BankModel 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() { BankModel 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() { BankModel idx; CHECK(idx.empty()); std::string json = idx.serialize(); auto back = BankModel::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 = BankModel::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 = BankModel::deserialize("{}"); CHECK(ok.has_value() && ok->empty()); } static void testRemoveAndQuery() { BankModel 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() { BankModel 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 = BankModel::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 = BankModel::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 = BankModel::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 = BankModel::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 = BankModel::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 = BankModel::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 = BankModel::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()); } // S2 test case 2: a legacy Sample JSON — written before the Phase S seam fields // existed, so it has NO "rootNote" or "loop" keys at all — parses to clean empty // optionals (no loss, no migration) and re-serializes without inventing values. // (The parser's forward-compat unknown-key skipping is what makes the reverse case // — new keys ignored by an old parser — safe too; here we test old-JSON→new-parser.) static void testLegacyJsonDefaults() { const char* legacy = "{\"samples\":[{\"id\":\"leg1\",\"relativePath\":\"bank/leg.wav\"," "\"displayName\":\"legacy\"," "\"sourceMode\":0,\"sourceRange\":{\"startSeconds\":1.5,\"endSeconds\":2.5," "\"startPpq\":0.0,\"endPpq\":0.0},\"trackGuids\":[]," "\"wetDry\":1.0,\"channelCount\":2,\"sampleRate\":48000," "\"lengthSeconds\":1.0,\"lengthBeats\":0.0,\"captureTempo\":120.0," "\"key\":null,\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-leg1\"," "\"provenance\":null,\"createdTimestamp\":0}]}"; auto r = BankModel::deserialize(legacy); CHECK(r.has_value()); if (r) { const Sample* s = r->query("leg1"); CHECK(s != nullptr); CHECK(s && !s->rootNote.has_value()); // clean default, not a guessed value CHECK(s && !s->loop.has_value()); // Re-serialize is lossless: parsing it again yields an equal index. This // proves the absent fields did not silently gain values on the way out. std::string out = r->serialize(); auto again = BankModel::deserialize(out); CHECK(again.has_value()); CHECK(again && *again == *r); if (again) { const Sample* s2 = again->query("leg1"); CHECK(s2 && !s2->rootNote.has_value()); CHECK(s2 && !s2->loop.has_value()); } } } // S2 test case 4: boundary values for the seam fields are representable and // round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid // zero-length marker); a loop whose end sits at the file's last frame. Also asserts // the deserialize-boundary validation rules reject out-of-range input rather than // storing a bogus value. static void testSeamFieldBoundaries() { // rootNote at both MIDI edges + equal-and-end-anchored loop points round-trip. BankModel idx; Sample lo = minimalSample("lo"); lo.contentHash = "h-lo"; lo.rootNote = 0; lo.loop = LoopPoints{0, 0}; // zero-length marker at frame 0 Sample hi = minimalSample("hi"); hi.contentHash = "h-hi"; hi.rootNote = 127; hi.loop = LoopPoints{100, 100}; // start == end elsewhere Sample end = minimalSample("end"); end.contentHash = "h-end"; end.loop = LoopPoints{0, 9223372036854775807LL}; // end at max int64 frame CHECK(idx.add(lo) == AddResult::Added); CHECK(idx.add(hi) == AddResult::Added); CHECK(idx.add(end) == AddResult::Added); auto back = BankModel::deserialize(idx.serialize()); CHECK(back.has_value()); CHECK(back && *back == idx); if (back) { CHECK(back->query("min-lo")->rootNote == 0); CHECK(back->query("min-hi")->rootNote == 127); // Named local: a brace-init with a comma inside CHECK(...) would be parsed // as two macro arguments by the preprocessor. const LoopPoints zeroLen{0, 0}; CHECK(back->query("min-lo")->loop == zeroLen); CHECK(back->query("min-end")->loop->end == 9223372036854775807LL); } // Validation rule (chosen for this design, surfaced in the handoff): // rootNote must be 0..127; loop must satisfy 0 <= start <= end. // Out-of-range input is rejected at the deserialize boundary (nullopt), mirroring // the existing enum-range and integer-overflow rejections — never clamped. const char* head = "{\"samples\":[{\"id\":\"bad\",\"relativePath\":\"bank/b.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,"; const char* tail = "\"levels\":{\"peakDb\":0.0,\"rmsDb\":0.0,\"lufs\":0.0}," "\"clipped\":false,\"tier\":0,\"contentHash\":\"h-bad\"," "\"provenance\":null,\"createdTimestamp\":0}]}"; CHECK(!BankModel::deserialize(std::string(head) + "\"rootNote\":128," + tail).has_value()); CHECK(!BankModel::deserialize(std::string(head) + "\"rootNote\":-1," + tail).has_value()); CHECK(!BankModel::deserialize( std::string(head) + "\"loop\":{\"start\":10,\"end\":5}," + tail).has_value()); // start > end CHECK(!BankModel::deserialize( std::string(head) + "\"loop\":{\"start\":-1,\"end\":5}," + tail).has_value()); // negative start } // S2 test case 3: the seam-field addition is purely additive — dedup-by-hash, tier // moves/filtering, and BankModel ordering are byte-for-byte unchanged by the // presence (or absence) of rootNote/loop. Two samples differing ONLY in seam fields // but sharing a content hash still collapse; a seam-populated sample tiers exactly // like any other. static void testSeamFieldsAdditiveInvariant() { BankModel idx; Sample a = fullSample("z"); // has rootNote + loop populated CHECK(idx.add(a) == AddResult::Added); // Same hash, seam fields cleared — dedup keys off contentHash only, so this // still collapses. Seam fields do NOT enter the dedup identity. Sample dup = fullSample("z2"); dup.contentHash = a.contentHash; dup.rootNote.reset(); dup.loop.reset(); CHECK(idx.add(dup) == AddResult::Collapsed); CHECK(idx.size() == 1); // Tier move on a seam-populated sample behaves exactly as before. CHECK(idx.query("id-z")->tier == Tier::Archive); CHECK(idx.moveTier("id-z", Tier::Scratch)); CHECK(idx.query("id-z")->tier == Tier::Scratch); CHECK(idx.query("id-z")->rootNote == 60); // move did not disturb seam fields } int main() { testFullFieldRoundTrip(); testDedupByHash(); testTierFilterAndMove(); testRelativePathInvariant(); testUpdateInPlace(); testEmptyIndexRoundTrip(); testMalformedJson(); testRemoveAndQuery(); testAbsolutePathDriveRelative(); testUnicodeEscapeDecoding(); testIntegerOverflow(); testEnumRangeValidation(); testLegacyJsonDefaults(); testSeamFieldBoundaries(); testSeamFieldsAdditiveInvariant(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }