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/confmgr" "github.com/uopi/uopi/internal/datasource" ) // writableSource is a minimal in-memory DataSource that records the last value // written to each signal, so config-apply/read nodes can be tested end-to-end. type writableSource struct { mu sync.Mutex written map[string]any } 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) 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) { s.mu.Lock() v, ok := s.written[sig] s.mu.Unlock() if ok { select { case ch <- datasource.Value{Data: v, Timestamp: time.Now()}: default: } } return func() {}, nil } func (s *writableSource) Write(_ context.Context, signal string, value any) error { s.mu.Lock() defer s.mu.Unlock() s.written[signal] = value return nil } func (s *writableSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) { return nil, datasource.ErrHistoryUnavailable } func (s *writableSource) get(signal string) (any, bool) { s.mu.Lock() defer s.mu.Unlock() v, ok := s.written[signal] return v, ok } // setupConfigEngine wires a broker (with a writable "tgt" source), a confmgr // store seeded with a set + instance, and an Engine bound to both. func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := context.Background() src := newWritableSource() brk := broker.New(ctx, log) brk.Register(src) cfg, err := confmgr.New(t.TempDir()) if err != nil { t.Fatal("confmgr.New:", err) } set, err := cfg.CreateSet(confmgr.ConfigSet{ Name: "tuning", Parameters: []confmgr.Parameter{ {Key: "gain", DS: "tgt", Signal: "GAIN", Type: confmgr.TypeFloat, Default: 1.0}, {Key: "offset", DS: "tgt", Signal: "OFFSET", Type: confmgr.TypeFloat, Default: 0.0}, }, }, "") if err != nil { t.Fatal("CreateSet:", err) } inst, err := cfg.CreateInstance(confmgr.ConfigInstance{ Name: "warm", SetID: set.ID, Values: map[string]any{"gain": 2.5, "offset": 10.0}, }, "") if err != nil { t.Fatal("CreateInstance:", err) } e := NewEngine(ctx, brk, nil, cfg, audit.Nop(), log) return e, src, inst.ID } func TestApplyConfigWritesEveryParam(t *testing.T) { e, src, instID := setupConfigEngine(t) cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} e.applyConfig(cg, instID) if got, ok := src.get("GAIN"); !ok || toNum(got) != 2.5 { t.Errorf("GAIN = %v (ok=%v), want 2.5", got, ok) } if got, ok := src.get("OFFSET"); !ok || toNum(got) != 10.0 { t.Errorf("OFFSET = %v (ok=%v), want 10.0", got, ok) } } func TestApplyConfigUnknownInstanceNoop(t *testing.T) { e, src, _ := setupConfigEngine(t) cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} e.applyConfig(cg, "does-not-exist") if len(src.written) != 0 { t.Errorf("expected no writes, got %v", src.written) } } func TestReadConfigParam(t *testing.T) { e, _, instID := setupConfigEngine(t) if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 2.5 { t.Errorf("readConfigParam(gain) = %v (ok=%v), want 2.5", v, ok) } // Missing param key. if _, ok := e.readConfigParam(instID, "nope"); ok { t.Errorf("readConfigParam(nope) returned ok=true, want false") } // Missing instance. if _, ok := e.readConfigParam("does-not-exist", "gain"); ok { t.Errorf("readConfigParam(missing instance) returned ok=true, want false") } } func TestWriteConfigParam(t *testing.T) { e, _, instID := setupConfigEngine(t) cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} e.writeConfigParam(cg, instID, "gain", 7.5) // A new revision should now resolve to the written value. if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 7.5 { t.Errorf("after write, gain = %v (ok=%v), want 7.5", v, ok) } inst, err := e.cfg.GetInstance(instID) if err != nil { t.Fatal("GetInstance:", err) } if inst.Version < 2 { t.Errorf("instance version = %d, want >= 2 (a new revision)", inst.Version) } } func TestCreateConfigInstance(t *testing.T) { e, _, srcID := setupConfigEngine(t) cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} src, err := e.cfg.GetInstance(srcID) if err != nil { t.Fatal("GetInstance:", err) } e.createConfigInstance(cg, src.SetID, "cold", srcID) insts, err := e.cfg.List(confmgr.KindInstance) if err != nil { t.Fatal("List:", err) } var found *confmgr.ConfigInstance for i := range insts { if insts[i].Name == "cold" { full, err := e.cfg.GetInstance(insts[i].ID) if err != nil { t.Fatal("GetInstance:", err) } found = &full } } if found == nil { t.Fatal("created instance 'cold' not found") } // Values copied from the source instance. if got := toNum(found.Values["gain"]); got != 2.5 { t.Errorf("copied gain = %v, want 2.5", got) } } func TestSnapshotConfig(t *testing.T) { e, src, instID := setupConfigEngine(t) cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} // Seed current live values for the set's target signals. _ = src.Write(context.Background(), "GAIN", 3.5) _ = src.Write(context.Background(), "OFFSET", -2.0) seed, err := e.cfg.GetInstance(instID) if err != nil { t.Fatal("GetInstance:", err) } e.snapshotConfig(cg, seed.SetID, "snap1") insts, err := e.cfg.List(confmgr.KindInstance) if err != nil { t.Fatal("List:", err) } var found *confmgr.ConfigInstance for i := range insts { if insts[i].Name == "snap1" { full, err := e.cfg.GetInstance(insts[i].ID) if err != nil { t.Fatal("GetInstance:", err) } found = &full } } if found == nil { t.Fatal("snapshot instance 'snap1' not found") } if got := toNum(found.Values["gain"]); got != 3.5 { t.Errorf("snapshot gain = %v, want 3.5 (current live value)", got) } if got := toNum(found.Values["offset"]); got != -2.0 { t.Errorf("snapshot offset = %v, want -2.0 (current live value)", got) } }