478 lines
17 KiB
C++
478 lines
17 KiB
C++
// Vector-packing sugar for external sinks (NISPS-USEQ spec §4.3/§4.4): a
|
|
// descriptor with vec_base_ch/vec_mod_ch set accepts its channel expressions
|
|
// flat, as [base…] with optional :offset [mod…], or as [[base mod] scalar …].
|
|
//
|
|
// Written at the language boundary like test_ext_registry.cpp: eval source
|
|
// text, tick the engine, and assert the user-visible contract — identical
|
|
// sink_values rows across forms, arity/exclusivity compile errors, per-sink
|
|
// LKG across a failed sugar edit, and unchanged plain-sink behaviour.
|
|
|
|
#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 SugarHarness {
|
|
SignalEngine engine;
|
|
double cell_values[MAX_CELLS] = {};
|
|
double hw_inputs[32] = {};
|
|
double outputs[MAX_OUTPUTS] = {};
|
|
double workspace[MAX_TOTAL_NODES] = {};
|
|
|
|
SugarHarness() {
|
|
engine.init_defaults();
|
|
reset_registry();
|
|
}
|
|
|
|
~SugarHarness() { 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));
|
|
if (!found && r.diagnostics[0].message) {
|
|
INFO("first diagnostic: " << r.diagnostics[0].message);
|
|
}
|
|
REQUIRE(found);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
uint8_t binding_row(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 i;
|
|
return MAX_SINK_BINDINGS; // sentinel: not bound
|
|
}
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// One 8+8 packed sink named for what it tests; bases 0.1..0.8, mods
|
|
// 0.01..0.08. Mirrors the nn/in firmware shape (spec §4.2/§4.3).
|
|
constexpr float kBases[8] = {0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f};
|
|
constexpr float kMods[8] = {0.01f, 0.02f, 0.03f, 0.04f,
|
|
0.05f, 0.06f, 0.07f, 0.08f};
|
|
|
|
const char* kFlat =
|
|
"(vec/in 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 "
|
|
"0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08)";
|
|
const char* kOffset =
|
|
"(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] "
|
|
":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08])";
|
|
const char* kPairs =
|
|
"(vec/in [[0.1 0.01] [0.2 0.02] [0.3 0.03] [0.4 0.04] "
|
|
"[0.5 0.05] [0.6 0.06] [0.7 0.07] [0.8 0.08]])";
|
|
|
|
} // namespace
|
|
|
|
// ── Descriptor validation (registration seam) ───────────────────────────────
|
|
|
|
TEST_CASE("vector-packing descriptor fields validate at registration",
|
|
"[sink_sugar][ext_registry]")
|
|
{
|
|
SugarHarness h;
|
|
|
|
SECTION("both zero (defaults) keeps today's plain sink")
|
|
{
|
|
REQUIRE(register_external_sink({"plain/out", 3, 0.0f, 1.0f, 50, 7}));
|
|
const ExternalSinkDesc* desc =
|
|
find_external_sink(internSymbol("plain/out"));
|
|
REQUIRE(desc != nullptr);
|
|
REQUIRE(desc->vec_base_ch == 0);
|
|
REQUIRE(desc->vec_mod_ch == 0);
|
|
}
|
|
|
|
SECTION("base + mod channels must both be set and sum to arity")
|
|
{
|
|
REQUIRE(register_external_sink(
|
|
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
|
|
const ExternalSinkDesc* desc =
|
|
find_external_sink(internSymbol("vec/in"));
|
|
REQUIRE(desc != nullptr);
|
|
REQUIRE(desc->vec_base_ch == 8);
|
|
REQUIRE(desc->vec_mod_ch == 8);
|
|
}
|
|
|
|
SECTION("half-packed descriptors are rejected")
|
|
{
|
|
REQUIRE(!register_external_sink(
|
|
{"bad/in", 16, 0.0f, 1.0f, 200, 7, 8, 0}));
|
|
REQUIRE(!register_external_sink(
|
|
{"bad/in", 16, 0.0f, 1.0f, 200, 7, 0, 8}));
|
|
REQUIRE(external_sink_registered(internSymbol("bad/in")) == false);
|
|
}
|
|
|
|
SECTION("the split must cover the arity exactly")
|
|
{
|
|
REQUIRE(!register_external_sink(
|
|
{"bad/in", 16, 0.0f, 1.0f, 200, 7, 7, 8})); // 15 of 16
|
|
REQUIRE(!register_external_sink(
|
|
{"bad/in", 12, 0.0f, 1.0f, 200, 7, 8, 8})); // 16 of 12
|
|
REQUIRE(external_sink_registered(internSymbol("bad/in")) == false);
|
|
}
|
|
}
|
|
|
|
// ── Accepted forms ──────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("three forms of one binding publish identical sink rows",
|
|
"[sink_sugar]")
|
|
{
|
|
SugarHarness h;
|
|
REQUIRE(register_external_sink(
|
|
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
|
|
|
|
SECTION("(a) flat, :offset, and pair forms agree")
|
|
{
|
|
h.eval_ok(kFlat);
|
|
h.tick_sinks(0.0);
|
|
const SinkBinding* binding = h.binding_for("vec/in");
|
|
REQUIRE(binding != nullptr);
|
|
REQUIRE(binding->arity == 16);
|
|
|
|
double expected[16];
|
|
for (uint8_t ch = 0; ch < 16; ch++)
|
|
expected[ch] = h.engine.sink_values[0][ch];
|
|
for (uint8_t i = 0; i < 8; i++) {
|
|
REQUIRE(expected[i] == Approx(kBases[i]));
|
|
REQUIRE(expected[8 + i] == Approx(kMods[i]));
|
|
}
|
|
|
|
h.eval_ok(kOffset);
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
h.tick_sinks(1.0);
|
|
for (uint8_t ch = 0; ch < 16; ch++)
|
|
REQUIRE(h.engine.sink_values[0][ch] == Approx(expected[ch]));
|
|
|
|
h.eval_ok(kPairs);
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
h.tick_sinks(2.0);
|
|
for (uint8_t ch = 0; ch < 16; ch++)
|
|
REQUIRE(h.engine.sink_values[0][ch] == Approx(expected[ch]));
|
|
|
|
// Every channel still occupies an ordinary sink pool slot.
|
|
for (uint8_t ch = 0; ch < 16; ch++) {
|
|
REQUIRE(binding->value_index[ch] >= SINK_SLOT_BASE);
|
|
REQUIRE(binding->value_index[ch] < MAX_OUTPUTS);
|
|
}
|
|
}
|
|
|
|
SECTION("(a2) a base-only vector defaults every modulation to zero")
|
|
{
|
|
h.eval_ok("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8])");
|
|
h.tick_sinks(0.0);
|
|
for (uint8_t i = 0; i < 8; i++) {
|
|
REQUIRE(h.engine.sink_values[0][i] == Approx(kBases[i]));
|
|
REQUIRE(h.engine.sink_values[0][8 + i] == Approx(0.0));
|
|
}
|
|
}
|
|
|
|
SECTION("(a3) a mixed pair vector binds scalar and paired entries")
|
|
{
|
|
h.eval_ok("(vec/in [[0.1 0.01] 0.2 0.3 [0.4 0.04] 0.5 0.6 0.7 0.8])");
|
|
h.tick_sinks(0.0);
|
|
for (uint8_t i = 0; i < 8; i++) {
|
|
REQUIRE(h.engine.sink_values[0][i] == Approx(kBases[i]));
|
|
const float mod = (i == 0 || i == 3) ? kMods[i] : 0.0f;
|
|
REQUIRE(h.engine.sink_values[0][8 + i] == Approx(mod));
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("entries may be arbitrary expressions in every form", "[sink_sugar]")
|
|
{
|
|
SugarHarness h;
|
|
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
|
|
REQUIRE(register_external_sink(
|
|
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
|
|
h.hw_inputs[2] = 0.25;
|
|
|
|
SECTION("(e1) flat form")
|
|
{
|
|
h.eval_ok("(vec/in (+ ctl/x 0.1) 0.2 0.3 0.4 0.5 0.6 0.7 0.8 "
|
|
"(- ctl/x 0.05) 0.02 0.03 0.04 0.05 0.06 0.07 0.08)");
|
|
h.tick_sinks(0.0);
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.35));
|
|
REQUIRE(h.engine.sink_values[0][8] == Approx(0.20));
|
|
}
|
|
|
|
SECTION("(e2) :offset form, expressions in both vectors")
|
|
{
|
|
h.eval_ok("(vec/in [(+ ctl/x 0.1) 0.2 0.3 0.4 0.5 0.6 0.7 0.8] "
|
|
":offset [(- ctl/x 0.05) (* ctl/x 8) 0.03 0.04 "
|
|
"0.05 0.06 0.07 0.08])");
|
|
h.tick_sinks(0.0);
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.35));
|
|
REQUIRE(h.engine.sink_values[0][8] == Approx(0.20));
|
|
REQUIRE(h.engine.sink_values[0][9] == Approx(2.0));
|
|
}
|
|
|
|
SECTION("(e3) pair form, expressions in both slots of a pair")
|
|
{
|
|
h.eval_ok("(vec/in [[(+ ctl/x 0.1) (- ctl/x 0.05)] "
|
|
"[(* ctl/x 2) 0.5] 0.3 0.4 0.5 0.6 0.7 0.8])");
|
|
h.tick_sinks(0.0);
|
|
REQUIRE(h.engine.sink_values[0][0] == Approx(0.35));
|
|
REQUIRE(h.engine.sink_values[0][8] == Approx(0.20));
|
|
REQUIRE(h.engine.sink_values[0][1] == Approx(0.5));
|
|
REQUIRE(h.engine.sink_values[0][9] == Approx(0.5));
|
|
// scalar entries still take the implicit zero modulation
|
|
REQUIRE(h.engine.sink_values[0][10] == Approx(0.0));
|
|
}
|
|
}
|
|
|
|
// ── Rejected forms ──────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("sugar arity errors are compile errors that bind nothing",
|
|
"[sink_sugar][arity]")
|
|
{
|
|
SugarHarness h;
|
|
REQUIRE(register_external_sink(
|
|
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
|
|
|
|
SECTION("(c1) base vector with 7 entries")
|
|
{
|
|
h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7] "
|
|
":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08])",
|
|
DiagnosticCategory::Arity);
|
|
}
|
|
|
|
SECTION("(c2) :offset vector with 9 entries")
|
|
{
|
|
h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] "
|
|
":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08 0.09])",
|
|
DiagnosticCategory::Arity);
|
|
}
|
|
|
|
SECTION("(c3) pair entry with three entries")
|
|
{
|
|
h.expect_error("(vec/in [[0.1 0.01 0.9] 0.2 0.3 0.4 0.5 0.6 0.7 0.8])",
|
|
DiagnosticCategory::Arity);
|
|
}
|
|
|
|
SECTION("(c3b) pair entry with one entry")
|
|
{
|
|
h.expect_error("(vec/in [[0.1] 0.2 0.3 0.4 0.5 0.6 0.7 0.8])",
|
|
DiagnosticCategory::Arity);
|
|
}
|
|
|
|
SECTION("(c4) outer vector with 7 entries")
|
|
{
|
|
h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7])",
|
|
DiagnosticCategory::Arity);
|
|
}
|
|
|
|
SECTION("(c5) flat form with 15 expressions")
|
|
{
|
|
h.expect_error("(vec/in 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 "
|
|
"0.01 0.02 0.03 0.04 0.05 0.06 0.07)",
|
|
DiagnosticCategory::Arity);
|
|
}
|
|
|
|
SECTION("unknown keyword in the sink form")
|
|
{
|
|
h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] "
|
|
":modulation [0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1])",
|
|
DiagnosticCategory::Syntax);
|
|
}
|
|
|
|
SECTION(":offset value that is not a vector")
|
|
{
|
|
h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] "
|
|
":offset 0.1)",
|
|
DiagnosticCategory::Syntax);
|
|
}
|
|
|
|
SECTION("trailing value after the channel vector")
|
|
{
|
|
h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] 0.9)",
|
|
DiagnosticCategory::Arity);
|
|
}
|
|
|
|
REQUIRE(h.binding_for("vec/in") == nullptr);
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
}
|
|
|
|
TEST_CASE(":offset and pair entries are mutually exclusive", "[sink_sugar]")
|
|
{
|
|
SugarHarness h;
|
|
REQUIRE(register_external_sink(
|
|
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
|
|
|
|
SECTION("(b1) a pair entry plus :offset")
|
|
{
|
|
h.expect_error("(vec/in [[0.1 0.01] 0.2 0.3 0.4 0.5 0.6 0.7 0.8] "
|
|
":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08])",
|
|
DiagnosticCategory::Syntax);
|
|
}
|
|
|
|
SECTION("(b2) a nested pair inside the :offset vector")
|
|
{
|
|
h.expect_error("(vec/in [0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8] "
|
|
":offset [[0.01 0.02] 0.02 0.03 0.04 "
|
|
"0.05 0.06 0.07 0.08])",
|
|
DiagnosticCategory::Syntax);
|
|
}
|
|
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
}
|
|
|
|
// ── LKG and lifecycle ───────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("a bad sugar edit keeps the prior binding while another sink stays "
|
|
"live",
|
|
"[sink_sugar][lkg]")
|
|
{
|
|
SugarHarness h;
|
|
REQUIRE(register_external_input({"ctl/x", 2, 1, 0.0f, "[0,1]"}));
|
|
REQUIRE(register_external_sink(
|
|
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
|
|
REQUIRE(register_external_sink({"midi/cc74", 1, 0.0f, 1.0f, 50, 7}));
|
|
|
|
h.eval_ok(kFlat);
|
|
h.eval_ok("(midi/cc74 ctl/x)");
|
|
h.hw_inputs[2] = 0.5;
|
|
h.tick_sinks(0.0);
|
|
|
|
const uint8_t vec_row = h.binding_row("vec/in");
|
|
const uint8_t cc_row = h.binding_row("midi/cc74");
|
|
REQUIRE(vec_row != MAX_SINK_BINDINGS);
|
|
REQUIRE(cc_row != MAX_SINK_BINDINGS);
|
|
|
|
double lkg[16];
|
|
for (uint8_t ch = 0; ch < 16; ch++)
|
|
lkg[ch] = h.engine.sink_values[vec_row][ch];
|
|
|
|
// Exclusivity violation (spec §4.5): the whole form fails.
|
|
h.expect_error("(vec/in [[0.9 0.01] 0.2 0.3 0.4 0.5 0.6 0.7 0.8] "
|
|
":offset [0.01 0.02 0.03 0.04 0.05 0.06 0.07 0.08])",
|
|
DiagnosticCategory::Syntax);
|
|
REQUIRE(h.engine.sink_binding_count == 2);
|
|
|
|
// The failed edit retained vec/in's row; the other sink kept publishing.
|
|
h.hw_inputs[2] = 0.75;
|
|
h.tick_sinks(1.0);
|
|
for (uint8_t ch = 0; ch < 16; ch++)
|
|
REQUIRE(h.engine.sink_values[vec_row][ch] == Approx(lkg[ch]));
|
|
REQUIRE(h.engine.sink_values[cc_row][0] == Approx(0.75));
|
|
|
|
// A corrected sugar edit then replaces the retained binding atomically.
|
|
h.eval_ok("(vec/in [0.8 0.8 0.8 0.8 0.8 0.8 0.8 0.8])");
|
|
h.tick_sinks(2.0);
|
|
for (uint8_t i = 0; i < 8; i++) {
|
|
REQUIRE(h.engine.sink_values[vec_row][i] == Approx(0.8));
|
|
REQUIRE(h.engine.sink_values[vec_row][8 + i] == Approx(0.0));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("unassign then rebind via sugar", "[sink_sugar]")
|
|
{
|
|
SugarHarness h;
|
|
REQUIRE(register_external_sink(
|
|
{"vec/in", 16, 0.0f, 1.0f, 200, 7, 8, 8}));
|
|
|
|
h.eval_ok(kFlat);
|
|
h.tick_sinks(0.0);
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
|
|
h.eval_ok("(unassign vec/in)");
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
REQUIRE(h.binding_for("vec/in") == nullptr);
|
|
|
|
h.eval_ok(kPairs);
|
|
REQUIRE(h.engine.sink_binding_count == 1);
|
|
const SinkBinding* binding = h.binding_for("vec/in");
|
|
REQUIRE(binding != nullptr);
|
|
REQUIRE(binding->arity == 16);
|
|
h.tick_sinks(1.0);
|
|
for (uint8_t i = 0; i < 8; i++) {
|
|
REQUIRE(h.engine.sink_values[0][i] == Approx(kBases[i]));
|
|
REQUIRE(h.engine.sink_values[0][8 + i] == Approx(kMods[i]));
|
|
}
|
|
}
|
|
|
|
// ── Plain sinks are untouched ───────────────────────────────────────────────
|
|
|
|
TEST_CASE("plain non-packed sinks reject the sugar forms", "[sink_sugar]")
|
|
{
|
|
SugarHarness h;
|
|
REQUIRE(register_external_sink({"bus/out", 3, 0.0f, 1.0f, 50, 0}));
|
|
|
|
// The sugar is descriptor-gated: a plain sink parses arguments as flat
|
|
// expressions, so the sugar shapes fail as they always did — the
|
|
// :offset keyword is an unknown name in expression position, and a
|
|
// nested pair vector compiles to a plain vector expression (its
|
|
// length), leaving too few flat arguments.
|
|
h.expect_error("(bus/out [1 2] :offset [3 4])",
|
|
DiagnosticCategory::UndefinedName);
|
|
h.expect_error("(bus/out [[1 2] 3])", DiagnosticCategory::Arity);
|
|
REQUIRE(h.engine.sink_binding_count == 0);
|
|
|
|
// The flat form still binds exactly as before.
|
|
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][2] == Approx(0.3));
|
|
}
|