343 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|