a6fa4e7c7c
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
236 lines
6.6 KiB
Go
236 lines
6.6 KiB
Go
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]Value{}}
|
|
|
|
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]Value{}}
|
|
|
|
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]Value{}}
|
|
|
|
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]Value{}}
|
|
|
|
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]Value{}}
|
|
|
|
// 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)
|
|
}
|
|
}
|