package dispenser import ( "context" "errors" "reflect" "sync" "testing" "time" ) func maintenanceClient(t *testing.T, p *scriptedTransport) (*Client, *time.Time) { t.Helper() transportAddress(t) now := time.Unix(100, 0) c := &Client{port: p, done: make(chan struct{}), activityWake: make(chan struct{}, 1), sequenceTiming: sequenceTiming{now: func() time.Time { return now }, wait: waitForSequence}} t.Cleanup(c.Close) return c, &now } func TestMaintenanceIdleScheduling(t *testing.T) { c, now := maintenanceClient(t, &scriptedTransport{}) c.StartMaintenance() first := c.activity.next c.StartMaintenance() if c.activity.next != first { t.Fatal("repeated start reset the timer") } *now = now.Add(59 * time.Second) c.maintainIdleCard() if len(c.port.(*scriptedTransport).writes) != 0 { t.Fatal("maintenance ran before one minute") } release1 := c.BeginForeground() release2 := c.BeginForeground() release1() if _, enabled := c.maintenanceDelay(); enabled { t.Fatal("timer enabled while another issuance active") } *now = now.Add(20 * time.Second) release2() release2() if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute { t.Fatalf("after last release delay=%v enabled=%t", delay, enabled) } c.StopMaintenance() if _, enabled := c.maintenanceDelay(); enabled { t.Fatal("stop left timer enabled") } } func TestMaintenanceUsesCommonEligibility(t *testing.T) { for _, tc := range []struct { name string st []byte pending bool age time.Duration fc7 bool }{ {"clear", status(0x30), false, 0, true}, {"staging", status(0x34), true, 3 * time.Second, true}, {"too early", status(0x30), true, time.Second, false}, {"encoder", status(0x33), true, time.Minute, false}, {"empty", status(0x38), false, 0, false}, {"uncertain", status(0x35), false, 0, false}, {"movement", []byte{0x31, 0x30, 0x30, 0x34}, true, time.Minute, false}, {"invalid", status(0x40), true, time.Minute, false}, } { t.Run(tc.name, func(t *testing.T) { p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(tc.st), vendorACK}} c, now := maintenanceClient(t, p) c.StartMaintenance() *now = now.Add(time.Minute) c.deliveryPending = tc.pending c.deliveryStarted = now.Add(-tc.age) callbacks := 0 c.OnStockUpdate(func(string) { callbacks++ }) c.maintainIdleCard() want := []string{"AP"} if tc.fc7 { want = append(want, "FC7") } if got := wireCommands(p); !reflect.DeepEqual(got, want) { t.Errorf("commands=%v want %v", got, want) } if callbacks != 0 { t.Errorf("maintenance published %d stock callbacks", callbacks) } if tc.pending && !tc.fc7 && !c.deliveryPending { t.Error("ineligible maintenance cleared pending") } if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute { t.Errorf("next maintenance delay=%v enabled=%t", delay, enabled) } }) } } func TestMaintenanceForegroundBetweenAPAndFC7(t *testing.T) { p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}} c, now := maintenanceClient(t, p) c.StartMaintenance() *now = now.Add(time.Minute) var release func() p.afterRead = func() { if len(p.chunks) == 1 && release == nil { release = c.BeginForeground() } } c.maintainIdleCard() if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) { t.Errorf("foreground during AP commands=%v want AP only", got) } if release == nil { t.Fatal("barrier not reached") } release() if delay, _ := c.maintenanceDelay(); delay != time.Minute { t.Errorf("foreground completion delay=%v", delay) } } func TestMaintenanceAdmissionConditions(t *testing.T) { c, _ := maintenanceClient(t, &scriptedTransport{}) c.StartMaintenance() generation := c.activity.generation ctx, cancel := context.WithCancel(context.Background()) if !c.admitMaintenance(ctx, generation) { t.Fatal("idle admission rejected") } cancel() if c.admitMaintenance(ctx, generation) { t.Fatal("canceled admission accepted") } expired, stop := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) defer stop() if c.admitMaintenance(expired, generation) { t.Fatal("expired admission accepted") } release := c.BeginForeground() if c.admitMaintenance(context.Background(), generation) { t.Fatal("foreground/stale admission accepted") } release() if c.admitMaintenance(context.Background(), generation) { t.Fatal("obsolete generation accepted") } generation = c.activity.generation c.StopMaintenance() if c.admitMaintenance(context.Background(), generation) { t.Fatal("stopped admission accepted") } } func TestPassivePollGenerationAtDispatch(t *testing.T) { c, _ := maintenanceClient(t, &scriptedTransport{}) generation, _ := c.passiveGeneration() release := c.BeginForeground() for _, active := range []bool{true, false} { if !active { release() } response := make(chan cmdResp, 1) c.handle(cmdReq{ctx: context.Background(), typ: cmdStatus, passive: true, generation: generation, respCh: response}) <-response if len(c.port.(*scriptedTransport).writes) != 0 { t.Errorf("active=%t stale passive request touched port", active) } } } func TestMaintenanceStopWaitsForAdmittedRead(t *testing.T) { p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}} c, now := maintenanceClient(t, p) c.StartMaintenance() *now = now.Add(time.Minute) entered, resume := make(chan struct{}), make(chan struct{}) var once sync.Once p.afterRead = func() { once.Do(func() { close(entered); <-resume }) } finished := make(chan struct{}) go func() { c.maintainIdleCard(); close(finished) }() <-entered stopped := make(chan struct{}) go func() { c.StopMaintenance(); close(stopped) }() // Wait until stop has disabled admission. for { c.activity.Lock() enabled := c.activity.enabled c.activity.Unlock() if !enabled { break } time.Sleep(time.Millisecond) } select { case <-stopped: t.Fatal("stop returned before admitted read finished") default: } close(resume) <-finished <-stopped if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP"}) { t.Errorf("stop commands=%v want AP only", got) } c.StopMaintenance() c.Close() c.Close() } func TestMaintenanceWorkerTimerWake(t *testing.T) { c := NewClient(nil, 1) c.StartMaintenance() release := c.BeginForeground() // An immediate obsolete timer is harmless while foreground is registered. c.activity.Lock() c.activity.next = time.Now().Add(-time.Second) c.activity.Unlock() c.wakeWorker() release() if delay, enabled := c.maintenanceDelay(); !enabled || delay <= 59*time.Second { t.Errorf("worker schedule delay=%v enabled=%t", delay, enabled) } c.Close() if r := c.doResponse(context.Background(), cmdStatus); r.err == nil { t.Fatal("closed client accepted a request") } } func TestMaintenanceWorkerTimerDispatch(t *testing.T) { transportAddress(t) p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x30)), vendorACK}} c := &Client{port: p, reqCh: make(chan cmdReq, 1), done: make(chan struct{}), activityWake: make(chan struct{}, 1), sequenceTiming: sequenceTiming{now: time.Now, wait: waitForSequence}} c.StartMaintenance() c.activity.Lock() c.activity.next = time.Now().Add(-time.Second) c.activity.Unlock() workerDone := make(chan struct{}) go func() { defer close(workerDone); c.loop() }() t.Cleanup(func() { c.Close(); <-workerDone }) dispatched := make(chan struct{}) // Observe completion through the activity guard, without touching the transport concurrently. go func() { for { c.activity.Lock() next := c.activity.next c.activity.Unlock() if time.Until(next) > 50*time.Second { close(dispatched) return } select { case <-c.done: return case <-time.After(time.Millisecond): } } }() select { case <-dispatched: case <-time.After(4 * time.Second): t.Fatal("worker timer did not finish maintenance") } c.StopMaintenance() if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "FC7"}) { t.Errorf("timer commands=%v", got) } } func TestForegroundEncoderClearanceCharacterization(t *testing.T) { for _, movement := range []bool{false, true} { st := status(0x33) if movement { st[0] = 0x31 } p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(st), vendorACK, apReply(st), vendorACK, apReply(st), vendorACK}} c, now := maintenanceClient(t, p) c.deliveryPending = true c.deliveryStarted = now.Add(-time.Minute) c.sequenceTiming.wait = func(context.Context, time.Duration) error { *now = now.Add(2 * time.Second); return nil } r := workerRequest(c, context.Background(), cmdToEncoder) want := []string{"AP", "AP", "AP"} if movement { if !errors.Is(r.err, context.DeadlineExceeded) || !c.deliveryPending { t.Errorf("movement err=%v pending=%t", r.err, c.deliveryPending) } } else { want = append(want, "FC7") if r.err != nil || c.deliveryPending { t.Errorf("internal fallback err=%v pending=%t", r.err, c.deliveryPending) } } if got := wireCommands(p); !reflect.DeepEqual(got, want) { t.Errorf("movement=%t commands=%v want %v", movement, got, want) } } } func TestForegroundEncoderClearanceCallerDeadline(t *testing.T) { p := &scriptedTransport{chunks: [][]byte{vendorACK, apReply(status(0x33)), vendorACK, apReply(status(0x33)), vendorACK, apReply(status(0x33))}} c, now := maintenanceClient(t, p) c.deliveryPending = true c.deliveryStarted = *now start := *now samples := 0 p.afterRead = func() { if len(p.chunks)%2 == 0 { // Only complete frame reads advance the observation clock. if len(p.chunks) == 4 || len(p.chunks) == 2 || len(p.chunks) == 0 { samples++ *now = now.Add(time.Second) } } } ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() c.sequenceTiming.wait = func(context.Context, time.Duration) error { if samples == 3 { <-ctx.Done() return ctx.Err() } *now = now.Add(time.Second) return nil } r := workerRequest(c, ctx, cmdToEncoder) if !errors.Is(r.err, context.DeadlineExceeded) || !c.deliveryPending { t.Errorf("caller deadline err=%v pending=%t", r.err, c.deliveryPending) } if got := wireCommands(p); !reflect.DeepEqual(got, []string{"AP", "AP", "AP"}) { t.Errorf("caller deadline commands=%v want AP only", got) } if elapsed := now.Sub(start); elapsed != 5*time.Second { t.Errorf("last observation elapsed=%v want 5s", elapsed) } } func TestMaintenanceFailuresAreQuietAndRetryLater(t *testing.T) { for _, mechanical := range []bool{false, true} { p := &scriptedTransport{chunks: [][]byte{{0x10, 0x06, 0x30}}} if mechanical { p.chunks = [][]byte{vendorACK, apReply(status(0x30))} } c, now := maintenanceClient(t, p) c.StartMaintenance() *now = now.Add(time.Minute) callbacks := 0 c.OnStockUpdate(func(string) { callbacks++ }) c.maintainIdleCard() want := []string{"AP"} if mechanical { want = append(want, "FC7") } if got := wireCommands(p); !reflect.DeepEqual(got, want) { t.Errorf("mechanical=%t commands=%v want %v", mechanical, got, want) } if callbacks != 0 { t.Errorf("maintenance failure callbacks=%d", callbacks) } if delay, enabled := c.maintenanceDelay(); !enabled || delay != time.Minute { t.Errorf("retry delay=%v enabled=%t", delay, enabled) } } }