package synthetic import ( "context" "log/slog" "os" "path/filepath" "testing" "time" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource" ) func newTestSynthetic(t *testing.T) (*Synthetic, *broker.Broker, context.CancelFunc) { t.Helper() dir := t.TempDir() log := slog.New(slog.NewTextHandler(os.Stderr, nil)) ctx, cancel := context.WithCancel(context.Background()) brk := broker.New(ctx, log) syn := New(dir, brk, log) return syn, brk, cancel } // TestNameAndConnect verifies basic construction and Connect. func TestNameAndConnect(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() if syn.Name() != "synthetic" { t.Errorf("Name: want %q, got %q", "synthetic", syn.Name()) } ctx := context.Background() if err := syn.Connect(ctx); err != nil { t.Fatalf("Connect: %v", err) } } // TestConnectCreatesEmptyFile verifies that Connect creates an empty // synthetic.json when none exists. func TestConnectCreatesEmptyFile(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() if err := syn.Connect(context.Background()); err != nil { t.Fatal(err) } path := filepath.Join(syn.storePath, "synthetic.json") data, err := os.ReadFile(path) if err != nil { t.Fatalf("expected file to be created: %v", err) } if string(data) == "" { t.Error("file should not be empty") } } // TestAddAndRemoveSignal exercises CRUD at runtime. func TestAddAndRemoveSignal(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() if err := syn.Connect(context.Background()); err != nil { t.Fatal(err) } def := SignalDef{ Name: "test_gain", DS: "stub", Signal: "sine_1hz", Pipeline: []NodeDef{ {Type: "gain", Params: map[string]any{"gain": 2.0}}, }, Meta: MetaOverride{Unit: "V"}, } if err := syn.AddSignal(def); err != nil { t.Fatalf("AddSignal: %v", err) } // Duplicate should fail. if err := syn.AddSignal(def); err == nil { t.Error("expected error for duplicate signal") } // Should be listed. metas, err := syn.ListSignals(context.Background()) if err != nil { t.Fatal(err) } if len(metas) != 1 || metas[0].Name != "test_gain" { t.Errorf("unexpected metas: %v", metas) } // GetMetadata should work. meta, err := syn.GetMetadata(context.Background(), "test_gain") if err != nil { t.Fatalf("GetMetadata: %v", err) } if meta.Unit != "V" { t.Errorf("unit: want V, got %q", meta.Unit) } // Remove it. if err := syn.RemoveSignal("test_gain"); err != nil { t.Fatalf("RemoveSignal: %v", err) } // Should be gone. if _, err := syn.GetMetadata(context.Background(), "test_gain"); err != datasource.ErrNotFound { t.Errorf("expected ErrNotFound after remove, got %v", err) } // Remove non-existing should return ErrNotFound. if err := syn.RemoveSignal("nonexistent"); err != datasource.ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } // TestAddSignalEmptyName validates that empty name is rejected. func TestAddSignalEmptyName(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() if err := syn.Connect(context.Background()); err != nil { t.Fatal(err) } if err := syn.AddSignal(SignalDef{}); err == nil { t.Error("expected error for empty signal name") } } // TestGetMetadataNotFound verifies ErrNotFound for unknown signal. func TestGetMetadataNotFound(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() if err := syn.Connect(context.Background()); err != nil { t.Fatal(err) } _, err := syn.GetMetadata(context.Background(), "no_such_signal") if err != datasource.ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } // TestWriteNotSupported ensures Write returns ErrNotWritable. func TestWriteNotSupported(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() err := syn.Write(context.Background(), "any", 1.0) if err != datasource.ErrNotWritable { t.Errorf("expected ErrNotWritable, got %v", err) } } // TestHistoryNotSupported ensures History returns ErrHistoryUnavailable. func TestHistoryNotSupported(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() _, err := syn.History(context.Background(), "any", time.Now(), time.Now(), 10) if err != datasource.ErrHistoryUnavailable { t.Errorf("expected ErrHistoryUnavailable, got %v", err) } } // TestPersistence verifies that definitions survive a restart (New+Connect). func TestPersistence(t *testing.T) { dir := t.TempDir() log := slog.New(slog.NewTextHandler(os.Stderr, nil)) ctx, cancel := context.WithCancel(context.Background()) brk := broker.New(ctx, log) syn := New(dir, brk, log) if err := syn.Connect(ctx); err != nil { t.Fatal(err) } def := SignalDef{ Name: "persisted", DS: "stub", Signal: "sine_1hz", Pipeline: []NodeDef{ {Type: "offset", Params: map[string]any{"offset": 5.0}}, }, } if err := syn.AddSignal(def); err != nil { t.Fatal(err) } cancel() // Reload. ctx2 := t.Context() brk2 := broker.New(ctx2, log) syn2 := New(dir, brk2, log) if err := syn2.Connect(ctx2); err != nil { t.Fatal(err) } meta, err := syn2.GetMetadata(ctx2, "persisted") if err != nil { t.Fatalf("after reload, GetMetadata: %v", err) } if meta.Name != "persisted" { t.Errorf("expected name %q, got %q", "persisted", meta.Name) } } // TestSubscribeUnknownSignal verifies that subscribing to unknown signal returns error. func TestSubscribeUnknownSignal(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() if err := syn.Connect(context.Background()); err != nil { t.Fatal(err) } ch := make(chan datasource.Value, 1) _, err := syn.Subscribe(context.Background(), "nonexistent", ch) if err != datasource.ErrNotFound { t.Errorf("expected ErrNotFound, got %v", err) } } // TestGetDefs verifies GetDefs returns current definitions. func TestGetDefs(t *testing.T) { syn, _, cancel := newTestSynthetic(t) defer cancel() if err := syn.Connect(context.Background()); err != nil { t.Fatal(err) } defs := syn.GetDefs() if len(defs) != 0 { t.Errorf("expected empty defs, got %d", len(defs)) } def := SignalDef{ Name: "test", DS: "stub", Signal: "noise", } if err := syn.AddSignal(def); err != nil { t.Fatal(err) } defs = syn.GetDefs() if len(defs) != 1 || defs[0].Name != "test" { t.Errorf("unexpected defs: %v", defs) } }