363 lines
11 KiB
Go
363 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|