This commit is contained in:
Martino Ferrari
2026-06-24 01:39:15 +02:00
parent 11120bedca
commit c0f7e662be
76 changed files with 4368 additions and 210 deletions
+3 -2
View File
@@ -23,14 +23,15 @@ type writableSource struct {
func newWritableSource() *writableSource { return &writableSource{written: map[string]any{}} }
func (s *writableSource) Name() string { return "tgt" }
func (s *writableSource) Connect(context.Context) error { return nil }
func (s *writableSource) Name() string { return "tgt" }
func (s *writableSource) Connect(context.Context) error { return nil }
func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
// Subscribe delivers the signal's last-written value once (if any), so a
// one-shot ReadNow (used by config snapshot) resolves immediately.
func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
+6 -6
View File
@@ -3,12 +3,12 @@
// Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports:
//
// * any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
// a,b,c comma-separated list of the above
// N a single value
// - any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
// a,b,c comma-separated list of the above
// N a single value
//
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
+4 -4
View File
@@ -98,10 +98,10 @@ func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *conf
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
cfg: cfg,
audit: rec,
broker: brk,
store: store,
cfg: cfg,
audit: rec,
log: log,
root: root,
live: map[string]float64{},
@@ -0,0 +1,93 @@
package controllogic
import (
"math"
"testing"
"github.com/uopi/uopi/internal/confmgr"
)
func TestFormatAny(t *testing.T) {
cases := []struct {
in any
want string
}{
{3.5, "3.5"},
{float64(42), "42"},
{"hello", "hello"},
{true, "true"},
{int64(7), "7"},
}
for _, c := range cases {
if got := formatAny(c.in); got != c.want {
t.Errorf("formatAny(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTestThreshold(t *testing.T) {
cases := []struct {
val float64
op string
cmp float64
want bool
}{
{1, "<", 2, true},
{3, "<", 2, false},
{2, ">=", 2, true},
{1, ">=", 2, false},
{2, "<=", 2, true},
{3, "<=", 2, false},
{2, "==", 2, true},
{2, "!=", 3, true},
{5, ">", 2, true}, // default branch
{1, "", 0, true}, // empty op → ">"
{math.NaN(), "<", 1, false}, // NaN never satisfies
}
for _, c := range cases {
if got := testThreshold(c.val, c.op, c.cmp); got != c.want {
t.Errorf("testThreshold(%v,%q,%v) = %v, want %v", c.val, c.op, c.cmp, got, c.want)
}
}
}
func TestParseFloat(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"3.14", 3.14},
{" 10 ", 10},
{"", 0},
{"not-a-number", 0},
}
for _, c := range cases {
if got := parseFloat(c.in); got != c.want {
t.Errorf("parseFloat(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestCoerceParamValue(t *testing.T) {
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeInt}, 3.9); v != int64(3) {
t.Errorf("int coerce = %v, want 3", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 0); v != false {
t.Errorf("bool 0 = %v, want false", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 1); v != true {
t.Errorf("bool 1 = %v, want true", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeString}, 2.5); v != "2.5" {
t.Errorf("string coerce = %v, want \"2.5\"", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeFloat}, 1.25); v != 1.25 {
t.Errorf("float coerce = %v, want 1.25", v)
}
}
func TestSign(t *testing.T) {
if sign(5) != 1 || sign(-5) != -1 || sign(0) != 0 {
t.Errorf("sign mismatch: %d %d %d", sign(5), sign(-5), sign(0))
}
}
+180
View File
@@ -0,0 +1,180 @@
package controllogic
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// pushSource is a DataSource whose Subscribe captures the broker's delivery
// channel per signal, letting a test push successive values to drive level/edge
// triggers. Writes are recorded so action.write effects can be asserted.
type pushSource struct {
mu sync.Mutex
chans map[string]chan<- datasource.Value
written map[string]any
}
func newPushSource() *pushSource {
return &pushSource{chans: map[string]chan<- datasource.Value{}, written: map[string]any{}}
}
func (s *pushSource) Name() string { return "tgt" }
func (s *pushSource) Connect(context.Context) error { return nil }
func (s *pushSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *pushSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
func (s *pushSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
s.chans[sig] = ch
s.mu.Unlock()
return func() {}, nil
}
func (s *pushSource) Write(_ context.Context, signal string, value any) error {
s.mu.Lock()
s.written[signal] = value
s.mu.Unlock()
return nil
}
func (s *pushSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *pushSource) chanFor(sig string) (chan<- datasource.Value, bool) {
s.mu.Lock()
defer s.mu.Unlock()
ch, ok := s.chans[sig]
return ch, ok
}
func (s *pushSource) get(sig string) (any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.written[sig]
return v, ok
}
// TestEngineReloadThresholdTrigger drives a real Reload generation: a
// trigger.threshold on tgt:IN (>5) wired to an action.write of 42 to tgt:OUT.
// Pushing IN below then above the threshold must fire exactly the rising edge.
func TestEngineReloadThresholdTrigger(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "watchdog",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.threshold", Params: map[string]string{
"signal": "tgt:IN", "op": ">", "value": "5",
}},
{ID: "a1", Kind: "action.write", Params: map[string]string{
"target": "tgt:OUT", "expr": "42",
}},
},
Wires: []Wire{{From: "t1", To: "a1"}},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
// t.Context() is cancelled at test cleanup, tearing the generation down.
// Wait until the engine has subscribed to tgt:IN.
var ch chan<- datasource.Value
waitFor(t, time.Second, func() bool {
c, ok := src.chanFor("IN")
if ok {
ch = c
}
return ok
})
// Below threshold: no fire (prev state seeds to false).
ch <- datasource.Value{Data: 0.0, Timestamp: time.Now()}
// Rising edge above threshold: fires the action.
ch <- datasource.Value{Data: 10.0, Timestamp: time.Now()}
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT")
return ok && toNum(v) == 42
})
if v, ok := src.get("OUT"); !ok || toNum(v) != 42 {
t.Fatalf("OUT = %v (ok=%v), want 42 after rising-edge trigger", v, ok)
}
}
// TestEngineReloadTimerIfWrite covers the timer trigger, startTriggers, and a
// flow.if then-branch driving an action.write.
func TestEngineReloadTimerIfWrite(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "ticker",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.timer", Params: map[string]string{"interval": "50"}},
{ID: "if1", Kind: "flow.if", Params: map[string]string{"cond": "2 > 1"}},
{ID: "a1", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT2", "expr": "7"}},
},
Wires: []Wire{
{From: "t1", To: "if1"},
{From: "if1", FromPort: "then", To: "a1"},
},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT2")
return ok && toNum(v) == 7
})
}
// waitFor polls cond until it returns true or the deadline elapses.
func waitFor(t *testing.T, d time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
if !cond() {
t.Fatalf("condition not met within %s", d)
}
}
+3 -3
View File
@@ -52,9 +52,9 @@ type callNode struct {
args []exprNode
}
func (n numNode) eval(R Resolver) float64 { return n.v }
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
func (n numNode) eval(R Resolver) float64 { return n.v }
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
func (n unNode) eval(R Resolver) float64 {
if n.op == "-" {
return -n.a.eval(R)
+11 -11
View File
@@ -61,17 +61,17 @@ type NodeGroup struct {
// Graph is a named, independently-enableable control-logic flow.
type Graph struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
}
func (n Node) param(key string) string {
+77
View File
@@ -0,0 +1,77 @@
package controllogic
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// TestStoreDeleteAndReload covers Delete (with trash backup), the ErrNotFound
// branches, List, and load() re-reading a persisted store from disk.
func TestStoreDeleteAndReload(t *testing.T) {
dir := t.TempDir()
s, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
g := Graph{ID: "g1", Name: "one", Enabled: true,
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
if err := s.Save(g); err != nil {
t.Fatalf("Save: %v", err)
}
if err := s.Save(Graph{ID: "g2", Name: "two"}); err != nil {
t.Fatalf("Save g2: %v", err)
}
if got := s.List(); len(got) != 2 {
t.Fatalf("List: want 2, got %d", len(got))
}
// Reload from disk: a fresh Store over the same dir must see both graphs.
s2, err := NewStore(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if got, err := s2.Get("g1"); err != nil || got.Name != "one" {
t.Errorf("reloaded g1 = %+v, %v", got, err)
}
// Delete writes a trash backup then removes the item.
if err := s.Delete("g1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get("g1"); err != ErrNotFound {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
if err := s.Delete("g1"); err != ErrNotFound {
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
}
// A trash file should now exist for g1.
trashDir := filepath.Join(dir, "trash", "controllogic")
entries, err := os.ReadDir(trashDir)
if err != nil || len(entries) == 0 {
t.Errorf("trash dir = %v entries, err %v; want >=1", len(entries), err)
}
}
// TestSplitCSV covers the comma-filter parser, including trimming and the
// empty-input (nil) case.
func TestSplitCSV(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{" ", nil},
{"a", []string{"a"}},
{" a , b ,, c ", []string{"a", "b", "c"}},
}
for _, tc := range cases {
if got := splitCSV(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}