197 lines
5.6 KiB
Go
197 lines
5.6 KiB
Go
package controllogic
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeObserver records every DebugEvent it receives, in order.
|
|
type fakeObserver struct {
|
|
mu sync.Mutex
|
|
events []DebugEvent
|
|
}
|
|
|
|
func (f *fakeObserver) Observe(ev DebugEvent) {
|
|
f.mu.Lock()
|
|
f.events = append(f.events, ev)
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
func (f *fakeObserver) snapshot() []DebugEvent {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]DebugEvent(nil), f.events...)
|
|
}
|
|
|
|
func (f *fakeObserver) last(nodeID string) (DebugEvent, bool) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for i := len(f.events) - 1; i >= 0; i-- {
|
|
if f.events[i].NodeID == nodeID {
|
|
return f.events[i], true
|
|
}
|
|
}
|
|
return DebugEvent{}, false
|
|
}
|
|
|
|
// thresholdWriteGraph is a 2-node flow: a timer trigger → an action.write of 42
|
|
// to "tgt:OUT". Used by both the observe and simulate tests.
|
|
func thresholdWriteGraph(id string) Graph {
|
|
return Graph{
|
|
ID: id,
|
|
Name: "flow",
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "100"}},
|
|
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "w"}},
|
|
}
|
|
}
|
|
|
|
// TestDebugObserverCapturesNodeValues drives a flow directly and asserts the
|
|
// observer saw the trigger fire and the write node's value — and that the real
|
|
// data-source write still happened (live mode, not dry-run).
|
|
func TestDebugObserverCapturesNodeValues(t *testing.T) {
|
|
e, src, _ := setupConfigEngine(t)
|
|
obs := &fakeObserver{}
|
|
e.SetDebugObserver(obs)
|
|
e.SetDebugWatch(map[string]bool{"g1": true})
|
|
|
|
cg := compile(thresholdWriteGraph("g1"))
|
|
cg.engine = e
|
|
cg.genCtx = context.Background()
|
|
cg.wg = &sync.WaitGroup{}
|
|
|
|
cg.activate("t")
|
|
cg.wg.Wait()
|
|
|
|
events := obs.snapshot()
|
|
if len(events) == 0 {
|
|
t.Fatal("observer captured no events")
|
|
}
|
|
// Trigger then write must both be reported.
|
|
if _, ok := obs.last("t"); !ok {
|
|
t.Error("no event for trigger node 't'")
|
|
}
|
|
wEv, ok := obs.last("w")
|
|
if !ok {
|
|
t.Fatal("no event for write node 'w'")
|
|
}
|
|
if !wEv.HasValue || wEv.Value != 42 {
|
|
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
|
}
|
|
if wEv.GraphID != "g1" {
|
|
t.Errorf("event GraphID = %q, want g1", wEv.GraphID)
|
|
}
|
|
// Live mode: the real write went through.
|
|
if got, ok := src.get("OUT"); !ok || toNum(got) != 42 {
|
|
t.Errorf("OUT = %v (ok=%v), want 42 written", got, ok)
|
|
}
|
|
}
|
|
|
|
// manualWriteGraph is a flow whose trigger never fires on its own (a threshold
|
|
// with no signal wired) → an action.write of 42 to "tgt:OUT". Used to prove
|
|
// FireTrigger forces a run that would otherwise never happen.
|
|
func manualWriteGraph(id string) Graph {
|
|
return Graph{
|
|
ID: id,
|
|
Name: "flow",
|
|
Nodes: []Node{
|
|
{ID: "t", Kind: "trigger.threshold", Params: map[string]string{"op": ">", "value": "0"}},
|
|
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
|
|
},
|
|
Wires: []Wire{{From: "t", To: "w"}},
|
|
}
|
|
}
|
|
|
|
// TestFireTriggerForcesRun verifies FireTrigger drives a flow whose trigger
|
|
// never fires on its own, routed through the simulate sandbox.
|
|
func TestFireTriggerForcesRun(t *testing.T) {
|
|
e, _, _ := setupConfigEngine(t)
|
|
obs := &fakeObserver{}
|
|
e.SetDebugObserver(obs)
|
|
e.SetDebugWatch(map[string]bool{})
|
|
|
|
stop := e.StartSimulate(manualWriteGraph("sim"))
|
|
defer stop()
|
|
|
|
// Nothing should have run yet — the threshold trigger has no signal.
|
|
time.Sleep(50 * time.Millisecond)
|
|
if _, ok := obs.last("w"); ok {
|
|
t.Fatal("write node ran before any manual fire")
|
|
}
|
|
|
|
if !e.FireTrigger("sim", "t") {
|
|
t.Fatal("FireTrigger returned false for an active simulate route")
|
|
}
|
|
|
|
// Poll for the write node event (the flow runs on its own goroutine).
|
|
var wEv DebugEvent
|
|
for i := 0; i < 100; i++ {
|
|
if ev, ok := obs.last("w"); ok {
|
|
wEv = ev
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if !wEv.HasValue || wEv.Value != 42 {
|
|
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
|
}
|
|
|
|
// An unknown route id must be a no-op (best-effort, returns false).
|
|
if e.FireTrigger("does-not-exist", "t") {
|
|
t.Error("FireTrigger returned true for an unknown route")
|
|
}
|
|
}
|
|
|
|
// TestDebugWatchGatesEmission verifies emitDebug is silent for graphs absent
|
|
// from the watch set, so unwatched live graphs cost nothing.
|
|
func TestDebugWatchGatesEmission(t *testing.T) {
|
|
e, _, _ := setupConfigEngine(t)
|
|
obs := &fakeObserver{}
|
|
e.SetDebugObserver(obs)
|
|
e.SetDebugWatch(map[string]bool{}) // nobody watching
|
|
|
|
cg := compile(thresholdWriteGraph("g1"))
|
|
cg.engine = e
|
|
cg.genCtx = context.Background()
|
|
cg.wg = &sync.WaitGroup{}
|
|
|
|
cg.activate("t")
|
|
cg.wg.Wait()
|
|
|
|
if got := obs.snapshot(); len(got) != 0 {
|
|
t.Errorf("observer received %d events for an unwatched graph, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
// TestSimulateSuppressesWrites runs the graph through the dry-run sandbox and
|
|
// asserts node events are still emitted (alwaysDebug) but NO real write occurs.
|
|
func TestSimulateSuppressesWrites(t *testing.T) {
|
|
e, src, _ := setupConfigEngine(t)
|
|
obs := &fakeObserver{}
|
|
e.SetDebugObserver(obs)
|
|
// Deliberately leave the watch set empty: simulate must emit regardless.
|
|
e.SetDebugWatch(map[string]bool{})
|
|
|
|
stop := e.StartSimulate(thresholdWriteGraph("sim"))
|
|
time.Sleep(250 * time.Millisecond) // let the 100 ms timer fire at least once
|
|
stop()
|
|
|
|
if _, ok := src.get("OUT"); ok {
|
|
t.Errorf("simulate performed a real write to OUT, want none")
|
|
}
|
|
wEv, ok := obs.last("w")
|
|
if !ok {
|
|
t.Fatal("simulate produced no event for write node 'w'")
|
|
}
|
|
if !wEv.HasValue || wEv.Value != 42 {
|
|
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
|
|
}
|
|
if wEv.GraphID != "sim" {
|
|
t.Errorf("simulate event GraphID = %q, want sim", wEv.GraphID)
|
|
}
|
|
}
|