peco.peco/screen_test.go
Masayoshi Wada 42c39e38c8 fix: Ctrl+Space (ToggleSelection) not working after tcell migration
tcellEventToEvent did not convert Ctrl+Space to KeyCtrlSpace for
CSI u terminals or traditional terminals (tcell emits KeyCtrlSpace
which fell outside the KeyCtrlA..KeyCtrlZ range).
2026-03-01 20:42:04 +09:00

612 lines
18 KiB
Go

package peco
import (
"bytes"
"context"
"errors"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"github.com/peco/peco/config"
"github.com/peco/peco/internal/keyseq"
"github.com/stretchr/testify/require"
)
// setCellCall records a single SetCell invocation.
type setCellCall struct {
x, y int
ch rune
}
// recordingScreen is a minimal Screen implementation that records SetCell calls.
// Used by screenPrint tests to verify exactly which cells are written.
type recordingScreen struct {
cells []setCellCall
w, h int
}
func (s *recordingScreen) Init(*config.Config) error { return nil }
func (s *recordingScreen) Close() error { return nil }
func (s *recordingScreen) Flush() error { return nil }
func (s *recordingScreen) PollEvent(context.Context, *config.Config) chan Event { return nil }
func (s *recordingScreen) Print(args PrintArgs) int { return screenPrint(s, args) }
func (s *recordingScreen) Resume(context.Context) error { return nil }
func (s *recordingScreen) SetCursor(int, int) {}
func (s *recordingScreen) SendEvent(Event) {}
func (s *recordingScreen) Suspend() {}
func (s *recordingScreen) Sync() {}
func (s *recordingScreen) Size() (int, int) { return s.w, s.h }
func (s *recordingScreen) SetCell(x, y int, ch rune, _, _ config.Attribute) {
s.cells = append(s.cells, setCellCall{x: x, y: y, ch: ch})
}
func TestScreenPrintTabWidth(t *testing.T) {
tests := []struct {
name string
msg string
x int
xOffset int
wantCells int // number of SetCell calls for tab expansion
wantX []int // x coordinates of SetCell calls
}{
{
name: "tab at column 0 expands to 4 spaces",
msg: "\t",
x: 0,
xOffset: 0,
wantCells: 4,
wantX: []int{0, 1, 2, 3},
},
{
name: "tab at column 1 expands to 3 spaces (next tab stop at 4)",
msg: "\t",
x: 1,
xOffset: 0,
wantCells: 3,
wantX: []int{1, 2, 3},
},
{
name: "tab at column 2 expands to 2 spaces",
msg: "\t",
x: 2,
xOffset: 0,
wantCells: 2,
wantX: []int{2, 3},
},
{
name: "tab at column 3 expands to 1 space",
msg: "\t",
x: 3,
xOffset: 0,
wantCells: 1,
wantX: []int{3},
},
{
name: "tab at column 4 expands to 4 spaces (next tab stop at 8)",
msg: "\t",
x: 4,
xOffset: 0,
wantCells: 4,
wantX: []int{4, 5, 6, 7},
},
{
name: "xOffset affects tab stop calculation",
msg: "\t",
x: 0,
xOffset: 1,
wantCells: 3, // 4 - (0+1)%4 = 3
wantX: []int{0, 1, 2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
scr := &recordingScreen{w: 80, h: 24}
written := screenPrint(scr, PrintArgs{
X: tt.x,
XOffset: tt.xOffset,
Y: 0,
Msg: tt.msg,
})
require.Equal(t, tt.wantCells, len(scr.cells), "number of SetCell calls")
require.Equal(t, tt.wantCells, written, "written count should match cells drawn")
gotX := make([]int, len(scr.cells))
for i, c := range scr.cells {
gotX[i] = c.x
require.Equal(t, ' ', c.ch, "tab should expand to spaces")
}
require.Equal(t, tt.wantX, gotX, "x coordinates of drawn cells")
})
}
}
func TestScreenPrintTabThenChar(t *testing.T) {
// Verifies that after a tab, the next character is placed at the correct position.
// With tab at column 0 expanding to 4 spaces, the next char should be at column 4.
scr := &recordingScreen{w: 80, h: 24}
written := screenPrint(scr, PrintArgs{
X: 0,
Y: 0,
Msg: "\tA",
})
// Tab (4 spaces) + 'A' (1 cell) = 5 cells drawn, 5 written
require.Equal(t, 5, len(scr.cells))
require.Equal(t, 5, written)
// The 'A' should be at x=4 (right after the 4-space tab)
lastCell := scr.cells[len(scr.cells)-1]
require.Equal(t, 'A', lastCell.ch)
require.Equal(t, 4, lastCell.x, "character after tab should be at x=4")
}
// 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 := t.Context()
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):
require.Fail(t, "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):
require.Fail(t, "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:
replyCh <- nil
}
}()
tb.Close()
select {
case <-exited:
// Goroutine exited via doneCh.
case <-time.After(2 * time.Second):
require.Fail(t, "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):
require.Fail(t, "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 send nil error (as PollEvent does after successful re-init).
go func() {
time.Sleep(50 * time.Millisecond)
replyCh := <-tb.resumeCh
replyCh <- nil
}()
done := make(chan struct{})
go func() {
require.NoError(t, tb.Resume(ctx))
close(done)
}()
select {
case <-done:
// Resume completed without deadlock.
case <-time.After(2 * time.Second):
require.Fail(t, "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)
replyCh <- nil
}()
require.NoError(t, tb.Resume(ctx))
select {
case <-received:
// The receiver goroutine got the message.
default:
require.Fail(t, "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() {
err := tb.Resume(ctx)
require.Error(t, err)
close(done)
}()
select {
case <-done:
// Resume returned promptly after context cancellation.
case <-time.After(2 * time.Second):
require.Fail(t, "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() {
err := tb.Resume(ctx)
require.Error(t, err)
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):
require.Fail(t, "Resume() did not unblock after context cancellation while waiting for reply")
}
// Verify context was indeed cancelled.
require.Error(t, ctx.Err())
}
func TestTcellScreenResumeInitError(t *testing.T) {
tb := NewTcellScreen()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
initErr := errors.New("simulated screen init failure")
// Simulate the polling goroutine: receive from resumeCh and send
// an error as if Init() failed.
go func() {
replyCh := <-tb.resumeCh
replyCh <- initErr
}()
err := tb.Resume(ctx)
require.Error(t, err)
require.Equal(t, initErr, err)
}
// TestTcellEventToEventCtrlC verifies that Ctrl-C is correctly converted
// to peco's Cancel key binding regardless of how the terminal reports it.
//
// tcell v2 has two families of ctrl key constants:
// - Raw ASCII control codes: KeyETX(3), KeyBS(8), etc.
// - High-level ctrl keys: KeyCtrlC(67), KeyCtrlH(72), etc.
//
// Traditional terminals produce raw bytes (0x03 for Ctrl-C) which tcell
// normalizes to Key=3 with ModCtrl. Enhanced terminals (CSI u / fixterms)
// produce KeyCtrlC(67) with ModCtrl. Peco's keyseq system encodes ctrl
// in the key value (KeyCtrlC=0x03) with Modifier=0. Both paths must
// produce the same peco Event. (issue #715)
func TestTcellEventToEventCtrlC(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tcellEv *tcell.EventKey
wantKey keyseq.KeyType
wantCh rune
wantMod keyseq.ModifierKey
}{
{
// Traditional terminal: raw byte 0x03 → tcell normalizes
// NewEventKey(KeyRune, rune(3), ModNone) to Key=3, Mod=ModCtrl
name: "traditional terminal: raw Ctrl-C byte",
tcellEv: tcell.NewEventKey(tcell.KeyRune, rune(3), tcell.ModNone),
wantKey: keyseq.KeyCtrlC,
wantCh: 0,
wantMod: keyseq.ModNone,
},
{
// CSI u terminal: \x1b[99;5u → tcell normalizes
// NewEventKey(KeyRune, 'c', ModCtrl) to Key=67(KeyCtrlC), Mod=ModCtrl
name: "CSI u terminal: Ctrl-C with modifier",
tcellEv: tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModCtrl),
wantKey: keyseq.KeyCtrlC,
wantCh: 0,
wantMod: keyseq.ModNone,
},
{
// Direct KeyETX without modifier (edge case)
name: "direct KeyETX without ModCtrl",
tcellEv: tcell.NewEventKey(tcell.KeyETX, 0, tcell.ModNone),
wantKey: keyseq.KeyCtrlC,
wantCh: 0,
wantMod: keyseq.ModNone,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tcellEventToEvent(tt.tcellEv)
require.Equal(t, EventKey, got.Type, "event type")
require.Equal(t, tt.wantKey, got.Key, "key")
require.Equal(t, tt.wantCh, got.Ch, "ch")
require.Equal(t, tt.wantMod, got.Mod, "modifier")
})
}
}
// TestTcellEventToEventCtrlKeysStripModCtrl verifies that all Ctrl+letter
// keys have ModCtrl stripped, since the control nature is encoded in the
// key value. Tests both the raw control code path (traditional terminals)
// and the KeyCtrl* path (CSI u terminals). Regression test for issue #715.
func TestTcellEventToEventCtrlKeysStripModCtrl(t *testing.T) {
t.Parallel()
for i := range 26 {
letter := rune('a' + i)
expectedKey := keyseq.KeyType(i + 1) // 0x01 (KeyCtrlA) through 0x1A (KeyCtrlZ)
// Raw control code path: tcell normalizes raw byte to Key=i+1, Mod=ModCtrl
// (except Backspace=0x08, Tab=0x09, Enter=0x0D, Escape=0x1B which go
// through the lookup table instead)
t.Run("raw/Ctrl-"+string(letter), func(t *testing.T) {
t.Parallel()
ev := tcell.NewEventKey(tcell.KeyRune, rune(i+1), tcell.ModNone)
got := tcellEventToEvent(ev)
require.Equal(t, EventKey, got.Type)
require.Equal(t, expectedKey, got.Key)
require.Equal(t, rune(0), got.Ch)
require.Equal(t, keyseq.ModNone, got.Mod,
"ModCtrl should be stripped for raw Ctrl+%c", letter)
})
// CSI u path: tcell normalizes KeyRune+'letter'+ModCtrl to KeyCtrl*(65+i)
t.Run("csi-u/Ctrl-"+string(letter), func(t *testing.T) {
t.Parallel()
ev := tcell.NewEventKey(tcell.KeyRune, letter, tcell.ModCtrl)
got := tcellEventToEvent(ev)
require.Equal(t, EventKey, got.Type)
require.Equal(t, expectedKey, got.Key)
require.Equal(t, rune(0), got.Ch)
require.Equal(t, keyseq.ModNone, got.Mod,
"ModCtrl should be stripped for CSI u Ctrl+%c", letter)
})
}
}
// TestTcellEventToEventCtrlSpace verifies that Ctrl+Space is correctly
// converted to KeyCtrlSpace (0x00) regardless of how the terminal reports it.
//
// Traditional terminals send NUL (0x00); tcell's input handler
// delivers this as KeyCtrlSpace(64) with ModCtrl.
// The KeyCtrlSpace..KeyCtrlZ path handles this.
//
// CSI u / enhanced terminals report Ctrl+Space as KeyRune with rune=' '
// and ModCtrl. This must also produce Key=KeyCtrlSpace, Mod=ModNone
// so that the ToggleSelectionAndSelectNext action fires correctly.
func TestTcellEventToEventCtrlSpace(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tcellEv *tcell.EventKey
wantKey keyseq.KeyType
wantCh rune
wantMod keyseq.ModifierKey
}{
{
// Traditional terminal: NUL byte (0x00).
// tcell's input handler calls
// NewEventKey(KeyCtrlSpace+Key(r), 0, ModCtrl)
// which yields key=KeyCtrlSpace(64), mod=ModCtrl.
// The KeyCtrlSpace..KeyCtrlZ path strips ModCtrl and
// maps to keyseq.KeyCtrlSpace(0x00).
name: "traditional terminal: NUL byte via KeyCtrlSpace+ModCtrl",
tcellEv: tcell.NewEventKey(tcell.KeyCtrlSpace, 0, tcell.ModCtrl),
wantKey: keyseq.KeyCtrlSpace,
wantCh: 0,
wantMod: keyseq.ModNone,
},
{
// CSI u / enhanced terminal: Ctrl+Space reported as
// KeyRune with rune=' ' and ModCtrl.
name: "CSI u terminal: Ctrl+Space as rune with ModCtrl",
tcellEv: tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModCtrl),
wantKey: keyseq.KeyCtrlSpace,
wantCh: 0,
wantMod: keyseq.ModNone,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := tcellEventToEvent(tt.tcellEv)
require.Equal(t, EventKey, got.Type, "event type")
require.Equal(t, tt.wantKey, got.Key, "key")
require.Equal(t, tt.wantCh, got.Ch, "ch")
require.Equal(t, tt.wantMod, got.Mod, "modifier")
})
}
}
// TestTcellEventToEventCtrlWithAltPreservesAlt verifies that Ctrl+Alt
// combinations strip ModCtrl but preserve ModAlt.
func TestTcellEventToEventCtrlWithAltPreservesAlt(t *testing.T) {
t.Parallel()
// Ctrl+Alt+C via CSI u path (most common for enhanced terminals)
t.Run("Ctrl-Alt-C", func(t *testing.T) {
t.Parallel()
ev := tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModCtrl|tcell.ModAlt)
got := tcellEventToEvent(ev)
require.Equal(t, EventKey, got.Type)
require.Equal(t, keyseq.KeyCtrlC, got.Key)
require.Equal(t, keyseq.ModAlt, got.Mod, "ModAlt should be preserved, ModCtrl stripped")
})
}