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) } } }