2026-08-17 14:08:21 +02:00
|
|
|
// External registers: named external inputs, external sinks, and cold
|
|
|
|
|
// commands (ext_registry.{h,cpp}).
|
|
|
|
|
//
|
|
|
|
|
// Written at the language boundary like the golden tests: eval source text,
|
|
|
|
|
// tick the engine, and assert user-visible values plus the registry seams'
|
|
|
|
|
// observable contracts (compile-time LKG independence, dirty publication,
|
|
|
|
|
// handler dispatch).
|
|
|
|
|
|
|
|
|
|
#define CATCH_CONFIG_MAIN
|
|
|
|
|
#include "../catch.hpp"
|
|
|
|
|
|
|
|
|
|
#include "src/signal_engine/signal_engine.h"
|
|
|
|
|
|
|
|
|
|
#include <cmath>
|
|
|
|
|
#include <cstring>
|
|
|
|
|
#include <string>
|
|
|
|
|
|
|
|
|
|
using namespace sig;
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
struct CommandCapture {
|
|
|
|
|
SymbolID cmd = 0;
|
|
|
|
|
ColdArg args[MAX_COLD_ARGS] = {};
|
|
|
|
|
uint8_t nargs = 0;
|
|
|
|
|
void* user = nullptr;
|
|
|
|
|
uint32_t calls = 0;
|
|
|
|
|
bool accept = true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
CommandCapture g_capture = {};
|
|
|
|
|
|
|
|
|
|
bool test_command_handler(SymbolID cmd, const ColdArg* args, uint8_t nargs,
|
|
|
|
|
void* user)
|
|
|
|
|
{
|
|
|
|
|
g_capture.cmd = cmd;
|
|
|
|
|
g_capture.nargs = nargs;
|
|
|
|
|
g_capture.user = user;
|
|
|
|
|
for (uint8_t i = 0; i < nargs; i++) g_capture.args[i] = args[i];
|
|
|
|
|
g_capture.calls++;
|
|
|
|
|
return g_capture.accept;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct ExtHarness {
|
|
|
|
|
SignalEngine engine;
|
|
|
|
|
double cell_values[MAX_CELLS] = {};
|
|
|
|
|
double hw_inputs[32] = {};
|
|
|
|
|
double outputs[MAX_OUTPUTS] = {};
|
|
|
|
|
double workspace[MAX_TOTAL_NODES] = {};
|
|
|
|
|
|
|
|
|
|
ExtHarness() {
|
|
|
|
|
engine.init_defaults();
|
|
|
|
|
reset_registry();
|
|
|
|
|
g_capture = CommandCapture{};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
~ExtHarness() { reset_registry(); }
|
|
|
|
|
|
|
|
|
|
EvalResult eval(const std::string& code)
|
|
|
|
|
{
|
|
|
|
|
return eval_cold(code.c_str(), static_cast<uint32_t>(code.size()),
|
|
|
|
|
engine);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void eval_ok(const std::string& code)
|
|
|
|
|
{
|
|
|
|
|
EvalResult r = eval(code);
|
|
|
|
|
INFO("code: " << code);
|
|
|
|
|
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
|
|
|
|
|
INFO("diagnostic: "
|
|
|
|
|
<< (r.diagnostics[0].message ? r.diagnostics[0].message
|
|
|
|
|
: ""));
|
|
|
|
|
}
|
|
|
|
|
REQUIRE(r.kind != EvalResult::Error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void expect_error(const std::string& code, DiagnosticCategory category)
|
|
|
|
|
{
|
|
|
|
|
EvalResult r = eval(code);
|
|
|
|
|
INFO("code: " << code);
|
|
|
|
|
REQUIRE(r.kind == EvalResult::Error);
|
|
|
|
|
REQUIRE(r.diagnostic_count > 0);
|
|
|
|
|
bool found = false;
|
|
|
|
|
for (uint8_t i = 0; i < r.diagnostic_count; ++i)
|
|
|
|
|
if (r.diagnostics[i].category == category) found = true;
|
|
|
|
|
INFO("expected category: " << category_to_cstr(category));
|
|
|
|
|
REQUIRE(found);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uint16_t output_index(const char* output_name)
|
|
|
|
|
{
|
|
|
|
|
SymbolID sym = internSymbol(output_name);
|
|
|
|
|
uint16_t idx = GraphBuilder::resolve_output_index(sym);
|
|
|
|
|
REQUIRE(idx != NODE_NONE);
|
|
|
|
|
return idx;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SinkBinding* binding_for(const char* sink_name)
|
|
|
|
|
{
|
|
|
|
|
const SymbolID sink = internSymbol(sink_name);
|
|
|
|
|
for (uint8_t i = 0; i < engine.sink_binding_count; i++)
|
|
|
|
|
if (engine.sink_bindings[i].sink == sink)
|
|
|
|
|
return &engine.sink_bindings[i];
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double tick(const char* output_name, double t)
|
|
|
|
|
{
|
|
|
|
|
std::memset(outputs, 0, sizeof(outputs));
|
|
|
|
|
std::memset(workspace, 0, sizeof(workspace));
|
|
|
|
|
engine.cells.snapshot_values(cell_values, MAX_CELLS);
|
|
|
|
|
|
|
|
|
|
ExecutionContext ctx;
|
|
|
|
|
ctx.t = t;
|
|
|
|
|
ctx.dt = 0.0;
|
|
|
|
|
ctx.cell_values = cell_values;
|
|
|
|
|
ctx.hw_inputs = hw_inputs;
|
|
|
|
|
ctx.data_pool = engine.cells.data_pool;
|
|
|
|
|
ctx.data_offsets = engine.cells.data_offsets;
|
|
|
|
|
ctx.data_lengths = engine.cells.data_lengths;
|
|
|
|
|
ctx.prev_outputs = engine.pool.prev_output_values;
|
|
|
|
|
ctx.output_values = outputs;
|
|
|
|
|
ctx.workspace = workspace;
|
|
|
|
|
execute_all_outputs(engine.pool, ctx);
|
|
|
|
|
publish_sink_values(engine, outputs);
|
|
|
|
|
const double value = outputs[output_index(output_name)];
|
|
|
|
|
commit_outputs(engine.pool, outputs);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tick without naming an output; sink publication still runs.
|
|
|
|
|
void tick_sinks(double t)
|
|
|
|
|
{
|
|
|
|
|
std::memset(outputs, 0, sizeof(outputs));
|
|
|
|
|
std::memset(workspace, 0, sizeof(workspace));
|
|
|
|
|
engine.cells.snapshot_values(cell_values, MAX_CELLS);
|
|
|
|
|
ExecutionContext ctx;
|
|
|
|
|
ctx.t = t;
|
|
|
|
|
ctx.dt = 0.0;
|
|
|
|
|
ctx.cell_values = cell_values;
|
|
|
|
|
ctx.hw_inputs = hw_inputs;
|
|
|
|
|
ctx.data_pool = engine.cells.data_pool;
|
|
|
|
|
ctx.data_offsets = engine.cells.data_offsets;
|
|
|
|
|
ctx.data_lengths = engine.cells.data_lengths;
|
|
|
|
|
ctx.prev_outputs = engine.pool.prev_output_values;
|
|
|
|
|
ctx.output_values = outputs;
|
|
|
|
|
ctx.workspace = workspace;
|
|
|
|
|
execute_all_outputs(engine.pool, ctx);
|
|
|
|
|
publish_sink_values(engine, outputs);
|
|
|
|
|
commit_outputs(engine.pool, outputs);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
// ── External inputs ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
TEST_CASE("external inputs resolve as graph leaves", "[ext_registry]")
|
|
|
|
|
{
|
|
|
|
|
ExtHarness h;
|
|
|
|
|
|
|
|
|
|
SECTION("registered input reads the executor hw snapshot")
|
|
|
|
|
{
|
|
|
|
|
REQUIRE(register_external_input(
|
|
|
|
|
{"meml/joy-x", 3, 1, 0.5f, "[0,1]"}));
|
|
|
|
|
h.hw_inputs[3] = 0.25;
|
|
|
|
|
h.eval_ok("(a1 (* 2 meml/joy-x))");
|
|
|
|
|
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("registered input usable inside a sink expression")
|
|
|
|
|
{
|
|
|
|
|
REQUIRE(register_external_input({"nn/out1", 4, 1, 0.5f, "[0,1]"}));
|
2026-08-27 08:55:41 +02:00
|
|
|
REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
|
2026-08-17 14:08:21 +02:00
|
|
|
h.hw_inputs[4] = 0.125;
|
2026-08-27 08:55:41 +02:00
|
|
|
h.eval_ok("(transport/mono nn/out1)");
|
2026-08-17 14:08:21 +02:00
|
|
|
h.tick_sinks(0.0);
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.125));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 08:55:41 +02:00
|
|
|
SECTION("multi-channel input is selected by a hot 1-based signal")
|
|
|
|
|
{
|
|
|
|
|
REQUIRE(register_external_input(
|
|
|
|
|
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
|
|
|
|
|
h.hw_inputs[4] = 0.1;
|
|
|
|
|
h.hw_inputs[5] = 0.2;
|
|
|
|
|
h.hw_inputs[6] = 0.3;
|
|
|
|
|
h.hw_inputs[7] = 0.4;
|
|
|
|
|
h.eval_ok("(a1 (nn/out (from-list [1 4] bar)))");
|
|
|
|
|
REQUIRE(h.tick("a1", 0.0) == Approx(0.1));
|
|
|
|
|
REQUIRE(h.tick("a1", 1.5) == Approx(0.4));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("invalid multi-channel selectors return the declared neutral")
|
|
|
|
|
{
|
|
|
|
|
REQUIRE(register_external_input(
|
|
|
|
|
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
|
|
|
|
|
h.eval_ok("(a1 (nn/out 0))");
|
|
|
|
|
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
|
|
|
|
|
h.eval_ok("(a1 (nn/out 5))");
|
|
|
|
|
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
|
|
|
|
|
h.eval_ok("(a1 (nn/out (/ 0 0)))");
|
|
|
|
|
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("multi-channel input cannot be read bare")
|
|
|
|
|
{
|
|
|
|
|
REQUIRE(register_external_input(
|
|
|
|
|
{"nn/out", 4, 4, 0.5f, "[0,1]"}));
|
|
|
|
|
h.expect_error("(a1 nn/out)", DiagnosticCategory::Arity);
|
|
|
|
|
h.expect_error("(a1 (nn/out))", DiagnosticCategory::Arity);
|
|
|
|
|
h.expect_error("(a1 (nn/out 1 2))", DiagnosticCategory::Arity);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 14:08:21 +02:00
|
|
|
SECTION("invalid descriptors are rejected")
|
|
|
|
|
{
|
|
|
|
|
REQUIRE(!register_external_input({nullptr, 0, 1, 0.0f, ""}));
|
|
|
|
|
REQUIRE(!register_external_input({"", 0, 1, 0.0f, ""}));
|
|
|
|
|
REQUIRE(!register_external_input({"bad/chan", 31, 2, 0.0f, ""}));
|
|
|
|
|
REQUIRE(register_external_input({"ok/in", 31, 1, 0.0f, ""}));
|
|
|
|
|
REQUIRE(register_external_input({"ok/in", 12, 1, 0.0f, ""})); // replace
|
|
|
|
|
h.hw_inputs[12] = 0.5;
|
|
|
|
|
h.eval_ok("(a1 ok/in)");
|
|
|
|
|
REQUIRE(h.tick("a1", 0.0) == Approx(0.5));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── External sinks ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
TEST_CASE("external sinks publish evaluated values", "[ext_registry]")
|
|
|
|
|
{
|
|
|
|
|
ExtHarness h;
|
|
|
|
|
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
|
2026-08-27 08:55:41 +02:00
|
|
|
REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
|
2026-08-17 14:08:21 +02:00
|
|
|
|
|
|
|
|
SECTION("assignment evaluates and raises the dirty flag")
|
|
|
|
|
{
|
2026-08-27 08:55:41 +02:00
|
|
|
h.eval_ok("(transport/mono ctl/x)");
|
2026-08-17 14:08:21 +02:00
|
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
|
|
|
|
|
|
|
|
h.hw_inputs[2] = 0.5;
|
|
|
|
|
h.tick_sinks(0.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.5));
|
|
|
|
|
REQUIRE(h.engine.sink_dirty[0]);
|
|
|
|
|
|
|
|
|
|
h.engine.sink_dirty[0] = false; // firmware acknowledgement
|
|
|
|
|
h.tick_sinks(1.0);
|
|
|
|
|
REQUIRE(!h.engine.sink_dirty[0]); // unchanged value suppressed
|
|
|
|
|
|
|
|
|
|
h.hw_inputs[2] = 0.9;
|
|
|
|
|
h.tick_sinks(2.0);
|
|
|
|
|
REQUIRE(h.engine.sink_dirty[0]);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.9));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("dirty uses one quantisation step as deadband")
|
|
|
|
|
{
|
2026-08-27 08:55:41 +02:00
|
|
|
h.eval_ok("(transport/mono ctl/x)");
|
2026-08-17 14:08:21 +02:00
|
|
|
h.hw_inputs[2] = 0.5;
|
|
|
|
|
h.tick_sinks(0.0);
|
|
|
|
|
h.engine.sink_dirty[0] = false;
|
|
|
|
|
|
|
|
|
|
// step = (1-0)/2^7 = 0.0078125; 0.003 stays inside the deadband
|
|
|
|
|
h.hw_inputs[2] = 0.503;
|
|
|
|
|
h.tick_sinks(1.0);
|
|
|
|
|
REQUIRE(!h.engine.sink_dirty[0]);
|
|
|
|
|
|
|
|
|
|
// 0.52 - 0.503 = 0.017 exceeds the step
|
|
|
|
|
h.hw_inputs[2] = 0.52;
|
|
|
|
|
h.tick_sinks(2.0);
|
|
|
|
|
REQUIRE(h.engine.sink_dirty[0]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("multi-channel sink publishes every channel")
|
|
|
|
|
{
|
|
|
|
|
REQUIRE(register_external_sink({"bus/out", 3, 0.0f, 1.0f, 200, 0}));
|
|
|
|
|
h.eval_ok("(bus/out 0.1 0.2 0.3)");
|
|
|
|
|
h.tick_sinks(0.0);
|
|
|
|
|
const SinkBinding* binding = h.binding_for("bus/out");
|
|
|
|
|
REQUIRE(binding != nullptr);
|
|
|
|
|
REQUIRE(binding->arity == 3);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.1));
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][1] == Approx(0.2));
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][2] == Approx(0.3));
|
|
|
|
|
|
|
|
|
|
// Reassignment keeps one binding and swaps every channel.
|
|
|
|
|
h.eval_ok("(bus/out 0.4 0.5 0.6)");
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
|
|
|
h.tick_sinks(1.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.4));
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][1] == Approx(0.5));
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][2] == Approx(0.6));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 14:37:14 +02:00
|
|
|
SECTION("full 16-channel sink binds and publishes every channel")
|
|
|
|
|
{
|
|
|
|
|
// The nn/in neural-input shape (NISPS-USEQ spec §4.2/§4.3): 8 base +
|
|
|
|
|
// 8 modulation expressions in one binding. MAX_SINK_ARITY and the
|
|
|
|
|
// pool arithmetic in ext_registry.h are sized for this.
|
|
|
|
|
REQUIRE(register_external_sink({"nn/in", 16, 0.0f, 1.0f, 200, 7}));
|
|
|
|
|
h.eval_ok("(nn/in 0.00 0.05 0.10 0.15 0.20 0.25 0.30 0.35 "
|
|
|
|
|
"0.5 0.4 0.3 0.2 0.1 0.0 -0.1 -0.2)");
|
|
|
|
|
h.tick_sinks(0.0);
|
|
|
|
|
const SinkBinding* binding = h.binding_for("nn/in");
|
|
|
|
|
REQUIRE(binding != nullptr);
|
|
|
|
|
REQUIRE(binding->arity == 16);
|
|
|
|
|
for (uint8_t ch = 0; ch < 16; ch++) {
|
|
|
|
|
const double expected = ch < 8 ? 0.05 * ch : 0.5 - 0.1 * (ch - 8);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][ch] == Approx(expected));
|
|
|
|
|
}
|
|
|
|
|
// Slot plan: 16 pool output slots at/above SINK_SLOT_BASE.
|
|
|
|
|
for (uint8_t ch = 0; ch < 16; ch++) {
|
|
|
|
|
REQUIRE(binding->value_index[ch] >= SINK_SLOT_BASE);
|
|
|
|
|
REQUIRE(binding->value_index[ch] < MAX_OUTPUTS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Rebinding swaps all 16 channels as one transaction.
|
|
|
|
|
h.eval_ok("(nn/in 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0)");
|
|
|
|
|
h.tick_sinks(1.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(1.0));
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][15] == Approx(0.0));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 14:08:21 +02:00
|
|
|
SECTION("wrong arity is a compile error and binds nothing")
|
|
|
|
|
{
|
2026-08-27 08:55:41 +02:00
|
|
|
h.expect_error("(transport/mono 0.1 0.2)", DiagnosticCategory::Arity);
|
|
|
|
|
h.expect_error("(transport/mono)", DiagnosticCategory::Arity);
|
2026-08-17 14:08:21 +02:00
|
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("unassign releases the binding")
|
|
|
|
|
{
|
2026-08-27 08:55:41 +02:00
|
|
|
h.eval_ok("(transport/mono ctl/x)");
|
2026-08-17 14:08:21 +02:00
|
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
2026-08-27 08:55:41 +02:00
|
|
|
h.eval_ok("(unassign transport/mono)");
|
2026-08-17 14:08:21 +02:00
|
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
2026-08-27 08:55:41 +02:00
|
|
|
REQUIRE(h.binding_for("transport/mono") == nullptr);
|
2026-08-17 14:08:21 +02:00
|
|
|
|
|
|
|
|
// Idempotent, and outputs keep working afterwards.
|
2026-08-27 08:55:41 +02:00
|
|
|
h.eval_ok("(unassign transport/mono)");
|
2026-08-17 14:08:21 +02:00
|
|
|
h.eval_ok("(a1 0.25)");
|
|
|
|
|
REQUIRE(h.tick("a1", 0.0) == Approx(0.25));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_CASE("sink LKG is independent per sink", "[ext_registry]")
|
|
|
|
|
{
|
|
|
|
|
ExtHarness h;
|
|
|
|
|
REQUIRE(register_external_input({"ctl/a", 2, 1, 0.0f, "[0,1]"}));
|
|
|
|
|
REQUIRE(register_external_input({"ctl/b", 3, 1, 0.0f, "[0,1]"}));
|
|
|
|
|
REQUIRE(register_external_sink({"sink/a", 1, 0.0f, 1.0f, 50, 0}));
|
|
|
|
|
REQUIRE(register_external_sink({"sink/b", 1, 0.0f, 1.0f, 50, 0}));
|
|
|
|
|
|
|
|
|
|
h.hw_inputs[2] = 0.25;
|
|
|
|
|
h.hw_inputs[3] = 0.75;
|
|
|
|
|
h.eval_ok("(sink/a ctl/a)");
|
|
|
|
|
h.eval_ok("(sink/b ctl/b)");
|
|
|
|
|
h.eval_ok("(a1 ctl/a)");
|
|
|
|
|
h.tick_sinks(0.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.25));
|
|
|
|
|
REQUIRE(h.engine.sink_values[1][0] == Approx(0.75));
|
|
|
|
|
|
|
|
|
|
const SinkBinding* binding_a = h.binding_for("sink/a");
|
|
|
|
|
REQUIRE(binding_a != nullptr);
|
|
|
|
|
const uint16_t root_before =
|
|
|
|
|
h.engine.pool.outputs[binding_a->value_index[0]].root_node;
|
|
|
|
|
|
|
|
|
|
// A failing edit to sink/a must retain sink/a's published graph while
|
|
|
|
|
// sink/b and the outputs keep running.
|
|
|
|
|
h.expect_error("(sink/a nosuch/input)", DiagnosticCategory::UndefinedName);
|
|
|
|
|
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 2);
|
|
|
|
|
const SinkBinding* after = h.binding_for("sink/a");
|
|
|
|
|
REQUIRE(after != nullptr);
|
|
|
|
|
REQUIRE(after->arity == 1);
|
|
|
|
|
REQUIRE(h.engine.pool.outputs[after->value_index[0]].root_node ==
|
|
|
|
|
root_before);
|
|
|
|
|
|
|
|
|
|
// The retained graph still tracks its input.
|
|
|
|
|
h.hw_inputs[2] = 0.5;
|
|
|
|
|
h.hw_inputs[3] = 0.8;
|
|
|
|
|
h.tick_sinks(1.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.5));
|
|
|
|
|
REQUIRE(h.engine.sink_values[1][0] == Approx(0.8));
|
|
|
|
|
REQUIRE(h.tick("a1", 2.0) == Approx(0.5));
|
|
|
|
|
|
|
|
|
|
// A repaired edit replaces the binding.
|
|
|
|
|
h.eval_ok("(sink/a 0.125)");
|
|
|
|
|
h.tick_sinks(3.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.125));
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 2);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 08:55:41 +02:00
|
|
|
TEST_CASE("multi-binding routed sink keeps independent route instances",
|
|
|
|
|
"[ext_registry][routed_sink]")
|
|
|
|
|
{
|
|
|
|
|
ExtHarness h;
|
|
|
|
|
REQUIRE(register_external_input({"ctl/value", 2, 1, 0.0f, "[0,1]"}));
|
|
|
|
|
REQUIRE(register_external_input({"ctl/cc", 3, 1, 74.0f, "[1,127]"}));
|
|
|
|
|
|
|
|
|
|
ExternalSinkDesc midi{};
|
|
|
|
|
midi.name = "midi/cc";
|
|
|
|
|
midi.arity = 2;
|
|
|
|
|
midi.min = 0.0f;
|
|
|
|
|
midi.max = 1.0f;
|
|
|
|
|
midi.max_rate_hz = 50;
|
|
|
|
|
midi.quant_bits = 7;
|
|
|
|
|
midi.route_ch = 1;
|
|
|
|
|
midi.route_min = 1.0f;
|
|
|
|
|
midi.route_max = 127.0f;
|
|
|
|
|
midi.allow_multiple_bindings = true;
|
|
|
|
|
REQUIRE(register_external_sink(midi));
|
|
|
|
|
|
|
|
|
|
h.hw_inputs[2] = 0.25;
|
|
|
|
|
h.hw_inputs[3] = 75.0;
|
|
|
|
|
h.eval_ok("(midi/cc 74 ctl/value)");
|
|
|
|
|
h.eval_ok("(midi/cc ctl/cc 0.5)");
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 2);
|
|
|
|
|
|
|
|
|
|
h.tick_sinks(0.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(74.0));
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][1] == Approx(0.25));
|
|
|
|
|
REQUIRE(h.engine.sink_values[1][0] == Approx(75.0));
|
|
|
|
|
REQUIRE(h.engine.sink_values[1][1] == Approx(0.5));
|
|
|
|
|
|
|
|
|
|
// The documented patterned route is an ordinary signal expression. Its
|
|
|
|
|
// token structure, not whitespace, is the stable route edit identity.
|
|
|
|
|
h.eval_ok("(midi/cc (from-list [76 77] bar) 0.125)");
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 3);
|
|
|
|
|
h.eval_ok("(midi/cc (from-list [76 77] bar) 0.25)");
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 3);
|
|
|
|
|
h.tick_sinks(0.5);
|
|
|
|
|
REQUIRE(h.engine.sink_values[2][1] == Approx(0.25));
|
|
|
|
|
|
|
|
|
|
// The leading CC expression is the route identity. Re-entering the same
|
|
|
|
|
// route replaces its value graph instead of leaking a duplicate binding.
|
|
|
|
|
h.eval_ok("(midi/cc 74 0.75)");
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 3);
|
|
|
|
|
h.tick_sinks(1.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(74.0));
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][1] == Approx(0.75));
|
|
|
|
|
|
|
|
|
|
// A failed edit retains all prior route instances.
|
|
|
|
|
h.expect_error("(midi/cc 74 nosuch/input)",
|
|
|
|
|
DiagnosticCategory::UndefinedName);
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 3);
|
|
|
|
|
h.tick_sinks(2.0);
|
|
|
|
|
REQUIRE(h.engine.sink_values[0][1] == Approx(0.75));
|
|
|
|
|
REQUIRE(h.engine.sink_values[1][1] == Approx(0.5));
|
|
|
|
|
|
|
|
|
|
// Route channels bypass the payload deadband so patterned addressing is
|
|
|
|
|
// observed even when the payload itself is unchanged.
|
|
|
|
|
h.engine.sink_dirty[0] = false;
|
|
|
|
|
h.engine.sink_dirty[1] = false;
|
|
|
|
|
h.hw_inputs[3] = 75.001;
|
|
|
|
|
h.tick_sinks(3.0);
|
|
|
|
|
REQUIRE(!h.engine.sink_dirty[0]);
|
|
|
|
|
REQUIRE(h.engine.sink_dirty[1]);
|
|
|
|
|
|
|
|
|
|
// Ordinary unassignment clears the whole dynamic route family.
|
|
|
|
|
h.eval_ok("(unassign midi/cc)");
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
|
|
|
REQUIRE(h.binding_for("midi/cc") == nullptr);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 14:08:21 +02:00
|
|
|
// ── Cold commands ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
TEST_CASE("registered cold commands dispatch to the handler", "[ext_registry]")
|
|
|
|
|
{
|
|
|
|
|
ExtHarness h;
|
|
|
|
|
int cookie = 0;
|
|
|
|
|
REQUIRE(register_external_command(internSymbol("meml/cmd"), 1, 2));
|
|
|
|
|
set_cold_command_handler(&test_command_handler, &cookie);
|
|
|
|
|
const SymbolID cmd_id = internSymbol("meml/cmd");
|
|
|
|
|
SECTION("integer and float arguments parse by shape")
|
|
|
|
|
{
|
|
|
|
|
h.eval_ok("(meml/cmd 3)");
|
|
|
|
|
REQUIRE(g_capture.calls == 1);
|
|
|
|
|
REQUIRE(g_capture.cmd == cmd_id);
|
|
|
|
|
REQUIRE(g_capture.user == &cookie);
|
|
|
|
|
REQUIRE(g_capture.nargs == 1);
|
|
|
|
|
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Int);
|
|
|
|
|
REQUIRE(g_capture.args[0].integer == 3);
|
|
|
|
|
|
|
|
|
|
h.eval_ok("(meml/cmd 2.5)");
|
|
|
|
|
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Number);
|
|
|
|
|
REQUIRE(g_capture.args[0].number == Approx(2.5f));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("symbol and vector arguments parse")
|
|
|
|
|
{
|
|
|
|
|
h.eval_ok("(meml/cmd level [1 2 3])");
|
|
|
|
|
REQUIRE(g_capture.nargs == 2);
|
|
|
|
|
REQUIRE(g_capture.args[0].kind == ColdArg::Kind::Symbol);
|
|
|
|
|
REQUIRE(g_capture.args[1].kind == ColdArg::Kind::Vector);
|
2026-08-17 14:37:14 +02:00
|
|
|
REQUIRE(g_capture.args[1].vec_len == 3);
|
2026-08-17 14:08:21 +02:00
|
|
|
REQUIRE(g_capture.args[1].vec[0] == Approx(1.0f));
|
|
|
|
|
REQUIRE(g_capture.args[1].vec[1] == Approx(2.0f));
|
|
|
|
|
REQUIRE(g_capture.args[1].vec[2] == Approx(3.0f));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("arity mismatch is an error and never calls the handler")
|
|
|
|
|
{
|
|
|
|
|
h.expect_error("(meml/cmd)", DiagnosticCategory::Arity);
|
|
|
|
|
h.expect_error("(meml/cmd 1 2 3)", DiagnosticCategory::Arity);
|
|
|
|
|
REQUIRE(g_capture.calls == 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("handler rejection is diagnosed")
|
|
|
|
|
{
|
|
|
|
|
g_capture.accept = false;
|
|
|
|
|
h.expect_error("(meml/cmd 1)", DiagnosticCategory::Runtime);
|
|
|
|
|
REQUIRE(g_capture.calls == 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("handler runs only after earlier edits compiled")
|
|
|
|
|
{
|
|
|
|
|
h.eval_ok("(a1 0.25)(meml/cmd 1)");
|
|
|
|
|
REQUIRE(g_capture.calls == 1);
|
|
|
|
|
REQUIRE(h.tick("a1", 0.0) == Approx(0.25));
|
|
|
|
|
|
|
|
|
|
// The failing edit stops the sequence before the command form.
|
|
|
|
|
g_capture.calls = 0;
|
|
|
|
|
h.expect_error("(a1 nosuch/input)(meml/cmd 1)",
|
|
|
|
|
DiagnosticCategory::UndefinedName);
|
|
|
|
|
REQUIRE(g_capture.calls == 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SECTION("unregistered heads keep the unknown-form behaviour")
|
|
|
|
|
{
|
|
|
|
|
EvalResult r = h.eval("(meml/nope 1)");
|
|
|
|
|
REQUIRE(r.kind == EvalResult::Error);
|
|
|
|
|
REQUIRE(g_capture.calls == 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_CASE("registry reset clears profile state", "[ext_registry]")
|
|
|
|
|
{
|
|
|
|
|
ExtHarness h;
|
|
|
|
|
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
|
2026-08-27 08:55:41 +02:00
|
|
|
REQUIRE(register_external_sink({"transport/mono", 1, 0.0f, 1.0f, 50, 7}));
|
2026-08-17 14:08:21 +02:00
|
|
|
reset_registry();
|
|
|
|
|
// Names no longer resolve as inputs or sinks.
|
|
|
|
|
h.expect_error("(a1 ctl/x)", DiagnosticCategory::UndefinedName);
|
2026-08-27 08:55:41 +02:00
|
|
|
EvalResult r = h.eval("(transport/mono 0.5)");
|
2026-08-17 14:08:21 +02:00
|
|
|
REQUIRE(r.kind == EvalResult::Error);
|
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
|
|
|
REQUIRE(cold_command_handler() == nullptr);
|
|
|
|
|
}
|