This commit is contained in:
Martino Ferrari
2026-06-24 01:39:15 +02:00
parent 11120bedca
commit c0f7e662be
76 changed files with 4368 additions and 210 deletions
+2 -2
View File
@@ -264,8 +264,8 @@ type CtrlInfo struct {
Access uint32 // proto.AccessRead | proto.AccessWrite bitmask
Double *proto.CtrlDouble // non-nil for DBFDouble / DBFFloat
Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
Enum *proto.CtrlEnum // non-nil for DBFEnum
Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
Enum *proto.CtrlEnum // non-nil for DBFEnum
Str *proto.CtrlString // non-nil for DBFString
}
+1 -1
View File
@@ -7,8 +7,8 @@ import (
"time"
"github.com/uopi/goca"
"github.com/uopi/goca/testca"
"github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
)
// newTestClient creates a Client pointing only at the fake server's addresses.
+10 -10
View File
@@ -57,15 +57,15 @@ type chanState struct {
cid uint32
pvName string
mu sync.RWMutex
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
dbfType int // native DBF field type
count uint32 // element count
access uint32 // AccessRead | AccessWrite bitmask
gotChan bool // CREATE_CHAN reply received for current connection
gotAccess bool // ACCESS_RIGHTS received for current connection
readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
monitors []*monState // active subscriptions
mu sync.RWMutex
sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
dbfType int // native DBF field type
count uint32 // element count
access uint32 // AccessRead | AccessWrite bitmask
gotChan bool // CREATE_CHAN reply received for current connection
gotAccess bool // ACCESS_RIGHTS received for current connection
readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
monitors []*monState // active subscriptions
}
func newChanState(cid uint32, pvName string) *chanState {
@@ -151,7 +151,7 @@ type circuit struct {
bySubID map[uint32]*monState // subID → monState (fast event dispatch)
byIOID map[uint32]chan reply // ioid → GET/PUT callback (circuit-wide)
writeQ chan []byte // serialised outbound message queue
writeQ chan []byte // serialised outbound message queue
seq atomic.Uint32 // shared ID sequence (cid, ioid, subID)
}
+109
View File
@@ -0,0 +1,109 @@
package ca
import (
"context"
"net"
"testing"
"github.com/uopi/goca/proto"
)
// ---- encodePut: every DBF branch + error paths -----------------------
func TestEncodePut(t *testing.T) {
cases := []struct {
name string
dbf int
v any
wantDBR uint16
}{
{"double", proto.DBFDouble, 1.5, proto.DBRDouble},
{"float", proto.DBFFloat, float32(2.0), proto.DBRDouble},
{"long", proto.DBFLong, int(3), proto.DBRLong},
{"short", proto.DBFShort, int16(4), proto.DBRShort},
{"char", proto.DBFChar, int(5), proto.DBRShort},
{"enum", proto.DBFEnum, int(1), proto.DBRShort},
{"string", proto.DBFString, "hi", proto.DBRString},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dbr, payload, err := encodePut(c.dbf, c.v)
if err != nil {
t.Fatalf("encodePut: %v", err)
}
if dbr != c.wantDBR {
t.Errorf("dbr = %d, want %d", dbr, c.wantDBR)
}
if len(payload) == 0 {
t.Error("empty payload")
}
})
}
// Coercion failures propagate.
if _, _, err := encodePut(proto.DBFDouble, "nope"); err == nil {
t.Error("double from string: want error")
}
if _, _, err := encodePut(proto.DBFLong, "nope"); err == nil {
t.Error("long from string: want error")
}
if _, _, err := encodePut(proto.DBFShort, "nope"); err == nil {
t.Error("short from string: want error")
}
// Unsupported field type.
if _, _, err := encodePut(9999, 1); err == nil {
t.Error("unsupported DBF: want error")
}
}
// ---- NewClient: error path + default name/host -----------------------
func TestNewClientNoAddrs(t *testing.T) {
if _, err := NewClient(context.Background(), Config{AutoAddrList: false}); err == nil {
t.Error("NewClient with no addresses: want error")
}
}
func TestNewClientDefaultsNameHost(t *testing.T) {
// AddrList present but ClientName/HostName empty → default branches run.
cli, err := NewClient(context.Background(), Config{
AddrList: []string{"127.0.0.1:5064"},
AutoAddrList: false,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
defer cli.Close()
if cli.cfg.ClientName == "" {
t.Error("ClientName should be defaulted")
}
}
// ---- EncodeSearchReply with an explicit server IP --------------------
func TestEncodeSearchReplyExplicitIP(t *testing.T) {
pkt := EncodeSearchReply(7, net.ParseIP("10.1.2.3"), 5064)
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
if err != nil {
t.Fatalf("DecodeHeader: %v", err)
}
if h.Command != proto.CmdSearch {
t.Errorf("command = %d, want CmdSearch", h.Command)
}
}
// ---- parseReply robustness -------------------------------------------
func TestParseReplyTruncated(t *testing.T) {
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
// Must not panic on a too-short datagram.
se.parseReply([]byte{0x00, 0x01}, src)
}
func TestParseReplyUnknownSearchID(t *testing.T) {
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
// Valid reply but no waiter is registered for this search ID → dropped.
se.parseReply(EncodeSearchReply(12345, nil, 5064), src)
}
+47
View File
@@ -0,0 +1,47 @@
package ca_test
import (
"context"
"testing"
"time"
)
// TestGetCtrlNotFound covers the resolve-failure branch of GetCtrl.
func TestGetCtrlNotFound(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if _, err := cli.GetCtrl(ctx, "NO:SUCH:PV"); err == nil {
t.Fatal("GetCtrl on missing PV: want error")
}
}
// TestPutNotFound covers the resolve-failure branch of Put.
func TestPutNotFound(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if err := cli.Put(ctx, "NO:SUCH:PV", 1.0); err == nil {
t.Fatal("Put on missing PV: want error")
}
}
// TestPutUncoercibleValue covers the encodePut-failure branch of Put: the PV
// resolves but the supplied value cannot be coerced to the native type.
func TestPutUncoercibleValue(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := cli.Put(ctx, "TEST:DOUBLE", []int{1, 2, 3}); err == nil {
t.Fatal("Put with uncoercible value: want error")
}
}
+104
View File
@@ -0,0 +1,104 @@
package ca_test
import (
"context"
"math"
"testing"
"time"
"github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
)
// TestGetDisconnectMidRequest covers the "circuit disconnected during GET" path
// in conn.go: the server drops the connection while a READ_NOTIFY is in flight,
// so the in-flight reply channel is closed by the reconnect loop and the waiter
// unblocks with ok=false.
func TestGetDisconnectMidRequest(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// First GET establishes the channel/circuit successfully.
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
t.Fatalf("initial Get: %v", err)
}
// Arm a mid-request disconnect for the next READ_NOTIFY.
srv.SetGetFault(testca.GetFaultDisconnect)
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
t.Fatal("Get with mid-request disconnect: want error")
}
}
// TestGetCorruptReply covers the "failed to decode GET reply" path: the server
// answers READ_NOTIFY with a payload too short for DecodeTimeValue.
func TestGetCorruptReply(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Establish the channel first so resolution succeeds.
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
t.Fatalf("initial Get: %v", err)
}
srv.SetGetFault(testca.GetFaultCorrupt)
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
t.Fatal("Get with corrupt reply: want error")
}
}
// TestServerDisconnectReconnects covers the CmdServerDisc dispatch branch and the
// reconnect loop: after an orderly server disconnect the client transparently
// reconnects, re-creates its channel, re-subscribes the monitor, and continues to
// receive updates.
func TestServerDisconnectReconnects(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ch := make(chan proto.TimeValue, 16)
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// Drain the initial value.
select {
case <-ch:
case <-ctx.Done():
t.Fatal("timeout waiting for initial value")
}
// Force an orderly disconnect; the client must reconnect on its own.
srv.Disconnect()
// After the reconnect settles, push a fresh value and expect to see it.
// The reconnect back-off is ~1s, so poll generously and re-arm the value
// until it is observed (the re-subscribe also delivers an initial value).
deadline := time.After(8 * time.Second)
tick := time.NewTicker(300 * time.Millisecond)
defer tick.Stop()
for {
select {
case tv := <-ch:
if math.Abs(tv.Double-88.8) < 1e-6 {
return // reconnect succeeded and the update flowed through
}
case <-tick.C:
_ = srv.SetValue("TEST:DOUBLE", 88.8)
case <-deadline:
t.Fatal("timeout waiting for post-reconnect update")
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package ca_test
import (
"context"
"testing"
"time"
)
// TestGetCtrl exercises GetCtrl across the native DBF types served by the mock,
// covering NativeCtrlType dispatch and the DBR_CTRL_* decoders.
func TestGetCtrl(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
t.Run("double", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:DOUBLE")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Double == nil {
t.Fatal("expected Double ctrl info")
}
})
t.Run("long", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:LONG")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Long == nil {
t.Fatal("expected Long ctrl info")
}
})
t.Run("enum", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:ENUM")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Enum == nil {
t.Fatal("expected Enum ctrl info")
}
})
t.Run("string", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:STRING")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Str == nil {
t.Fatal("expected Str ctrl info")
}
})
}
+139
View File
@@ -0,0 +1,139 @@
package ca
import (
"testing"
"github.com/uopi/goca/proto"
)
// ---- value coercion helpers ------------------------------------------
func TestToFloat64(t *testing.T) {
cases := []struct {
in any
want float64
}{
{float64(1.5), 1.5},
{float32(2.5), 2.5},
{int(3), 3},
{int64(4), 4},
{int32(5), 5},
{int16(6), 6},
{uint64(7), 7},
{uint32(8), 8},
{true, 1},
{false, 0},
}
for _, c := range cases {
got, err := toFloat64(c.in)
if err != nil || got != c.want {
t.Errorf("toFloat64(%v) = %v, %v; want %v", c.in, got, err, c.want)
}
}
if _, err := toFloat64("nope"); err == nil {
t.Error("toFloat64(string): want error")
}
}
func TestToInt32(t *testing.T) {
cases := []struct {
in any
want int32
}{
{int(3), 3},
{int64(4), 4},
{int32(5), 5},
{int16(6), 6},
{float64(7.9), 7},
{float32(8.9), 8},
{true, 1},
{false, 0},
}
for _, c := range cases {
got, err := toInt32(c.in)
if err != nil || got != c.want {
t.Errorf("toInt32(%v) = %v, %v; want %v", c.in, got, err, c.want)
}
}
if _, err := toInt32("nope"); err == nil {
t.Error("toInt32(string): want error")
}
}
func TestToString(t *testing.T) {
if s, _ := toString("hi"); s != "hi" {
t.Errorf("toString(string) = %q", s)
}
if s, _ := toString([]byte("bytes")); s != "bytes" {
t.Errorf("toString([]byte) = %q", s)
}
if s, _ := toString(42); s != "42" {
t.Errorf("toString(int) = %q, want 42", s)
}
}
// ---- ConfigFromEnv ---------------------------------------------------
func TestConfigFromEnv(t *testing.T) {
t.Setenv("EPICS_CA_ADDR_LIST", "1.2.3.4 5.6.7.8")
t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "no")
cfg := ConfigFromEnv()
if len(cfg.AddrList) != 2 || cfg.AddrList[0] != "1.2.3.4" {
t.Errorf("AddrList = %v", cfg.AddrList)
}
if cfg.AutoAddrList {
t.Error("AutoAddrList should be false when env = no")
}
if cfg.ClientName == "" {
t.Error("ClientName should be populated")
}
t.Setenv("EPICS_CA_ADDR_LIST", "")
t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "yes")
cfg = ConfigFromEnv()
if !cfg.AutoAddrList {
t.Error("AutoAddrList should default to true")
}
if len(cfg.AddrList) != 0 {
t.Errorf("AddrList should be empty, got %v", cfg.AddrList)
}
}
// ---- address helpers -------------------------------------------------
func TestResolveAddrs(t *testing.T) {
got := resolveAddrs([]string{"host", "host2:9999", ""}, 5064)
if len(got) != 2 || got[0] != "host:5064" || got[1] != "host2:9999" {
t.Errorf("resolveAddrs = %v", got)
}
}
func TestLocalBroadcastAddrs(t *testing.T) {
// Should not panic; the result is environment-dependent.
_ = localBroadcastAddrs(5064)
}
// ---- isAllFF ---------------------------------------------------------
func TestIsAllFF(t *testing.T) {
if !isAllFF([]byte{0xFF, 0xFF, 0xFF}) {
t.Error("all-FF should be true")
}
if isAllFF([]byte{0xFF, 0x00}) {
t.Error("mixed should be false")
}
}
// ---- EncodedSearchPair -----------------------------------------------
func TestEncodedSearchPair(t *testing.T) {
pkt := EncodedSearchPair("TEST:PV", 7)
// First header must be a VERSION message.
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
if err != nil {
t.Fatalf("DecodeHeader: %v", err)
}
if h.Command != proto.CmdVersion {
t.Errorf("first command = %d, want CmdVersion", h.Command)
}
}
+13 -13
View File
@@ -218,19 +218,19 @@ func DecodeTimeValue(dbrType uint16, count uint32, payload []byte) (TimeValue, b
// CtrlDouble is the decoded form of a DBR_CTRL_DOUBLE payload.
type CtrlDouble struct {
Status int16
Severity AlarmSeverity
Precision int16
Units string
UpperDispLimit float64
LowerDispLimit float64
UpperAlarmLimit float64
UpperWarnLimit float64
LowerWarnLimit float64
LowerAlarmLimit float64
UpperCtrlLimit float64
LowerCtrlLimit float64
Value float64
Status int16
Severity AlarmSeverity
Precision int16
Units string
UpperDispLimit float64
LowerDispLimit float64
UpperAlarmLimit float64
UpperWarnLimit float64
LowerWarnLimit float64
LowerAlarmLimit float64
UpperCtrlLimit float64
LowerCtrlLimit float64
Value float64
}
// DecodeCtrlDouble decodes a DBR_CTRL_DOUBLE payload (minimum 88 bytes).
+2 -2
View File
@@ -31,8 +31,8 @@ const DefaultPort = 5064
// Search reply data_type values.
const (
SearchReply = 10 // DO_REPLY: ask server to respond
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
SearchReply = 10 // DO_REPLY: ask server to respond
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
)
// AccessRights bitmask values (parameter2 of CmdAccessRights message).
+163
View File
@@ -0,0 +1,163 @@
package proto_test
import (
"bytes"
"encoding/binary"
"math"
"testing"
"github.com/uopi/goca/proto"
)
// -------------------------------------------------------------------------- //
// DBR_TIME_* variants not covered by proto_test.go //
// -------------------------------------------------------------------------- //
func TestDecodeTimeFloat(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := make([]byte, 4)
binary.BigEndian.PutUint32(val, math.Float32bits(2.5))
tv, ok := proto.DecodeTimeValue(proto.DBRTimeFloat, 1, append(hdr, val...))
if !ok || math.Abs(float64(tv.Float)-2.5) > 1e-6 {
t.Errorf("float: ok=%v Float=%g", ok, tv.Float)
}
}
func TestDecodeTimeShort(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := []byte{0x00, 0x00, 0xFF, 0xD6} // 2-byte pad + int16(-42)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeShort, 1, append(hdr, val...))
if !ok || tv.Short != -42 {
t.Errorf("short: ok=%v Short=%d", ok, tv.Short)
}
}
func TestDecodeTimeChar(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := []byte{0x00, 0x00, 0x00, 0x41} // pad0 + pad1 + value 'A'
tv, ok := proto.DecodeTimeValue(proto.DBRTimeChar, 1, append(hdr, val...))
if !ok || tv.Char != 0x41 {
t.Errorf("char: ok=%v Char=%d", ok, tv.Char)
}
}
func TestDecodeTimeUnknownType(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
if _, ok := proto.DecodeTimeValue(9999, 1, append(hdr, make([]byte, 16)...)); ok {
t.Error("unknown DBR type should return false")
}
}
// -------------------------------------------------------------------------- //
// DBR_CTRL_LONG / DBR_CTRL_STRING //
// -------------------------------------------------------------------------- //
func TestDecodeCtrlLong(t *testing.T) {
p := make([]byte, 48)
binary.BigEndian.PutUint16(p[2:], 2) // severity = Major
copy(p[4:], "counts")
i32 := func(off int, v int32) { binary.BigEndian.PutUint32(p[off:], uint32(v)) }
i32(12, 1000) // upper_disp
i32(16, -10) // lower_disp
i32(44, 777) // value
cl, ok := proto.DecodeCtrlLong(p)
if !ok {
t.Fatal("DecodeCtrlLong returned false")
}
if cl.Units != "counts" || cl.Value != 777 || cl.UpperDispLimit != 1000 || cl.LowerDispLimit != -10 {
t.Errorf("got %+v", cl)
}
if cl.Severity != proto.SeverityMajor {
t.Errorf("severity = %v, want Major", cl.Severity)
}
if _, ok := proto.DecodeCtrlLong(make([]byte, 10)); ok {
t.Error("short CtrlLong payload should return false")
}
}
func TestDecodeCtrlString(t *testing.T) {
p := make([]byte, 44)
binary.BigEndian.PutUint16(p[2:], 1) // severity = Minor
copy(p[4:], "READY")
cs, ok := proto.DecodeCtrlString(p)
if !ok {
t.Fatal("DecodeCtrlString returned false")
}
if cs.Value != "READY" || cs.Severity != proto.SeverityMinor {
t.Errorf("got %+v", cs)
}
if _, ok := proto.DecodeCtrlString(make([]byte, 10)); ok {
t.Error("short CtrlString payload should return false")
}
}
// -------------------------------------------------------------------------- //
// EncodeShort //
// -------------------------------------------------------------------------- //
func TestEncodeShort(t *testing.T) {
b := proto.EncodeShort(-7)
if len(b) != 8 {
t.Fatalf("len = %d, want 8", len(b))
}
if got := int16(binary.BigEndian.Uint16(b)); got != -7 {
t.Errorf("decoded = %d, want -7", got)
}
}
// -------------------------------------------------------------------------- //
// NativeCtrlType //
// -------------------------------------------------------------------------- //
func TestNativeCtrlType(t *testing.T) {
cases := []struct {
dbf int
want uint16
}{
{proto.DBFDouble, proto.DBRCtrlDouble},
{proto.DBFFloat, proto.DBRCtrlDouble},
{proto.DBFLong, proto.DBRCtrlLong},
{proto.DBFShort, proto.DBRCtrlLong},
{proto.DBFChar, proto.DBRCtrlLong},
{proto.DBFEnum, proto.DBRCtrlEnum},
{proto.DBFString, proto.DBRCtrlString},
{9999, proto.DBRCtrlDouble}, // default
}
for _, c := range cases {
if got := proto.NativeCtrlType(c.dbf); got != c.want {
t.Errorf("NativeCtrlType(%d) = %d, want %d", c.dbf, got, c.want)
}
}
}
// -------------------------------------------------------------------------- //
// DecodeHeader: extended encoding + error path //
// -------------------------------------------------------------------------- //
func TestDecodeHeaderExtended(t *testing.T) {
raw := make([]byte, 24)
binary.BigEndian.PutUint16(raw[0:], proto.CmdReadNotify)
binary.BigEndian.PutUint16(raw[2:], 0xFFFF) // extended marker
binary.BigEndian.PutUint16(raw[6:], 0x0000)
binary.BigEndian.PutUint32(raw[16:], 70000) // real payload size
binary.BigEndian.PutUint32(raw[20:], 5) // real count
h, n, err := proto.DecodeHeader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
if n != 24 {
t.Errorf("bytes read = %d, want 24", n)
}
if h.PayloadSize != 70000 || h.DataCount != 5 {
t.Errorf("extended header = %+v", h)
}
}
func TestDecodeHeaderTruncated(t *testing.T) {
if _, _, err := proto.DecodeHeader(bytes.NewReader([]byte{0x00, 0x01})); err == nil {
t.Error("truncated header: want error")
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ func TestDecodeTimeDouble(t *testing.T) {
const want = 3.14159
hdr := buildTimeHeader(0, 0, sec, nsec)
pad := make([]byte, 4) // RISC pad
pad := make([]byte, 4) // RISC pad
val := make([]byte, 8)
binary.BigEndian.PutUint64(val, math.Float64bits(want))
payload := append(append(hdr, pad...), val...)
+68 -9
View File
@@ -29,13 +29,21 @@ type Server struct {
tcpLn net.Listener
udpCn *net.UDPConn
mu sync.RWMutex
pvs map[string]*serverPV
subs map[uint32]*serverSub // subID → sub
mu sync.RWMutex
pvs map[string]*serverPV
subs map[uint32]*serverSub // subID → sub
getFault int // fault to inject on the next READ_NOTIFY
done chan struct{}
}
// Fault modes for SetGetFault, used to exercise the client's error paths.
const (
GetFaultNone = iota // serve replies normally
GetFaultDisconnect // drop the connection mid-request
GetFaultCorrupt // reply with an undecodable (too-short) payload
)
type serverPV struct {
spec PVSpec
mu sync.RWMutex
@@ -43,11 +51,11 @@ type serverPV struct {
}
type serverSub struct {
pvName string
subID uint32
cid uint32
sid uint32
conn net.Conn
pvName string
subID uint32
cid uint32
sid uint32
conn net.Conn
dbrType uint16
count uint32
}
@@ -93,6 +101,29 @@ func (s *Server) Close() {
s.udpCn.Close()
}
// SetGetFault arms a fault to be injected on the next READ_NOTIFY request.
// It is reset to GetFaultNone after a single request is faulted.
func (s *Server) SetGetFault(mode int) {
s.mu.Lock()
s.getFault = mode
s.mu.Unlock()
}
// Disconnect pushes a SERVER_DISCONN message to every connected subscriber,
// simulating an orderly server shutdown that forces clients to reconnect.
func (s *Server) Disconnect() {
msg := proto.BuildMessage(proto.Header{Command: proto.CmdServerDisc}, nil)
seen := make(map[net.Conn]bool)
s.mu.RLock()
for _, sub := range s.subs {
if sub.conn != nil && !seen[sub.conn] {
seen[sub.conn] = true
_, _ = sub.conn.Write(msg)
}
}
s.mu.RUnlock()
}
// SetValue updates a PV's value and pushes a monitor event to all subscribers.
func (s *Server) SetValue(pvName string, val any) error {
s.mu.RLock()
@@ -350,6 +381,31 @@ func (s *Server) handleConn(conn net.Conn) {
continue
}
// Fault injection: consume a one-shot armed fault, if any.
s.mu.Lock()
fault := s.getFault
s.getFault = GetFaultNone
s.mu.Unlock()
switch fault {
case GetFaultDisconnect:
// Drop the connection mid-request: the client's in-flight GET
// unblocks with ok=false ("circuit disconnected during GET").
conn.Close()
return
case GetFaultCorrupt:
// Reply with a too-short payload so DecodeTimeValue fails
// ("failed to decode GET reply").
reply := proto.BuildMessage(proto.Header{
Command: proto.CmdReadNotify,
DataType: hdr.DataType,
DataCount: hdr.DataCount,
Parameter1: cid,
Parameter2: ioid,
}, []byte{0, 0, 0, 0})
conn.Write(reply)
continue
}
s.mu.RLock()
pv := s.pvs[pvName]
s.mu.RUnlock()
@@ -591,7 +647,10 @@ func nullStr(b []byte) string {
// Minimal io.Reader for proto.DecodeHeader //
// -------------------------------------------------------------------------- //
type bytesReader struct{ b []byte; i int }
type bytesReader struct {
b []byte
i int
}
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
+3 -3
View File
@@ -224,10 +224,10 @@ func (cl *Client) sendSearchRequest(seq uint32, pvName string) {
laddr := cl.udp.LocalAddr().(*net.UDPAddr)
binary.Write(&buf, binary.LittleEndian, uint16(laddr.Port))
buf.WriteByte(1) // transportCount = 1
buf.WriteByte(0x01) // transport[0]: TCP
buf.WriteByte(1) // transportCount = 1
buf.WriteByte(0x01) // transport[0]: TCP
binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
binary.Write(&buf, binary.LittleEndian, uint32(seq)) // channelID (reuse seq)
pvdata.WriteString(&buf, pvName)
+6 -7
View File
@@ -38,7 +38,7 @@ type serverChannel struct {
type monitorSub struct {
ioid uint32
sid uint32 // server channel ID — needed for pipeline ACKs and DESTROY
sid uint32 // server channel ID — needed for pipeline ACKs and DESTROY
cid uint32
desc pvdata.FieldDesc // structure descriptor (received on INIT response)
updates chan MonitorEvent
@@ -56,10 +56,10 @@ type MonitorEvent struct {
// conn is a single PVA TCP connection to one server.
type conn struct {
nc net.Conn
br *bufio.Reader // shared reader — used by handshake then readLoop
bw *bufio.Writer
mu sync.Mutex // protects writes + state maps
nc net.Conn
br *bufio.Reader // shared reader — used by handshake then readLoop
bw *bufio.Writer
mu sync.Mutex // protects writes + state maps
// auto-incrementing IDs
nextCID atomic.Uint32
@@ -68,7 +68,7 @@ type conn struct {
// state
chans map[uint32]*serverChannel // keyed by client CID
byPV map[string]*serverChannel // keyed by PV name
monitors map[uint32]*monitorSub // keyed by IOID
monitors map[uint32]*monitorSub // keyed by IOID
done chan struct{}
}
@@ -573,4 +573,3 @@ func asError(s Status) error {
}
return fmt.Errorf("%s", s.Error())
}
+15 -15
View File
@@ -59,21 +59,21 @@ const (
// ---- Control message sub-commands -------------------------------------
const (
CtrlSetMarker byte = 0x00
CtrlAckMarker byte = 0x01
CtrlSetByteOrder byte = 0x02
CtrlEchoRequest byte = 0x03
CtrlEchoResponse byte = 0x04
CtrlSetMarker byte = 0x00
CtrlAckMarker byte = 0x01
CtrlSetByteOrder byte = 0x02
CtrlEchoRequest byte = 0x03
CtrlEchoResponse byte = 0x04
)
// ---- Status codes -----------------------------------------------------
const (
StatusOK byte = 0xFF // special "OK" short encoding
StatusOKFull byte = 0x00 // full OK message (type=OK, msg="")
StatusWarn byte = 0x01
StatusError byte = 0x02
StatusFatal byte = 0x03
StatusOK byte = 0xFF // special "OK" short encoding
StatusOKFull byte = 0x00 // full OK message (type=OK, msg="")
StatusWarn byte = 0x01
StatusError byte = 0x02
StatusFatal byte = 0x03
)
// ---- Request sub-command bits -----------------------------------------
@@ -90,11 +90,11 @@ const (
// PVAHeader is the 8-byte fixed header on every PVA message.
//
// byte 0 : magic 0xCA
// byte 1 : protocol version (0x01)
// byte 2 : flags (see flag* constants)
// byte 3 : command code
// bytes 47 : payload size (uint32, matches byte-order flag)
// byte 0 : magic 0xCA
// byte 1 : protocol version (0x01)
// byte 2 : flags (see flag* constants)
// byte 3 : command code
// bytes 47 : payload size (uint32, matches byte-order flag)
type PVAHeader struct {
Version byte
Flags byte
+175
View File
@@ -0,0 +1,175 @@
package pva
import (
"bytes"
"encoding/binary"
"testing"
)
// ---- Header codec -----------------------------------------------------
func TestHeaderRoundTrip(t *testing.T) {
h := PVAHeader{Version: 1, Flags: flagApp, Command: CmdGet, Size: 1234}
var buf bytes.Buffer
if err := WriteHeader(&buf, h); err != nil {
t.Fatal(err)
}
got, err := ReadHeader(&buf)
if err != nil {
t.Fatal(err)
}
if got.Version != h.Version || got.Command != h.Command || got.Size != h.Size {
t.Errorf("round-trip header: got %+v want %+v", got, h)
}
}
func TestReadHeaderBadMagic(t *testing.T) {
raw := []byte{0xAB, 0x01, 0x00, CmdGet, 0, 0, 0, 0}
if _, err := ReadHeader(bytes.NewReader(raw)); err == nil {
t.Error("bad magic: want error")
}
}
func TestReadHeaderTruncated(t *testing.T) {
if _, err := ReadHeader(bytes.NewReader([]byte{0xCA, 0x01})); err == nil {
t.Error("truncated header: want error")
}
}
func TestHeaderBigEndian(t *testing.T) {
// Hand-assemble a big-endian server header with size 0x00010203.
raw := []byte{magic, 0x01, flagBigEndian | flagFromServer, CmdMonitor, 0x00, 0x01, 0x02, 0x03}
h, err := ReadHeader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
if h.isLittleEndian() {
t.Error("expected big-endian header")
}
if !h.isFromServer() {
t.Error("expected from-server bit")
}
if h.byteOrder() != binary.BigEndian {
t.Error("byteOrder should be big-endian")
}
if h.Size != 0x00010203 {
t.Errorf("size = %#x, want 0x00010203", h.Size)
}
}
func TestHeaderPredicates(t *testing.T) {
le := PVAHeader{Flags: flagApp}
if !le.isLittleEndian() || le.isFromServer() || le.isControl() {
t.Errorf("app/LE header predicates wrong: %+v", le)
}
ctrl := PVAHeader{Flags: flagControl}
if !ctrl.isControl() {
t.Error("control header should report isControl")
}
}
// ---- BuildMessage -----------------------------------------------------
func TestBuildMessage(t *testing.T) {
payload := []byte{1, 2, 3, 4, 5}
// Pass the server flag too; BuildMessage must clear it (client direction).
msg := BuildMessage(CmdCreateChannel, flagFromServer, payload)
h, err := ReadHeader(bytes.NewReader(msg))
if err != nil {
t.Fatal(err)
}
if h.Command != CmdCreateChannel {
t.Errorf("command = %#x, want %#x", h.Command, CmdCreateChannel)
}
if h.isFromServer() {
t.Error("BuildMessage must clear the from-server flag")
}
if int(h.Size) != len(payload) {
t.Errorf("size = %d, want %d", h.Size, len(payload))
}
if got := msg[headerSize:]; !bytes.Equal(got, payload) {
t.Errorf("payload = %v, want %v", got, payload)
}
}
// ---- Status decoding --------------------------------------------------
func TestReadStatusOKShort(t *testing.T) {
s, err := ReadStatus(bytes.NewReader([]byte{StatusOK}), binary.LittleEndian)
if err != nil {
t.Fatal(err)
}
if !s.OK() || s.Error() != "" {
t.Errorf("short OK status: got %+v", s)
}
}
func TestReadStatusError(t *testing.T) {
var buf bytes.Buffer
buf.WriteByte(StatusError)
buf.WriteByte(0x04) // compact size 4
buf.WriteString("boom")
buf.WriteByte(0x00) // empty stack
s, err := ReadStatus(&buf, binary.LittleEndian)
if err != nil {
t.Fatal(err)
}
if s.OK() {
t.Error("error status should not be OK")
}
if s.Message != "boom" {
t.Errorf("message = %q, want boom", s.Message)
}
if s.Error() == "" {
t.Error("Error() should be non-empty for an error status")
}
}
func TestReadStatusTruncated(t *testing.T) {
if _, err := ReadStatus(bytes.NewReader(nil), binary.LittleEndian); err == nil {
t.Error("empty status: want error")
}
// type byte present but the message string is missing.
if _, err := ReadStatus(bytes.NewReader([]byte{StatusError}), binary.LittleEndian); err == nil {
t.Error("status missing message: want error")
}
}
// ---- readPVAString ----------------------------------------------------
func TestReadPVAString(t *testing.T) {
// Short form.
short := append([]byte{0x03}, []byte("abc")...)
s, err := readPVAString(bytes.NewReader(short), binary.LittleEndian)
if err != nil || s != "abc" {
t.Errorf("short string: got %q err %v", s, err)
}
// Empty.
s, err = readPVAString(bytes.NewReader([]byte{0x00}), binary.LittleEndian)
if err != nil || s != "" {
t.Errorf("empty string: got %q err %v", s, err)
}
// Extended form: 0xFF + int32 length + bytes.
var ext bytes.Buffer
ext.WriteByte(0xFF)
_ = binary.Write(&ext, binary.LittleEndian, int32(5))
ext.WriteString("hello")
s, err = readPVAString(&ext, binary.LittleEndian)
if err != nil || s != "hello" {
t.Errorf("extended string: got %q err %v", s, err)
}
}
// ---- env --------------------------------------------------------------
func TestAddrsFromEnv(t *testing.T) {
t.Setenv("EPICS_PVA_ADDR_LIST", "")
if got := addrsFromEnv(); got != nil {
t.Errorf("empty env: got %v, want nil", got)
}
t.Setenv("EPICS_PVA_ADDR_LIST", " 1.2.3.4 5.6.7.8 ")
got := addrsFromEnv()
if len(got) != 2 || got[0] != "1.2.3.4" || got[1] != "5.6.7.8" {
t.Errorf("addr list: got %v", got)
}
}
+3 -1
View File
@@ -3,7 +3,9 @@ package pvdata
import "io"
// BitSet is a variable-length set of bit indices, encoded on the wire as:
// compact-size(n_bytes) + n_bytes of little-endian bits.
//
// compact-size(n_bytes) + n_bytes of little-endian bits.
//
// Bit 0 of byte 0 is field index 0, bit 1 of byte 0 is field index 1, etc.
// Used in pvData Monitor responses for "changed" and "overrun" masks.
type BitSet struct {
+361
View File
@@ -0,0 +1,361 @@
package pvdata_test
import (
"bytes"
"math"
"reflect"
"testing"
"github.com/uopi/gopva/pvdata"
)
// ---- Scalar value codec: every type code -----------------------------
func TestScalarRoundTrip(t *testing.T) {
cases := []struct {
name string
tc byte
v pvdata.Value
}{
{"bool-true", pvdata.TypeCodeBoolean, true},
{"bool-false", pvdata.TypeCodeBoolean, false},
{"byte", pvdata.TypeCodeByte, int8(-12)},
{"short", pvdata.TypeCodeShort, int16(-3000)},
{"int", pvdata.TypeCodeInt, int32(-100000)},
{"long", pvdata.TypeCodeLong, int64(-1 << 40)},
{"ubyte", pvdata.TypeCodeUByte, uint8(200)},
{"ushort", pvdata.TypeCodeUShort, uint16(60000)},
{"uint", pvdata.TypeCodeUInt, uint32(4000000000)},
{"ulong", pvdata.TypeCodeULong, uint64(1 << 50)},
{"float", pvdata.TypeCodeFloat, float32(1.5)},
{"double", pvdata.TypeCodeDouble, float64(2.5)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
desc := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: c.tc}
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, c.v); err != nil {
t.Fatalf("WriteValue: %v", err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatalf("ReadValue: %v", err)
}
if got != c.v {
t.Errorf("got %v (%T) want %v (%T)", got, got, c.v, c.v)
}
})
}
}
// ---- Scalar array codec: every array code ----------------------------
func TestScalarArrayRoundTrip(t *testing.T) {
cases := []struct {
name string
tc byte
v pvdata.Value
}{
{"bool", pvdata.TypeCodeBoolArr, []bool{true, false, true}},
{"byte", pvdata.TypeCodeByteArr, []int8{-1, 2, -3}},
{"short", pvdata.TypeCodeShortArr, []int16{-1, 2, 30000}},
{"int", pvdata.TypeCodeIntArr, []int32{-1, 2, 1 << 30}},
{"long", pvdata.TypeCodeLongArr, []int64{-1, 1 << 40}},
{"ubyte", pvdata.TypeCodeUByteArr, []uint8{1, 2, 255}},
{"ushort", pvdata.TypeCodeUShortArr, []uint16{1, 60000}},
{"uint", pvdata.TypeCodeUIntArr, []uint32{1, 4000000000}},
{"ulong", pvdata.TypeCodeULongArr, []uint64{1, 1 << 50}},
{"float", pvdata.TypeCodeFloatArr, []float32{1.5, -2.25}},
{"double", pvdata.TypeCodeDoubleArr, []float64{1.5, -2.25}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
desc := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: c.tc}
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, c.v); err != nil {
t.Fatalf("WriteValue: %v", err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatalf("ReadValue: %v", err)
}
if !reflect.DeepEqual(got, c.v) {
t.Errorf("got %v want %v", got, c.v)
}
})
}
}
func TestStringArrayRoundTrip(t *testing.T) {
desc := pvdata.FieldDesc{Kind: pvdata.KindStringArr, TypeCode: pvdata.TypeCodeStringArr}
for _, v := range [][]string{{"a", "", "epics"}, {}} {
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, v); err != nil {
t.Fatalf("WriteValue: %v", err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatalf("ReadValue: %v", err)
}
gs := got.([]string)
if len(gs) != len(v) {
t.Fatalf("len: got %d want %d", len(gs), len(v))
}
for i := range v {
if gs[i] != v[i] {
t.Errorf("[%d]: got %q want %q", i, gs[i], v[i])
}
}
}
}
// ---- FieldDesc round-trip for every Kind -----------------------------
func TestFieldDescVariants(t *testing.T) {
descs := []pvdata.FieldDesc{
{TypeCode: pvdata.TypeCodeNull},
{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString},
{Kind: pvdata.KindStringArr, TypeCode: pvdata.TypeCodeStringArr},
{Kind: pvdata.KindVariant, TypeCode: pvdata.TypeCodeVariant},
{Kind: pvdata.KindScalarArr, TypeCode: pvdata.TypeCodeDoubleArr},
{Kind: pvdata.KindUnion, TypeCode: pvdata.TypeCodeUnion, TypeID: "u", Fields: []pvdata.Field{
{Name: "a", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
}},
{Kind: pvdata.KindStructArr, TypeCode: pvdata.TypeCodeStructArr, TypeID: "s", Fields: []pvdata.Field{
{Name: "x", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}},
}},
{Kind: pvdata.KindUnionArr, TypeCode: pvdata.TypeCodeUnionArr, TypeID: "ua", Fields: []pvdata.Field{
{Name: "y", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
}},
}
for _, d := range descs {
var buf bytes.Buffer
if err := pvdata.WriteFieldDesc(&buf, d); err != nil {
t.Fatalf("WriteFieldDesc(%#x): %v", d.TypeCode, err)
}
got, err := pvdata.ReadFieldDesc(&buf)
if err != nil {
t.Fatalf("ReadFieldDesc(%#x): %v", d.TypeCode, err)
}
if got.TypeCode != d.TypeCode {
t.Errorf("TypeCode: got %#x want %#x", got.TypeCode, d.TypeCode)
}
if got.TypeID != d.TypeID {
t.Errorf("TypeID: got %q want %q", got.TypeID, d.TypeID)
}
if len(got.Fields) != len(d.Fields) {
t.Errorf("Fields: got %d want %d", len(got.Fields), len(d.Fields))
}
}
}
// ---- Struct array value (readStructArr) ------------------------------
func TestStructArrRoundTrip(t *testing.T) {
fields := []pvdata.Field{
{Name: "x", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
}
elems := []pvdata.StructValue{
{TypeID: "p", Fields: []pvdata.FieldValue{{Name: "x", Value: int32(1)}}},
{TypeID: "p", Fields: []pvdata.FieldValue{{Name: "x", Value: int32(2)}}},
}
var buf bytes.Buffer
if err := pvdata.WriteSize(&buf, int64(len(elems))); err != nil {
t.Fatal(err)
}
sdesc := pvdata.FieldDesc{Kind: pvdata.KindStruct, TypeCode: pvdata.TypeCodeStruct, TypeID: "p", Fields: fields}
for _, sv := range elems {
if err := pvdata.WriteValue(&buf, sdesc, sv); err != nil {
t.Fatal(err)
}
}
adesc := pvdata.FieldDesc{Kind: pvdata.KindStructArr, TypeCode: pvdata.TypeCodeStructArr, TypeID: "p", Fields: fields}
got, err := pvdata.ReadValue(&buf, adesc)
if err != nil {
t.Fatal(err)
}
arr := got.([]pvdata.StructValue)
if len(arr) != 2 || arr[0].Fields[0].Value.(int32) != 1 || arr[1].Fields[0].Value.(int32) != 2 {
t.Errorf("got %+v", arr)
}
}
// ---- Union value (readUnion / writeUnion) ----------------------------
func unionDesc() pvdata.FieldDesc {
return pvdata.FieldDesc{
Kind: pvdata.KindUnion, TypeCode: pvdata.TypeCodeUnion, TypeID: "u",
Fields: []pvdata.Field{
{Name: "i", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
{Name: "s", Desc: pvdata.FieldDesc{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString}},
},
}
}
func TestUnionSelectedValue(t *testing.T) {
desc := unionDesc()
// Manually encode: select field 1 (string) with value "hi".
var buf bytes.Buffer
if err := pvdata.WriteSize(&buf, 1); err != nil {
t.Fatal(err)
}
if err := pvdata.WriteValue(&buf, desc.Fields[1].Desc, "hi"); err != nil {
t.Fatal(err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatal(err)
}
uv := got.(pvdata.UnionValue)
if uv.Selected != 1 || uv.Name != "s" || uv.Value.(string) != "hi" {
t.Errorf("got %+v", uv)
}
}
func TestUnionNullSelector(t *testing.T) {
desc := unionDesc()
// writeUnion only writes the selector; an out-of-range selector → null.
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, pvdata.UnionValue{Selected: 7}); err != nil {
t.Fatal(err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatal(err)
}
uv := got.(pvdata.UnionValue)
if uv.Selected != 7 || uv.Value != nil {
t.Errorf("got %+v, want null selector", uv)
}
}
func TestUnionArrRoundTrip(t *testing.T) {
desc := pvdata.FieldDesc{
Kind: pvdata.KindUnionArr, TypeCode: pvdata.TypeCodeUnionArr, TypeID: "u",
Fields: []pvdata.Field{
{Name: "i", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
},
}
var buf bytes.Buffer
if err := pvdata.WriteSize(&buf, 2); err != nil {
t.Fatal(err)
}
for _, val := range []int32{10, 20} {
if err := pvdata.WriteSize(&buf, 0); err != nil { // selector 0
t.Fatal(err)
}
if err := pvdata.WriteValue(&buf, desc.Fields[0].Desc, val); err != nil {
t.Fatal(err)
}
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatal(err)
}
arr := got.([]pvdata.UnionValue)
if len(arr) != 2 || arr[0].Value.(int32) != 10 || arr[1].Value.(int32) != 20 {
t.Errorf("got %+v", arr)
}
}
// ---- Variant value (readVariant + WriteValue variant path) -----------
func TestVariantRoundTrip(t *testing.T) {
inner := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}
vv := pvdata.VariantValue{Desc: inner, Value: float64(9.5)}
desc := pvdata.FieldDesc{Kind: pvdata.KindVariant, TypeCode: pvdata.TypeCodeVariant}
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, vv); err != nil {
t.Fatal(err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatal(err)
}
gvv := got.(pvdata.VariantValue)
if gvv.Value.(float64) != 9.5 || gvv.Desc.TypeCode != pvdata.TypeCodeDouble {
t.Errorf("got %+v", gvv)
}
}
// ---- Compact size: extended (>MaxInt32) encodings --------------------
func TestCompactSizeLarge(t *testing.T) {
cases := []int64{math.MaxInt32, math.MaxInt32 + 1, 1 << 40}
for _, n := range cases {
var buf bytes.Buffer
if err := pvdata.WriteSize(&buf, n); err != nil {
t.Fatalf("WriteSize(%d): %v", n, err)
}
got, err := pvdata.ReadSize(&buf)
if err != nil {
t.Fatalf("ReadSize(%d): %v", n, err)
}
if got != n {
t.Errorf("round-trip size %d → %d", n, got)
}
}
}
// ---- Error / truncation paths ----------------------------------------
func TestReadErrors(t *testing.T) {
if _, err := pvdata.ReadSize(bytes.NewReader(nil)); err == nil {
t.Error("ReadSize on empty: want error")
}
if _, err := pvdata.ReadString(bytes.NewReader([]byte{0x05})); err == nil {
t.Error("ReadString truncated: want error")
}
if _, err := pvdata.ReadFieldDesc(bytes.NewReader(nil)); err == nil {
t.Error("ReadFieldDesc on empty: want error")
}
// Unknown scalar type code.
badScalar := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: 0x99}
if _, err := pvdata.ReadValue(bytes.NewReader([]byte{1, 2, 3, 4}), badScalar); err == nil {
t.Error("ReadValue unknown scalar code: want error")
}
// Unknown array type code (after a valid size byte).
badArr := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: 0x99}
if _, err := pvdata.ReadValue(bytes.NewReader([]byte{0x01}), badArr); err == nil {
t.Error("ReadValue unknown array code: want error")
}
// Unknown kind.
if _, err := pvdata.ReadValue(bytes.NewReader(nil), pvdata.FieldDesc{Kind: pvdata.Kind(99)}); err == nil {
t.Error("ReadValue unknown kind: want error")
}
// WriteValue unsupported kind (struct array has no writer).
if err := pvdata.WriteValue(&bytes.Buffer{}, pvdata.FieldDesc{Kind: pvdata.KindStructArr}, nil); err == nil {
t.Error("WriteValue unsupported kind: want error")
}
}
// ---- BitSet edges ----------------------------------------------------
func TestBitSetEdges(t *testing.T) {
bs := pvdata.NewBitSet()
if bs.Has(0) {
t.Error("fresh NewBitSet should be empty")
}
bs.Set(0)
bs.Set(5)
bs.Set(12)
if got := bs.FieldIndices(); !reflect.DeepEqual(got, []int{0, 5, 12}) {
t.Errorf("FieldIndices: got %v want [0 5 12]", got)
}
if bs.Has(100) { // far out of range → no panic, false
t.Error("Has(100) should be false")
}
// Empty BitSet round-trips as a zero-length payload.
var buf bytes.Buffer
if err := pvdata.WriteBitSet(&buf, pvdata.NewBitSet()); err != nil {
t.Fatal(err)
}
got, err := pvdata.ReadBitSet(&buf)
if err != nil {
t.Fatal(err)
}
if len(got.FieldIndices()) != 0 {
t.Errorf("empty BitSet round-trip: got %v", got.FieldIndices())
}
}
+34 -33
View File
@@ -22,30 +22,30 @@ import (
// types. Values from the spec §3.1.
const (
TypeCodeBoolean byte = 0x00
TypeCodeByte byte = 0x20 // int8
TypeCodeShort byte = 0x21 // int16
TypeCodeInt byte = 0x22 // int32
TypeCodeLong byte = 0x23 // int64
TypeCodeUByte byte = 0x24 // uint8
TypeCodeUShort byte = 0x25 // uint16
TypeCodeUInt byte = 0x26 // uint32
TypeCodeULong byte = 0x27 // uint64
TypeCodeFloat byte = 0x42 // float32
TypeCodeDouble byte = 0x43 // float64
TypeCodeString byte = 0x60
TypeCodeStruct byte = 0x80
TypeCodeUnion byte = 0x81
TypeCodeBoolArr byte = 0x08
TypeCodeByteArr byte = 0x28
TypeCodeShortArr byte = 0x29
TypeCodeIntArr byte = 0x2A
TypeCodeLongArr byte = 0x2B
TypeCodeUByteArr byte = 0x2C
TypeCodeBoolean byte = 0x00
TypeCodeByte byte = 0x20 // int8
TypeCodeShort byte = 0x21 // int16
TypeCodeInt byte = 0x22 // int32
TypeCodeLong byte = 0x23 // int64
TypeCodeUByte byte = 0x24 // uint8
TypeCodeUShort byte = 0x25 // uint16
TypeCodeUInt byte = 0x26 // uint32
TypeCodeULong byte = 0x27 // uint64
TypeCodeFloat byte = 0x42 // float32
TypeCodeDouble byte = 0x43 // float64
TypeCodeString byte = 0x60
TypeCodeStruct byte = 0x80
TypeCodeUnion byte = 0x81
TypeCodeBoolArr byte = 0x08
TypeCodeByteArr byte = 0x28
TypeCodeShortArr byte = 0x29
TypeCodeIntArr byte = 0x2A
TypeCodeLongArr byte = 0x2B
TypeCodeUByteArr byte = 0x2C
TypeCodeUShortArr byte = 0x2D
TypeCodeUIntArr byte = 0x2E
TypeCodeULongArr byte = 0x2F
TypeCodeFloatArr byte = 0x4A
TypeCodeUIntArr byte = 0x2E
TypeCodeULongArr byte = 0x2F
TypeCodeFloatArr byte = 0x4A
TypeCodeDoubleArr byte = 0x4B
TypeCodeStringArr byte = 0x68
TypeCodeStructArr byte = 0x88
@@ -142,19 +142,19 @@ const (
KindScalarArr // variable-length array of scalars
KindString // pvData string (treated specially)
KindStringArr
KindStruct // structure
KindStruct // structure
KindStructArr
KindUnion // discriminated union
KindUnion // discriminated union
KindUnionArr
KindVariant // any (variant)
KindVariant // any (variant)
)
// FieldDesc describes the type of a single field in a pvData structure.
type FieldDesc struct {
Kind Kind
TypeCode byte // scalar/array type code; 0 for struct/union
TypeID string // optional structure/union type ID string
Fields []Field // child fields for struct/union
Kind Kind
TypeCode byte // scalar/array type code; 0 for struct/union
TypeID string // optional structure/union type ID string
Fields []Field // child fields for struct/union
}
// Field is a named FieldDesc (one member of a struct or union).
@@ -293,9 +293,10 @@ func WriteFieldDesc(w io.Writer, fd FieldDesc) error {
// callers type-assert to concrete types listed below.
//
// Scalar types:
// bool, int8, int16, int32, int64,
// uint8, uint16, uint32, uint64,
// float32, float64, string
//
// bool, int8, int16, int32, int64,
// uint8, uint16, uint32, uint64,
// float32, float64, string
//
// Array types: []T for each scalar T above; []string.
// Struct: StructValue
+1 -1
View File
@@ -130,7 +130,7 @@ func TestValueDouble(t *testing.T) {
func TestValueIntArray(t *testing.T) {
desc := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: pvdata.TypeCodeIntArr}
var v pvdata.Value = []int32{1, 2, 3, -7, 1<<30}
var v pvdata.Value = []int32{1, 2, 3, -7, 1 << 30}
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, v); err != nil {
t.Fatal(err)