416 lines
14 KiB
C++
416 lines
14 KiB
C++
// 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]"}));
|
|
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
|
|
h.hw_inputs[4] = 0.125;
|
|
h.eval_ok("(midi/cc74 nn/out1)");
|
|
h.tick_sinks(0.0);
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.125));
|
|
}
|
|
|
|
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]"}));
|
|
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
|
|
|
|
SECTION("assignment evaluates and raises the dirty flag")
|
|
{
|
|
h.eval_ok("(midi/cc74 ctl/x)");
|
|
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")
|
|
{
|
|
h.eval_ok("(midi/cc74 ctl/x)");
|
|
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));
|
|
}
|
|
|
|
SECTION("wrong arity is a compile error and binds nothing")
|
|
{
|
|
h.expect_error("(midi/cc74 0.1 0.2)", DiagnosticCategory::Arity);
|
|
h.expect_error("(midi/cc74)", DiagnosticCategory::Arity);
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
}
|
|
|
|
SECTION("unassign releases the binding")
|
|
{
|
|
h.eval_ok("(midi/cc74 ctl/x)");
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
h.eval_ok("(unassign midi/cc74)");
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
REQUIRE(h.binding_for("midi/cc74") == nullptr);
|
|
|
|
// Idempotent, and outputs keep working afterwards.
|
|
h.eval_ok("(unassign midi/cc74)");
|
|
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);
|
|
}
|
|
|
|
// ── 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[0].symbol == internSymbol("level"));
|
|
REQUIRE(g_capture.args[1].kind == ColdArg::Kind::Vector);
|
|
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]"}));
|
|
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
|
|
reset_registry();
|
|
// Names no longer resolve as inputs or sinks.
|
|
h.expect_error("(a1 ctl/x)", DiagnosticCategory::UndefinedName);
|
|
EvalResult r = h.eval("(midi/cc74 0.5)");
|
|
REQUIRE(r.kind == EvalResult::Error);
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
REQUIRE(cold_command_handler() == nullptr);
|
|
}
|