package dispenser import ( "bytes" "context" "errors" "fmt" "io" "os" "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 TestACKScannerFragmentation(t *testing.T) { transportAddress(t) for _, address := range []string{"00", "15"} { Address = []byte(address) wrong := []byte("15") if address == "15" { wrong = []byte("00") } for _, prefix := range [][]byte{nil, {0x30}, {0x10}, {address[1]}, {ACK, wrong[0], wrong[1], 0x30}, {NAK, wrong[0], wrong[1]}} { for _, token := range []byte{ACK, NAK} { wire := append(append([]byte(nil), prefix...), token, Address[0], Address[1]) for mask := 0; mask < 1<<(len(wire)-1); mask++ { t.Run(fmt.Sprintf("%s/%X/%d", address, wire, mask), func(t *testing.T) { p := &scriptedTransport{} start := 0 for i := 1; i < len(wire); i++ { if mask&(1<<(i-1)) != 0 { p.chunks = append(p.chunks, wire[start:i]) start = i } } p.chunks = append(p.chunks, wire[start:]) err := dispatchCommand(context.Background(), p, commandFC7, 0) if (err != nil) != (token == NAK) { t.Fatalf("dispatchCommand(% X)=%v", wire, err) } want := [][]byte{createPacket(Address, commandFC7)} if token == ACK { want = append(want, append([]byte{ENQ}, Address...)) } if len(p.writes) != len(want) { t.Fatalf("writes=% X want=% X", p.writes, want) } for i := range want { if !bytes.Equal(p.writes[i], want[i]) { t.Errorf("write=% X want=% X", p.writes[i], want[i]) } } if len(p.chunks) != 0 { t.Errorf("unread token bytes=% X", p.chunks) } }) } } } } } func TestACKScannerBoundsAndFollowingTransaction(t *testing.T) { transportAddress(t) for _, address := range []string{"00", "15"} { Address = []byte(address) ack := append([]byte{ACK}, Address...) for _, leading := range []int{61, 62, 64} { wire := append(bytes.Repeat([]byte{0xff}, leading), ack...) p := &scriptedTransport{chunks: [][]byte{wire}} err := dispatchCommand(context.Background(), p, commandRS, 0) if (err == nil) != (leading == 61) { t.Errorf("leading=%d error=%v", leading, err) } if leading != 61 && (len(p.writes) != 1 || len(bytes.Join(p.chunks, nil)) != len(wire)-64) { t.Fatalf("scan exceeded bound: writes=% X remaining=% X", p.writes, p.chunks) } } wire := append(append(append([]byte{0x10}, ack...), ack...), 0xfe) p := &scriptedTransport{chunks: [][]byte{wire}} for i := 0; i < 2; i++ { if err := dispatchCommand(context.Background(), p, commandFC7, 0); err != nil { t.Fatal(err) } } if len(p.writes) != 4 || !bytes.Equal(bytes.Join(p.chunks, nil), []byte{0xfe}) { t.Fatalf("transaction boundary lost: writes=% X remaining=% X", p.writes, p.chunks) } } } type ackReadErrorTransport struct { *scriptedTransport reads, failAt int err error } func (p *ackReadErrorTransport) Read(b []byte) (int, error) { p.reads++ n, err := p.scriptedTransport.Read(b) if p.reads == p.failAt { return n, p.err } return n, err } func TestACKScannerReadErrors(t *testing.T) { transportAddress(t) for _, address := range []string{"00", "15"} { Address = []byte(address) for _, token := range []byte{ACK, NAK} { for _, readErr := range []error{io.EOF, os.ErrDeadlineExceeded, io.ErrClosedPipe} { for _, failAt := range []int{1, 2} { p := &ackReadErrorTransport{scriptedTransport: &scriptedTransport{chunks: [][]byte{{token, Address[0], Address[1]}}}, failAt: failAt, err: readErr} err := dispatchCommand(context.Background(), p, commandFC7, 0) if failAt == 1 && !errors.Is(err, readErr) { t.Fatalf("incomplete token error=%v want=%v", err, readErr) } if failAt == 2 && ((err == nil) != (token == ACK) || errors.Is(err, readErr)) { t.Fatalf("complete token %02X with read error returned %v", token, err) } wantWrites := 1 if failAt == 2 && token == ACK { wantWrites = 2 } if len(p.writes) != wantWrites || p.reads != failAt { t.Fatalf("writes=%d reads=%d want=%d/%d", len(p.writes), p.reads, wantWrites, failAt) } } } } for _, wire := range [][]byte{nil, {0x30, ACK, Address[0]}, {0xff, 0xfe}, {ACK, Address[0]}} { p := &scriptedTransport{chunks: [][]byte{wire}} if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil || len(p.writes) != 1 { t.Fatalf("incomplete/garbage % X: err=%v writes=% X", wire, err, p.writes) } } for _, cancelAt := range []int{1, 2} { ctx, cancel := context.WithCancel(context.Background()) p := &ackReadErrorTransport{scriptedTransport: &scriptedTransport{chunks: [][]byte{{ACK, Address[0], Address[1]}}}, failAt: 2, err: io.EOF} p.afterRead = func() { if p.reads == cancelAt { cancel() } } err := dispatchCommand(ctx, p, commandFC7, 0) cancel() if !errors.Is(err, context.Canceled) || len(p.writes) != 1 || p.reads != cancelAt { t.Fatalf("cancel read %d: err=%v writes=%d reads=%d", cancelAt, err, len(p.writes), p.reads) } } } } 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 = func() { if len(p.chunks) == 1 { 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 == 3 { 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)) } }