Files
groundstation/internal/display/display_test.go
T

343 lines
8.1 KiB
Go

package display
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
type fakeCanvas struct {
mu sync.Mutex
width, height int
clears int
texts []string
flushes int
}
func (c *fakeCanvas) Size() (int, int) { return c.width, c.height }
func (c *fakeCanvas) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.clears++
}
func (c *fakeCanvas) Text(_, _ int, message string) {
c.mu.Lock()
defer c.mu.Unlock()
c.texts = append(c.texts, message)
}
func (c *fakeCanvas) Flush() {
c.mu.Lock()
defer c.mu.Unlock()
c.flushes++
}
func (c *fakeCanvas) lineCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.texts)
}
func (c *fakeCanvas) lines() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.texts...)
}
func (c *fakeCanvas) drawCounts() (clears, flushes int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.clears, c.flushes
}
type eventResult struct {
redraw bool
err error
}
type fakeSession struct {
canvas *fakeCanvas
events chan eventResult
mu sync.Mutex
closed int
}
func newFakeSession() *fakeSession {
return &fakeSession{
canvas: &fakeCanvas{width: 800, height: 600},
events: make(chan eventResult, 8),
}
}
func (s *fakeSession) Canvas() Canvas { return s.canvas }
func (s *fakeSession) WaitEvent(ctx context.Context) (bool, error) {
select {
case <-ctx.Done():
return false, ctx.Err()
case result := <-s.events:
return result.redraw, result.err
}
}
func (s *fakeSession) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
s.closed++
return nil
}
func (s *fakeSession) closeCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.closed
}
type fakeOpener struct {
open func(attempt int) (Session, error)
}
func (o fakeOpener) Open(context.Context) (Session, error) {
return o.open(0)
}
func waitFor(t *testing.T, condition func() bool, description string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("timed out waiting for %s", description)
}
func tinyOptions() Options {
return Options{RetryDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond}
}
func TestRunDrawsTemplateWhenTargetConnects(t *testing.T) {
session := newFakeSession()
opener := fakeOpener{open: func(int) (Session, error) { return session, nil }}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
}()
waitFor(t, func() bool { return session.canvas.lineCount() > 0 }, "initial template draw")
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not stop after cancel")
}
if clears, flushes := session.canvas.drawCounts(); clears == 0 || flushes == 0 {
t.Fatalf("template must clear and flush the canvas (clears=%d flushes=%d)", clears, flushes)
}
if session.closeCount() != 1 {
t.Fatalf("session closed %d times, want 1", session.closeCount())
}
}
func TestRunKeepsRetryingWhileTargetUnavailable(t *testing.T) {
session := newFakeSession()
var opens int32
opener := fakeOpener{open: func(int) (Session, error) {
if atomic.AddInt32(&opens, 1) <= 2 {
return nil, errors.New("x11 down")
}
return session, nil
}}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
}()
waitFor(t, func() bool {
return atomic.LoadInt32(&opens) >= 3 && session.canvas.lineCount() > 0
}, "connect after retries")
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not stop after cancel")
}
}
func TestRunRedrawsTemplateOnEvent(t *testing.T) {
session := newFakeSession()
opener := fakeOpener{open: func(int) (Session, error) { return session, nil }}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
}()
waitFor(t, func() bool { return session.canvas.lineCount() == 1 }, "initial template draw")
session.events <- eventResult{redraw: true}
waitFor(t, func() bool { return session.canvas.lineCount() == 2 }, "redraw on event")
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not stop after cancel")
}
}
func TestRunReconnectsAfterSessionFailure(t *testing.T) {
first := newFakeSession()
second := newFakeSession()
var opens int32
opener := fakeOpener{open: func(int) (Session, error) {
if atomic.AddInt32(&opens, 1) == 1 {
return first, nil
}
return second, nil
}}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
}()
waitFor(t, func() bool { return first.canvas.lineCount() == 1 }, "draw on first session")
first.events <- eventResult{err: errors.New("connection lost")}
waitFor(t, func() bool { return second.canvas.lineCount() == 1 }, "draw on reconnected session")
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not stop after cancel")
}
if first.closeCount() != 1 {
t.Fatalf("failed session closed %d times, want 1", first.closeCount())
}
}
func TestRunLayersDrawsEveryLayerOnItsOwnCanvas(t *testing.T) {
canvasSession := newFakeSession()
osdSession := newFakeSession()
layers := []Layer{
{
Name: "fallback-canvas",
Opener: fakeOpener{open: func(int) (Session, error) { return canvasSession, nil }},
Template: NewStatusTemplate("canvas"),
},
{
Name: "osd-overlay",
Opener: fakeOpener{open: func(int) (Session, error) { return osdSession, nil }},
Template: NewOSDTemplate(NewVideoPresence(), stubSource{}),
},
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
RunLayers(ctx, layers)
close(done)
}()
waitFor(t, func() bool { return canvasSession.canvas.lineCount() == 1 }, "canvas layer draw")
waitFor(t, func() bool {
clears, _ := osdSession.canvas.drawCounts()
return clears == 1
}, "osd layer transparent clear")
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("run layers did not stop after cancel")
}
if got := canvasSession.closeCount(); got != 1 {
t.Fatalf("canvas session closed %d times, want 1", got)
}
if got := osdSession.closeCount(); got != 1 {
t.Fatalf("osd session closed %d times, want 1", got)
}
}
func TestRunLayersKeepsOtherLayerAliveWhenOneTargetFails(t *testing.T) {
alive := newFakeSession()
layers := []Layer{
{
Name: "dead",
Opener: fakeOpener{open: func(int) (Session, error) { return nil, errors.New("target down") }},
Template: NewStatusTemplate("never drawn"),
},
{
Name: "alive",
Opener: fakeOpener{open: func(int) (Session, error) { return alive, nil }},
Template: NewOSDTemplate(NewVideoPresence(), stubSource{}),
},
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
RunLayers(ctx, layers)
close(done)
}()
waitFor(t, func() bool {
clears, _ := alive.canvas.drawCounts()
return clears == 1
}, "alive layer keeps drawing while the other target fails")
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("run layers did not stop after cancel")
}
if got := alive.closeCount(); got != 1 {
t.Fatalf("alive session closed %d times, want 1", got)
}
}
func TestRunStopsWithoutConnectingWhenContextAlreadyCancelled(t *testing.T) {
var opens int32
opener := fakeOpener{open: func(int) (Session, error) {
atomic.AddInt32(&opens, 1)
return nil, errors.New("must not be called")
}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions()); err != nil {
t.Fatalf("run: %v", err)
}
if atomic.LoadInt32(&opens) != 0 {
t.Fatalf("opener called %d times after cancellation", opens)
}
}