//go:build !epics // Integration tests for the pure-Go EPICS datasource adapter. // These tests use the in-process fake CA server from the goca testca package // and therefore do not require a real EPICS installation. package epics_test import ( "context" "math" "testing" "time" "github.com/uopi/goca/proto" "github.com/uopi/goca/testca" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource/epics" ) // newTestDS creates a datasource.DataSource backed by a fake CA server. func newTestDS(t *testing.T, pvs []testca.PVSpec) (datasource.DataSource, *testca.Server) { t.Helper() srv, err := testca.New(pvs) if err != nil { t.Fatalf("testca.New: %v", err) } t.Cleanup(srv.Close) ds := epics.New(srv.UDPAddr(), "", nil) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) t.Cleanup(cancel) if err := ds.Connect(ctx); err != nil { t.Fatalf("Connect: %v", err) } return ds, srv } // -------------------------------------------------------------------------- // // GetMetadata // // -------------------------------------------------------------------------- // func TestPureGo_GetMetadataDouble(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:DOUBLE", DBFType: proto.DBFDouble, Count: 1, Value: 1.0}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() meta, err := ds.GetMetadata(ctx, "DS:DOUBLE") if err != nil { t.Fatalf("GetMetadata: %v", err) } if meta.Name != "DS:DOUBLE" { t.Errorf("Name = %q, want %q", meta.Name, "DS:DOUBLE") } if meta.Type != datasource.TypeFloat64 { t.Errorf("Type = %v, want TypeFloat64", meta.Type) } if !meta.Writable { t.Error("expected Writable=true") } } func TestPureGo_GetMetadataString(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:STR", DBFType: proto.DBFString, Count: 1, Value: "hello"}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() meta, err := ds.GetMetadata(ctx, "DS:STR") if err != nil { t.Fatalf("GetMetadata: %v", err) } if meta.Type != datasource.TypeString { t.Errorf("Type = %v, want TypeString", meta.Type) } } func TestPureGo_GetMetadataEnum(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:ENUM", DBFType: proto.DBFEnum, Count: 1, Value: int16(0)}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() meta, err := ds.GetMetadata(ctx, "DS:ENUM") if err != nil { t.Fatalf("GetMetadata: %v", err) } if meta.Type != datasource.TypeEnum { t.Errorf("Type = %v, want TypeEnum", meta.Type) } } func TestPureGo_GetMetadataReadOnly(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:RO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0, Access: proto.AccessRead}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() meta, err := ds.GetMetadata(ctx, "DS:RO") if err != nil { t.Fatalf("GetMetadata: %v", err) } if meta.Writable { t.Error("expected Writable=false for read-only PV") } } func TestPureGo_GetMetadataCached(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:CACHED", DBFType: proto.DBFDouble, Count: 1, Value: 1.0}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() m1, err := ds.GetMetadata(ctx, "DS:CACHED") if err != nil { t.Fatalf("first GetMetadata: %v", err) } m2, err := ds.GetMetadata(ctx, "DS:CACHED") if err != nil { t.Fatalf("second GetMetadata: %v", err) } if m1.Name != m2.Name || m1.Type != m2.Type { t.Error("second GetMetadata returned different result from cache") } } // -------------------------------------------------------------------------- // // Subscribe // // -------------------------------------------------------------------------- // func TestPureGo_SubscribeInitialValue(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:SUB", DBFType: proto.DBFDouble, Count: 1, Value: 7.5}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() ch := make(chan datasource.Value, 16) unsub, err := ds.Subscribe(ctx, "DS:SUB", ch) if err != nil { t.Fatalf("Subscribe: %v", err) } defer unsub() // Drain MetaUpdate and value frames — first numeric value wins. deadline := time.After(5 * time.Second) for { select { case v := <-ch: if v.MetaUpdate { continue } f, ok := v.Data.(float64) if !ok { t.Fatalf("Data type = %T, want float64", v.Data) } if math.Abs(f-7.5) > 1e-6 { t.Errorf("Data = %g, want 7.5", f) } return case <-deadline: t.Fatal("timeout waiting for initial value") } } } func TestPureGo_SubscribeUpdates(t *testing.T) { ds, srv := newTestDS(t, []testca.PVSpec{ {Name: "DS:UPD", DBFType: proto.DBFDouble, Count: 1, Value: 1.0}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() ch := make(chan datasource.Value, 16) unsub, err := ds.Subscribe(ctx, "DS:UPD", ch) if err != nil { t.Fatalf("Subscribe: %v", err) } defer unsub() // Drain initial frames. deadline := time.After(3 * time.Second) drainLoop: for { select { case v := <-ch: if !v.MetaUpdate { break drainLoop } case <-deadline: t.Fatal("timeout draining initial frames") } } // Push new value via server. srv.SetValue("DS:UPD", 99.9) deadline = time.After(3 * time.Second) for { select { case v := <-ch: if v.MetaUpdate { continue } f, ok := v.Data.(float64) if !ok { t.Fatalf("Data type = %T, want float64", v.Data) } if math.Abs(f-99.9) > 1e-6 { t.Errorf("Data = %g, want 99.9", f) } return case <-deadline: t.Fatal("timeout waiting for updated value") } } } // -------------------------------------------------------------------------- // // Write // // -------------------------------------------------------------------------- // func TestPureGo_WriteDouble(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:W", DBFType: proto.DBFDouble, Count: 1, Value: 0.0}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() ch := make(chan datasource.Value, 16) unsub, err := ds.Subscribe(ctx, "DS:W", ch) if err != nil { t.Fatalf("Subscribe: %v", err) } defer unsub() // Drain initial. deadline := time.After(3 * time.Second) drainLoop: for { select { case v := <-ch: if !v.MetaUpdate { break drainLoop } case <-deadline: t.Fatal("timeout draining initial frames") } } if err := ds.Write(ctx, "DS:W", float64(3.14)); err != nil { t.Fatalf("Write: %v", err) } deadline = time.After(3 * time.Second) for { select { case v := <-ch: if v.MetaUpdate { continue } f, ok := v.Data.(float64) if !ok { continue } if math.Abs(f-3.14) > 1e-6 { t.Errorf("post-write Data = %g, want 3.14", f) } return case <-deadline: t.Fatal("timeout waiting for post-write monitor update") } } } func TestPureGo_WriteReadOnly(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:WRO", DBFType: proto.DBFDouble, Count: 1, Value: 0.0, Access: proto.AccessRead}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := ds.Write(ctx, "DS:WRO", float64(1.0)) if err == nil { t.Fatal("expected error writing to read-only PV") } } // -------------------------------------------------------------------------- // // ListSignals // // -------------------------------------------------------------------------- // func TestPureGo_ListSignals(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:LS1", DBFType: proto.DBFDouble, Count: 1, Value: 0.0}, {Name: "DS:LS2", DBFType: proto.DBFDouble, Count: 1, Value: 0.0}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // GetMetadata for each PV to populate the cache. for _, name := range []string{"DS:LS1", "DS:LS2"} { if _, err := ds.GetMetadata(ctx, name); err != nil { t.Fatalf("GetMetadata(%q): %v", name, err) } } sigs, err := ds.ListSignals(ctx) if err != nil { t.Fatalf("ListSignals: %v", err) } if len(sigs) < 2 { t.Errorf("ListSignals returned %d signals, want ≥ 2", len(sigs)) } } // -------------------------------------------------------------------------- // // History // // -------------------------------------------------------------------------- // func TestPureGo_HistoryUnavailable(t *testing.T) { ds, _ := newTestDS(t, []testca.PVSpec{ {Name: "DS:HIST", DBFType: proto.DBFDouble, Count: 1, Value: 0.0}, }) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := ds.History(ctx, "DS:HIST", time.Now().Add(-time.Hour), time.Now(), 100) if err != datasource.ErrHistoryUnavailable { t.Errorf("History with no archiveURL: want ErrHistoryUnavailable, got %v", err) } }