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