package m31 import ( "context" "encoding/binary" "errors" "fmt" "io" "net" "os" "testing" "testing/synctest" "time" "github.com/stretchr/testify/require" "onehouse/driver" ) func TestTCPClientEncodesAndChecksModbusOperations(t *testing.T) { testCases := []struct { name string call func(*tcpClient) error request []byte reply []byte }{ { name: "reads low bit first across byte boundaries", call: func(c *tcpClient) error { value, err := c.ReadInputs(t.Context(), 9) require.Equal(t, []bool{true, false, true, false, false, false, false, false, true}, value) return err }, request: []byte{2, 0, 0, 0, 9}, reply: []byte{2, 2, 5, 1}, }, { name: "reads coil state", call: func(c *tcpClient) error { value, err := c.ReadCoils(t.Context(), 1) require.Equal(t, []bool{true}, value) return err }, request: []byte{1, 0, 0, 0, 1}, reply: []byte{1, 1, 1}, }, { name: "reads full sixteen bit registers", call: func(c *tcpClient) error { value, err := c.ReadRegisters(t.Context(), 8, 2) require.Equal(t, []uint16{10000, 65535}, value) return err }, request: []byte{3, 0, 8, 0, 2}, reply: []byte{3, 4, 0x27, 0x10, 0xff, 0xff}, }, { name: "writes true with function five", call: func(c *tcpClient) error { return c.WriteCoil(t.Context(), 7, true) }, request: []byte{5, 0, 7, 0xff, 0}, reply: []byte{5, 0, 7, 0xff, 0}, }, { name: "writes false with function five", call: func(c *tcpClient) error { return c.WriteCoil(t.Context(), 7, false) }, request: []byte{5, 0, 7, 0, 0}, reply: []byte{5, 0, 7, 0, 0}, }, { name: "writes a packed contiguous coil range", call: func(c *tcpClient) error { return c.WriteCoils(t.Context(), 6, []bool{true, false, true, false, false, false, false, false, true}) }, request: []byte{15, 0, 6, 0, 9, 2, 5, 1}, reply: []byte{15, 0, 6, 0, 9}, }, { name: "writes a holding register with function sixteen", call: func(c *tcpClient) error { return c.WriteRegister(t.Context(), 0x30d4, 200) }, request: []byte{16, 0x30, 0xd4, 0, 1, 2, 0, 200}, reply: []byte{16, 0x30, 0xd4, 0, 1}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { request, err := wireCall(t, tc.call, func(header []byte) []byte { return append(append(header[:4:4], words(uint16(len(tc.reply)+1))...), append([]byte{7}, tc.reply...)...) }) require.NoError(t, err) require.Equal(t, append([]byte{0, 1, 0, 0, 0, byte(len(tc.request) + 1), 7}, tc.request...), request) }) } } func TestTCPClientRejectsInvalidAndTruncatedResponses(t *testing.T) { testCases := []struct { name string reply []byte }{ {name: "rejects another transaction", reply: []byte{0, 2, 0, 0, 0, 4, 7, 1, 1, 0}}, {name: "rejects another protocol", reply: []byte{0, 1, 0, 1, 0, 4, 7, 1, 1, 0}}, {name: "rejects another unit", reply: []byte{0, 1, 0, 0, 0, 4, 8, 1, 1, 0}}, {name: "rejects empty PDU", reply: []byte{0, 1, 0, 0, 0, 1, 7}}, {name: "rejects excessive PDU", reply: []byte{0, 1, 0, 0, 0, 255, 7}}, {name: "rejects truncated header", reply: []byte{0, 1}}, {name: "rejects truncated body", reply: []byte{0, 1, 0, 0, 0, 4, 7, 1}}, {name: "rejects wrong function", reply: []byte{0, 1, 0, 0, 0, 4, 7, 2, 1, 0}}, {name: "rejects exception", reply: []byte{0, 1, 0, 0, 0, 3, 7, 0x81, 2}}, {name: "rejects wrong byte count", reply: []byte{0, 1, 0, 0, 0, 4, 7, 1, 2, 0}}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { _, err := wireCall(t, func(c *tcpClient) error { _, err := c.ReadCoils(t.Context(), 1) return err }, func([]byte) []byte { return tc.reply }) require.Error(t, err) }) } } func TestTCPClientRejectsInvalidRangesAndAcknowledgements(t *testing.T) { c := &tcpClient{} _, err := c.ReadInputs(t.Context(), 0) require.Error(t, err) _, err = c.ReadCoils(t.Context(), 2001) require.Error(t, err) _, err = c.ReadRegisters(t.Context(), 0, 0) require.Error(t, err) _, err = c.ReadRegisters(t.Context(), 0, 126) require.Error(t, err) _, err = c.ReadRegisters(t.Context(), 65535, 2) require.Error(t, err) require.Error(t, c.WriteCoils(t.Context(), 0, nil)) require.Error(t, c.WriteCoils(t.Context(), 65535, []bool{true, true})) require.Error(t, c.WriteCoils(t.Context(), 0, make([]bool, 1969))) require.Error(t, c.WriteCoil(t.Context(), 0, true)) require.NoError(t, c.Close()) require.Error(t, c.Connect(t.Context(), "", 0)) require.Error(t, c.Connect(t.Context(), "", 248)) require.Error(t, c.Connect(t.Context(), "", 1)) _, err = wireCall(t, func(c *tcpClient) error { return c.WriteRegister(t.Context(), 0, 20) }, func(header []byte) []byte { return append(header[:7:7], 16, 0, 0, 0, 2) }) require.Error(t, err) _, err = wireCall(t, func(c *tcpClient) error { _, readErr := c.ReadRegisters(t.Context(), 0, 1) return readErr }, func(header []byte) []byte { return append(header[:7:7], 3, 3, 0, 0, 0) }) require.Error(t, err) } func TestTCPClientCancellationAndTimeouts(t *testing.T) { testCases := []struct { name string cancel bool write bool }{ {name: "cancels a blocked write", cancel: true, write: true}, {name: "cancels a blocked read", cancel: true}, {name: "limits a blocked write", write: true}, {name: "limits a blocked read"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { conn, peer := net.Pipe() c := &tcpClient{conn: conn, unit: 1} t.Cleanup(func() { require.NoError(t, c.Close()) require.NoError(t, peer.Close()) }) ctx, cancel := context.WithCancel(t.Context()) defer cancel() result := make(chan error, 1) if !tc.write { go func() { var request [12]byte _, err := io.ReadFull(peer, request[:]) result <- err }() } done := make(chan error, 1) go func() { _, err := c.ReadCoils(ctx, 1) done <- err }() synctest.Wait() if !tc.write { require.NoError(t, <-result) } if tc.cancel { cancel() } else { time.Sleep(2 * time.Second) } err := <-done require.Error(t, err) if tc.cancel { require.ErrorIs(t, err, context.Canceled) } }) }) } } func TestTCPClientCancellationIsAnExpectedRunnerStop(t *testing.T) { testCases := []struct { name string reading bool }{ {name: "stops a blocked write"}, {name: "stops a blocked read", reading: true}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { conn, peer := net.Pipe() c := &tcpClient{conn: conn, unit: 1} t.Cleanup(func() { require.NoError(t, peer.Close()) }) ctx, cancel := context.WithCancel(t.Context()) defer cancel() ready := make(chan error, 1) if tc.reading { go func() { var request [12]byte _, err := io.ReadFull(peer, request[:]) ready <- err }() } done := make(chan error, 1) go func() { done <- driver.Run(ctx, []driver.Driver{&requestDriver{client: c}}, nil) }() synctest.Wait() if tc.reading { require.NoError(t, <-ready) } cancel() require.NoError(t, <-done) }) }) } } func TestTCPClientUsesCallerDeadlineAndStartsClosed(t *testing.T) { client := NewTCPClient() _, err := client.ReadCoils(t.Context(), 1) require.Error(t, err) require.NoError(t, client.Close()) synctest.Test(t, func(t *testing.T) { conn, peer := net.Pipe() c := &tcpClient{conn: conn, unit: 1} t.Cleanup(func() { require.NoError(t, c.Close()) require.NoError(t, peer.Close()) }) ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond) defer cancel() started := time.Now() _, err := c.ReadCoils(ctx, 1) require.Error(t, err) require.Equal(t, time.Millisecond, time.Since(started)) }) } func TestTCPClientCancellationPreservesIndependentFailure(t *testing.T) { synctest.Test(t, func(t *testing.T) { conn, peer := net.Pipe() t.Cleanup(func() { require.NoError(t, peer.Close()) }) ctx, cancel := context.WithCancel(t.Context()) defer cancel() failure := errors.New("independent write failure") transport := &interruptConn{ Conn: conn, cancel: cancel, failure: errors.Join(os.ErrDeadlineExceeded, failure), interrupted: make(chan struct{}), } c := &tcpClient{conn: transport, unit: 1} t.Cleanup(func() { require.NoError(t, c.Close()) }) _, err := c.ReadCoils(ctx, 1) require.ErrorIs(t, err, failure) require.ErrorIs(t, err, context.Canceled) }) } type requestDriver struct { client *tcpClient } func (d *requestDriver) Run(ctx context.Context) (err error) { defer func() { err = errors.Join(err, d.client.Close()) }() _, err = d.client.ReadCoils(ctx, 1) return err } func (d *requestDriver) Refresh() { } type interruptConn struct { net.Conn cancel context.CancelFunc failure error interrupted chan struct{} } func (c *interruptConn) Write([]byte) (int, error) { c.cancel() <-c.interrupted return 0, c.failure } func (c *interruptConn) SetDeadline(deadline time.Time) error { defer close(c.interrupted) err := c.Conn.SetDeadline(deadline) if err != nil { return fmt.Errorf("interrupt fake connection: %w", err) } return nil } func wireCall(t *testing.T, call func(*tcpClient) error, response func([]byte) []byte) ([]byte, error) { t.Helper() conn, peer := net.Pipe() c := &tcpClient{conn: conn, unit: 7} t.Cleanup(func() { require.NoError(t, c.Close()) require.NoError(t, peer.Close()) }) requests := make(chan []byte, 1) done := make(chan error, 1) go func() { var header [7]byte _, err := io.ReadFull(peer, header[:]) if err != nil { done <- err return } body := make([]byte, int(binary.BigEndian.Uint16(header[4:6]))-1) _, err = io.ReadFull(peer, body) requests <- append(header[:7:7], body...) if err == nil { _, err = peer.Write(response(header[:])) } if errors.Is(err, io.ErrClosedPipe) { err = nil } err = errors.Join(err, peer.Close()) done <- err }() err := call(c) require.NoError(t, c.Close()) require.NoError(t, <-done) return <-requests, err }