peco.peco/screen_test.go
2026-02-17 07:38:56 +09:00

285 lines
6.9 KiB
Go

package peco
import (
"bytes"
"context"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"github.com/stretchr/testify/require"
)
// panickingScreen wraps a tcell.Screen and panics on PollEvent.
// Used to test that TcellScreen's PollEvent goroutine logs panics
// instead of silently swallowing them.
type panickingScreen struct {
tcell.Screen
}
func (s *panickingScreen) PollEvent() tcell.Event {
panic("test: deliberate panic in PollEvent")
}
// TestTcellScreenPollEventLogsPanic verifies that when a panic occurs
// in the PollEvent goroutine, it is logged to errWriter rather than
// being silently swallowed (the bug described in CODE_REVIEW.md §3.2).
func TestTcellScreenPollEventLogsPanic(t *testing.T) {
var buf bytes.Buffer
ts := NewTcellScreen()
ts.errWriter = &buf
// Set the screen to a wrapper that panics on PollEvent.
sim := tcell.NewSimulationScreen("")
sim.Init()
ts.screen = &panickingScreen{Screen: sim}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
evCh := ts.PollEvent(ctx, nil)
// The goroutine should panic, log it, and close the channel.
select {
case _, ok := <-evCh:
require.False(t, ok, "expected channel to be closed after panic")
case <-time.After(2 * time.Second):
t.Fatal("PollEvent channel was not closed after panic")
}
// Verify that the panic was logged (not silently swallowed).
output := buf.String()
require.Contains(t, output, "peco: panic in PollEvent goroutine")
require.Contains(t, output, "test: deliberate panic in PollEvent")
ts.Close()
}
// TestTcellScreenSuspendHandlerExitsOnClose verifies that the suspend handler
// goroutine (started by PollEvent) exits when Close() is called, even if
// the context has not been cancelled. This is the goroutine leak described
// in CODE_REVIEW.md §7.1.
func TestTcellScreenSuspendHandlerExitsOnClose(t *testing.T) {
tb := NewTcellScreen()
// Use a context that will NOT be cancelled during this test.
// The goroutine must exit via doneCh, not ctx.Done().
ctx := context.Background()
// Start a goroutine that mimics the suspend handler's select loop.
exited := make(chan struct{})
go func() {
defer close(exited)
for {
select {
case <-ctx.Done():
return
case <-tb.doneCh:
return
case <-tb.suspendCh:
tb.finiScreen()
}
}
}()
// Permanently close the screen.
tb.Close()
select {
case <-exited:
// Goroutine exited via doneCh — no leak.
case <-time.After(2 * time.Second):
t.Fatal("suspend handler goroutine did not exit after Close()")
}
}
// TestTcellScreenPollingGoroutineExitsOnClose verifies that a goroutine blocked
// on resumeCh (as the polling goroutine would be after screen finalization)
// exits when Close() is called.
func TestTcellScreenPollingGoroutineExitsOnClose(t *testing.T) {
tb := NewTcellScreen()
ctx := context.Background()
exited := make(chan struct{})
go func() {
defer close(exited)
// Simulate the polling goroutine waiting for resume after screen==nil.
select {
case <-ctx.Done():
return
case <-tb.doneCh:
return
case replyCh := <-tb.resumeCh:
close(replyCh)
}
}()
tb.Close()
select {
case <-exited:
// Goroutine exited via doneCh.
case <-time.After(2 * time.Second):
t.Fatal("polling goroutine did not exit after Close()")
}
}
// TestTcellScreenCloseIdempotent verifies that Close() can be called multiple
// times without panicking (important because the suspend handler calls
// finiScreen and then Close() is called at shutdown).
func TestTcellScreenCloseIdempotent(t *testing.T) {
tb := NewTcellScreen()
require.NotPanics(t, func() {
tb.Close()
tb.Close()
tb.Close()
})
}
// TestTcellScreenSuspendThenClose verifies that a suspend (which calls finiScreen)
// followed by a permanent Close() works correctly — the doneCh should be
// closed by Close() even though finiScreen was already called.
func TestTcellScreenSuspendThenClose(t *testing.T) {
tb := NewTcellScreen()
ctx := context.Background()
exited := make(chan struct{})
go func() {
defer close(exited)
for {
select {
case <-ctx.Done():
return
case <-tb.doneCh:
return
case <-tb.suspendCh:
tb.finiScreen()
}
}
}()
// Send a suspend signal, which calls finiScreen (not Close).
tb.Suspend()
// Give the goroutine time to process the suspend.
time.Sleep(50 * time.Millisecond)
// Now permanently close.
tb.Close()
select {
case <-exited:
// Goroutine exited after Close() following a suspend.
case <-time.After(2 * time.Second):
t.Fatal("suspend handler goroutine did not exit after suspend + Close()")
}
}
func TestTcellScreenResumeNoDeadlock(t *testing.T) {
tb := NewTcellScreen()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Simulate the polling goroutine: receive from resumeCh after a short delay,
// then close the reply channel (as PollEvent does after re-init).
go func() {
time.Sleep(50 * time.Millisecond)
replyCh := <-tb.resumeCh
close(replyCh)
}()
done := make(chan struct{})
go func() {
tb.Resume(ctx)
close(done)
}()
select {
case <-done:
// Resume completed without deadlock.
case <-time.After(2 * time.Second):
t.Fatal("Resume() deadlocked")
}
}
func TestTcellScreenResumeDoesNotDropSend(t *testing.T) {
tb := NewTcellScreen()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
received := make(chan struct{})
go func() {
replyCh := <-tb.resumeCh
close(received)
close(replyCh)
}()
tb.Resume(ctx)
select {
case <-received:
// The receiver goroutine got the message.
default:
t.Fatal("receiver did not get the resume message")
}
}
func TestTcellScreenResumeContextCancelled(t *testing.T) {
tb := NewTcellScreen()
ctx, cancel := context.WithCancel(context.Background())
// Cancel immediately so Resume cannot deliver on resumeCh.
cancel()
done := make(chan struct{})
go func() {
tb.Resume(ctx)
close(done)
}()
select {
case <-done:
// Resume returned promptly after context cancellation.
case <-time.After(2 * time.Second):
t.Fatal("Resume() did not unblock after context cancellation")
}
}
func TestTcellScreenResumeContextCancelledWhileWaitingForReply(t *testing.T) {
tb := NewTcellScreen()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Accept the resume request but never close the reply channel.
// This tests that the second select also respects ctx.Done().
go func() {
<-tb.resumeCh // receive but don't close replyCh
}()
done := make(chan struct{})
go func() {
tb.Resume(ctx)
close(done)
}()
// Give Resume time to pass the first select and block on the second.
time.Sleep(50 * time.Millisecond)
cancel()
select {
case <-done:
// Resume returned after context cancellation during reply wait.
case <-time.After(2 * time.Second):
t.Fatal("Resume() did not unblock after context cancellation while waiting for reply")
}
// Verify context was indeed cancelled.
require.Error(t, ctx.Err())
}