hardlink/internal/dispenser/observation_test.go

243 lines
7.4 KiB
Go

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