hardlink/internal/dispenser/transport_test.go

273 lines
9.1 KiB
Go

package dispenser
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"testing"
"time"
)
// Golden frames reconstructed from K720_Dll.dll's big-endian length and XOR
// algorithm (SendCmd 0x100050C0, Query 0x10005280, SensorQuery 0x10005420).
var vendorFrames = []struct {
name string
command []byte
frame []byte
}{
{"AP", []byte{2, 'A', 'P'}, []byte{2, 0x30, 0x30, 0, 2, 0x41, 0x50, 3, 0x12}},
{"RF", []byte{2, 'R', 'F'}, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x46, 3, 0x17}},
{"FC7", commandFC7, []byte{2, 0x30, 0x30, 0, 3, 0x46, 0x43, 0x37, 3, 0x30}},
{"FC0", commandFC0, []byte{2, 0x30, 0x30, 0, 3, 0x46, 0x43, 0x30, 3, 0x37}},
{"RS", commandRS, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x53, 3, 2}},
}
func TestVendorOutboundFrames(t *testing.T) {
for _, tc := range vendorFrames {
t.Run(tc.name, func(t *testing.T) {
if got := createPacket([]byte("00"), tc.command); !bytes.Equal(got, tc.frame) {
t.Errorf("createPacket(%s) = % X, want % X", tc.name, got, tc.frame)
}
if got := calculateBCC(tc.frame[:len(tc.frame)-1]); got != tc.frame[len(tc.frame)-1] {
t.Errorf("calculateBCC(%s) = %02X, want %02X", tc.name, got, tc.frame[len(tc.frame)-1])
}
})
}
}
type scriptedTransport struct {
chunks [][]byte
writes [][]byte
readErr error
writeErr error
shortWrite int
afterRead func()
afterWrite func()
}
func (p *scriptedTransport) Read(b []byte) (int, error) {
if len(p.chunks) == 0 {
if p.readErr != nil {
return 0, p.readErr
}
return 0, io.EOF
}
n := copy(b, p.chunks[0])
p.chunks[0] = p.chunks[0][n:]
if len(p.chunks[0]) == 0 {
p.chunks = p.chunks[1:]
}
if p.afterRead != nil {
p.afterRead()
}
return n, nil
}
func (p *scriptedTransport) Write(b []byte) (int, error) {
p.writes = append(p.writes, append([]byte(nil), b...))
if p.afterWrite != nil {
p.afterWrite()
}
if p.writeErr != nil {
return 0, p.writeErr
}
if p.shortWrite == len(p.writes) {
return len(b) - 1, nil
}
return len(b), nil
}
func transportAddress(t *testing.T) {
t.Helper()
old := Address
Address = []byte("00")
t.Cleanup(func() { Address = old })
}
// Independent SF response vectors: status is 30 30 30 [33].
var vendorAP = []byte{2, 0x30, 0x30, 0, 6, 'S', 'F', 0x30, 0x30, 0x30, 0x33, 3, 0x11}
var vendorRF = []byte{2, 0x30, 0x30, 0, 5, 'S', 'F', 0x30, 0x30, 0x30, 3, 0x21}
var vendorACK = []byte{6, 0x30, 0x30}
func TestVendorQueryFragmentation(t *testing.T) {
transportAddress(t)
for i, frame := range [][]byte{vendorAP, vendorRF} {
wire := append(append([]byte(nil), vendorACK...), frame...)
for split := 1; split < len(wire); split++ {
t.Run(fmt.Sprintf("%s/split%d", vendorFrames[i].name, split), func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{wire[:split], wire[split:]}}
got, err := queryStatus(context.Background(), p, vendorFrames[i].command, 4-i, 0)
if err != nil || !bytes.Equal(got, frame[7:len(frame)-2]) {
t.Fatalf("queryStatus(split=%d) = % X, %v, want % X, nil", split, got, err, frame[7:len(frame)-2])
}
if len(p.writes) != 2 || !bytes.Equal(p.writes[0], vendorFrames[i].frame) || !bytes.Equal(p.writes[1], []byte{5, 0x30, 0x30}) {
t.Errorf("queryStatus writes = % X, want command then ENQ", p.writes)
}
})
}
t.Run(vendorFrames[i].name+"/one-byte", func(t *testing.T) {
p := &scriptedTransport{}
for _, b := range wire {
p.chunks = append(p.chunks, []byte{b})
}
if _, err := queryStatus(context.Background(), p, vendorFrames[i].command, 4-i, 0); err != nil {
t.Errorf("queryStatus(one-byte reads) = %v, want nil", err)
}
})
}
}
func TestVendorQueryRejectsInvalidFrames(t *testing.T) {
transportAddress(t)
for _, tc := range []struct {
name string
offset int
value byte
}{
{"STX", 0, 1}, {"address high", 1, '1'}, {"address low", 2, '1'},
{"zero length", 4, 0}, {"short length", 4, 5}, {"long length", 4, 7}, {"high length", 3, 0xff},
{"type S", 5, 'X'}, {"type F", 6, 'X'}, {"ETX", 11, 4}, {"BCC", 12, 0},
} {
t.Run(tc.name, func(t *testing.T) {
frame := append([]byte(nil), vendorAP...)
frame[tc.offset] = tc.value
// Keep checksum valid when testing type/ETX to isolate those checks.
if tc.offset == 5 || tc.offset == 6 || tc.offset == 11 {
frame[12] = 0
for _, b := range frame[:12] {
frame[12] ^= b
}
}
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
if got, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err == nil || got != nil {
t.Errorf("queryStatus(%s) = % X, %v, want nil/error", tc.name, got, err)
}
if len(p.writes) != 2 {
t.Errorf("queryStatus(%s) writes=%d, want 2 without resend", tc.name, len(p.writes))
}
})
}
for n := 0; n < len(vendorAP); n++ {
t.Run(fmt.Sprintf("truncated%d", n), func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, vendorAP[:n]}}
if _, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err == nil {
t.Errorf("queryStatus(%d-byte frame) succeeded, want error", n)
}
})
}
}
func TestTransportACKAndMechanicalDispatch(t *testing.T) {
transportAddress(t)
for _, tc := range vendorFrames[2:] {
p := &scriptedTransport{chunks: [][]byte{{6}, {'0'}, {'0'}}}
if err := dispatchCommand(context.Background(), p, tc.command, 0); err != nil {
t.Fatalf("dispatchCommand(%s)=%v, want nil", tc.name, err)
}
if len(p.writes) != 2 || !bytes.Equal(p.writes[0], tc.frame) || !bytes.Equal(p.writes[1], []byte{5, '0', '0'}) {
t.Errorf("dispatchCommand(%s) writes=% X, want command then ENQ", tc.name, p.writes)
}
}
for _, ack := range [][]byte{{0x15, '0', '0'}, {6, '1', '0'}, {6, '0', '1'}, {6}, {6, '0'}, {}} {
p := &scriptedTransport{chunks: [][]byte{ack}}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil {
t.Errorf("dispatchCommand(ACK=% X) succeeded, want error", ack)
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(ACK=% X) writes=%d, want 1", ack, len(p.writes))
}
}
}
func TestTransportShortWritesAndErrors(t *testing.T) {
transportAddress(t)
failure := errors.New("serial failure")
for _, stage := range []int{1, 2} {
p := &scriptedTransport{chunks: [][]byte{vendorACK}, shortWrite: stage}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); !errors.Is(err, io.ErrShortWrite) {
t.Errorf("dispatchCommand(short write %d)=%v, want ErrShortWrite", stage, err)
}
if len(p.writes) != stage {
t.Errorf("short write %d writes=%d, want %d", stage, len(p.writes), stage)
}
}
for _, p := range []*scriptedTransport{{writeErr: failure}, {readErr: failure}, {chunks: [][]byte{{}}}} {
if err := dispatchCommand(context.Background(), p, commandRS, 0); err == nil {
t.Error("dispatchCommand(I/O failure) succeeded, want error")
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(I/O failure) writes=%d, want 1", len(p.writes))
}
}
}
func TestTransportCancellation(t *testing.T) {
transportAddress(t)
for _, stage := range []string{"before write", "processing wait", "after ACK", "after ENQ", "after header"} {
t.Run(stage, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := &scriptedTransport{chunks: [][]byte{vendorACK, vendorAP}}
wantWrites := 1
switch stage {
case "before write":
cancel()
wantWrites = 0
case "processing wait":
p.afterWrite = cancel
case "after ACK":
p.afterRead = cancel
case "after ENQ":
wantWrites = 2
p.afterWrite = func() {
if len(p.writes) == 2 {
cancel()
}
}
case "after header":
wantWrites = 2
reads := 0
p.afterRead = func() {
reads++
if reads == 2 {
cancel()
}
}
}
if _, err := queryStatus(ctx, p, vendorFrames[0].command, 4, 0); !errors.Is(err, context.Canceled) {
t.Errorf("queryStatus(cancel %s)=%v, want Canceled", stage, err)
}
if len(p.writes) != wantWrites {
t.Errorf("queryStatus(cancel %s) writes=%d, want %d", stage, len(p.writes), wantWrites)
}
})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
p := &scriptedTransport{}
if err := dispatchCommand(ctx, p, commandRS, time.Second); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("dispatchCommand(deadline in wait)=%v, want DeadlineExceeded", err)
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(deadline) writes=%d, want 1", len(p.writes))
}
}
func TestTransportTrailingDataNotConsumedAsStatus(t *testing.T) {
transportAddress(t)
frame := append(append([]byte(nil), vendorAP...), 0xff, 0xfe, 0xfd)
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
if _, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err != nil {
t.Fatalf("queryStatus(frame with trailing bytes)=%v, want nil", err)
}
if len(p.chunks) != 1 || !bytes.Equal(p.chunks[0], []byte{0xff, 0xfe, 0xfd}) {
t.Fatalf("remaining bytes=% X, want FF FE FD", p.chunks)
}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil {
t.Error("dispatchCommand(trailing garbage) succeeded, want invalid ACK")
}
if len(p.writes) != 3 {
t.Errorf("writes after trailing garbage=%d, want 3 (no ENQ/resend)", len(p.writes))
}
}