ModuLisp/test/signal_engine/test_live_edit.cpp

341 lines
13 KiB
C++
Raw Normal View History

// Live-edit state identity and slot management tests.
//
// Covers: live-edit slot allocation, value clamping, duplicate :id detection,
// cross-output :id uniqueness, keyword validation, MAX_LIVE_SLOTS cap,
// eager-consume head rejection, defstate :initial rejection, and
// dead-slot warning.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/cold_eval.h"
#include "src/signal_engine/graph_builder.h"
#include "src/signal_engine/executor.h"
#include <cstring>
#include <cmath>
// ── Test harness ────────────────────────────────────────────────────────────
struct LiveEditHarness {
sig::SignalEngine engine;
LiveEditHarness() {
sig::GraphBuilder::init_symbols();
engine.init_defaults();
}
sig::EvalResult eval(const char* code) {
return sig::eval_cold(code, (uint32_t)strlen(code), engine);
}
double tick(double t) {
double output_values[sig::MAX_OUTPUTS] = {};
double node_values[sig::MAX_TOTAL_NODES];
double cell_values[sig::MAX_CELLS];
engine.cells.snapshot_values(cell_values, sig::MAX_CELLS);
double hw_inputs[32] = {};
sig::ExecutionContext ctx;
ctx.t = t;
ctx.dt = 0.001;
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 = output_values;
ctx.workspace = node_values;
sig::execute_all_outputs(engine.pool, ctx);
return output_values[0]; // a1
}
};
// ── Basic slot allocation ───────────────────────────────────────────────────
TEST_CASE("live-edit allocates a slot and returns seed value", "[live-edit]")
{
LiveEditHarness h;
auto r = h.eval("(a1 (live-edit 0.5 :id \"x\" :min 0 :max 1))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(std::string(h.engine.pool.live_slots[0].id) == "x");
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.5));
double v = h.tick(0.0);
REQUIRE(v == Approx(0.5));
}
TEST_CASE("set_live_slot_value clamps to [min,max]", "[live-edit]")
{
LiveEditHarness h;
h.eval("(a1 (live-edit 0.5 :id \"x\" :min 0 :max 1))");
h.engine.pool.set_live_slot_value("x", 2.0);
REQUIRE(h.engine.pool.live_slots[0].value == Approx(1.0));
h.engine.pool.set_live_slot_value("x", -1.0);
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.0));
}
TEST_CASE("set_live_slot_value ignores unknown ID", "[live-edit]")
{
LiveEditHarness h;
h.eval("(a1 (live-edit 0.5 :id \"x\" :min 0 :max 1))");
// Should not crash or modify any slot
h.engine.pool.set_live_slot_value("nonexistent", 0.7);
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.5));
}
TEST_CASE("boolean live-edit preserves its variant and coerces writes", "[live-edit][variant]")
{
LiveEditHarness h;
auto r = h.eval("(a1 (live-edit true :id \"gate\"))");
REQUIRE(r.kind != sig::EvalResult::Error);
const auto& slot = h.engine.pool.live_slots[0];
REQUIRE(slot.variant == sig::NodePool::SlotVariant::Boolean);
REQUIRE(slot.seed == Approx(1.0));
REQUIRE(h.tick(0.0) == Approx(1.0));
h.engine.pool.set_live_slot_value("gate", 0.0);
REQUIRE(h.tick(0.0) == Approx(0.0));
h.engine.pool.set_live_slot_value("gate", -9.0);
REQUIRE(h.tick(0.0) == Approx(1.0));
}
TEST_CASE("keyword live-edit preserves options and validates writes", "[live-edit][variant]")
{
LiveEditHarness h;
auto r = h.eval(
"(a1 (live-edit :up :id \"direction\" :options [:left :up :right]))");
REQUIRE(r.kind != sig::EvalResult::Error);
const auto& slot = h.engine.pool.live_slots[0];
REQUIRE(slot.variant == sig::NodePool::SlotVariant::Keyword);
REQUIRE(slot.options_count == 3);
REQUIRE(std::string(slot.options[0]) == ":left");
REQUIRE(std::string(slot.options[1]) == ":up");
REQUIRE(std::string(slot.options[2]) == ":right");
REQUIRE(slot.seed == Approx(1.0));
REQUIRE(h.tick(0.0) == Approx(1.0));
h.engine.pool.set_live_slot_value("direction", 2.0);
REQUIRE(h.tick(0.0) == Approx(2.0));
h.engine.pool.set_live_slot_value("direction", 9.0);
REQUIRE(h.tick(0.0) == Approx(2.0));
}
TEST_CASE("live-edit step and precision metadata reach the runtime slot", "[live-edit][metadata]")
{
LiveEditHarness h;
auto r = h.eval(
"(a1 (live-edit 0.5 :id \"fine\" :min 0 :max 1 "
":name \"Fine control\" :step 0.01 :precision 2))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slots[0].step == Approx(0.01));
REQUIRE(h.engine.pool.live_slots[0].precision == 2);
}
TEST_CASE("keyword live-edit reorders options without changing the selected keyword",
"[live-edit][variant][recompile]")
{
LiveEditHarness h;
REQUIRE(h.eval(
"(a1 (live-edit :up :id \"direction\" :options [:up :down]))").kind !=
sig::EvalResult::Error);
h.engine.pool.set_live_slot_value("direction", 1.0); // :down
REQUIRE(h.eval(
"(a1 (live-edit :up :id \"direction\" :options [:down :up]))").kind !=
sig::EvalResult::Error);
const auto& slot = h.engine.pool.live_slots[0];
REQUIRE(std::string(slot.options[(int)slot.value]) == ":down");
REQUIRE(slot.value == Approx(0.0));
}
TEST_CASE("keyword live-edit without options is repaired to a singleton", "[live-edit][variant]")
{
LiveEditHarness h;
auto r = h.eval("(a1 (live-edit :solo :id \"mode\"))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slots[0].options_count == 1);
REQUIRE(std::string(h.engine.pool.live_slots[0].options[0]) == ":solo");
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.0));
}
// ── Cross-output duplicate :id detection (useq-ef7) ────────────────────────
TEST_CASE("duplicate :id in same output is rejected", "[live-edit][ef7]")
{
LiveEditHarness h;
auto r = h.eval(
"(a1 (+ (live-edit 0.5 :id \"dup\" :min 0 :max 1)"
" (live-edit 0.3 :id \"dup\" :min 0 :max 1)))");
REQUIRE(r.kind == sig::EvalResult::Error);
}
TEST_CASE("cross-output duplicate :id is rejected", "[live-edit][ef7]")
{
LiveEditHarness h;
// First output succeeds
auto r1 = h.eval("(a1 (live-edit 0.5 :id \"shared\" :min 0 :max 1))");
REQUIRE(r1.kind != sig::EvalResult::Error);
// Second output with same :id in same eval batch should fail.
// We need both in the same eval_cold call for the cross-output check.
LiveEditHarness h2;
auto r2 = h2.eval(
"(a1 (live-edit 0.5 :id \"x\" :min 0 :max 1))"
"(a2 (live-edit 0.5 :id \"x\" :min 0 :max 1))");
REQUIRE(r2.kind == sig::EvalResult::Error);
}
TEST_CASE("cross-output duplicate :id is rejected across separate eval calls",
"[live-edit][ownership]")
{
LiveEditHarness h;
auto first = h.eval("(a1 (live-edit 0.5 :id \"owned\" :min 0 :max 1))");
REQUIRE(first.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
auto duplicate = h.eval("(a2 (live-edit 0.25 :id \"owned\" :min 0 :max 1))");
REQUIRE(duplicate.kind == sig::EvalResult::Error);
REQUIRE(duplicate.diagnostic_count > 0);
REQUIRE(std::string(duplicate.diagnostics[0].message).find("another signal") !=
std::string::npos);
REQUIRE(h.engine.pool.outputs[1].root_node == sig::NODE_NONE);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.tick(0.0) == Approx(0.5));
}
TEST_CASE("different :ids across outputs are allowed", "[live-edit][ef7]")
{
LiveEditHarness h;
auto r = h.eval(
"(a1 (live-edit 0.5 :id \"knob1\" :min 0 :max 1))"
"(a2 (live-edit 0.3 :id \"knob2\" :min 0 :max 1))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 2);
}
// ── MAX_LIVE_SLOTS bump (useq-eh9) ─────────────────────────────────────────
TEST_CASE("MAX_LIVE_SLOTS is 256", "[live-edit][eh9]")
{
REQUIRE(sig::MAX_LIVE_SLOTS == 256);
}
TEST_CASE("replacing one output with fresh live-edit IDs reclaims old slots",
"[live-edit][reclaim]")
{
LiveEditHarness h;
for (size_t i = 0; i < sig::MAX_LIVE_SLOTS + 32; i++) {
std::string code = "(a1 (live-edit 0.5 :id \"knob-" +
std::to_string(i) + "\" :min 0 :max 1))";
auto r = h.eval(code.c_str());
INFO("iteration " << i);
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(std::string(h.engine.pool.live_slots[0].id) ==
"knob-" + std::to_string(i));
}
REQUIRE(h.tick(0.0) == Approx(0.5));
}
TEST_CASE("rejected live-edit replacement restores slot metadata and value",
"[live-edit][rollback]")
{
LiveEditHarness h;
REQUIRE(h.eval("(a1 (live-edit 0.5 :id \"stable\" :min 0 :max 1))").kind !=
sig::EvalResult::Error);
h.engine.pool.set_live_slot_value("stable", 0.8);
auto rejected = h.eval(
"(a1 (+ (live-edit 5 :id \"stable\" :min 4 :max 6)"
" (live-edit 5 :id \"stable\" :min 4 :max 6)))");
REQUIRE(rejected.kind == sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
REQUIRE(h.engine.pool.live_slots[0].min_val == Approx(0.0));
REQUIRE(h.engine.pool.live_slots[0].max_val == Approx(1.0));
REQUIRE(h.engine.pool.live_slots[0].seed == Approx(0.5));
REQUIRE(h.engine.pool.live_slots[0].value == Approx(0.8));
REQUIRE(h.tick(0.0) == Approx(0.8));
}
// ── Warning #3: slot allocated but never read (useq-ijk) ───────────────────
// TODO: Full cross-output dead-slot detection requires post-compilation analysis
// across all outputs. This test covers the within-single-output case where the
// live-edit value is discarded by the enclosing form.
TEST_CASE("warning emitted when live-edit slot is unused in output", "[live-edit][ijk]")
{
// This test checks the within-single-output dead-slot warning.
// (do (live-edit ...) 1.0) — live-edit is allocated but discarded.
LiveEditHarness h;
auto r = h.eval("(a1 (do (live-edit 0.5 :id \"unused\" :min 0 :max 1) 1.0))");
// Compilation should succeed (it's a warning, not an error)
REQUIRE(r.kind != sig::EvalResult::Error);
// The warning is emitted during compilation, but successful publication
// immediately reclaims the unreachable slot.
REQUIRE(h.engine.pool.live_slot_count == 0);
// The output value should be 1.0 (the last form in do)
double v = h.tick(0.0);
REQUIRE(v == Approx(1.0));
}
// ── Eager-consume head rejection (useq-tz9) ────────────────────────────────
TEST_CASE("live-edit rejected as argument of set-bpm", "[live-edit][tz9]")
{
LiveEditHarness h;
auto r = h.eval("(set-bpm (live-edit 120 :id \"bpm\" :min 60 :max 240))");
REQUIRE(r.kind == sig::EvalResult::Error);
// Check that the error message mentions live-edit
REQUIRE(r.diagnostic_count > 0);
REQUIRE(std::string(r.diagnostics[0].message).find("live-edit") != std::string::npos);
}
TEST_CASE("live-edit rejected as argument of set-time-sig", "[live-edit][tz9]")
{
LiveEditHarness h;
auto r = h.eval("(set-time-sig (live-edit 4 :id \"ts\" :min 2 :max 8))");
REQUIRE(r.kind == sig::EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
REQUIRE(std::string(r.diagnostics[0].message).find("live-edit") != std::string::npos);
}
TEST_CASE("non-live-edit args to set-bpm still work", "[live-edit][tz9]")
{
LiveEditHarness h;
auto r = h.eval("(set-bpm 140)");
REQUIRE(r.kind != sig::EvalResult::Error);
}
// ── defstate :initial rejection (useq-726) ──────────────────────────────────
TEST_CASE("live-edit rejected as defstate initial value", "[live-edit][726]")
{
LiveEditHarness h;
auto r = h.eval("(defstate mystate (live-edit 0 :id \"init\" :min 0 :max 10) (+ mystate 1))");
REQUIRE(r.kind == sig::EvalResult::Error);
REQUIRE(r.diagnostic_count > 0);
REQUIRE(std::string(r.diagnostics[0].message).find("live-edit") != std::string::npos);
}
TEST_CASE("defstate with normal initial value still works", "[live-edit][726]")
{
LiveEditHarness h;
auto r = h.eval("(defstate counter 0 (+ counter 1))");
REQUIRE(r.kind != sig::EvalResult::Error);
}
TEST_CASE("live-edit in defstate update expression is allowed", "[live-edit][726]")
{
LiveEditHarness h;
auto r = h.eval("(defstate x 0 (live-edit 0.5 :id \"update\" :min 0 :max 1))");
REQUIRE(r.kind != sig::EvalResult::Error);
REQUIRE(h.engine.pool.live_slot_count == 1);
}