mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
1117 lines
30 KiB
Go
1117 lines
30 KiB
Go
package peco
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"context"
|
|
|
|
"github.com/peco/peco/filter"
|
|
"github.com/peco/peco/hub"
|
|
"github.com/peco/peco/internal/keyseq"
|
|
"github.com/peco/peco/line"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// recordingHub wraps nullHub but records SendPaging, SendStatusMsg, and SendDraw calls.
|
|
type recordingHub struct {
|
|
nullHub
|
|
mu sync.Mutex
|
|
pagingArgs []hub.PagingRequest
|
|
statusMsgs []string
|
|
drawArgs []*hub.DrawOptions
|
|
}
|
|
|
|
func (h *recordingHub) SendPaging(_ context.Context, v hub.PagingRequest) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.pagingArgs = append(h.pagingArgs, v)
|
|
}
|
|
|
|
func (h *recordingHub) SendStatusMsg(_ context.Context, msg string, _ time.Duration) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.statusMsgs = append(h.statusMsgs, msg)
|
|
}
|
|
|
|
func (h *recordingHub) SendDraw(_ context.Context, v *hub.DrawOptions) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.drawArgs = append(h.drawArgs, v)
|
|
}
|
|
|
|
func (h *recordingHub) getDrawArgs() []*hub.DrawOptions {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
dst := make([]*hub.DrawOptions, len(h.drawArgs))
|
|
copy(dst, h.drawArgs)
|
|
return dst
|
|
}
|
|
|
|
func (h *recordingHub) getPagingArgs() []hub.PagingRequest {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
dst := make([]hub.PagingRequest, len(h.pagingArgs))
|
|
copy(dst, h.pagingArgs)
|
|
return dst
|
|
}
|
|
|
|
func (h *recordingHub) getStatusMsgs() []string {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
dst := make([]string, len(h.statusMsgs))
|
|
copy(dst, h.statusMsgs)
|
|
return dst
|
|
}
|
|
|
|
func (h *recordingHub) reset() {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.pagingArgs = nil
|
|
h.statusMsgs = nil
|
|
h.drawArgs = nil
|
|
}
|
|
|
|
func TestActionFunc(t *testing.T) {
|
|
called := 0
|
|
af := ActionFunc(func(_ context.Context, _ *Peco, _ Event) {
|
|
called++
|
|
})
|
|
af.Execute(context.TODO(), nil, Event{})
|
|
if !assert.Equal(t, called, 1, "Expected ActionFunc to be called once, but it got called %d times", called) {
|
|
return
|
|
}
|
|
}
|
|
|
|
func TestPagingActions(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
name string
|
|
action string
|
|
expected hub.PagingRequest
|
|
}{
|
|
{"SelectUp", "peco.SelectUp", hub.ToLineAbove},
|
|
{"SelectDown", "peco.SelectDown", hub.ToLineBelow},
|
|
{"ScrollPageUp", "peco.ScrollPageUp", hub.ToScrollPageUp},
|
|
{"ScrollPageDown", "peco.ScrollPageDown", hub.ToScrollPageDown},
|
|
{"ScrollLeft", "peco.ScrollLeft", hub.ToScrollLeft},
|
|
{"ScrollRight", "peco.ScrollRight", hub.ToScrollRight},
|
|
{"ScrollFirstItem", "peco.ScrollFirstItem", hub.ToScrollFirstItem},
|
|
{"ScrollLastItem", "peco.ScrollLastItem", hub.ToScrollLastItem},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
rHub := &recordingHub{}
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
state.currentLineBuffer = NewMemoryBuffer(0)
|
|
|
|
action, ok := nameToActions[tt.action]
|
|
require.True(t, ok, "action %s should exist", tt.action)
|
|
|
|
action.Execute(ctx, state, Event{})
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
require.Len(t, pagingArgs, 1, "expected exactly one SendPaging call")
|
|
require.Equal(t, tt.expected, pagingArgs[0],
|
|
"expected paging request %v, got %v", tt.expected, pagingArgs[0])
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActionNames(t *testing.T) {
|
|
// These names MUST exist
|
|
names := []string{
|
|
"peco.ForwardChar",
|
|
"peco.BackwardChar",
|
|
"peco.ForwardWord",
|
|
"peco.BackwardWord",
|
|
"peco.BeginningOfLine",
|
|
"peco.EndOfLine",
|
|
"peco.EndOfFile",
|
|
"peco.DeleteForwardChar",
|
|
"peco.DeleteBackwardChar",
|
|
"peco.DeleteForwardWord",
|
|
"peco.DeleteBackwardWord",
|
|
"peco.KillEndOfLine",
|
|
"peco.DeleteAll",
|
|
"peco.SelectPreviousPage",
|
|
"peco.SelectNextPage",
|
|
"peco.SelectPrevious",
|
|
"peco.SelectNext",
|
|
"peco.ToggleSelection",
|
|
"peco.ToggleSelectionAndSelectNext",
|
|
"peco.RotateMatcher",
|
|
"peco.Finish",
|
|
"peco.Cancel",
|
|
"peco.FreezeResults",
|
|
"peco.UnfreezeResults",
|
|
"peco.ZoomIn",
|
|
"peco.ZoomOut",
|
|
}
|
|
for _, name := range names {
|
|
if _, ok := nameToActions[name]; !ok {
|
|
t.Errorf("Action %s should exist, but it does not", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func expectCaretPos(t *testing.T, c *Caret, expect int) bool {
|
|
return assert.Equal(t, expect, c.Pos(), "Expected caret position %d, got %d", expect, c.Pos())
|
|
}
|
|
|
|
func expectQueryString(t *testing.T, q *Query, expect string) bool {
|
|
return assert.Equal(t, expect, q.String(), "Expected '%s', got '%s'", expect, q.String())
|
|
}
|
|
|
|
func TestDoDeleteForwardChar(t *testing.T) {
|
|
state := newPeco()
|
|
q := state.Query()
|
|
c := state.Caret()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go state.Run(ctx)
|
|
defer cancel()
|
|
|
|
<-state.Ready()
|
|
|
|
q.Set("Hello, World!")
|
|
c.SetPos(5)
|
|
|
|
doDeleteForwardChar(ctx, state, Event{})
|
|
|
|
if !expectQueryString(t, q, "Hello World!") {
|
|
return
|
|
}
|
|
if !expectCaretPos(t, c, 5) {
|
|
return
|
|
}
|
|
|
|
c.SetPos(q.Len())
|
|
doDeleteForwardChar(ctx, state, Event{})
|
|
|
|
expectQueryString(t, q, "Hello World!")
|
|
expectCaretPos(t, c, q.Len())
|
|
|
|
c.SetPos(0)
|
|
doDeleteForwardChar(ctx, state, Event{})
|
|
|
|
expectQueryString(t, q, "ello World!")
|
|
expectCaretPos(t, c, 0)
|
|
}
|
|
|
|
func TestDoDeleteForwardWord(t *testing.T) {
|
|
state := newPeco()
|
|
q := state.Query()
|
|
c := state.Caret()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go state.Run(ctx)
|
|
defer cancel()
|
|
|
|
<-state.Ready()
|
|
|
|
q.Set("Hello, World!")
|
|
c.SetPos(5)
|
|
|
|
// delete the comma
|
|
doDeleteForwardWord(ctx, state, Event{})
|
|
if !expectQueryString(t, q, "Hello World!") {
|
|
return
|
|
}
|
|
|
|
if !expectCaretPos(t, c, 5) {
|
|
return
|
|
}
|
|
|
|
// at the end of the query, should not delete anything
|
|
c.SetPos(q.Len())
|
|
doDeleteForwardWord(ctx, state, Event{})
|
|
|
|
if !expectQueryString(t, q, "Hello World!") {
|
|
return
|
|
}
|
|
if !expectCaretPos(t, c, q.Len()) {
|
|
return
|
|
}
|
|
|
|
// back to the first column, should delete 'Hello'
|
|
c.SetPos(0)
|
|
doDeleteForwardWord(ctx, state, Event{})
|
|
|
|
if !expectQueryString(t, q, " World!") {
|
|
return
|
|
}
|
|
|
|
if !expectCaretPos(t, c, 0) {
|
|
return
|
|
}
|
|
|
|
// should delete "World"
|
|
c.SetPos(1)
|
|
doDeleteForwardWord(ctx, state, Event{})
|
|
|
|
if !expectQueryString(t, q, " ") {
|
|
return
|
|
}
|
|
}
|
|
|
|
func TestDoDeleteBackwardChar(t *testing.T) {
|
|
state := newPeco()
|
|
q := state.Query()
|
|
c := state.Caret()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go state.Run(ctx)
|
|
defer cancel()
|
|
|
|
<-state.Ready()
|
|
|
|
q.Set("Hello, World!")
|
|
c.SetPos(5)
|
|
|
|
doDeleteBackwardChar(ctx, state, Event{})
|
|
|
|
expectQueryString(t, q, "Hell, World!")
|
|
expectCaretPos(t, c, 4)
|
|
|
|
c.SetPos(q.Len())
|
|
doDeleteBackwardChar(ctx, state, Event{})
|
|
|
|
expectQueryString(t, q, "Hell, World")
|
|
expectCaretPos(t, c, q.Len())
|
|
|
|
c.SetPos(0)
|
|
doDeleteBackwardChar(ctx, state, Event{})
|
|
|
|
expectQueryString(t, q, "Hell, World")
|
|
expectCaretPos(t, c, 0)
|
|
}
|
|
|
|
func TestDoDeleteBackwardWord(t *testing.T) {
|
|
state := newPeco()
|
|
q := state.Query()
|
|
c := state.Caret()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go state.Run(ctx)
|
|
defer cancel()
|
|
|
|
<-state.Ready()
|
|
|
|
// In case of an overflow (bug)
|
|
q.Set("foo")
|
|
c.SetPos(5)
|
|
doDeleteBackwardWord(ctx, state, Event{})
|
|
|
|
// https://github.com/peco/peco/pull/184#issuecomment-54026739
|
|
|
|
// Case 1. " foo<caret>" -> " "
|
|
q.Set(" foo")
|
|
c.SetPos(4)
|
|
doDeleteBackwardWord(ctx, state, Event{})
|
|
|
|
if !expectQueryString(t, q, " ") {
|
|
return
|
|
}
|
|
|
|
if !expectCaretPos(t, c, 1) {
|
|
return
|
|
}
|
|
|
|
// Case 2. "foo bar<caret>" -> "foo "
|
|
q.Set("foo bar")
|
|
c.SetPos(7)
|
|
doDeleteBackwardWord(ctx, state, Event{})
|
|
|
|
if !expectQueryString(t, q, "foo ") {
|
|
return
|
|
}
|
|
|
|
if !expectCaretPos(t, c, 4) {
|
|
return
|
|
}
|
|
}
|
|
|
|
func writeQueryToPrompt(t *testing.T, screen Screen, message string) {
|
|
for str := message; true; {
|
|
r, size := utf8.DecodeRuneInString(str)
|
|
if r == utf8.RuneError {
|
|
assert.Equal(t, 0, size, "when in error, we should have size == 0")
|
|
return
|
|
}
|
|
|
|
if r == ' ' {
|
|
screen.SendEvent(Event{Type: EventKey, Key: keyseq.KeySpace})
|
|
} else {
|
|
screen.SendEvent(Event{Type: EventKey, Ch: r})
|
|
}
|
|
str = str[size:]
|
|
}
|
|
}
|
|
|
|
func TestDoAcceptChar(t *testing.T) {
|
|
state := newPeco()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go state.Run(ctx)
|
|
defer cancel()
|
|
|
|
<-state.Ready()
|
|
|
|
message := "Hello, World!"
|
|
writeQueryToPrompt(t, state.screen, message)
|
|
require.Eventually(t, func() bool {
|
|
return state.Query().String() == message
|
|
}, 5*time.Second, 10*time.Millisecond, "Expected query to be populated as '%s'", message)
|
|
|
|
state.Caret().Move(-1 * len("World!"))
|
|
writeQueryToPrompt(t, state.screen, "Cruel ")
|
|
|
|
expected := "Hello, Cruel World!"
|
|
require.Eventually(t, func() bool {
|
|
return state.Query().String() == expected
|
|
}, 5*time.Second, 10*time.Millisecond, "Expected query to be populated as '%s'", expected)
|
|
}
|
|
|
|
func TestRotateFilter(t *testing.T) {
|
|
state := newPeco()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go state.Run(ctx)
|
|
defer cancel()
|
|
|
|
<-state.Ready()
|
|
|
|
size := state.filters.Size()
|
|
if size <= 1 {
|
|
t.Skip("Can't proceed testing, only have 1 filter registered")
|
|
return
|
|
}
|
|
|
|
var prev filter.Filter
|
|
first := state.Filters().Current()
|
|
prev = first
|
|
for i := 0; i < size; i++ {
|
|
prevFilter := prev
|
|
state.screen.SendEvent(Event{Type: EventKey, Key: keyseq.KeyCtrlR})
|
|
|
|
require.Eventually(t, func() bool {
|
|
return state.Filters().Current() != prevFilter
|
|
}, 5*time.Second, 10*time.Millisecond, "filter should have rotated at iteration %d", i)
|
|
prev = state.Filters().Current()
|
|
}
|
|
|
|
if first != prev {
|
|
t.Errorf("should have rotated back to first one, but didn't")
|
|
}
|
|
|
|
// TODO toggle ExecQuery()
|
|
}
|
|
|
|
func TestBeginningOfLineAndEndOfLine(t *testing.T) {
|
|
state := newPeco()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go state.Run(ctx)
|
|
defer cancel()
|
|
|
|
<-state.Ready()
|
|
|
|
message := "Hello, World!"
|
|
writeQueryToPrompt(t, state.screen, message)
|
|
state.screen.SendEvent(Event{Type: EventKey, Key: keyseq.KeyCtrlA})
|
|
|
|
require.Eventually(t, func() bool {
|
|
return state.Caret().Pos() == 0
|
|
}, 5*time.Second, 10*time.Millisecond, "Expected caret position to be 0")
|
|
|
|
state.screen.SendEvent(Event{Type: EventKey, Key: keyseq.KeyCtrlE})
|
|
|
|
require.Eventually(t, func() bool {
|
|
return state.Caret().Pos() == len(message)
|
|
}, 5*time.Second, 10*time.Millisecond, "Expected caret position to be %d", len(message))
|
|
}
|
|
|
|
func TestBackToInitialFilter(t *testing.T) {
|
|
state := newPeco()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go state.Run(ctx)
|
|
defer cancel()
|
|
|
|
<-state.Ready()
|
|
|
|
state.config.Keymap["C-q"] = "peco.BackToInitialFilter"
|
|
if !assert.NoError(t, state.populateKeymap(), "populateKeymap expected to succeed") {
|
|
return
|
|
}
|
|
|
|
if !assert.Equal(t, state.Filters().Index(), 0, "Expected filter to be at position 0, got %d", state.Filters().Index()) {
|
|
return
|
|
}
|
|
|
|
state.screen.SendEvent(Event{Type: EventKey, Key: keyseq.KeyCtrlR})
|
|
require.Eventually(t, func() bool {
|
|
return state.Filters().Index() == 1
|
|
}, 5*time.Second, 10*time.Millisecond, "Expected filter to be at position 1")
|
|
|
|
state.screen.SendEvent(Event{Type: EventKey, Key: keyseq.KeyCtrlQ})
|
|
require.Eventually(t, func() bool {
|
|
return state.Filters().Index() == 0
|
|
}, 5*time.Second, 10*time.Millisecond, "Expected filter to be at position 0")
|
|
}
|
|
|
|
func TestGHIssue574_PreviousSelectionLastLineNotUpdated(t *testing.T) {
|
|
// Issue #574: In doGoToPreviousSelection, lastLine is initialized to
|
|
// math.MaxUint64 and the condition `selectedLine.ID() >= lastLine` can
|
|
// never be true, so lastLine never gets updated. When wrapping around
|
|
// (cursor is before/at the first selected line), it should jump to the
|
|
// last selected line, but instead it jumps to math.MaxUint64.
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create lines with known IDs.
|
|
// We use IDs 10, 20, 30, 40, 50 for five lines.
|
|
lines := []line.Line{
|
|
line.NewRaw(10, "line-10", false, false),
|
|
line.NewRaw(20, "line-20", false, false),
|
|
line.NewRaw(30, "line-30", false, false),
|
|
line.NewRaw(40, "line-40", false, false),
|
|
line.NewRaw(50, "line-50", false, false),
|
|
}
|
|
|
|
// Build a MemoryBuffer containing those lines.
|
|
mb := NewMemoryBuffer(0)
|
|
mb.lines = lines
|
|
|
|
rHub := &recordingHub{}
|
|
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
|
|
// Set the current line buffer to our prepared buffer.
|
|
state.currentLineBuffer = mb
|
|
|
|
// Select lines with IDs 20 and 40.
|
|
state.Selection().Add(lines[1]) // ID=20
|
|
state.Selection().Add(lines[3]) // ID=40
|
|
|
|
t.Run("wrap around to last selected line", func(t *testing.T) {
|
|
// Position cursor at line index 0 (ID=10), which is before all
|
|
// selected lines. There is no "previous" selection, so the function
|
|
// should wrap around to the last selected line (ID=40).
|
|
state.Location().SetLineNumber(0)
|
|
rHub.reset()
|
|
|
|
doGoToPreviousSelection(ctx, state, Event{})
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs, "should have sent a status message")
|
|
require.Equal(t, "Previous Selection (first)", statusMsgs[0],
|
|
"should wrap around when no previous selection exists")
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
// Expect two paging calls: ToScrollFirstItem, then JumpToLineRequest(lastLine)
|
|
require.Len(t, pagingArgs, 2, "expected 2 paging args")
|
|
|
|
jlr, ok := pagingArgs[1].(hub.JumpToLineRequest)
|
|
require.True(t, ok, "second paging arg should be JumpToLineRequest")
|
|
|
|
// The bug: lastLine stays at math.MaxUint64 instead of being updated to 40.
|
|
// JumpToLineRequest is int, so MaxUint64 wraps to -1 on 64-bit.
|
|
require.True(t, jlr.Line() >= 0,
|
|
"lastLine must not be negative (math.MaxUint64 cast to int), got %d", jlr.Line())
|
|
require.Equal(t, 40, jlr.Line(),
|
|
"should jump to the last selected line (ID=40)")
|
|
})
|
|
|
|
t.Run("previous selection found", func(t *testing.T) {
|
|
// Position cursor at line index 4 (ID=50), which is after both
|
|
// selected lines. Should find previous selection at ID=40.
|
|
state.Location().SetLineNumber(4)
|
|
rHub.reset()
|
|
|
|
doGoToPreviousSelection(ctx, state, Event{})
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs, "should have sent a status message")
|
|
require.Equal(t, "Previous Selection", statusMsgs[0])
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
require.Len(t, pagingArgs, 2, "expected 2 paging args")
|
|
|
|
jlr, ok := pagingArgs[1].(hub.JumpToLineRequest)
|
|
require.True(t, ok, "second paging arg should be JumpToLineRequest")
|
|
require.Equal(t, 40, jlr.Line(),
|
|
"should jump to the previous selected line (ID=40)")
|
|
})
|
|
|
|
t.Run("skips to nearest previous selection", func(t *testing.T) {
|
|
// Position cursor at line index 3 (ID=40). The previous selection
|
|
// should be ID=20, not ID=40 (since 40 is not < 40).
|
|
state.Location().SetLineNumber(3)
|
|
rHub.reset()
|
|
|
|
doGoToPreviousSelection(ctx, state, Event{})
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs, "should have sent a status message")
|
|
require.Equal(t, "Previous Selection", statusMsgs[0])
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
require.Len(t, pagingArgs, 2, "expected 2 paging args")
|
|
|
|
jlr, ok := pagingArgs[1].(hub.JumpToLineRequest)
|
|
require.True(t, ok, "second paging arg should be JumpToLineRequest")
|
|
require.Equal(t, 20, jlr.Line(),
|
|
"should jump to ID=20, the nearest previous selected line")
|
|
})
|
|
}
|
|
|
|
func TestNextSelectionNavigation(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
lines := []line.Line{
|
|
line.NewRaw(10, "line-10", false, false),
|
|
line.NewRaw(20, "line-20", false, false),
|
|
line.NewRaw(30, "line-30", false, false),
|
|
line.NewRaw(40, "line-40", false, false),
|
|
line.NewRaw(50, "line-50", false, false),
|
|
}
|
|
|
|
mb := NewMemoryBuffer(0)
|
|
mb.lines = lines
|
|
|
|
rHub := &recordingHub{}
|
|
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
state.currentLineBuffer = mb
|
|
|
|
state.Selection().Add(lines[1]) // ID=20
|
|
state.Selection().Add(lines[3]) // ID=40
|
|
|
|
t.Run("next selection found", func(t *testing.T) {
|
|
// Cursor at line index 0 (ID=10). Next selection should be ID=20.
|
|
state.Location().SetLineNumber(0)
|
|
rHub.reset()
|
|
|
|
doGoToNextSelection(ctx, state, Event{})
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs)
|
|
require.Equal(t, "Next Selection", statusMsgs[0])
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
require.Len(t, pagingArgs, 2)
|
|
|
|
jlr, ok := pagingArgs[1].(hub.JumpToLineRequest)
|
|
require.True(t, ok)
|
|
require.Equal(t, 20, jlr.Line(),
|
|
"should jump to the next selected line (ID=20)")
|
|
})
|
|
|
|
t.Run("skips to nearest next selection", func(t *testing.T) {
|
|
// Cursor at line index 1 (ID=20). Next selection should be ID=40.
|
|
state.Location().SetLineNumber(1)
|
|
rHub.reset()
|
|
|
|
doGoToNextSelection(ctx, state, Event{})
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs)
|
|
require.Equal(t, "Next Selection", statusMsgs[0])
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
require.Len(t, pagingArgs, 2)
|
|
|
|
jlr, ok := pagingArgs[1].(hub.JumpToLineRequest)
|
|
require.True(t, ok)
|
|
require.Equal(t, 40, jlr.Line(),
|
|
"should jump to the next selected line (ID=40)")
|
|
})
|
|
|
|
t.Run("wrap around to first selected line", func(t *testing.T) {
|
|
// Cursor at line index 4 (ID=50), past all selections.
|
|
// Should wrap around to the first selected line (ID=20).
|
|
state.Location().SetLineNumber(4)
|
|
rHub.reset()
|
|
|
|
doGoToNextSelection(ctx, state, Event{})
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs)
|
|
require.Equal(t, "Next Selection (first)", statusMsgs[0],
|
|
"should wrap around when no next selection exists")
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
require.Len(t, pagingArgs, 2)
|
|
|
|
jlr, ok := pagingArgs[1].(hub.JumpToLineRequest)
|
|
require.True(t, ok)
|
|
require.Equal(t, 20, jlr.Line(),
|
|
"should wrap to the first selected line (ID=20)")
|
|
})
|
|
}
|
|
|
|
func TestGHIssue428_PgUpPgDnDefaultBindings(t *testing.T) {
|
|
// Issue #428: PgUp/PgDn keys should be bound by default to
|
|
// ScrollPageUp/ScrollPageDown, just like Home/End are bound
|
|
// to ScrollFirstItem/ScrollLastItem.
|
|
|
|
ctx := context.Background()
|
|
rHub := &recordingHub{}
|
|
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
state.currentLineBuffer = NewMemoryBuffer(0)
|
|
|
|
// Populate the keymap with defaults (no custom config).
|
|
state.config.Keymap = map[string]string{}
|
|
state.config.Action = map[string][]string{}
|
|
require.NoError(t, state.populateKeymap(), "populateKeymap should succeed")
|
|
|
|
km := state.Keymap()
|
|
|
|
t.Run("PgDn triggers ScrollPageDown", func(t *testing.T) {
|
|
rHub.reset()
|
|
|
|
ev := Event{Key: keyseq.KeyPgdn}
|
|
err := km.ExecuteAction(ctx, state, ev)
|
|
require.NoError(t, err, "PgDn should resolve to an action")
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
require.Len(t, pagingArgs, 1, "expected one paging call")
|
|
require.Equal(t, hub.ToScrollPageDown, pagingArgs[0],
|
|
"PgDn should trigger ScrollPageDown")
|
|
})
|
|
|
|
t.Run("PgUp triggers ScrollPageUp", func(t *testing.T) {
|
|
rHub.reset()
|
|
|
|
ev := Event{Key: keyseq.KeyPgup}
|
|
err := km.ExecuteAction(ctx, state, ev)
|
|
require.NoError(t, err, "PgUp should resolve to an action")
|
|
|
|
pagingArgs := rHub.getPagingArgs()
|
|
require.Len(t, pagingArgs, 1, "expected one paging call")
|
|
require.Equal(t, hub.ToScrollPageUp, pagingArgs[0],
|
|
"PgUp should trigger ScrollPageUp")
|
|
})
|
|
}
|
|
|
|
// TestGHIssue455_RefreshScreenSendsForceSync verifies that doRefreshScreen
|
|
// sends DrawOptions with both DisableCache and ForceSync set to true.
|
|
func TestGHIssue455_RefreshScreenSendsForceSync(t *testing.T) {
|
|
ctx := context.Background()
|
|
rHub := &recordingHub{}
|
|
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
state.currentLineBuffer = NewMemoryBuffer(0)
|
|
|
|
doRefreshScreen(ctx, state, Event{})
|
|
|
|
drawArgs := rHub.getDrawArgs()
|
|
require.Len(t, drawArgs, 1, "expected exactly 1 SendDraw call")
|
|
|
|
opts := drawArgs[0]
|
|
require.NotNil(t, opts, "SendDraw argument should not be nil")
|
|
require.True(t, opts.DisableCache, "DisableCache should be true")
|
|
require.True(t, opts.ForceSync, "ForceSync should be true for screen refresh")
|
|
}
|
|
|
|
func TestDoFreezeResults(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
makeLines := func(values ...string) []line.Line {
|
|
lines := make([]line.Line, len(values))
|
|
for i, v := range values {
|
|
lines[i] = line.NewRaw(uint64(i), v, false, false)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
t.Run("freeze captures current buffer", func(t *testing.T) {
|
|
rHub := &recordingHub{}
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
|
|
lines := makeLines("alpha", "beta", "gamma")
|
|
mb := NewMemoryBuffer(0)
|
|
mb.lines = lines
|
|
state.currentLineBuffer = mb
|
|
|
|
state.Query().Set("test")
|
|
state.Caret().SetPos(4)
|
|
|
|
doFreezeResults(ctx, state, Event{})
|
|
|
|
fs := state.FrozenSource()
|
|
require.NotNil(t, fs, "frozenSource should be set")
|
|
require.Equal(t, 3, fs.Size(), "frozen buffer should have 3 lines")
|
|
|
|
for i, expected := range []string{"alpha", "beta", "gamma"} {
|
|
l, err := fs.LineAt(i)
|
|
require.NoError(t, err)
|
|
require.Equal(t, expected, l.Buffer())
|
|
}
|
|
|
|
require.Equal(t, 0, state.Query().Len(), "query should be cleared")
|
|
require.Equal(t, 0, state.Caret().Pos(), "caret should be at 0")
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.Contains(t, statusMsgs, "Results frozen")
|
|
})
|
|
|
|
t.Run("ResetCurrentLineBuffer uses frozen source", func(t *testing.T) {
|
|
rHub := &recordingHub{}
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
state.source = &Source{}
|
|
|
|
lines := makeLines("frozen1", "frozen2")
|
|
frozen := NewMemoryBuffer(0)
|
|
frozen.lines = lines
|
|
frozen.MarkComplete()
|
|
state.SetFrozenSource(frozen)
|
|
|
|
state.ResetCurrentLineBuffer()
|
|
|
|
buf := state.CurrentLineBuffer()
|
|
require.Equal(t, 2, buf.Size(), "should use frozen source")
|
|
l, err := buf.LineAt(0)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "frozen1", l.Buffer())
|
|
})
|
|
|
|
t.Run("unfreeze reverts to original source", func(t *testing.T) {
|
|
rHub := &recordingHub{}
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
|
|
origLines := makeLines("orig1", "orig2", "orig3")
|
|
origSource := &Source{}
|
|
origSource.lines = origLines
|
|
state.source = origSource
|
|
|
|
frozen := NewMemoryBuffer(0)
|
|
frozen.lines = makeLines("frozen1")
|
|
frozen.MarkComplete()
|
|
state.SetFrozenSource(frozen)
|
|
state.currentLineBuffer = frozen
|
|
|
|
state.Query().Set("test")
|
|
state.Caret().SetPos(4)
|
|
|
|
doUnfreezeResults(ctx, state, Event{})
|
|
|
|
require.Nil(t, state.FrozenSource(), "frozenSource should be nil")
|
|
require.Equal(t, 0, state.Query().Len(), "query should be cleared")
|
|
require.Equal(t, 0, state.Caret().Pos(), "caret should be at 0")
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.Contains(t, statusMsgs, "Results unfrozen")
|
|
})
|
|
|
|
t.Run("freeze with empty buffer does nothing", func(t *testing.T) {
|
|
rHub := &recordingHub{}
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
state.currentLineBuffer = NewMemoryBuffer(0)
|
|
|
|
doFreezeResults(ctx, state, Event{})
|
|
|
|
require.Nil(t, state.FrozenSource(), "frozenSource should not be set")
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.Contains(t, statusMsgs, "Nothing to freeze")
|
|
})
|
|
|
|
t.Run("unfreeze when not frozen does nothing", func(t *testing.T) {
|
|
rHub := &recordingHub{}
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
|
|
doUnfreezeResults(ctx, state, Event{})
|
|
|
|
require.Nil(t, state.FrozenSource(), "frozenSource should remain nil")
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.Contains(t, statusMsgs, "No frozen results")
|
|
})
|
|
}
|
|
|
|
func TestContextBuffer(t *testing.T) {
|
|
// Helper: create source lines with sequential IDs (0-based, matching line.ID())
|
|
makeSource := func(n int) *MemoryBuffer {
|
|
mb := NewMemoryBuffer(n)
|
|
for i := 0; i < n; i++ {
|
|
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line-%d", i), false, false))
|
|
}
|
|
return mb
|
|
}
|
|
|
|
// Helper: create a filtered buffer with specific source indices as matches
|
|
makeFiltered := func(source *MemoryBuffer, indices []int) *MemoryBuffer {
|
|
mb := NewMemoryBuffer(len(indices))
|
|
for _, idx := range indices {
|
|
l, _ := source.LineAt(idx)
|
|
mb.lines = append(mb.lines, l)
|
|
}
|
|
return mb
|
|
}
|
|
|
|
t.Run("single match in middle", func(t *testing.T) {
|
|
source := makeSource(10)
|
|
filtered := makeFiltered(source, []int{5})
|
|
|
|
cb := NewContextBuffer(filtered, source, 2)
|
|
|
|
// Should have lines 3,4,5,6,7 (5 entries: 2 context before, match, 2 context after)
|
|
require.Equal(t, 5, cb.Size())
|
|
|
|
// Lines 3,4 should be ContextLine
|
|
l0, _ := cb.LineAt(0)
|
|
_, isCtx0 := l0.(*ContextLine)
|
|
require.True(t, isCtx0, "line 0 should be ContextLine")
|
|
require.Equal(t, uint64(3), l0.ID())
|
|
|
|
l1, _ := cb.LineAt(1)
|
|
_, isCtx1 := l1.(*ContextLine)
|
|
require.True(t, isCtx1, "line 1 should be ContextLine")
|
|
require.Equal(t, uint64(4), l1.ID())
|
|
|
|
// Line 5 should be the matched line (not ContextLine)
|
|
l2, _ := cb.LineAt(2)
|
|
_, isCtx2 := l2.(*ContextLine)
|
|
require.False(t, isCtx2, "line 2 should be the matched line, not ContextLine")
|
|
require.Equal(t, uint64(5), l2.ID())
|
|
|
|
// Lines 6,7 should be ContextLine
|
|
l3, _ := cb.LineAt(3)
|
|
_, isCtx3 := l3.(*ContextLine)
|
|
require.True(t, isCtx3, "line 3 should be ContextLine")
|
|
|
|
l4, _ := cb.LineAt(4)
|
|
_, isCtx4 := l4.(*ContextLine)
|
|
require.True(t, isCtx4, "line 4 should be ContextLine")
|
|
|
|
// matchEntryIndices: filtered index 0 -> entry index 2
|
|
require.Equal(t, 2, cb.MatchEntryIndices()[0])
|
|
})
|
|
|
|
t.Run("overlapping context merges", func(t *testing.T) {
|
|
source := makeSource(10)
|
|
// Two matches close together: indices 3 and 5 with context=2
|
|
// Ranges: [1,5] and [3,7] -> merged: [1,7]
|
|
filtered := makeFiltered(source, []int{3, 5})
|
|
|
|
cb := NewContextBuffer(filtered, source, 2)
|
|
|
|
// Should have lines 1,2,3,4,5,6,7 (7 entries)
|
|
require.Equal(t, 7, cb.Size())
|
|
|
|
// Check matched lines are not ContextLine
|
|
l2, _ := cb.LineAt(2) // source index 3
|
|
_, isCtx := l2.(*ContextLine)
|
|
require.False(t, isCtx, "matched line at source index 3 should not be ContextLine")
|
|
require.Equal(t, uint64(3), l2.ID())
|
|
|
|
l4, _ := cb.LineAt(4) // source index 5
|
|
_, isCtx2 := l4.(*ContextLine)
|
|
require.False(t, isCtx2, "matched line at source index 5 should not be ContextLine")
|
|
require.Equal(t, uint64(5), l4.ID())
|
|
|
|
// matchEntryIndices: filtered 0 -> entry 2, filtered 1 -> entry 4
|
|
require.Equal(t, 2, cb.MatchEntryIndices()[0])
|
|
require.Equal(t, 4, cb.MatchEntryIndices()[1])
|
|
})
|
|
|
|
t.Run("match at boundary", func(t *testing.T) {
|
|
source := makeSource(5)
|
|
// Match at index 0 with context=3 -> range [0, 3] (clamped start)
|
|
filtered := makeFiltered(source, []int{0})
|
|
|
|
cb := NewContextBuffer(filtered, source, 3)
|
|
|
|
// Should have lines 0,1,2,3 (4 entries)
|
|
require.Equal(t, 4, cb.Size())
|
|
|
|
// First line should be the match (not context)
|
|
l0, _ := cb.LineAt(0)
|
|
_, isCtx := l0.(*ContextLine)
|
|
require.False(t, isCtx, "line 0 should be matched, not context")
|
|
require.Equal(t, uint64(0), l0.ID())
|
|
|
|
// Lines 1-3 should be context
|
|
for i := 1; i < 4; i++ {
|
|
l, _ := cb.LineAt(i)
|
|
_, isCtx := l.(*ContextLine)
|
|
require.True(t, isCtx, "line %d should be ContextLine", i)
|
|
}
|
|
})
|
|
|
|
t.Run("match at end boundary", func(t *testing.T) {
|
|
source := makeSource(5)
|
|
// Match at index 4 (last) with context=3 -> range [1, 4] (clamped end)
|
|
filtered := makeFiltered(source, []int{4})
|
|
|
|
cb := NewContextBuffer(filtered, source, 3)
|
|
|
|
// Should have lines 1,2,3,4 (4 entries)
|
|
require.Equal(t, 4, cb.Size())
|
|
|
|
// Last line should be the match
|
|
l3, _ := cb.LineAt(3)
|
|
_, isCtx := l3.(*ContextLine)
|
|
require.False(t, isCtx, "last line should be matched, not context")
|
|
require.Equal(t, uint64(4), l3.ID())
|
|
})
|
|
|
|
t.Run("empty filtered buffer", func(t *testing.T) {
|
|
source := makeSource(10)
|
|
filtered := NewMemoryBuffer(0)
|
|
|
|
cb := NewContextBuffer(filtered, source, 3)
|
|
|
|
require.Equal(t, 0, cb.Size())
|
|
})
|
|
}
|
|
|
|
func TestDoZoomInOut(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Build a source with 10 lines (IDs 0-9)
|
|
makeState := func() (*Peco, *recordingHub, *MemoryBuffer, *MemoryBuffer) {
|
|
source := NewMemoryBuffer(10)
|
|
for i := 0; i < 10; i++ {
|
|
source.lines = append(source.lines, line.NewRaw(uint64(i), fmt.Sprintf("line-%d", i), false, false))
|
|
}
|
|
|
|
// Filtered buffer: matches at indices 3 and 7
|
|
filtered := NewMemoryBuffer(2)
|
|
l3, _ := source.LineAt(3)
|
|
l7, _ := source.LineAt(7)
|
|
filtered.lines = append(filtered.lines, l3, l7)
|
|
|
|
rHub := &recordingHub{}
|
|
state := New()
|
|
state.hub = rHub
|
|
state.selection = NewSelection()
|
|
state.source = &Source{}
|
|
state.source.lines = source.lines
|
|
state.currentLineBuffer = filtered
|
|
|
|
return state, rHub, source, filtered
|
|
}
|
|
|
|
t.Run("ZoomIn with filtered results", func(t *testing.T) {
|
|
state, rHub, _, filtered := makeState()
|
|
state.Location().SetLineNumber(0) // cursor on first match
|
|
|
|
doZoomIn(ctx, state, Event{})
|
|
|
|
// Should have set a context buffer
|
|
buf := state.CurrentLineBuffer()
|
|
_, isCtx := buf.(*ContextBuffer)
|
|
require.True(t, isCtx, "current buffer should be ContextBuffer after ZoomIn")
|
|
|
|
// Pre-zoom state should be saved
|
|
require.Equal(t, filtered, state.PreZoomBuffer(), "preZoomBuffer should be the filtered buffer")
|
|
require.Equal(t, 0, state.PreZoomLineNo(), "preZoomLineNo should be 0")
|
|
|
|
// Should have sent a draw
|
|
drawArgs := rHub.getDrawArgs()
|
|
require.NotEmpty(t, drawArgs, "should have sent a draw")
|
|
|
|
// Context buffer should have entries around matches 3 and 7
|
|
ctxBuf := buf.(*ContextBuffer)
|
|
require.True(t, ctxBuf.Size() > 2, "context buffer should have more entries than just matches")
|
|
})
|
|
|
|
t.Run("ZoomOut restores state", func(t *testing.T) {
|
|
state, rHub, _, filtered := makeState()
|
|
state.Location().SetLineNumber(0)
|
|
|
|
// ZoomIn first
|
|
doZoomIn(ctx, state, Event{})
|
|
rHub.reset()
|
|
|
|
// ZoomOut
|
|
doZoomOut(ctx, state, Event{})
|
|
|
|
// Buffer should be restored
|
|
require.Equal(t, filtered, state.CurrentLineBuffer(), "buffer should be restored to filtered")
|
|
|
|
// Cursor should be restored
|
|
require.Equal(t, 0, state.Location().LineNumber(), "cursor should be restored")
|
|
|
|
// Pre-zoom state should be cleared
|
|
require.Nil(t, state.PreZoomBuffer(), "preZoomBuffer should be nil after ZoomOut")
|
|
|
|
// Should have sent a draw
|
|
drawArgs := rHub.getDrawArgs()
|
|
require.NotEmpty(t, drawArgs, "should have sent a draw")
|
|
})
|
|
|
|
t.Run("ZoomIn when not filtered (source buffer)", func(t *testing.T) {
|
|
state, rHub, _, _ := makeState()
|
|
// Set current buffer to source
|
|
state.currentLineBuffer = state.source
|
|
|
|
doZoomIn(ctx, state, Event{})
|
|
|
|
// Should be a no-op with status message
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs)
|
|
require.Equal(t, "Nothing to zoom into", statusMsgs[0])
|
|
|
|
// PreZoom should not be set
|
|
require.Nil(t, state.PreZoomBuffer())
|
|
})
|
|
|
|
t.Run("ZoomOut when not zoomed", func(t *testing.T) {
|
|
state, rHub, _, _ := makeState()
|
|
|
|
doZoomOut(ctx, state, Event{})
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs)
|
|
require.Equal(t, "Not zoomed in", statusMsgs[0])
|
|
})
|
|
|
|
t.Run("ZoomIn when already zoomed", func(t *testing.T) {
|
|
state, rHub, _, _ := makeState()
|
|
state.Location().SetLineNumber(0)
|
|
|
|
// ZoomIn first
|
|
doZoomIn(ctx, state, Event{})
|
|
rHub.reset()
|
|
|
|
// ZoomIn again
|
|
doZoomIn(ctx, state, Event{})
|
|
|
|
statusMsgs := rHub.getStatusMsgs()
|
|
require.NotEmpty(t, statusMsgs)
|
|
require.Equal(t, "Already zoomed in", statusMsgs[0])
|
|
})
|
|
}
|