package dispenser import ( "context" "errors" "fmt" "io" "testing" "time" ) func TestStrictAPFailuresBecomeUnusablePreparationObservations(t *testing.T) { transportAddress(t) cases := []struct { name string chunks [][]byte readErr error }{ {"invalid ACK 10 06 30", [][]byte{{0x10, 0x06, 0x30}}, nil}, {"truncated", [][]byte{vendorACK, vendorAP[:8]}, nil}, {"no response", nil, nil}, {"timeout", nil, context.DeadlineExceeded}, {"read failure", nil, io.ErrClosedPipe}, } for _, offset := range []int{0, 1, 2, 3, 4, 5, 6, 11, 12} { frame := append([]byte(nil), vendorAP...) frame[offset] ^= 0xff if offset == 5 || offset == 6 || offset == 11 { frame[12] = calculateBCC(frame[:12]) } cases = append(cases, struct { name string chunks [][]byte readErr error }{fmt.Sprintf("frame byte %d", offset), [][]byte{vendorACK, frame}, nil}) } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { p := &scriptedTransport{chunks: tc.chunks, readErr: tc.readErr} st, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0) if err == nil { t.Fatal("strict queryStatus accepted invalid transaction") } c, d := newSequenceTestClient(t, cmdResp{status: st, err: err}, cmdResp{status: status(0x37)}) if _, err := c.PrepareCurrentCard(context.Background()); err != nil { t.Errorf("preparation after %s = %v, want opportunity", tc.name, err) } if commandCount(d.commands, cmdToEncoder) != 1 || commandCount(d.commands, cmdReset) != 0 { t.Errorf("commands=%v, want one FC7 and no RS", d.commands) } }) } } func TestUnusableObservationsReclassifyAndShareUncertainty(t *testing.T) { for _, later := range []byte{0x38, 0x30, 0x37, 0x35, 0x40} { t.Run(fmt.Sprintf("later %X", later), func(t *testing.T) { responses := []cmdResp{{err: io.ErrUnexpectedEOF}, {status: status(0x40)}} responses = append(responses, positionResponses(later, 25)...) c, d := newSequenceTestClient(t, responses...) _, err := c.PrepareCurrentCard(context.Background()) var want error if later == 0x38 { want = ErrCardWellEmpty } if later == 0x30 { want = ErrPreparationExhausted } if !errors.Is(err, want) { t.Errorf("later %X error=%v, want %v", later, err, want) } resets := 0 if later == 0x30 { resets = 3 } if commandCount(d.commands, cmdReset) != resets { t.Errorf("later %X commands=%v, want %d RS", later, d.commands, resets) } }) } c, d := newSequenceTestClient(t, cmdResp{err: io.EOF}, cmdResp{err: io.EOF}, cmdResp{status: status(0x35)}, cmdResp{status: status(0x40)}, cmdResp{err: io.EOF}, cmdResp{status: status(0x39)}) start := c.now() if _, err := c.PrepareCurrentCard(context.Background()); err != nil { t.Fatal(err) } if elapsed := c.now().Sub(start); elapsed != sequenceUncertainWait { t.Errorf("mixed uncertainty elapsed=%v, want %v", elapsed, sequenceUncertainWait) } if commandCount(d.commands, cmdToEncoder) != 1 { t.Errorf("mixed uncertainty commands=%v, want one FC7", d.commands) } } func TestObservationAtPreparationDeadline(t *testing.T) { for _, last := range []cmdResp{{err: io.EOF}, {status: status(0x40)}, {status: status(0x35)}, {status: status(0x30)}, {status: status(0x38)}} { c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, last) 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()) var want error if len(last.status) == 4 && last.status[3] == 0x30 { want = ErrPreparationExhausted } if len(last.status) == 4 && last.status[3] == 0x38 { want = ErrCardWellEmpty } if !errors.Is(err, want) { t.Errorf("deadline observation=%v error=%v, want %v", last, err, want) } } } func TestWorkerClearanceFallbackAndMovement(t *testing.T) { for _, mode := range []string{"unusable", "ambiguous", "movement", "movement then unusable", "empty", "cancel"} { t.Run(mode, func(t *testing.T) { p := &scriptedTransport{} for i := 0; i < 6; i++ { switch mode { case "unusable", "cancel": p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30}) case "ambiguous": p.chunks = append(p.chunks, vendorACK, apReply(status(0x37))) case "empty": p.chunks = append(p.chunks, vendorACK, apReply(status(0x38))) default: if mode == "movement then unusable" && i > 0 { p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30}) } else { p.chunks = append(p.chunks, vendorACK, apReply([]byte{0x31, 0x30, 0x30, 0x34})) } } } c, now := deliveryWorkerClient(t, p) c.sequenceTiming.wait = func(ctx context.Context, d time.Duration) error { if ctx.Err() != nil { return ctx.Err() } *now = now.Add(2 * d) return nil } c.deliveryPending = true c.deliveryStarted = c.now() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if mode == "cancel" { p.afterRead = cancel } r := c.doResponse(ctx, cmdDeliveryClearance) if mode == "cancel" { if !errors.Is(r.err, context.Canceled) { t.Errorf("cancel=%v", r.err) } return } var want error if mode == "movement" { want = context.DeadlineExceeded } if mode == "empty" { want = ErrCardWellEmpty } if !errors.Is(r.err, want) { t.Errorf("clearance %s error=%v want %v", mode, r.err, want) } if c.deliveryPending != (want != nil) { t.Errorf("clearance %s pending=%t", mode, c.deliveryPending) } if mode != "empty" && now.Sub(time.Unix(0, 0)) != deliveryClearanceTimeout { t.Errorf("clearance %s wait=%v want 6s", mode, now.Sub(time.Unix(0, 0))) } for _, command := range wireCommands(p) { if command != "AP" { t.Errorf("clearance issued %s, want AP only", command) } } }) } } func TestWorkerAssumedClearanceAllowsOneFC7(t *testing.T) { for _, prepare := range []bool{false, true} { p := &scriptedTransport{} dispatched := false p.afterWrite = func() { w := p.writes[len(p.writes)-1] if len(w) <= 6 || w[0] != STX { return } switch string(w[5 : len(w)-2]) { case "AP": if dispatched { p.chunks = append(p.chunks, vendorACK, apReply(status(0x35))) } else { p.chunks = append(p.chunks, []byte{0x10, 0x06, 0x30}) } case "FC7": dispatched = true p.chunks = append(p.chunks, vendorACK) } } c, now := deliveryWorkerClient(t, p) c.deliveryPending = true c.deliveryStarted = c.now() var err error if prepare { _, err = c.PrepareCurrentCard(context.Background()) } else { err = c.BeginPrepareNextCard(context.Background()) } if err != nil { t.Fatalf("prepare=%t error=%v", prepare, err) } wantElapsed := deliveryClearanceTimeout if prepare { wantElapsed += sequenceUncertainWait } if elapsed := now.Sub(time.Unix(0, 0)); elapsed != wantElapsed { t.Errorf("prepare=%t elapsed=%v want %v", prepare, elapsed, wantElapsed) } commands := wireCommands(p) fc7 := 0 for _, cmd := range commands { if cmd == "FC7" { fc7++ } if cmd == "RS" { t.Errorf("unexpected RS: %v", commands) } } if fc7 != 1 || c.deliveryPending { t.Errorf("prepare=%t commands=%v pending=%t, want one FC7 and cleared", prepare, commands, c.deliveryPending) } if !prepare && commands[len(commands)-1] != "FC7" { t.Errorf("prestaging polled after FC7: %v", commands) } } }