405 lines
11 KiB
Go
405 lines
11 KiB
Go
package dispenser
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
type fakeSequenceDevice struct {
|
|
statusResponses []cmdResp
|
|
commandErrors map[cmdType][]error
|
|
commands []cmdType
|
|
}
|
|
|
|
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)
|
|
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 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 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, 0x39}},
|
|
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, 13)
|
|
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 != 13 {
|
|
t.Fatalf("status reads = %d, want 13", 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)
|
|
}
|
|
})
|
|
}
|
|
}
|