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) } }