package scpi import ( "bufio" "context" "net" "strings" "sync" "testing" "time" "github.com/uopi/uopi/internal/datasource" ) // mockInstrument is an in-process line-based SCPI server. It answers queries // from a fixed table and records commands that produce no response (writes). type mockInstrument struct { ln net.Listener mu sync.Mutex answers map[string]string // query → response writes []string } func newMockInstrument(t *testing.T) *mockInstrument { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } m := &mockInstrument{ln: ln, answers: map[string]string{}} go m.serve() t.Cleanup(func() { ln.Close() }) return m } func (m *mockInstrument) addr() string { return m.ln.Addr().String() } func (m *mockInstrument) setAnswer(q, a string) { m.mu.Lock() m.answers[q] = a m.mu.Unlock() } func (m *mockInstrument) writeLog() []string { m.mu.Lock() defer m.mu.Unlock() return append([]string(nil), m.writes...) } func (m *mockInstrument) serve() { for { conn, err := m.ln.Accept() if err != nil { return } go m.handle(conn) } } func (m *mockInstrument) handle(conn net.Conn) { defer conn.Close() br := bufio.NewReader(conn) for { line, err := br.ReadString('\n') if err != nil { return } cmd := strings.TrimRight(line, "\r\n") m.mu.Lock() if strings.HasSuffix(cmd, "?") { resp, ok := m.answers[cmd] if !ok { resp = "0" } m.mu.Unlock() conn.Write([]byte(resp + "\n")) continue } m.writes = append(m.writes, cmd) m.mu.Unlock() } } func testCfg(addr string) Config { return Config{ Enabled: true, PollIntervalMs: 20, Instruments: []Instrument{{ Name: "dmm", Address: addr, Channels: []Channel{ {Name: "volt", Query: "MEAS:VOLT?", WriteCmd: "VOLT %v", Type: "float", Unit: "V"}, {Name: "id", Query: "*IDN?", Type: "string"}, {Name: "n", Query: "COUNT?", Type: "int"}, {Name: "out", Query: "OUTP?", WriteCmd: "OUTP", Type: "bool"}, }, }}, } } func TestQueryTypes(t *testing.T) { srv := newMockInstrument(t) srv.setAnswer("MEAS:VOLT?", "12.34") srv.setAnswer("*IDN?", "ACME,DMM,1,2.0") srv.setAnswer("COUNT?", "7") srv.setAnswer("OUTP?", "ON") s, err := New(testCfg(srv.addr())) if err != nil { t.Fatalf("New: %v", err) } defer s.Close() v, err := s.readSignal(s.signals["dmm:volt"]) if err != nil { t.Fatalf("read volt: %v", err) } if f, ok := v.Data.(float64); !ok || f < 12.33 || f > 12.35 { t.Errorf("volt = %v (%T), want 12.34", v.Data, v.Data) } id, _ := s.readSignal(s.signals["dmm:id"]) if id.Data != "ACME,DMM,1,2.0" { t.Errorf("id = %v", id.Data) } n, _ := s.readSignal(s.signals["dmm:n"]) if iv, ok := n.Data.(int64); !ok || iv != 7 { t.Errorf("n = %v (%T), want 7", n.Data, n.Data) } out, _ := s.readSignal(s.signals["dmm:out"]) if b, ok := out.Data.(bool); !ok || !b { t.Errorf("out = %v, want true", out.Data) } } func TestWrite(t *testing.T) { srv := newMockInstrument(t) s, _ := New(testCfg(srv.addr())) defer s.Close() ctx := context.Background() if err := s.Write(ctx, "dmm:volt", 3.3); err != nil { t.Fatalf("write volt: %v", err) } // bool write uses a template with no verb → "OUTP ". if err := s.Write(ctx, "dmm:out", true); err != nil { t.Fatalf("write out: %v", err) } // Give the server a moment to record both writes. deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if len(srv.writeLog()) >= 2 { break } time.Sleep(5 * time.Millisecond) } got := srv.writeLog() if len(got) != 2 || got[0] != "VOLT 3.3" || got[1] != "OUTP true" { t.Errorf("writes = %v, want [VOLT 3.3, OUTP true]", got) } } func TestWriteErrors(t *testing.T) { srv := newMockInstrument(t) s, _ := New(testCfg(srv.addr())) defer s.Close() ctx := context.Background() if err := s.Write(ctx, "dmm:missing", 1); err != datasource.ErrNotFound { t.Errorf("missing = %v, want ErrNotFound", err) } if err := s.Write(ctx, "dmm:id", 1); err != datasource.ErrNotWritable { t.Errorf("read-only = %v, want ErrNotWritable", err) } } func TestSubscribe(t *testing.T) { srv := newMockInstrument(t) srv.setAnswer("MEAS:VOLT?", "5.0") s, _ := New(testCfg(srv.addr())) defer s.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() ch := make(chan datasource.Value, 4) stop, err := s.Subscribe(ctx, "dmm:volt", ch) if err != nil { t.Fatalf("subscribe: %v", err) } defer stop() select { case v := <-ch: if v.Quality != datasource.QualityGood { t.Errorf("quality = %v", v.Quality) } if f, ok := v.Data.(float64); !ok || f != 5.0 { t.Errorf("value = %v, want 5.0", v.Data) } case <-time.After(2 * time.Second): t.Fatal("timed out") } } func TestSubscribeBadQuality(t *testing.T) { cfg := testCfg("127.0.0.1:1") // refused s, _ := New(cfg) defer s.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() ch := make(chan datasource.Value, 1) stop, _ := s.Subscribe(ctx, "dmm:volt", ch) defer stop() select { case v := <-ch: if v.Quality != datasource.QualityBad { t.Errorf("quality = %v, want bad", v.Quality) } case <-time.After(2 * time.Second): t.Fatal("timed out") } } func TestValidation(t *testing.T) { if _, err := New(Config{Instruments: []Instrument{{Name: "", Address: "x"}}}); err == nil { t.Error("want error for missing name") } if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil { t.Error("want error for duplicate instrument") } if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "vxi11"}}}); err == nil { t.Error("want error for unimplemented vxi11 transport") } if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "bogus"}}}); err == nil { t.Error("want error for unknown transport") } if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Channels: []Channel{{Name: "c"}}}}}); err == nil { t.Error("want error for channel without query") } } func TestFormatWrite(t *testing.T) { if got := formatWrite("VOLT %v", 3.3); got != "VOLT 3.3" { t.Errorf("verb template = %q", got) } if got := formatWrite("OUTP", true); got != "OUTP true" { t.Errorf("plain template = %q", got) } } func TestParseValue(t *testing.T) { if v, _ := parseValue(datasource.TypeBool, "OFF"); v != false { t.Errorf("OFF = %v, want false", v) } if v, _ := parseValue(datasource.TypeBool, "2.0"); v != true { t.Errorf("2.0 bool = %v, want true", v) } if _, err := parseValue(datasource.TypeFloat64, "notnum"); err == nil { t.Error("want parse error for non-numeric float") } }