Initial go port of epics
This commit is contained in:
@@ -29,10 +29,11 @@ type StubConfig struct {
|
||||
}
|
||||
|
||||
type EPICSConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
CAAddrList string `toml:"ca_addr_list"`
|
||||
ArchiveURL string `toml:"archive_url"`
|
||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
CAAddrList string `toml:"ca_addr_list"`
|
||||
ArchiveURL string `toml:"archive_url"`
|
||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||
PVNames []string `toml:"pv_names"`
|
||||
}
|
||||
|
||||
type SyntheticConfig struct {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/config"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
if cfg.Server.Listen == "" {
|
||||
t.Error("Default Listen must not be empty")
|
||||
}
|
||||
if cfg.Server.StorageDir == "" {
|
||||
t.Error("Default StorageDir must not be empty")
|
||||
}
|
||||
if !cfg.Datasource.Stub.Enabled {
|
||||
t.Error("Stub should be enabled by default")
|
||||
}
|
||||
if !cfg.Datasource.EPICS.Enabled {
|
||||
t.Error("EPICS should be enabled by default")
|
||||
}
|
||||
if !cfg.Datasource.Synthetic.Enabled {
|
||||
t.Error("Synthetic should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptyPath(t *testing.T) {
|
||||
// Empty path should return defaults without error.
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load with empty path: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != config.Default().Server.Listen {
|
||||
t.Errorf("Listen = %q, want default %q", cfg.Server.Listen, config.Default().Server.Listen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTOML(t *testing.T) {
|
||||
toml := `
|
||||
[server]
|
||||
listen = ":9090"
|
||||
storage_dir = "/tmp/uopi-test"
|
||||
|
||||
[datasource.stub]
|
||||
enabled = false
|
||||
|
||||
[datasource.epics]
|
||||
enabled = false
|
||||
ca_addr_list = "192.168.1.1"
|
||||
`
|
||||
f := filepath.Join(t.TempDir(), "uopi.toml")
|
||||
if err := os.WriteFile(f, []byte(toml), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(f)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != ":9090" {
|
||||
t.Errorf("Listen = %q, want :9090", cfg.Server.Listen)
|
||||
}
|
||||
if cfg.Server.StorageDir != "/tmp/uopi-test" {
|
||||
t.Errorf("StorageDir = %q", cfg.Server.StorageDir)
|
||||
}
|
||||
if cfg.Datasource.Stub.Enabled {
|
||||
t.Error("Stub.Enabled should be false")
|
||||
}
|
||||
if cfg.Datasource.EPICS.Enabled {
|
||||
t.Error("EPICS.Enabled should be false")
|
||||
}
|
||||
if cfg.Datasource.EPICS.CAAddrList != "192.168.1.1" {
|
||||
t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBadPath(t *testing.T) {
|
||||
_, err := config.Load("/no/such/file.toml")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverrides(t *testing.T) {
|
||||
t.Setenv("UOPI_SERVER_LISTEN", ":7777")
|
||||
t.Setenv("UOPI_SERVER_STORAGE_DIR", "/env/storage")
|
||||
t.Setenv("UOPI_EPICS_CA_ADDR_LIST", "10.0.0.1 10.0.0.2")
|
||||
t.Setenv("UOPI_EPICS_ARCHIVE_URL", "http://archive/")
|
||||
t.Setenv("UOPI_EPICS_CHANNEL_FINDER_URL", "http://cf/")
|
||||
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != ":7777" {
|
||||
t.Errorf("Listen = %q, want :7777", cfg.Server.Listen)
|
||||
}
|
||||
if cfg.Server.StorageDir != "/env/storage" {
|
||||
t.Errorf("StorageDir = %q", cfg.Server.StorageDir)
|
||||
}
|
||||
if cfg.Datasource.EPICS.CAAddrList != "10.0.0.1 10.0.0.2" {
|
||||
t.Errorf("CAAddrList = %q", cfg.Datasource.EPICS.CAAddrList)
|
||||
}
|
||||
if cfg.Datasource.EPICS.ArchiveURL != "http://archive/" {
|
||||
t.Errorf("ArchiveURL = %q", cfg.Datasource.EPICS.ArchiveURL)
|
||||
}
|
||||
if cfg.Datasource.EPICS.ChannelFinderURL != "http://cf/" {
|
||||
t.Errorf("ChannelFinderURL = %q", cfg.Datasource.EPICS.ChannelFinderURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvTrimsWhitespace(t *testing.T) {
|
||||
t.Setenv("UOPI_SERVER_LISTEN", " :8888 ")
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Listen != ":8888" {
|
||||
t.Errorf("Listen = %q, expected whitespace trimmed", cfg.Server.Listen)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build epics
|
||||
|
||||
package epics
|
||||
|
||||
import (
|
||||
|
||||
@@ -85,7 +85,9 @@ type EPICS struct {
|
||||
// caAddrList is used to set EPICS_CA_ADDR_LIST at runtime (may be empty to
|
||||
// rely on the environment). archiveURL is the base URL of an EPICS Archive
|
||||
// Appliance instance for history queries (may be empty).
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource {
|
||||
// pvNames is accepted for API compatibility with the pure-Go build but ignored
|
||||
// in the CGo build (the CGo implementation populates ListSignals via callbacks).
|
||||
func New(caAddrList, archiveURL string, pvNames []string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,311 @@
|
||||
//go:build !epics
|
||||
|
||||
// Package epics provides an EPICS Channel Access data source.
|
||||
// When built without the "epics" build tag (i.e. without CGo and EPICS Base),
|
||||
// this stub is compiled instead, so that the rest of the codebase can call
|
||||
// epics.Available() to decide whether to register the data source.
|
||||
// Package epics provides an EPICS Channel Access data source for uopi using a
|
||||
// pure-Go CA implementation (no CGo, no EPICS Base installation required).
|
||||
// When built WITH the "epics" build tag, the CGo-based implementation in
|
||||
// epics.go is used instead; that version requires CGo and a working EPICS Base.
|
||||
package epics
|
||||
|
||||
import "github.com/uopi/uopi/internal/datasource"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog" //nolint:depguard
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
// Available reports whether the EPICS Channel Access data source is compiled in.
|
||||
// It always returns false when built without the "epics" build tag.
|
||||
func Available() bool { return false }
|
||||
ca "github.com/uopi/goca"
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// New is a placeholder that returns nil when EPICS support is not compiled in.
|
||||
// Callers must check Available() before calling New().
|
||||
func New(caAddrList, archiveURL string) datasource.DataSource { return nil }
|
||||
// Available always returns true for the pure-Go build.
|
||||
func Available() bool { return true }
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// EPICS data source (pure-Go implementation) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// EPICS is the pure-Go Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
pvNames []string // pre-fetched at connect time for ListSignals
|
||||
|
||||
client *ca.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
metadata map[string]datasource.Metadata
|
||||
}
|
||||
|
||||
// New creates a new EPICS Channel Access data source.
|
||||
//
|
||||
// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses).
|
||||
// Leave empty to use the environment variable.
|
||||
// archiveURL is the base URL of an EPICS Archive Appliance instance; leave
|
||||
// empty to disable history queries.
|
||||
// pvNames is an optional list of PV names whose metadata will be pre-fetched
|
||||
// at connect time so they appear in ListSignals immediately (useful for the
|
||||
// edit-mode signal tree).
|
||||
func New(caAddrList, archiveURL string, pvNames []string) datasource.DataSource {
|
||||
return &EPICS{
|
||||
caAddrList: caAddrList,
|
||||
archiveURL: archiveURL,
|
||||
pvNames: pvNames,
|
||||
metadata: make(map[string]datasource.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (e *EPICS) Name() string { return "epics" }
|
||||
|
||||
// Connect creates the CA client and starts background I/O goroutines.
|
||||
// ctx governs the lifetime of the client; cancel it to shut everything down.
|
||||
func (e *EPICS) Connect(ctx context.Context) error {
|
||||
cfg := ca.ConfigFromEnv()
|
||||
if e.caAddrList != "" {
|
||||
cfg.AddrList = strings.Fields(e.caAddrList)
|
||||
cfg.AutoAddrList = false
|
||||
}
|
||||
slog.Info("epics: connecting CA client", "addrs", cfg.AddrList, "auto_addr", cfg.AutoAddrList)
|
||||
cli, err := ca.NewClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epics: %w", err)
|
||||
}
|
||||
e.client = cli
|
||||
slog.Info("epics: CA client started", "addrs", cfg.AddrList)
|
||||
|
||||
// Pre-fetch metadata for any configured PV names so they appear in
|
||||
// ListSignals as soon as the datasource is ready. We open a short-lived
|
||||
// subscription (which drives channel connection) and then fetch CTRL info.
|
||||
if len(e.pvNames) > 0 {
|
||||
go func() {
|
||||
for _, pv := range e.pvNames {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
func() {
|
||||
tvCh := make(chan proto.TimeValue, 1)
|
||||
subCtx, subCancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer subCancel()
|
||||
cancel, err := e.client.Subscribe(subCtx, pv, tvCh)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
// Channel is now connected; fetch CTRL metadata.
|
||||
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer mcancel()
|
||||
_, _ = e.GetMetadata(mctx, pv)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Subscribe //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Subscribe registers ch to receive live value updates for signal.
|
||||
// It blocks until the CA channel is connected (or ctx expires).
|
||||
// A background goroutine forwards proto.TimeValue updates to ch as
|
||||
// datasource.Value. Call the returned CancelFunc to unsubscribe.
|
||||
func (e *EPICS) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
slog.Info("epics: subscribing", "pv", signal)
|
||||
tvCh := make(chan proto.TimeValue, 16)
|
||||
|
||||
clientCancel, err := e.client.Subscribe(ctx, signal, tvCh)
|
||||
if err != nil {
|
||||
slog.Error("epics: subscribe failed", "pv", signal, "err", err)
|
||||
return nil, fmt.Errorf("epics: subscribe %q: %w", signal, err)
|
||||
}
|
||||
slog.Info("epics: subscribed OK", "pv", signal)
|
||||
|
||||
// Fetch and cache metadata now that the channel is connected.
|
||||
// Send MetaUpdate so the dispatcher re-broadcasts metadata to clients.
|
||||
go func() {
|
||||
// Small delay gives ACCESS_RIGHTS message time to arrive before GetCtrl.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
mctx, mcancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer mcancel()
|
||||
if _, err := e.GetMetadata(mctx, signal); err == nil {
|
||||
select {
|
||||
case ch <- datasource.Value{MetaUpdate: true}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
// Forward goroutine: convert proto.TimeValue → datasource.Value.
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case tv := <-tvCh:
|
||||
slog.Debug("epics: value received", "pv", signal, "double", tv.Double, "severity", tv.Severity)
|
||||
val := e.timeValueToDS(signal, tv)
|
||||
select {
|
||||
case ch <- val:
|
||||
default: // drop if consumer is slow
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cancel := func() {
|
||||
clientCancel()
|
||||
close(done)
|
||||
}
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// timeValueToDS converts a proto.TimeValue to a datasource.Value.
|
||||
func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Value {
|
||||
var data any
|
||||
if tv.Doubles != nil {
|
||||
data = tv.Doubles
|
||||
} else if tv.Str != "" {
|
||||
data = tv.Str
|
||||
} else {
|
||||
// Use cached metadata type for correct int64 / float64 distinction.
|
||||
e.mu.RLock()
|
||||
meta, hasMeta := e.metadata[signal]
|
||||
e.mu.RUnlock()
|
||||
if hasMeta && (meta.Type == datasource.TypeInt64 || meta.Type == datasource.TypeEnum) {
|
||||
data = int64(tv.Long)
|
||||
} else {
|
||||
data = tv.Double
|
||||
}
|
||||
}
|
||||
|
||||
quality := datasource.QualityGood
|
||||
switch tv.Severity {
|
||||
case proto.SeverityMinor:
|
||||
quality = datasource.QualityUncertain
|
||||
case proto.SeverityMajor, proto.SeverityInvalid:
|
||||
quality = datasource.QualityBad
|
||||
}
|
||||
|
||||
return datasource.Value{
|
||||
Timestamp: tv.Timestamp,
|
||||
Data: data,
|
||||
Quality: quality,
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// GetMetadata //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// GetMetadata returns metadata for signal, using a cached value when available.
|
||||
// On the first call for a signal it performs a DBR_CTRL_* GET to retrieve
|
||||
// units, display limits, enum strings, and access rights.
|
||||
func (e *EPICS) GetMetadata(ctx context.Context, signal string) (datasource.Metadata, error) {
|
||||
e.mu.RLock()
|
||||
if m, ok := e.metadata[signal]; ok {
|
||||
e.mu.RUnlock()
|
||||
return m, nil
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
slog.Info("epics: fetching metadata", "pv", signal)
|
||||
ci, err := e.client.GetCtrl(ctx, signal)
|
||||
if err != nil {
|
||||
slog.Error("epics: GetMetadata failed", "pv", signal, "err", err)
|
||||
return datasource.Metadata{}, fmt.Errorf("epics: GetMetadata %q: %w", signal, err)
|
||||
}
|
||||
|
||||
m := e.ctrlToMeta(signal, ci)
|
||||
slog.Info("epics: metadata fetched", "pv", signal, "type", m.Type, "unit", m.Unit, "writable", m.Writable)
|
||||
|
||||
e.mu.Lock()
|
||||
e.metadata[signal] = m
|
||||
e.mu.Unlock()
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ctrlToMeta converts a ca.CtrlInfo to a datasource.Metadata.
|
||||
func (e *EPICS) ctrlToMeta(name string, ci ca.CtrlInfo) datasource.Metadata {
|
||||
m := datasource.Metadata{
|
||||
Name: name,
|
||||
Writable: ci.Access&proto.AccessWrite != 0,
|
||||
}
|
||||
|
||||
switch {
|
||||
case ci.Double != nil:
|
||||
m.Type = datasource.TypeFloat64
|
||||
if ci.Count > 1 {
|
||||
m.Type = datasource.TypeFloat64Array
|
||||
}
|
||||
m.Unit = ci.Double.Units
|
||||
m.DisplayLow = ci.Double.LowerDispLimit
|
||||
m.DisplayHigh = ci.Double.UpperDispLimit
|
||||
m.DriveLow = ci.Double.LowerCtrlLimit
|
||||
m.DriveHigh = ci.Double.UpperCtrlLimit
|
||||
|
||||
case ci.Long != nil:
|
||||
m.Type = datasource.TypeInt64
|
||||
m.Unit = ci.Long.Units
|
||||
m.DisplayLow = float64(ci.Long.LowerDispLimit)
|
||||
m.DisplayHigh = float64(ci.Long.UpperDispLimit)
|
||||
m.DriveLow = float64(ci.Long.LowerCtrlLimit)
|
||||
m.DriveHigh = float64(ci.Long.UpperCtrlLimit)
|
||||
|
||||
case ci.Enum != nil:
|
||||
m.Type = datasource.TypeEnum
|
||||
m.EnumStrings = ci.Enum.Strings
|
||||
|
||||
case ci.Str != nil:
|
||||
m.Type = datasource.TypeString
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// ListSignals //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// ListSignals returns metadata for all signals with cached information.
|
||||
func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
out := make([]datasource.Metadata, 0, len(e.metadata))
|
||||
for _, m := range e.metadata {
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Write //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// Write puts a new value onto the named CA channel.
|
||||
// value must be one of: float64, float32, int64, int32, int, int16, string, bool.
|
||||
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
|
||||
if err := e.client.Put(ctx, signal, value); err != nil {
|
||||
return fmt.Errorf("epics: write %q: %w", signal, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// History //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// History delegates to the Archive Appliance client, or returns
|
||||
// ErrHistoryUnavailable if no archive URL is configured.
|
||||
func (e *EPICS) History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]datasource.Value, error) {
|
||||
if e.archiveURL == "" {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
return fetchArchiveHistory(ctx, e.archiveURL, signal, start, end, maxPoints)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package stub_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
)
|
||||
|
||||
func newStub(t *testing.T) *stub.Stub {
|
||||
t.Helper()
|
||||
s := stub.New()
|
||||
if err := s.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
if stub.New().Name() != "stub" {
|
||||
t.Error("Name() should return 'stub'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSignals(t *testing.T) {
|
||||
s := newStub(t)
|
||||
sigs, err := s.ListSignals(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListSignals: %v", err)
|
||||
}
|
||||
if len(sigs) == 0 {
|
||||
t.Fatal("expected at least one signal")
|
||||
}
|
||||
names := make(map[string]bool)
|
||||
for _, m := range sigs {
|
||||
names[m.Name] = true
|
||||
}
|
||||
for _, want := range []string{"sine_1hz", "ramp_10s", "toggle_1hz", "noise", "setpoint"} {
|
||||
if !names[want] {
|
||||
t.Errorf("signal %q not in ListSignals", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetadata(t *testing.T) {
|
||||
s := newStub(t)
|
||||
m, err := s.GetMetadata(context.Background(), "sine_1hz")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata: %v", err)
|
||||
}
|
||||
if m.Type != datasource.TypeFloat64 {
|
||||
t.Errorf("Type = %v, want TypeFloat64", m.Type)
|
||||
}
|
||||
if m.Unit == "" {
|
||||
t.Error("expected non-empty Unit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetadataNotFound(t *testing.T) {
|
||||
s := newStub(t)
|
||||
_, err := s.GetMetadata(context.Background(), "no:such:pv")
|
||||
if err != datasource.ErrNotFound {
|
||||
t.Errorf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeReceivesValues(t *testing.T) {
|
||||
s := newStub(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "sine_1hz", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityGood {
|
||||
t.Errorf("Quality = %v, want Good", v.Quality)
|
||||
}
|
||||
if _, ok := v.Data.(float64); !ok {
|
||||
t.Errorf("Data type = %T, want float64", v.Data)
|
||||
}
|
||||
if v.Timestamp.IsZero() {
|
||||
t.Error("Timestamp is zero")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for first value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeUnknown(t *testing.T) {
|
||||
s := newStub(t)
|
||||
_, err := s.Subscribe(context.Background(), "no:such", make(chan datasource.Value, 1))
|
||||
if err != datasource.ErrNotFound {
|
||||
t.Errorf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeCancel(t *testing.T) {
|
||||
s := newStub(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "ramp_10s", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
|
||||
// Drain one value to confirm it's running.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for first value")
|
||||
}
|
||||
|
||||
stop() // cancel subscription
|
||||
|
||||
// Drain remaining buffered values then confirm channel goes quiet.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
for len(ch) > 0 {
|
||||
<-ch
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if len(ch) > 0 {
|
||||
t.Error("channel still receiving after stop()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSetpoint(t *testing.T) {
|
||||
s := newStub(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Write(context.Background(), "setpoint", float64(99.0)); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "setpoint", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
f, ok := v.Data.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("Data type = %T, want float64", v.Data)
|
||||
}
|
||||
if f != 99.0 {
|
||||
t.Errorf("setpoint value = %g, want 99.0", f)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for setpoint value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadOnly(t *testing.T) {
|
||||
s := newStub(t)
|
||||
err := s.Write(context.Background(), "sine_1hz", float64(0))
|
||||
if err != datasource.ErrNotWritable {
|
||||
t.Errorf("want ErrNotWritable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNotFound(t *testing.T) {
|
||||
s := newStub(t)
|
||||
err := s.Write(context.Background(), "no:such", float64(0))
|
||||
if err != datasource.ErrNotFound {
|
||||
t.Errorf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistory(t *testing.T) {
|
||||
s := newStub(t)
|
||||
_, err := s.History(context.Background(), "sine_1hz", time.Now().Add(-time.Hour), time.Now(), 100)
|
||||
if err != datasource.ErrHistoryUnavailable {
|
||||
t.Errorf("want ErrHistoryUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleBool(t *testing.T) {
|
||||
s := newStub(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "toggle_1hz", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if _, ok := v.Data.(bool); !ok {
|
||||
t.Errorf("toggle_1hz Data type = %T, want bool", v.Data)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
)
|
||||
|
||||
func TestCounters(t *testing.T) {
|
||||
// Counters are package-level atomics; exercise each Inc function.
|
||||
metrics.IncWsConns()
|
||||
metrics.IncWsConns()
|
||||
metrics.DecWsConns()
|
||||
metrics.IncMsgIn()
|
||||
metrics.IncMsgOut()
|
||||
metrics.IncWrites()
|
||||
metrics.IncHistoryReqs()
|
||||
// No panic = pass; values are additive across tests but that's fine.
|
||||
}
|
||||
|
||||
func TestHandlerFormat(t *testing.T) {
|
||||
handler := metrics.Handler(func() int { return 42 })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
body := rec.Body.String()
|
||||
|
||||
// Must contain all metric names.
|
||||
required := []string{
|
||||
"uopi_uptime_seconds",
|
||||
"uopi_ws_connections",
|
||||
"uopi_signal_subscriptions",
|
||||
"uopi_ws_messages_in_total",
|
||||
"uopi_ws_messages_out_total",
|
||||
"uopi_write_ops_total",
|
||||
"uopi_history_requests_total",
|
||||
}
|
||||
for _, name := range required {
|
||||
if !strings.Contains(body, name) {
|
||||
t.Errorf("metric %q not found in response body", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Type must be set for Prometheus scrapers.
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/plain") {
|
||||
t.Errorf("Content-Type = %q, want text/plain", ct)
|
||||
}
|
||||
|
||||
// activeSubs value 42 must appear.
|
||||
if !strings.Contains(body, "42") {
|
||||
t.Error("activeSubs value 42 not found in body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerNilActiveSubs(t *testing.T) {
|
||||
handler := metrics.Handler(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req) // must not panic
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
|
||||
const sampleXML = `<interface id="test-if" name="Test Interface" version="1"><widget/></interface>`
|
||||
|
||||
func newStore(t *testing.T) *storage.Store {
|
||||
t.Helper()
|
||||
s, err := storage.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// List //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestListEmpty(t *testing.T) {
|
||||
s := newStore(t)
|
||||
list, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("expected empty list, got %d items", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAfterCreate(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Create([]byte(sampleXML)); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
list, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(list))
|
||||
}
|
||||
if list[0].Name != "Test Interface" {
|
||||
t.Errorf("Name = %q, want %q", list[0].Name, "Test Interface")
|
||||
}
|
||||
if list[0].Version != 1 {
|
||||
t.Errorf("Version = %d, want 1", list[0].Version)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Create + Get //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestCreateAndGet(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("Create returned empty ID")
|
||||
}
|
||||
|
||||
data, err := s.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", id, err)
|
||||
}
|
||||
if string(data) != sampleXML {
|
||||
t.Errorf("Get returned %q, want %q", data, sampleXML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUsesEmbeddedID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
// ID derived from xml id attr or name slug.
|
||||
if id == "" {
|
||||
t.Error("ID should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDuplicateGetsUniqueID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id1, err := s.Create([]byte(sampleXML))
|
||||
if err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
}
|
||||
id2, err := s.Create([]byte(sampleXML))
|
||||
if err != nil {
|
||||
t.Fatalf("second Create: %v", err)
|
||||
}
|
||||
if id1 == id2 {
|
||||
t.Errorf("duplicate Create returned same ID %q", id1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateInvalidXML(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Create([]byte("not xml"))
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid XML")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Get errors //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestGetNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Get("does-not-exist")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInvalidID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Get("../traversal")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get path-traversal ID: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEmptyID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Get("")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get empty ID: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Update //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
updated := `<interface id="test-if" name="Updated" version="2"><widget/></interface>`
|
||||
if err := s.Update(id, []byte(updated)); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
data, err := s.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after update: %v", err)
|
||||
}
|
||||
if string(data) != updated {
|
||||
t.Errorf("post-update content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
err := s.Update("no-such-id", []byte(sampleXML))
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Update missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Delete //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := s.Delete(id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
_, err = s.Get(id)
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
err := s.Delete("ghost")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Clone //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestClone(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(sampleXML))
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
newID, err := s.Clone(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Clone: %v", err)
|
||||
}
|
||||
if newID == id {
|
||||
t.Error("Clone should produce a different ID")
|
||||
}
|
||||
orig, _ := s.Get(id)
|
||||
copy, _ := s.Get(newID)
|
||||
if string(orig) != string(copy) {
|
||||
t.Error("Clone content should match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
_, err := s.Clone("ghost")
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Clone missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user