828 lines
28 KiB
Go
828 lines
28 KiB
Go
package dispenser
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type fakeSequenceDevice struct {
|
|
statusResponses []cmdResp
|
|
commandErrors map[cmdType][]error
|
|
commands []cmdType
|
|
commandTimes []time.Time
|
|
}
|
|
|
|
func newSequenceTestClient(t *testing.T, statusResponses ...cmdResp) (*Client, *fakeSequenceDevice) {
|
|
t.Helper()
|
|
|
|
device := &fakeSequenceDevice{
|
|
statusResponses: append([]cmdResp(nil), statusResponses...),
|
|
commandErrors: make(map[cmdType][]error),
|
|
}
|
|
now := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC)
|
|
client := &Client{
|
|
reqCh: make(chan cmdReq),
|
|
done: make(chan struct{}),
|
|
sequenceTiming: sequenceTiming{
|
|
now: func() time.Time {
|
|
return now
|
|
},
|
|
wait: func(ctx context.Context, duration time.Duration) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
now = now.Add(duration)
|
|
return nil
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-client.done:
|
|
return
|
|
case request := <-client.reqCh:
|
|
device.commands = append(device.commands, request.typ)
|
|
device.commandTimes = append(device.commandTimes, now)
|
|
if request.typ == cmdStatus {
|
|
if len(device.statusResponses) == 0 {
|
|
request.respCh <- cmdResp{err: errors.New("unexpected status read")}
|
|
continue
|
|
}
|
|
response := device.statusResponses[0]
|
|
device.statusResponses = device.statusResponses[1:]
|
|
request.respCh <- response
|
|
continue
|
|
}
|
|
|
|
var err error
|
|
if queued := device.commandErrors[request.typ]; len(queued) > 0 {
|
|
err = queued[0]
|
|
device.commandErrors[request.typ] = queued[1:]
|
|
}
|
|
request.respCh <- cmdResp{err: err}
|
|
}
|
|
}
|
|
}()
|
|
t.Cleanup(client.Close)
|
|
|
|
return client, device
|
|
}
|
|
|
|
func status(position byte) []byte {
|
|
return []byte{0x30, 0x30, 0x30, position}
|
|
}
|
|
|
|
func commandCount(commands []cmdType, target cmdType) int {
|
|
count := 0
|
|
for _, command := range commands {
|
|
if command == target {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func prepareFailureResponses(code byte, count int) []cmdResp {
|
|
responses := make([]cmdResp, count)
|
|
for i := range responses {
|
|
responses[i] = cmdResp{status: []byte{code, 0x30, 0x30, 0x30}}
|
|
}
|
|
return responses
|
|
}
|
|
|
|
func TestPreparationResetRecovery(t *testing.T) {
|
|
for _, operation := range []string{"current", "next"} {
|
|
for _, code := range []byte{0x32, 0x36} {
|
|
t.Run(operation+string(rune(code)), func(t *testing.T) {
|
|
responses := prepareFailureResponses(code, 9) // Initial read, seven polls, post-reset read.
|
|
responses = append(responses, cmdResp{status: status(0x33)})
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
// An apparently fresh passive cache must never replace physical reads.
|
|
client.lastStatus = status(0x33)
|
|
client.lastStatusT = time.Now()
|
|
client.statusTTL = time.Hour
|
|
prepare := client.PrepareCurrentCard
|
|
if operation == "next" {
|
|
prepare = client.PrepareNextCard
|
|
}
|
|
if _, err := prepare(context.Background()); err != nil {
|
|
t.Fatalf("prepare(%s, %X) = %v, want success", operation, code, err)
|
|
}
|
|
want := []cmdType{cmdStatus, cmdToEncoder}
|
|
for i := 0; i < 7; i++ {
|
|
want = append(want, cmdStatus)
|
|
}
|
|
want = append(want, cmdReset, cmdStatus, cmdToEncoder, cmdStatus)
|
|
if !reflect.DeepEqual(device.commands, want) {
|
|
t.Errorf("prepare commands = %v, want %v", device.commands, want)
|
|
}
|
|
if len(device.commandTimes) != len(want) {
|
|
t.Fatalf("command times = %d, want %d", len(device.commandTimes), len(want))
|
|
}
|
|
if elapsed := device.commandTimes[9].Sub(device.commandTimes[1]); elapsed != sequenceRetryAfter {
|
|
t.Errorf("RS elapsed = %s, want %s", elapsed, sequenceRetryAfter)
|
|
}
|
|
if elapsed := device.commandTimes[10].Sub(device.commandTimes[9]); elapsed != sequenceResetWait {
|
|
t.Errorf("post-reset status elapsed = %s, want %s", elapsed, sequenceResetWait)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func TestPreparationFailureAfterReset(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
code byte
|
|
wantError string
|
|
}{
|
|
{"prepare failure times out", 0x32, "timed out"},
|
|
{"combined failure is final", 0x36, "Command cannot execute"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
client, device := newSequenceTestClient(t, prepareFailureResponses(test.code, 30)...)
|
|
_, err := client.PrepareCurrentCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
|
t.Errorf("PrepareCurrentCard(%X) error = %v, want %q", test.code, err, test.wantError)
|
|
}
|
|
if resets, commands := commandCount(device.commands, cmdReset), commandCount(device.commands, cmdToEncoder); resets != 1 || commands != 2 {
|
|
t.Errorf("recovery commands = %v, want one RS and two FC7", device.commands)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreparationHardErrorsNeverReset(t *testing.T) {
|
|
for _, failure := range [][]byte{
|
|
{0x32, 0x30, 0x32, 0x30}, // Jam alongside prepare failure.
|
|
{0x36, 0x30, 0x34, 0x30}, // Overlap alongside combined failure.
|
|
{0x32, 0x30, 0x30, 0x38}, // Empty.
|
|
{0x36, 0x32, 0x30, 0x30}, // Independent dispense error.
|
|
{0x32, 0x31, 0x30, 0x30}, // Independent capture error.
|
|
{0x34, 0x30, 0x30, 0x30}, // Command rejection alone.
|
|
{0x30, 0x30, 0x30, 0x40}, // Unknown.
|
|
{0x32}, // Malformed.
|
|
} {
|
|
responses := prepareFailureResponses(0x32, 7)
|
|
responses = append(responses, cmdResp{status: failure})
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
if _, err := client.PrepareCurrentCard(context.Background()); err == nil {
|
|
t.Errorf("PrepareCurrentCard(% X) error = nil, want hard error", failure)
|
|
}
|
|
if got := commandCount(device.commands, cmdReset); got != 0 {
|
|
t.Errorf("PrepareCurrentCard(% X) resets = %d, want 0", failure, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreparationProgressAtRecoveryThreshold(t *testing.T) {
|
|
for _, moving := range [][]byte{{0x31, 0x30, 0x30, 0x30}, {0x32, 0x38, 0x30, 0x30}} {
|
|
responses := prepareFailureResponses(0x32, 7)
|
|
responses = append(responses, cmdResp{status: moving}, cmdResp{status: moving}, cmdResp{status: status(0x33)})
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
if _, err := client.PrepareCurrentCard(context.Background()); err != nil {
|
|
t.Fatalf("PrepareCurrentCard(moving % X) = %v, want success", moving, err)
|
|
}
|
|
if commandCount(device.commands, cmdReset) != 0 || commandCount(device.commands, cmdToEncoder) != 1 {
|
|
t.Errorf("PrepareCurrentCard(moving % X) commands = %v, want initial FC7 only", moving, device.commands)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreparationRequiresConsecutiveFailures(t *testing.T) {
|
|
responses := prepareFailureResponses(0x32, 7)
|
|
responses = append(responses,
|
|
cmdResp{status: []byte{0x31, 0x30, 0x30, 0x30}},
|
|
cmdResp{status: []byte{0x32, 0x30, 0x30, 0x30}},
|
|
cmdResp{status: status(0x33)},
|
|
)
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
if _, err := client.PrepareCurrentCard(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := commandCount(device.commands, cmdReset); got != 0 {
|
|
t.Errorf("PrepareCurrentCard(interrupted failure sequence) resets = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestPreparationPostResetStatus(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
responses []cmdResp
|
|
wantFC7 int
|
|
wantError string
|
|
}{
|
|
{"already at encoder", []cmdResp{{status: status(0x33)}}, 1, ""},
|
|
{"moving then encoder", []cmdResp{{status: []byte{0x31, 0x30, 0x30, 0x30}}, {status: status(0x33)}}, 1, ""},
|
|
{"moving then stationary", []cmdResp{{status: []byte{0x31, 0x30, 0x30, 0x30}}, {status: status(0x30)}, {status: status(0x33)}}, 2, ""},
|
|
{"hard error", []cmdResp{{status: []byte{0x32, 0x30, 0x32, 0x30}}}, 1, "Card jammed"},
|
|
{"read failure", []cmdResp{{err: errors.New("post-reset read failed")}}, 1, "post-reset read failed"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
responses := append(prepareFailureResponses(0x32, 8), test.responses...)
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
_, err := client.PrepareCurrentCard(context.Background())
|
|
if test.wantError == "" && err != nil {
|
|
t.Errorf("PrepareCurrentCard(%s) = %v, want success", test.name, err)
|
|
}
|
|
if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) {
|
|
t.Errorf("PrepareCurrentCard(%s) = %v, want %q", test.name, err, test.wantError)
|
|
}
|
|
if commandCount(device.commands, cmdReset) != 1 || commandCount(device.commands, cmdToEncoder) != test.wantFC7 {
|
|
t.Errorf("PrepareCurrentCard(%s) commands = %v, want one RS and %d FC7", test.name, device.commands, test.wantFC7)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPreparationRecoveryCommandFailures(t *testing.T) {
|
|
for _, command := range []cmdType{cmdReset, cmdToEncoder} {
|
|
client, device := newSequenceTestClient(t, prepareFailureResponses(0x32, 9)...)
|
|
failure := errors.New("command failed")
|
|
device.commandErrors[command] = []error{failure}
|
|
wantFC7 := 1
|
|
if command == cmdToEncoder {
|
|
device.commandErrors[command] = []error{nil, failure}
|
|
wantFC7 = 2
|
|
}
|
|
_, err := client.PrepareCurrentCard(context.Background())
|
|
if !errors.Is(err, failure) {
|
|
t.Errorf("PrepareCurrentCard(failed command %v) = %v, want %v", command, err, failure)
|
|
}
|
|
if commandCount(device.commands, cmdReset) != 1 || commandCount(device.commands, cmdToEncoder) != wantFC7 {
|
|
t.Errorf("PrepareCurrentCard(failed command %v) commands = %v, want one RS and %d FC7", command, device.commands, wantFC7)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreparationCancellationDuringResetSettle(t *testing.T) {
|
|
client, device := newSequenceTestClient(t, prepareFailureResponses(0x32, 9)...)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
wait := client.sequenceTiming.wait
|
|
client.sequenceTiming.wait = func(ctx context.Context, duration time.Duration) error {
|
|
if duration == sequenceResetWait {
|
|
cancel()
|
|
}
|
|
return wait(ctx, duration)
|
|
}
|
|
_, err := client.PrepareCurrentCard(ctx)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("PrepareCurrentCard(cancel during reset) = %v, want context.Canceled", err)
|
|
}
|
|
if commandCount(device.commands, cmdReset) != 1 || commandCount(device.commands, cmdToEncoder) != 1 || device.commands[len(device.commands)-1] != cmdReset {
|
|
t.Errorf("PrepareCurrentCard(cancel during reset) commands = %v, want no commands after RS", device.commands)
|
|
}
|
|
}
|
|
|
|
func TestResetPacket(t *testing.T) {
|
|
got := createPacket([]byte{0x30, 0x30}, commandRS)
|
|
want := []byte{0x02, 0x30, 0x30, 0x00, 0x02, 0x52, 0x53, 0x03, 0x02}
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("createPacket(RS) = % X, want % X", got, want)
|
|
}
|
|
}
|
|
|
|
func TestPreparationDeadlineDuringResetSettle(t *testing.T) {
|
|
client, device := newSequenceTestClient(t, prepareFailureResponses(0x32, 4)...)
|
|
wait := client.sequenceTiming.wait
|
|
waits := 0
|
|
client.sequenceTiming.wait = func(ctx context.Context, duration time.Duration) error {
|
|
waits++
|
|
if waits == 1 {
|
|
// Simulate a delayed poll, leaving only one second for recovery.
|
|
duration = 15 * time.Second
|
|
} else if duration != time.Second {
|
|
t.Errorf("reset settle duration = %s, want remaining 1s", duration)
|
|
}
|
|
return wait(ctx, duration)
|
|
}
|
|
_, err := client.PrepareCurrentCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "timed out") {
|
|
t.Errorf("PrepareCurrentCard(late recovery) = %v, want timeout", err)
|
|
}
|
|
want := []cmdType{cmdStatus, cmdToEncoder, cmdStatus, cmdStatus, cmdReset}
|
|
if !reflect.DeepEqual(device.commands, want) {
|
|
t.Errorf("PrepareCurrentCard(late recovery) commands = %v, want %v", device.commands, want)
|
|
}
|
|
}
|
|
|
|
func TestPreparationPlainRetryConsumesRecoveryBudget(t *testing.T) {
|
|
responses := make([]cmdResp, 8)
|
|
for i := range responses {
|
|
responses[i] = cmdResp{status: status(0x34)}
|
|
}
|
|
responses = append(responses, prepareFailureResponses(0x32, 20)...)
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
_, err := client.PrepareCurrentCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "timed out") {
|
|
t.Errorf("PrepareCurrentCard(failure after plain retry) = %v, want timeout", err)
|
|
}
|
|
if commandCount(device.commands, cmdReset) != 0 || commandCount(device.commands, cmdToEncoder) != 2 {
|
|
t.Errorf("PrepareCurrentCard(failure after plain retry) commands = %v, want two FC7 and no RS", device.commands)
|
|
}
|
|
}
|
|
|
|
func TestPreparationFirstAttemptDoesNotReset(t *testing.T) {
|
|
for _, failures := range []int{0, 2} {
|
|
responses := []cmdResp{{status: status(0x34)}}
|
|
responses = append(responses, prepareFailureResponses(0x32, failures)...)
|
|
responses = append(responses, cmdResp{status: status(0x33)})
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
if _, err := client.PrepareCurrentCard(context.Background()); err != nil {
|
|
t.Errorf("PrepareCurrentCard(%d transient failures) = %v, want success", failures, err)
|
|
}
|
|
if commandCount(device.commands, cmdReset) != 0 || commandCount(device.commands, cmdToEncoder) != 1 {
|
|
t.Errorf("PrepareCurrentCard(%d transient failures) commands = %v, want one FC7 and no RS", failures, device.commands)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrepareCurrentCardAcceptsEncoderPositionWithStaleDiagnostics(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status []byte
|
|
wantStock string
|
|
}{
|
|
{
|
|
name: "dispense error and jam",
|
|
status: []byte{0x30, 0x32, 0x32, 0x33},
|
|
wantStock: "Card jammed",
|
|
},
|
|
{
|
|
name: "supply diagnostics",
|
|
status: []byte{0x32, 0x30, 0x31, 0x33},
|
|
wantStock: "Card pre-empty",
|
|
},
|
|
{
|
|
name: "combined stale jam and supply diagnostics",
|
|
status: []byte{0x30, 0x32, 0x33, 0x33},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var logOutput bytes.Buffer
|
|
standardLogger := log.StandardLogger()
|
|
previousOutput := standardLogger.Out
|
|
standardLogger.SetOutput(&logOutput)
|
|
t.Cleanup(func() {
|
|
standardLogger.SetOutput(previousOutput)
|
|
})
|
|
|
|
client, device := newSequenceTestClient(t, cmdResp{status: test.status})
|
|
|
|
stock, err := client.PrepareCurrentCard(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stock != test.wantStock {
|
|
t.Fatalf("stock status = %q, want %q", stock, test.wantStock)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 0 {
|
|
t.Fatalf("to-encoder commands = %d, want 0", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 1 {
|
|
t.Fatalf("status reads = %d, want 1", got)
|
|
}
|
|
logged := logOutput.String()
|
|
if !strings.Contains(logged, "card confirmed at encoder") ||
|
|
!strings.Contains(logged, statusDescription(test.status)) ||
|
|
!strings.Contains(logged, "raw status") {
|
|
t.Fatalf("encoder diagnostic log = %q", logged)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrepareCurrentCardToleratesTransientPrepareFailureUntilEncoder(t *testing.T) {
|
|
client, device := newSequenceTestClient(t,
|
|
cmdResp{status: []byte{0x32, 0x30, 0x30, 0x30}},
|
|
cmdResp{status: []byte{0x32, 0x30, 0x30, 0x30}},
|
|
cmdResp{status: []byte{0x32, 0x30, 0x30, 0x30}},
|
|
cmdResp{status: []byte{0x32, 0x30, 0x30, 0x33}},
|
|
)
|
|
|
|
stock, err := client.PrepareCurrentCard(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stock != "Preparing card fails" {
|
|
t.Fatalf("stock status = %q, want Preparing card fails", stock)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
|
t.Fatalf("to-encoder commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 4 {
|
|
t.Fatalf("status reads = %d, want 4", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareCurrentCardDoesNotMaskHardErrorAlongsideTransientPrepareFailure(t *testing.T) {
|
|
client, _ := newSequenceTestClient(t,
|
|
cmdResp{status: []byte{0x32, 0x30, 0x32, 0x30}},
|
|
)
|
|
|
|
_, err := client.PrepareCurrentCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "Card jammed") {
|
|
t.Fatalf("error = %v, want Card jammed", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareCurrentCardPollingAllowsTransientMovementWithStaleErrors(t *testing.T) {
|
|
client, device := newSequenceTestClient(t,
|
|
cmdResp{status: status(0x34)},
|
|
cmdResp{status: []byte{0x31, 0x38, 0x32, 0x30}},
|
|
cmdResp{status: []byte{0x30, 0x32, 0x32, 0x33}},
|
|
)
|
|
|
|
stock, err := client.PrepareCurrentCard(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stock != "Card jammed" {
|
|
t.Fatalf("stock status = %q, want Card jammed", stock)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
|
t.Fatalf("to-encoder commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 3 {
|
|
t.Fatalf("status reads = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareCurrentCardRejectsFailureWithoutEncoderOrMovement(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
response cmdResp
|
|
wantError string
|
|
wantStock string
|
|
}{
|
|
{
|
|
name: "status read error",
|
|
response: cmdResp{err: errors.New("serial read failed")},
|
|
wantError: "serial read failed",
|
|
},
|
|
{
|
|
name: "malformed status",
|
|
response: cmdResp{status: []byte{0x30, 0x30, 0x31}},
|
|
wantError: "malformed dispenser status",
|
|
},
|
|
{
|
|
name: "unknown status",
|
|
response: cmdResp{status: []byte{0x30, 0x30, 0x30, 0x40}},
|
|
wantError: "unknown dispenser status",
|
|
},
|
|
{
|
|
name: "jammed preparation",
|
|
response: cmdResp{status: []byte{0x30, 0x30, 0x32, 0x34}},
|
|
wantError: "Card jammed",
|
|
wantStock: "Card jammed",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
client, _ := newSequenceTestClient(t, test.response)
|
|
|
|
stock, err := client.PrepareCurrentCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
|
t.Fatalf("error = %v, want containing %q", err, test.wantError)
|
|
}
|
|
if stock != test.wantStock {
|
|
t.Fatalf("stock status = %q, want %q", stock, test.wantStock)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrepareCurrentCardReturnsAuthoritativeEmptyWellError(t *testing.T) {
|
|
client, device := newSequenceTestClient(t, cmdResp{status: status(0x38)})
|
|
|
|
stock, err := client.PrepareCurrentCard(context.Background())
|
|
if !errors.Is(err, ErrCardWellEmpty) {
|
|
t.Fatalf("error = %v, want ErrCardWellEmpty", err)
|
|
}
|
|
if stock != "Card empty" {
|
|
t.Fatalf("stock status = %q, want Card empty", stock)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 0 {
|
|
t.Fatalf("to-encoder commands = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestDeliverCurrentCardSucceedsAfterCommandAcceptanceWithoutStatusPolling(t *testing.T) {
|
|
staleStatus := []byte{0x30, 0x32, 0x32, 0x33}
|
|
client, device := newSequenceTestClient(t, cmdResp{status: staleStatus})
|
|
|
|
stock, err := client.DeliverCurrentCard(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stock != "" {
|
|
t.Fatalf("stock status = %q, want empty", stock)
|
|
}
|
|
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
|
|
t.Fatalf("out-of-mouth commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
|
t.Fatalf("status reads = %d, want 0", got)
|
|
}
|
|
if len(device.statusResponses) != 1 {
|
|
t.Fatalf("queued status responses = %d, want stale status unread", len(device.statusResponses))
|
|
}
|
|
}
|
|
|
|
func TestDeliverCurrentCardRejectsCommandFailures(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{name: "serial dispatch failure", err: errors.New("serial write failed")},
|
|
{name: "invalid acknowledgement", err: errors.New("unexpected response status")},
|
|
{name: "missing acknowledgement", err: errors.New("no response from dispenser")},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
client, device := newSequenceTestClient(t)
|
|
device.commandErrors[cmdOutOfMouth] = []error{test.err}
|
|
|
|
_, err := client.DeliverCurrentCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), test.err.Error()) {
|
|
t.Fatalf("error = %v, want containing %q", err, test.err)
|
|
}
|
|
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
|
|
t.Fatalf("out-of-mouth commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
|
t.Fatalf("status reads = %d, want 0", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeliverCurrentCardPreservesContextCancellation(t *testing.T) {
|
|
client, _ := newSequenceTestClient(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := client.DeliverCurrentCard(ctx)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareCurrentCardRetriesOnceThenTimesOut(t *testing.T) {
|
|
responses := make([]cmdResp, 17)
|
|
for index := range responses {
|
|
responses[index] = cmdResp{status: status(0x34)}
|
|
}
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
|
|
_, err := client.PrepareCurrentCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "timed out") {
|
|
t.Fatalf("error = %v, want preparation timeout", err)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 2 {
|
|
t.Fatalf("to-encoder commands = %d, want initial command plus one halfway retry", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 17 {
|
|
t.Fatalf("status reads = %d, want 17", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareCurrentCardPropagatesContextCancellation(t *testing.T) {
|
|
client, _ := newSequenceTestClient(t,
|
|
cmdResp{status: status(0x34)},
|
|
cmdResp{status: status(0x34)},
|
|
)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
client.sequenceTiming.wait = func(context.Context, time.Duration) error {
|
|
cancel()
|
|
return ctx.Err()
|
|
}
|
|
|
|
_, err := client.PrepareCurrentCard(ctx)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestBeginPrepareNextCardDispatchesWithoutReadinessPolling(t *testing.T) {
|
|
client, device := newSequenceTestClient(t)
|
|
|
|
if err := client.BeginPrepareNextCard(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
|
t.Fatalf("to-encoder commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
|
t.Fatalf("status reads = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestBeginPrepareNextCardReturnsDispatchFailureWithoutPolling(t *testing.T) {
|
|
client, device := newSequenceTestClient(t)
|
|
device.commandErrors[cmdToEncoder] = []error{errors.New("dispatch failed")}
|
|
|
|
err := client.BeginPrepareNextCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "dispatch failed") {
|
|
t.Fatalf("error = %v, want dispatch failure", err)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
|
t.Fatalf("to-encoder commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
|
t.Fatalf("status reads = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestPrepareCardAtEncoderRequiresConfirmedEncoderState(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
responses []cmdResp
|
|
wantError string
|
|
wantToEncoder int
|
|
}{
|
|
{
|
|
name: "already at encoder",
|
|
responses: []cmdResp{{status: status(0x33)}},
|
|
wantToEncoder: 0,
|
|
},
|
|
{
|
|
name: "moves ready card to encoder",
|
|
responses: []cmdResp{
|
|
{status: status(0x34)},
|
|
{status: status(0x33)},
|
|
},
|
|
wantToEncoder: 1,
|
|
},
|
|
{
|
|
name: "empty card well",
|
|
responses: []cmdResp{{status: status(0x38)}},
|
|
wantError: CardWellEmptyMessage,
|
|
wantToEncoder: 0,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
client, device := newSequenceTestClient(t, test.responses...)
|
|
|
|
_, err := client.PrepareNextCard(context.Background())
|
|
if test.wantError == "" && err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) {
|
|
t.Fatalf("error = %v, want containing %q", err, test.wantError)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != test.wantToEncoder {
|
|
t.Fatalf("to-encoder commands = %d, want %d", got, test.wantToEncoder)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPositionFlags(t *testing.T) {
|
|
for position := byte(0x30); position <= 0x3F; position++ {
|
|
t.Run(fmt.Sprintf("%02X", position), func(t *testing.T) {
|
|
st := status(position)
|
|
wantEncoder := position&0x02 != 0
|
|
wantEmpty := position&0x08 != 0
|
|
if got := isAtEncoderPosition(st); got != wantEncoder {
|
|
t.Errorf("isAtEncoderPosition(% X) = %t, want %t", st, got, wantEncoder)
|
|
}
|
|
if got := isCardWellEmpty(st); got != wantEmpty {
|
|
t.Errorf("isCardWellEmpty(% X) = %t, want %t", st, got, wantEmpty)
|
|
}
|
|
if err := validateDispenserStatusData(st); err != nil {
|
|
t.Errorf("validateDispenserStatusData(% X) = %v, want nil", st, err)
|
|
}
|
|
ready, err := preparationStatus("test", st, true)
|
|
if ready != wantEncoder {
|
|
t.Errorf("preparationStatus(% X) ready = %t, want %t", st, ready, wantEncoder)
|
|
}
|
|
wantEmptyError := wantEmpty && !wantEncoder
|
|
if errors.Is(err, ErrCardWellEmpty) != wantEmptyError || (err != nil && !wantEmptyError) {
|
|
t.Errorf("preparationStatus(% X) error = %v, want empty error %t", st, err, wantEmptyError)
|
|
}
|
|
wantStock := ""
|
|
if wantEmpty {
|
|
wantStock = "Card empty"
|
|
}
|
|
if got := stockTake(st); got != wantStock {
|
|
t.Errorf("stockTake(% X) = %q, want %q", st, got, wantStock)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEncoderPositionPrecedesDiagnostics(t *testing.T) {
|
|
for _, position := range []byte{0x32, 0x33, 0x36, 0x37, 0x3A, 0x3B, 0x3E, 0x3F} {
|
|
for _, diagnostics := range [][]byte{
|
|
{0x30, 0x30, 0x30},
|
|
{0x32, 0x30, 0x30},
|
|
{0x36, 0x30, 0x30},
|
|
{0x34, 0x32, 0x32},
|
|
{0x30, 0x31, 0x34},
|
|
{0xFF, 0xFF, 0xFF},
|
|
} {
|
|
st := append(append([]byte(nil), diagnostics...), position)
|
|
for _, operation := range []string{"current", "next"} {
|
|
t.Run(fmt.Sprintf("%s/% X", operation, st), func(t *testing.T) {
|
|
client, device := newSequenceTestClient(t, cmdResp{status: st})
|
|
prepare := client.PrepareCurrentCard
|
|
if operation == "next" {
|
|
prepare = client.PrepareNextCard
|
|
}
|
|
if _, err := prepare(context.Background()); err != nil {
|
|
t.Errorf("prepare(% X) = %v, want success", st, err)
|
|
}
|
|
want := []cmdType{cmdStatus}
|
|
if !reflect.DeepEqual(device.commands, want) {
|
|
t.Errorf("prepare(% X) commands = %v, want %v", st, device.commands, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCombinedEncoderPositionStopsRecovery(t *testing.T) {
|
|
for _, afterReset := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("afterReset=%t", afterReset), func(t *testing.T) {
|
|
count := 7 // Encoder confirmation at the recovery threshold, before RS.
|
|
wantResets := 0
|
|
if afterReset {
|
|
count = 8 // Encoder confirmation on the fresh post-reset read.
|
|
wantResets = 1
|
|
}
|
|
responses := append(prepareFailureResponses(0x32, count), cmdResp{status: []byte{0x36, 0x32, 0x34, 0x3F}})
|
|
client, device := newSequenceTestClient(t, responses...)
|
|
if _, err := client.PrepareCurrentCard(context.Background()); err != nil {
|
|
t.Errorf("PrepareCurrentCard(combined encoder) = %v, want success", err)
|
|
}
|
|
if got := commandCount(device.commands, cmdReset); got != wantResets {
|
|
t.Errorf("PrepareCurrentCard(combined encoder) RS count = %d, want %d", got, wantResets)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
|
t.Errorf("PrepareCurrentCard(combined encoder) FC7 count = %d, want 1", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInvalidPositionEncoding(t *testing.T) {
|
|
for _, st := range [][]byte{nil, {0x30, 0x30, 0x30}, status(0x02), status(0x2F), status(0x40), status(0x72), status(0xFF)} {
|
|
if isAtEncoderPosition(st) || isCardWellEmpty(st) {
|
|
t.Errorf("invalid status % X confirmed encoder or empty, want neither", st)
|
|
}
|
|
if ready, err := preparationStatus("test", st, true); ready || err == nil {
|
|
t.Errorf("preparationStatus(% X) = (%t, %v), want false and error", st, ready, err)
|
|
}
|
|
}
|
|
st := []byte{0xFF, 0xFF, 0xFF, 0x37, 0xFF}
|
|
if ready, err := preparationStatus("test", st, false); !ready || err != nil {
|
|
t.Errorf("preparationStatus(% X) = (%t, %v), want encoder precedence", st, ready, err)
|
|
}
|
|
st = append(status(0x35), 0xFF)
|
|
if ready, err := preparationStatus("test", st, true); ready || err == nil {
|
|
t.Errorf("preparationStatus(% X) = (%t, %v), want malformed error", st, ready, err)
|
|
}
|
|
}
|
|
|
|
func TestPositionDescriptions(t *testing.T) {
|
|
for _, test := range []struct {
|
|
position byte
|
|
want string
|
|
}{
|
|
{0x30, ""},
|
|
{0x31, "Card at pre-dispense position; "},
|
|
{0x32, "Card at encoder position; "},
|
|
{0x34, "Card at mouth position; "},
|
|
{0x37, "Card at pre-dispense position; Card at encoder position; Card at mouth position; "},
|
|
{0x39, "Card at pre-dispense position; Card empty; "},
|
|
{0x3F, "Card at pre-dispense position; Card at encoder position; Card at mouth position; Card empty; "},
|
|
{0x40, "Unknown status 0x40 at position 4; "},
|
|
} {
|
|
if got := statusDescription(status(test.position)); got != test.want {
|
|
t.Errorf("statusDescription(position %X) = %q, want %q", test.position, got, test.want)
|
|
}
|
|
}
|
|
}
|