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) } }