// Standalone tests for reasampler::instrument::map::bank_sync — no REAPER, no VST3, no framework. // The S9 bank-generation change-detection + the S8 assignment-request consume DECISION // (the yes/no maths the instrument's off-audio-thread poll runs). The shell owns the // cadence + side effects; this proves the decision rules without a host. // // Covers: parseBankGeneration (absent/malformed/overflow/negative/valid whole-string), // formatBankGeneration round-trip, bankGenerationChanged, and every consumeDecision rule // (no request / not-newer / non-target / unresolvable-drop / apply), asserting both the // apply flag AND the advanced-marker value so a stale request is never re-evaluated. #include "../src/core/instrument/map/bank_sync.h" #include #include #include #include using namespace reasampler; using namespace reasampler::instrument::map; static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // --- parseBankGeneration ----------------------------------------------------- static void testParseAbsentAndMalformed() { // Absent / empty -> generation 0 (the pre-S9 default; a project with no stamp). CHECK(parseBankGeneration("") == 0); CHECK(parseBankGeneration("") == kBankGenerationAbsent); // Malformed -> 0, never a crash, never a partial value. CHECK(parseBankGeneration("abc") == 0); CHECK(parseBankGeneration("12x") == 0); // trailing garbage rejects whole CHECK(parseBankGeneration("x12") == 0); // leading garbage CHECK(parseBankGeneration("1 2") == 0); // embedded space CHECK(parseBankGeneration("+5") == 0); // sign rejected CHECK(parseBankGeneration("-5") == 0); // negative rejected CHECK(parseBankGeneration(" 5") == 0); // leading space CHECK(parseBankGeneration("5.0") == 0); // decimal point } static void testParseValid() { CHECK(parseBankGeneration("0") == 0); CHECK(parseBankGeneration("1") == 1); CHECK(parseBankGeneration("42") == 42); CHECK(parseBankGeneration("00042") == 42); // leading zeros are still digits -> 42 CHECK(parseBankGeneration("9007199254740993") == 9007199254740993LL); // > 2^53 } static void testParseOverflow() { // A 19-digit int64-max is fine; anything past it rejects to 0 (never wraps). CHECK(parseBankGeneration("9223372036854775807") == 9223372036854775807LL); // INT64_MAX CHECK(parseBankGeneration("9223372036854775808") == 0); // INT64_MAX + 1 -> reject CHECK(parseBankGeneration("99999999999999999999") == 0); // 20 nines -> reject } static void testFormatRoundTrip() { CHECK(formatBankGeneration(0) == "0"); CHECK(formatBankGeneration(1) == "1"); CHECK(formatBankGeneration(123456789) == "123456789"); // Round-trips: format then parse yields the original for the valid domain. for (std::int64_t g : {std::int64_t{0}, std::int64_t{1}, std::int64_t{7}, std::int64_t{9007199254740993LL}}) { CHECK(parseBankGeneration(formatBankGeneration(g)) == g); } } // --- bankGenerationChanged --------------------------------------------------- static void testGenerationChanged() { CHECK(!bankGenerationChanged(0, 0)); // pre-S9 idle: no stamp seen, no stamp now CHECK(bankGenerationChanged(0, 1)); // first bump after a pre-S9 baseline -> change CHECK(bankGenerationChanged(5, 6)); // normal increment CHECK(!bankGenerationChanged(6, 6)); // idle poll (coalesced): no change CHECK(bankGenerationChanged(6, 3)); // a project switch/reload can lower it -> change } // --- consumeDecision --------------------------------------------------------- static AssignmentRequest makeReq(const std::string& bank, const std::string& sample, std::int64_t gen) { AssignmentRequest r; r.bankId = bank; r.sampleId = sample; r.generation = gen; return r; } // Rule 1a: no pending request -> nothing to do, marker unchanged. static void testNoRequest() { const auto d = consumeDecision(std::nullopt, /*lastConsumed*/ 5, /*resolves*/ true, /*isFocusedTarget*/ true); CHECK(!d.apply); CHECK(d.consumedGeneration == 5); // marker held } // Rule 1b: a request no newer than what we already consumed (re-open case) -> no re-apply. static void testNotNewerNotReapplied() { // The persisted marker equals the request generation: the user already got this assign, // possibly changed away from it. It MUST NOT re-apply on re-open. const auto same = consumeDecision(makeReq("b", "s", 100), 100, true, true); CHECK(!same.apply); CHECK(same.consumedGeneration == 100); // unchanged // An older request (a stale value lingering) is likewise ignored. const auto older = consumeDecision(makeReq("b", "s", 90), 100, true, true); CHECK(!older.apply); CHECK(older.consumedGeneration == 100); } // Rule 2: a NEW request but this instance is not the target -> do not apply AND do not // advance the marker (must stay eligible if focus later lands here — no thundering herd). static void testNonTargetStaysEligible() { const auto d = consumeDecision(makeReq("b", "s", 200), /*lastConsumed*/ 100, /*resolves*/ true, /*isFocusedTarget*/ false); CHECK(!d.apply); CHECK(d.consumedGeneration == 100); // marker NOT advanced -> still eligible later } // Rule 3: a NEW request, target, but unresolvable -> DROP silently. Marker advances so it // is never re-evaluated, but no selection change (assignment_request.h reader requirement). static void testUnresolvableDroppedSilently() { const auto d = consumeDecision(makeReq("b", "deleted-sample", 200), /*lastConsumed*/ 100, /*resolves*/ false, /*isFocusedTarget*/ true); CHECK(!d.apply); // no selection change CHECK(d.consumedGeneration == 200); // consumed-as-seen: never re-evaluated CHECK(d.sampleId.empty()); // nothing to apply } // Rule 4: a NEW request, target, resolvable -> APPLY selection + advance the marker. static void testAppliedWhenNewTargetResolvable() { const auto d = consumeDecision(makeReq("bank-7", "cap-42", 200), /*lastConsumed*/ 100, /*resolves*/ true, /*isFocusedTarget*/ true); CHECK(d.apply); CHECK(d.bankId == "bank-7"); CHECK(d.sampleId == "cap-42"); CHECK(d.consumedGeneration == 200); } // Re-assigning the SAME sample id under a NEW generation must re-apply (the generation is // the disambiguator; a recapture/re-drop of the same id is a fresh assign, not a no-op). static void testSameIdNewGenerationReapplies() { // First consume at gen 100. const auto first = consumeDecision(makeReq("b", "s", 100), 50, true, true); CHECK(first.apply); CHECK(first.consumedGeneration == 100); // Same id, higher generation, marker now at 100 -> applies again. const auto second = consumeDecision(makeReq("b", "s", 150), 100, true, true); CHECK(second.apply); CHECK(second.sampleId == "s"); CHECK(second.consumedGeneration == 150); } // A fresh instance (lastConsumed == 0) applies a first assign — the default marker must not // swallow the first request. static void testFreshInstanceAppliesFirst() { const auto d = consumeDecision(makeReq("b", "s", 1), 0, true, true); CHECK(d.apply); CHECK(d.consumedGeneration == 1); } // Re-import / dedup-collapse path: the extension ingests a sample that already exists in // the bank (dedup collapse: the bank_generation counter does NOT advance because no new // sample was added), but a new assign_request is still written with a HIGHER assign // generation (the ingest disambiguator, independent of bank_generation). // // The consumeDecision must apply the request — its own generation is the "is this new?" // discriminator, and it is strictly greater than lastConsumed. bank_generation does not // enter consumeDecision at all; this test proves the two counters are fully independent. static void testDedupCollapseAssignAppliesWhenBankGenerationUnchanged() { // Simulate: bank_generation is 5 both before and after the dedup ingest (unchanged). // The assign_request generation is 300 (new; lastConsumed was 200 from the prior assign). // The existing sample resolves (it is in the bank — dedup kept it there). const auto d = consumeDecision(makeReq("pool", "existing-sample-id", 300), /*lastConsumed*/ 200, /*resolves*/ true, /*isFocusedTarget*/ true); CHECK(d.apply); CHECK(d.bankId == "pool"); CHECK(d.sampleId == "existing-sample-id"); CHECK(d.consumedGeneration == 300); // marker advanced to the new assign generation // bank_generation (5) is not a parameter here — this test documents its absence from // consumeDecision: only the assign_request's own generation drives the consume decision. } int main() { testParseAbsentAndMalformed(); testParseValid(); testParseOverflow(); testFormatRoundTrip(); testGenerationChanged(); testNoRequest(); testNotNewerNotReapplied(); testNonTargetStaysEligible(); testUnresolvableDroppedSilently(); testAppliedWhenNewTargetResolvable(); testSameIdNewGenerationReapplies(); testFreshInstanceAppliesFirst(); testDedupCollapseAssignAppliesWhenBankGenerationUnchanged(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }