From 088063d9cd17e16002a1a53c4490786f503294b6 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 24 Jun 2026 19:45:40 +0200 Subject: [PATCH] controllogic: add action.array.* nodes (push/set/remove/pop/clear) --- internal/controllogic/engine.go | 100 +++++++++++++++++++++++++++ internal/controllogic/engine_test.go | 55 +++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/internal/controllogic/engine.go b/internal/controllogic/engine.go index 98e03a0..5a8e808 100644 --- a/internal/controllogic/engine.go +++ b/internal/controllogic/engine.go @@ -276,6 +276,31 @@ func (e *Engine) liveGet(ds, name string) float64 { return v } +// setPath assigns v at the nested index path within arr, growing (zero-filling) +// as needed and resolving negative indices against length. Port of logic.ts. +func setPath(arr []Value, path []int, v Value) []Value { + if len(path) == 0 { + return arr + } + k := path[0] + if k < 0 { + k = len(arr) + k + } + if k < 0 { + return arr + } + for len(arr) <= k { + arr = append(arr, 0.0) + } + if len(path) == 1 { + arr[k] = v + return arr + } + sub, _ := arr[k].([]Value) + arr[k] = setPath(sub, path[1:], v) + return arr +} + // write applies an action.write/lua-set to a target: a bare name updates a // graph-local var; a ds:name target writes to the data source. func (e *Engine) write(cg *compiledGraph, target string, val Value) { @@ -686,6 +711,13 @@ func compile(g Graph) *compiledGraph { } case "action.write", "action.log": wantExpr(n.param("expr")) + case "action.array.push": + wantExpr(n.param("expr")) + case "action.array.set": + wantExpr(n.param("expr")) + wantExpr(n.param("index")) + case "action.array.remove": + wantExpr(n.param("index")) case "action.lua": cg.luaNodes[n.ID] = newLuaRuntime(n.param("script")) for _, r := range luaGetRefs(n.param("script")) { @@ -1095,6 +1127,74 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { } cg.follow(node.ID, "out", ctx) + case "action.array.push": + name := strings.TrimSpace(node.param("array")) + if name != "" { + val := EvalValue(node.param("expr"), ctx.resolve) + cur, _ := cg.getLocal(name).([]Value) + cg.setLocal(name, append(append([]Value{}, cur...), val)) + } + cg.follow(node.ID, "out", ctx) + + case "action.array.set": + name := strings.TrimSpace(node.param("array")) + if name != "" { + cur, _ := cg.getLocal(name).([]Value) + arr := append([]Value{}, cur...) + var path []int + ok := true + for _, s := range strings.Split(node.param("index"), ",") { + f := EvalExpr(strings.TrimSpace(s), ctx.resolve) + if math.IsNaN(f) { + ok = false + break + } + path = append(path, int(f)) + } + val := EvalValue(node.param("expr"), ctx.resolve) + if ok && len(path) > 0 { + arr = setPath(arr, path, val) + } + cg.setLocal(name, arr) + } + cg.follow(node.ID, "out", ctx) + + case "action.array.remove": + name := strings.TrimSpace(node.param("array")) + if name != "" { + cur, _ := cg.getLocal(name).([]Value) + arr := append([]Value{}, cur...) + i := int(EvalExpr(node.param("index"), ctx.resolve)) + k := i + if k < 0 { + k = len(arr) + k + } + if k >= 0 && k < len(arr) { + arr = append(arr[:k], arr[k+1:]...) + } + cg.setLocal(name, arr) + } + cg.follow(node.ID, "out", ctx) + + case "action.array.pop": + name := strings.TrimSpace(node.param("array")) + if name != "" { + cur, _ := cg.getLocal(name).([]Value) + arr := append([]Value{}, cur...) + if len(arr) > 0 { + arr = arr[:len(arr)-1] + } + cg.setLocal(name, arr) + } + cg.follow(node.ID, "out", ctx) + + case "action.array.clear": + name := strings.TrimSpace(node.param("array")) + if name != "" { + cg.setLocal(name, []Value{}) // setLocal applies sizing (fixed → zero-pad) + } + cg.follow(node.ID, "out", ctx) + default: cg.follow(node.ID, "out", ctx) } diff --git a/internal/controllogic/engine_test.go b/internal/controllogic/engine_test.go index 8822588..4bda071 100644 --- a/internal/controllogic/engine_test.go +++ b/internal/controllogic/engine_test.go @@ -1,10 +1,65 @@ package controllogic import ( + "context" "reflect" "testing" ) +// runFlowOnce compiles g, wires a minimal engine/context onto the compiled graph +// (enough for the run/follow path and the lock-free emitDebug guard), then drives +// the flow from triggerID synchronously and returns the compiled graph so callers +// can inspect locals. +func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph { + t.Helper() + cg := compile(g) + cg.engine = &Engine{} + cg.genCtx = context.Background() + R := func(ds, name string) Value { + switch ds { + case "local": + return cg.getLocal(name) + default: + return 0.0 + } + } + ctx := &runCtx{fired: triggerID, resolve: R} + cg.follow(triggerID, "out", ctx) + return cg +} + +func TestArrayPushNode(t *testing.T) { + g := Graph{ + ID: "g", Name: "n", + StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1]", Sizing: "dynamic"}}, + Nodes: []Node{ + {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, + {ID: "p", Kind: "action.array.push", Params: map[string]string{"array": "buf", "expr": "5"}}, + }, + Wires: []Wire{{From: "t", To: "p"}}, + } + cg := runFlowOnce(t, g, "t") + if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 5.0}) { + t.Fatalf("after push buf = %#v", got) + } +} + +func TestArrayClearNode(t *testing.T) { + g := Graph{ + ID: "g", Name: "n", + StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "fixed", Capacity: 3}}, + Nodes: []Node{ + {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, + {ID: "c", Kind: "action.array.clear", Params: map[string]string{"array": "buf"}}, + }, + Wires: []Wire{{From: "t", To: "c"}}, + } + cg := runFlowOnce(t, g, "t") + if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) { + t.Fatalf("after clear buf = %#v", got) + } +} + func TestCompileInitsLocalsFromDecls(t *testing.T) { g := Graph{ ID: "g", Name: "n",