Files
groundstation/internal/display/display_test.go
T
piotr a31bed8aeb feat: independent fullscreen kiosk display foundation (#1)
Kiosk process owns its display lifecycle: waits for X11, keeps the
fullscreen black canvas below video, stops deterministically on signal.
Supervisor starts it before video, restarts only on process death, and
shuts the whole process group down; X11 downtime never restarts the
supervisor. Per-target opener/template abstractions for future MIPI.
2026-09-30 23:12:06 +02:00

262 lines
6.0 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 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)
}
}