mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
555 lines
16 KiB
Go
555 lines
16 KiB
Go
package peco
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"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) error { return nil }
|
|
func (s *recordingScreen) Close() error { return nil }
|
|
func (s *recordingScreen) Flush() error { return nil }
|
|
func (s *recordingScreen) PollEvent(context.Context, *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, _, _ 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):
|
|
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:
|
|
replyCh <- nil
|
|
}
|
|
}()
|
|
|
|
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 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):
|
|
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)
|
|
replyCh <- nil
|
|
}()
|
|
|
|
require.NoError(t, 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() {
|
|
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):
|
|
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() {
|
|
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):
|
|
t.Fatal("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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
})
|
|
}
|