package dispenser import ( "bytes" "context" "errors" "fmt" "reflect" "strings" "testing" "time" ) type fakeSequenceDevice struct { statusResponses []cmdResp commandErrors map[cmdType][]error commands []cmdType commandTimes []time.Time onCommand func(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: clearance := request.typ == cmdDeliveryClearance if clearance { request.typ = cmdStatus } device.commands = append(device.commands, request.typ) device.commandTimes = append(device.commandTimes, now) if device.onCommand != nil { device.onCommand(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] if clearance && request.ctx.Err() == nil { response.status = usableObservation(response.status, response.err, "fake clearance") response.err = nil } 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 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 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 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 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) } } } func positionResponses(position byte, count int) []cmdResp { responses := make([]cmdResp, count) for i := range responses { responses[i] = cmdResp{status: status(position)} } return responses } func TestPreparationClasses(t *testing.T) { classes := map[preparationClass][]byte{ encoderConfirmed: {0x32, 0x33, 0x36, 0x37, 0x3A, 0x3B, 0x3E, 0x3F}, positionUncertain: {0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D}, positionWellEmpty: {0x38}, positionNoCard: {0x30}, } for want, positions := range classes { for _, position := range positions { st := []byte{0xFF, 0x36, 0x32, position} if got, err := classifyPreparationStatus(st); got != want || err != nil { t.Errorf("classify(% X)=%s,%v, want %s", st, got, err, want) } } } for _, st := range [][]byte{nil, {0x30}, status(0x02), status(0x40), status(0xFF), {0x30, 0x30, 0x30, 0x37, 0x30}} { if got, err := classifyPreparationStatus(st); err == nil { t.Errorf("classify(% X)=%s,nil, want malformed error", st, got) } } } func TestPreparationAllPositionsBothMethods(t *testing.T) { for _, next := range []bool{false, true} { for position := byte(0x30); position <= 0x3F; position++ { t.Run(fmt.Sprintf("next=%t/position=%X", next, position), func(t *testing.T) { responses := positionResponses(position, 40) for i := range responses { responses[i].status[0], responses[i].status[1], responses[i].status[2] = 0xFF, 0x38, 0x34 } c, d := newSequenceTestClient(t, responses...) // Passive encoder data must never replace fresh AP. c.lastStatus, c.lastStatusT, c.statusTTL = status(0x33), time.Now(), time.Hour prepare := c.PrepareCurrentCard if next { prepare = c.PrepareNextCard } started := c.now() _, err := prepare(context.Background()) wantFC7, wantRS, wantElapsed := 0, 0, time.Duration(0) var wantErr error switch position { case 0x30: wantFC7, wantRS, wantElapsed, wantErr = 4, 3, 18*time.Second, ErrPreparationExhausted case 0x38: wantErr = ErrCardWellEmpty case 0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D: wantFC7, wantElapsed = 1, 4*time.Second } if !errors.Is(err, wantErr) { t.Errorf("prepare(%X) error=%v, want %v", position, err, wantErr) } if commandCount(d.commands, cmdToEncoder) != wantFC7 || commandCount(d.commands, cmdReset) != wantRS { t.Errorf("prepare(%X) commands=%v, want FC7=%d RS=%d", position, d.commands, wantFC7, wantRS) } if got := c.now().Sub(started); got != wantElapsed { t.Errorf("prepare(%X) elapsed=%s, want %s", position, got, wantElapsed) } }) } } } func TestThreeShakesAreRequestLocalAndSettled(t *testing.T) { c, d := newSequenceTestClient(t, positionResponses(0x30, 34)...) for request := 0; request < 2; request++ { start := len(d.commands) if _, err := c.PrepareCurrentCard(context.Background()); !errors.Is(err, ErrPreparationExhausted) { t.Fatalf("request %d error=%v, want exhausted", request, err) } want := []cmdType{cmdStatus, cmdToEncoder} for attempt := 0; attempt < 4; attempt++ { want = append(want, cmdStatus, cmdStatus, cmdStatus, cmdStatus) if attempt < 3 { want = append(want, cmdReset, cmdToEncoder) } } if !reflect.DeepEqual(d.commands[start:], want) { t.Errorf("request %d commands=%v, want %v", request, d.commands[start:], want) } var lastFC7 time.Time for i := start; i < len(d.commands); i++ { if d.commands[i] == cmdReset && d.commandTimes[i].Sub(lastFC7) != 3*time.Second { t.Errorf("RS at %s after FC7, want 3s", d.commandTimes[i].Sub(lastFC7)) } if d.commands[i] == cmdToEncoder { if i > start && d.commands[i-1] == cmdReset && d.commandTimes[i].Sub(d.commandTimes[i-1]) != 2*time.Second { t.Errorf("FC7 settle=%s, want 2s", d.commandTimes[i].Sub(d.commandTimes[i-1])) } lastFC7 = d.commandTimes[i] } } } } func TestPreparationReclassifiesEverySample(t *testing.T) { for _, tc := range []struct { name string positions []byte wantRS int wantTime time.Duration wantErr error }{ {"uncertain changes do not restart timer", []byte{0x30, 0x31, 0x34, 0x35, 0x39, 0x3C}, 0, 4 * time.Second, nil}, {"uncertain becomes encoder", []byte{0x30, 0x34, 0x37}, 0, time.Second, nil}, {"uncertain becomes empty", []byte{0x30, 0x34, 0x38}, 0, time.Second, ErrCardWellEmpty}, {"no sensors becomes empty", []byte{0x30, 0x30, 0x38}, 0, time.Second, ErrCardWellEmpty}, {"uncertain returns to shake path twice", []byte{0x30, 0x34, 0x30, 0x30, 0x30, 0x34, 0x34, 0x30, 0x30, 0x37}, 2, 10 * time.Second, nil}, } { t.Run(tc.name, func(t *testing.T) { var responses []cmdResp for _, p := range tc.positions { responses = append(responses, cmdResp{status: status(p)}) } c, d := newSequenceTestClient(t, responses...) start := c.now() _, err := c.PrepareCurrentCard(context.Background()) if !errors.Is(err, tc.wantErr) || c.now().Sub(start) != tc.wantTime || commandCount(d.commands, cmdReset) != tc.wantRS { t.Errorf("prepare(%s) error=%v elapsed=%s commands=%v, want error=%v time=%s RS=%d", tc.name, err, c.now().Sub(start), d.commands, tc.wantErr, tc.wantTime, tc.wantRS) } }) } } func TestEveryShakeReclassifiesEncoderEmptyAndUncertain(t *testing.T) { for shake := 1; shake <= 3; shake++ { for _, position := range []byte{0x37, 0x38, 0x35} { responses := positionResponses(0x30, 1+4*shake) responses = append(responses, positionResponses(position, 5)...) c, d := newSequenceTestClient(t, responses...) _, err := c.PrepareCurrentCard(context.Background()) var wantErr error if position == 0x38 { wantErr = ErrCardWellEmpty } if !errors.Is(err, wantErr) || commandCount(d.commands, cmdReset) != shake || commandCount(d.commands, cmdToEncoder) != shake+1 { t.Errorf("shake %d position=%X error=%v commands=%v, want %v and no further shake", shake, position, err, d.commands, wantErr) } } } } func TestPreparationTransportAndDispatchFailuresDoNotShake(t *testing.T) { failure := errors.New("serial failure") for _, response := range []cmdResp{{err: failure}, {status: []byte{0x30}}, {status: status(0x40)}} { c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, response) if _, err := c.PrepareCurrentCard(context.Background()); err != nil { t.Errorf("AP failure %v error=%v, want encoder opportunity", response, err) } if commandCount(d.commands, cmdToEncoder) != 1 || commandCount(d.commands, cmdReset) != 0 { t.Errorf("AP failure commands=%v, want FC7 once and no RS", d.commands) } } for _, command := range []cmdType{cmdToEncoder, cmdReset} { c, d := newSequenceTestClient(t, positionResponses(0x30, 10)...) d.commandErrors[command] = []error{failure} if _, err := c.PrepareCurrentCard(context.Background()); !errors.Is(err, failure) { t.Errorf("command %v error=%v, want serial failure", command, err) } if commandCount(d.commands, command) != 1 { t.Errorf("command %v retried: %v", command, d.commands) } } } func TestPreparationStopsAtCancellationAndDeadlineStages(t *testing.T) { for _, stage := range []string{"initial AP", "FC7", "poll", "RS", "settle"} { for _, callerCanceled := range []bool{false, true} { if stage == "initial AP" && !callerCanceled { continue } t.Run(fmt.Sprintf("%s/cancel=%t", stage, callerCanceled), func(t *testing.T) { c, d := newSequenceTestClient(t, positionResponses(0x30, 30)...) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) now := c.now() c.sequenceTiming.now = func() time.Time { return now } stoppedAt := -1 stop := func() { if stoppedAt >= 0 { return } stoppedAt = len(d.commands) if callerCanceled { cancel() } else { now = now.Add(sequenceTimeout) } } c.sequenceTiming.wait = func(_ context.Context, duration time.Duration) error { now = now.Add(duration) if (stage == "poll" && duration == sequencePollInterval) || (stage == "settle" && duration == sequenceResetWait) { stop() } return nil } d.onCommand = func(cmd cmdType) { if (stage == "initial AP" && cmd == cmdStatus) || (stage == "FC7" && cmd == cmdToEncoder) || (stage == "RS" && cmd == cmdReset) { stop() } } _, err := c.PrepareCurrentCard(ctx) wantErr := error(ErrPreparationExhausted) if callerCanceled { wantErr = context.Canceled } else if stage == "initial AP" { wantErr = context.DeadlineExceeded } if !errors.Is(err, wantErr) || len(d.commands) != stoppedAt { t.Errorf("stage=%s cancel=%t error=%v commands=%v stoppedAt=%d, want %v and no commands after stop", stage, callerCanceled, err, d.commands, stoppedAt, wantErr) } }) } } } func TestPositionFlagsRemainDiagnostic(t *testing.T) { for position := byte(0x30); position <= 0x3F; position++ { st := status(position) if got := isAtEncoderPosition(st); got != (position&0x02 != 0) { t.Errorf("encoder sensor(%X)=%t", position, got) } if got := isCardWellEmpty(st); got != (position&0x08 != 0) { t.Errorf("stock empty flag(%X)=%t", position, got) } wantStock := "" if position&0x08 != 0 { wantStock = "Card empty" } if got := stockTake(st); got != wantStock { t.Errorf("stockTake(%X)=%q, want %q", position, got, wantStock) } } } func TestPreparationCallerDeadlineIsNotRetryableExhaustion(t *testing.T) { c, d := newSequenceTestClient(t) ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) defer cancel() _, err := c.PrepareCurrentCard(ctx) if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrPreparationExhausted) || len(d.commands) != 0 { t.Errorf("expired caller: err=%v commands=%v, want caller deadline and no commands", err, d.commands) } } func TestPreparationExpiredEncoderSampleHandsOff(t *testing.T) { c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, cmdResp{status: status(0x37)}) now := c.now() c.sequenceTiming.now = func() time.Time { return now } d.onCommand = func(cmd cmdType) { if cmd == cmdStatus && commandCount(d.commands, cmdStatus) == 2 { now = now.Add(sequenceTimeout) } } _, err := c.PrepareCurrentCard(context.Background()) if err != nil { t.Errorf("late encoder sample: err=%v, want handoff", err) } if len(d.commands) != 3 { t.Errorf("late encoder sample commands=%v, want AP FC7 AP", d.commands) } }