package controllogic import ( "context" "reflect" "sync" "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 TestArraySetNode(t *testing.T) { g := Graph{ ID: "g", Name: "n", StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[0,0,0]", Sizing: "dynamic"}}, Nodes: []Node{ {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, {ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "buf", "index": "1", "expr": "9"}}, }, Wires: []Wire{{From: "t", To: "s"}}, } cg := runFlowOnce(t, g, "t") if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 9.0, 0.0}) { t.Fatalf("after set buf = %#v", got) } } func TestArrayRemoveNode(t *testing.T) { g := Graph{ ID: "g", Name: "n", StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[10,20,30]", Sizing: "dynamic"}}, Nodes: []Node{ {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, {ID: "r", Kind: "action.array.remove", Params: map[string]string{"array": "buf", "index": "-1"}}, }, Wires: []Wire{{From: "t", To: "r"}}, } cg := runFlowOnce(t, g, "t") if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{10.0, 20.0}) { t.Fatalf("after remove buf = %#v", got) } } func TestArrayPopNode(t *testing.T) { g := Graph{ ID: "g", Name: "n", StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "dynamic"}}, Nodes: []Node{ {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, {ID: "p", Kind: "action.array.pop", Params: map[string]string{"array": "buf"}}, }, Wires: []Wire{{From: "t", To: "p"}}, } cg := runFlowOnce(t, g, "t") if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0}) { t.Fatalf("after pop buf = %#v", got) } } // TestArraySetNodeNested verifies set with a comma-separated path mutates a nested // sub-array, and (crucially) that setPath does not mutate the previously stored slice // in place — the stored value must be replaced by a fresh tree. func TestArraySetNodeNested(t *testing.T) { g := Graph{ ID: "g", Name: "n", StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[1,2],[3,4]]", Sizing: "dynamic"}}, Nodes: []Node{ {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, {ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,1", "expr": "9"}}, }, Wires: []Wire{{From: "t", To: "s"}}, } cg := compile(g) before := cg.getLocal("grid") cg.engine = &Engine{} cg.genCtx = context.Background() R := func(ds, name string) Value { if ds == "local" { return cg.getLocal(name) } return 0.0 } cg.follow("t", "out", &runCtx{fired: "t", resolve: R}) want := []Value{[]Value{1.0, 9.0}, []Value{3.0, 4.0}} if got := cg.getLocal("grid"); !reflect.DeepEqual(got, want) { t.Fatalf("after nested set grid = %#v", got) } // The pre-mutation snapshot must be untouched (no shared-backing in-place write). if !reflect.DeepEqual(before, []Value{[]Value{1.0, 2.0}, []Value{3.0, 4.0}}) { t.Fatalf("setPath mutated the prior stored value in place: %#v", before) } } // TestArraySetNodeConcurrentNoRace drives many concurrent set+read flows against the // same nested-array local; with -race it guards the setPath copy-on-descend fix. func TestArraySetNodeConcurrentNoRace(t *testing.T) { g := Graph{ ID: "g", Name: "n", StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[0,0],[0,0]]", Sizing: "dynamic"}}, Nodes: []Node{ {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, {ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,0", "expr": "1"}}, }, Wires: []Wire{{From: "t", To: "s"}}, } cg := compile(g) cg.engine = &Engine{} cg.genCtx = context.Background() R := func(ds, name string) Value { if ds == "local" { return cg.getLocal(name) } return 0.0 } var wg sync.WaitGroup for i := 0; i < 32; i++ { wg.Add(2) go func() { defer wg.Done(); cg.follow("t", "out", &runCtx{fired: "t", resolve: R}) }() go func() { defer wg.Done() // Read the inner element concurrently; with the in-place setPath bug the // writer mutates this same sub-array backing, producing a data race. if arr, ok := cg.getLocal("grid").([]Value); ok && len(arr) > 0 { if sub, ok := arr[0].([]Value); ok && len(sub) > 0 { _ = sub[0] } } }() } wg.Wait() } func TestCompileInitsLocalsFromDecls(t *testing.T) { g := Graph{ ID: "g", Name: "n", StateVars: []StateVar{ {Name: "count", Type: "number", Initial: "7"}, {Name: "flag", Type: "bool", Initial: "true"}, {Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4}, }, } cg := compile(g) if got := cg.getLocal("count"); got != Value(7.0) { t.Fatalf("count = %#v", got) } if got := cg.getLocal("flag"); got != Value(1.0) { t.Fatalf("flag = %#v", got) } if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) { t.Fatalf("buf = %#v", got) } } func TestSetLocalAppliesSizing(t *testing.T) { g := Graph{ID: "g", Name: "n", StateVars: []StateVar{ {Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2}, }} cg := compile(g) cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0}) if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) { t.Fatalf("sized buf = %#v", got) } } func TestResolverReturnsLocalValue(t *testing.T) { g := Graph{ID: "g", Name: "n", StateVars: []StateVar{ {Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"}, }} cg := compile(g) R := func(ds, name string) Value { if ds == "local" { return cg.getLocal(name) } return 0.0 } if v := EvalExpr("buf[1]", R); v != 20 { t.Fatalf("buf[1] = %v", v) } if v := EvalExpr("sum(buf)", R); v != 30 { t.Fatalf("sum(buf) = %v", v) } } func TestEmitDebugAcceptsArray(t *testing.T) { g := Graph{ID: "g", Name: "n"} cg := compile(g) cg.engine = &Engine{} // no observer installed; emitDebug must not panic cg.emitDebug("x", []Value{1.0, 2.0}, true) cg.emitDebug("x", 3.0, true) }