feat(S9/S8): bank-generation change-detection + instrument-side assignment reader

Extension stamps a monotonic bank_generation counter, bumped at content
mutations; the VST3 instrument polls it off-thread on an editor timer and
consumes assignment requests, refreshing playback hands-free.
This commit is contained in:
2026-07-26 23:46:06 -04:00
parent 725f3e7d3c
commit 66d47fb410
21 changed files with 838 additions and 36 deletions
+179
View File
@@ -0,0 +1,179 @@
// Standalone tests for reasampler::vst::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/vst/bank_sync.h"
#include <cstdint>
#include <cstdio>
#include <optional>
#include <string>
using namespace reasampler;
using namespace reasampler::vst;
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);
}
int main() {
testParseAbsentAndMalformed();
testParseValid();
testParseOverflow();
testFormatRoundTrip();
testGenerationChanged();
testNoRequest();
testNotNewerNotReapplied();
testNonTargetStaysEligible();
testUnresolvableDroppedSilently();
testAppliedWhenNewTargetResolvable();
testSameIdNewGenerationReapplies();
testFreshInstanceAppliesFirst();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+56
View File
@@ -979,6 +979,58 @@ static void testComponentStateV4StereoWithZoneOverridesRoundTrip() {
!back.map.zones[1].startPoint.has_value());
}
// --- v5 component state: the S8/S9 last-consumed-assignment marker -------------
static void testComponentStateV5MarkerRoundTrip() {
// The consumed-assignment generation (S8 reader marker) round-trips through the v5 envelope
// alongside selection + mode + zones. A non-zero, > 32-bit value proves the 8-byte LE field.
ComponentState s;
s.selectionId = "pick";
s.channelMode = ChannelMode::Stereo;
s.lastConsumedAssignGeneration = 1700000123456LL; // > INT32_MAX
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
const ComponentState back = deserializeComponentState(serializeComponentState(s));
CHECK(back.lastConsumedAssignGeneration == 1700000123456LL); // marker survives
CHECK(back.channelMode == ChannelMode::Stereo);
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
}
static void testComponentStateDefaultMarkerIsZero() {
// A default-constructed state has marker 0 and round-trips 0 — a fresh instance's first
// assign (generation >= 1) must not be swallowed by a non-zero default.
const ComponentState back = deserializeComponentState(serializeComponentState(ComponentState{}));
CHECK(back.lastConsumedAssignGeneration == 0);
}
static void testComponentStateV4LiftsMarkerToZero() {
// A GENUINE v4 blob (version tag 4: mode byte, then id + zones — NO 8-byte marker) must lift
// with lastConsumedAssignGeneration = 0 and its mode/selection/zones intact. Build it by hand
// (serializeComponentState now emits v5, so we cannot use it to make a v4 blob). This proves
// an already-saved pre-S8/S9 instance restores cleanly and its first assign still applies.
std::vector<std::uint8_t> v4;
v4.push_back(4); v4.push_back(0); v4.push_back(0); v4.push_back(0); // version 4
v4.push_back(1); // channel mode = stereo
const std::string id = "saved";
v4.push_back(static_cast<std::uint8_t>(id.size())); v4.push_back(0); v4.push_back(0); v4.push_back(0);
v4.insert(v4.end(), id.begin(), id.end());
v4.push_back(0); v4.push_back(0); v4.push_back(0); v4.push_back(0); // zone count 0
const ComponentState back = deserializeComponentState(v4);
CHECK(back.lastConsumedAssignGeneration == 0); // no marker in v4 -> default 0
CHECK(back.channelMode == ChannelMode::Stereo); // v4 mode byte still honored
CHECK(back.selectionId == "saved");
CHECK(back.map.zones.empty());
}
static void testComponentStateV5TruncatedMarker() {
// A v5 blob truncated inside the 8-byte marker (mode byte present, marker cut short) -> empty,
// mono + marker 0 default holds (bounded read, never throws across the host).
std::vector<std::uint8_t> t{5, 0, 0, 0, 1, 0xAA, 0xBB}; // version 5, mode byte, 2 marker bytes
const ComponentState back = deserializeComponentState(t);
CHECK(back.lastConsumedAssignGeneration == 0);
CHECK(back.selectionId.empty() && back.map.zones.empty());
}
int main() {
testSelectByIdHit();
testSelectEmptyIdIsSilence();
@@ -1049,6 +1101,10 @@ int main() {
testComponentStateV1V2LiftToMono();
testComponentStateV4TruncatedModeByte();
testComponentStateV4StereoWithZoneOverridesRoundTrip();
testComponentStateV5MarkerRoundTrip();
testComponentStateDefaultMarkerIsZero();
testComponentStateV4LiftsMarkerToZero();
testComponentStateV5TruncatedMarker();
if (g_fail == 0) std::printf("sample_map: all tests passed\n");
return g_fail != 0;