mirror of
https://github.com/peco/peco.git
synced 2026-09-10 07:16:29 -04:00
514 lines
16 KiB
Go
514 lines
16 KiB
Go
package peco
|
|
|
|
import (
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/mattn/go-runewidth"
|
|
"github.com/peco/peco/config"
|
|
"github.com/peco/peco/filter"
|
|
"github.com/peco/peco/hub"
|
|
"github.com/peco/peco/line"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestLayoutType(t *testing.T) {
|
|
layouts := []struct {
|
|
value string
|
|
expectOK bool
|
|
}{
|
|
{config.LayoutTypeTopDown, true},
|
|
{config.LayoutTypeBottomUp, true},
|
|
{config.LayoutTypeTopDownQueryBottom, true},
|
|
{"foobar", false},
|
|
}
|
|
for _, l := range layouts {
|
|
valid := config.IsValidLayoutType(l.value)
|
|
require.Equal(t, l.expectOK, valid,
|
|
"IsValidLayoutType(%s)", l.value)
|
|
}
|
|
}
|
|
|
|
func TestPrintScreen(t *testing.T) {
|
|
screen := NewDummyScreen()
|
|
|
|
makeVerifier := func(initX, initY int, fill bool) func(string) {
|
|
return func(msg string) {
|
|
screen.reset()
|
|
t.Logf("Checking printScreen(%d, %d, %s, %t)", initX, initY, msg, fill)
|
|
width := utf8.RuneCountInString(msg)
|
|
screen.Print(PrintArgs{
|
|
X: initX,
|
|
Y: initY,
|
|
Fg: config.ColorDefault,
|
|
Bg: config.ColorDefault,
|
|
Msg: msg,
|
|
Fill: fill,
|
|
})
|
|
events := screen.events["SetCell"]
|
|
if !fill {
|
|
require.Equal(t, width, len(events),
|
|
"SetCell event count for %q (fill=false)", msg)
|
|
return
|
|
}
|
|
|
|
// fill == true
|
|
w, _ := screen.Size()
|
|
if rw := runewidth.StringWidth(msg); rw != width {
|
|
w -= rw - width
|
|
}
|
|
require.Equal(t, w, len(events),
|
|
"SetCell event count for %q (fill=true)", msg)
|
|
}
|
|
}
|
|
|
|
verify := makeVerifier(0, 0, false)
|
|
verify("Hello, World!")
|
|
verify("日本語")
|
|
|
|
verify = makeVerifier(0, 0, true)
|
|
verify("Hello, World!")
|
|
verify("日本語")
|
|
}
|
|
|
|
func TestScreenStatusBar(t *testing.T) {
|
|
screen := NewDummyScreen()
|
|
st, err := newScreenStatusBar(screen, AnchorBottom, 0, config.NewStyleSet())
|
|
require.NoError(t, err)
|
|
st.PrintStatus("Hello, World!", 0)
|
|
|
|
events := screen.events
|
|
require.Equal(t, 1, len(events["Flush"]), "Flush event count")
|
|
}
|
|
|
|
func TestNullStatusBar(t *testing.T) {
|
|
screen := NewDummyScreen()
|
|
var st StatusBar = nullStatusBar{}
|
|
st.PrintStatus("Hello, World!", 0)
|
|
|
|
events := screen.events
|
|
require.Equal(t, 0, len(events["Flush"]), "nullStatusBar should not Flush")
|
|
}
|
|
|
|
func TestMergeAttribute(t *testing.T) {
|
|
colors := config.StringToFg
|
|
|
|
// merge colors
|
|
tests := [][]string{
|
|
{"red", "green", "yellow"},
|
|
{"red", "blue", "magenta"},
|
|
{"green", "blue", "cyan"},
|
|
{"yellow", "blue", "white"},
|
|
{"magenta", "green", "white"},
|
|
{"cyan", "red", "white"},
|
|
{"yellow", "magenta", "white"},
|
|
{"magenta", "cyan", "white"},
|
|
{"cyan", "yellow", "white"},
|
|
}
|
|
|
|
for _, c := range tests {
|
|
m := mergeAttribute(colors[c[0]], colors[c[1]])
|
|
require.Equal(t, colors[c[2]], m, "(%s + %s) should be %s", c[0], c[1], c[2])
|
|
}
|
|
|
|
// merge with white
|
|
for name, c := range colors {
|
|
m := mergeAttribute(c, colors["white"])
|
|
require.Equal(t, colors["white"], m, "%s + white should be white", name)
|
|
}
|
|
|
|
// merge attributes
|
|
m := mergeAttribute(config.AttrBold|colors["red"], config.AttrUnderline|colors["cyan"])
|
|
require.Equal(t, config.AttrBold|config.AttrUnderline|colors["white"], m)
|
|
}
|
|
|
|
// TestGHIssue294_PromptStyleUsedForPromptPrefix verifies that UserPrompt.Draw
|
|
// uses the Prompt style (not Basic) when rendering the prompt prefix string.
|
|
func TestGHIssue294_PromptStyleUsedForPromptPrefix(t *testing.T) {
|
|
styles := config.NewStyleSet()
|
|
styles.Prompt.Fg = config.ColorGreen | config.AttrBold
|
|
styles.Prompt.Bg = config.ColorBlue
|
|
// Make sure Basic is different so we can distinguish them.
|
|
styles.Basic.Fg = config.ColorDefault
|
|
styles.Basic.Bg = config.ColorDefault
|
|
|
|
screen := NewDummyScreen()
|
|
prompt, err := NewUserPrompt(screen, AnchorTop, 0, "QUERY>", styles)
|
|
require.NoError(t, err)
|
|
|
|
state := New()
|
|
state.screen = screen
|
|
|
|
state.Filters().Add(filter.NewIgnoreCase())
|
|
|
|
prompt.Draw(state)
|
|
|
|
// Collect SetCell events for y=0 (the prompt row).
|
|
events := screen.events["SetCell"]
|
|
|
|
promptStr := "QUERY>"
|
|
promptLen := len(promptStr)
|
|
require.True(t, len(events) >= promptLen,
|
|
"expected at least %d SetCell events, got %d", promptLen, len(events))
|
|
|
|
// The first promptLen cells should use the Prompt style colors.
|
|
for i := range promptLen {
|
|
ev := events[i]
|
|
x := ev[0].(int)
|
|
ch := ev[2].(rune)
|
|
fg := ev[3].(config.Attribute)
|
|
bg := ev[4].(config.Attribute)
|
|
|
|
require.Equal(t, i, x, "expected x=%d", i)
|
|
require.Equal(t, rune(promptStr[i]), ch, "expected character %c at position %d", promptStr[i], i)
|
|
require.Equal(t, styles.Prompt.Fg, fg,
|
|
"cell at x=%d should use Prompt.Fg, got %v", i, fg)
|
|
require.Equal(t, styles.Prompt.Bg, bg,
|
|
"cell at x=%d should use Prompt.Bg, got %v", i, bg)
|
|
}
|
|
|
|
// The cells after the prompt should NOT use the Prompt style —
|
|
// they should use the Query style (for the query text area).
|
|
if len(events) > promptLen {
|
|
ev := events[promptLen]
|
|
fg := ev[3].(config.Attribute)
|
|
require.NotEqual(t, styles.Prompt.Fg, fg,
|
|
"cell after prompt should not use Prompt style")
|
|
}
|
|
}
|
|
|
|
// TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine verifies that matched
|
|
// text highlighting in ListArea.Draw does not extend to the screen edge.
|
|
func TestGHIssue460_MatchedStyleDoesNotBleedToEndOfLine(t *testing.T) {
|
|
// Use a distinct Matched.Bg so we can detect it in SetCell events.
|
|
styles := config.NewStyleSet()
|
|
styles.Matched.Bg = config.ColorBlue
|
|
|
|
matchedBg := mergeAttribute(styles.Basic.Bg, styles.Matched.Bg) // config.ColorBlue
|
|
basicBg := styles.Basic.Bg // config.ColorDefault
|
|
|
|
// Helper: set up a Peco state with one matched line and draw it,
|
|
// returning the SetCell events for the line's row (y=0).
|
|
drawAndCollect := func(t *testing.T, text string, matches [][]int) []interceptorArgs {
|
|
t.Helper()
|
|
|
|
screen := NewDummyScreen()
|
|
listArea, err := NewListArea(screen, AnchorTop, 0, true, styles)
|
|
require.NoError(t, err)
|
|
|
|
state := New()
|
|
state.screen = screen
|
|
|
|
mb := NewMemoryBuffer(0)
|
|
raw := line.NewRaw(0, text, false, false)
|
|
matched := line.NewMatched(raw, matches)
|
|
mb.lines = append(mb.lines, matched)
|
|
// Add a second line so we can set the cursor on it,
|
|
// keeping line 0 in Basic (non-selected) style.
|
|
mb.lines = append(mb.lines, line.NewRaw(1, "other", false, false))
|
|
state.currentLineBuffer = mb
|
|
|
|
loc := state.Location()
|
|
loc.SetPage(1)
|
|
loc.SetPerPage(10)
|
|
loc.SetLineNumber(1) // select line 1, so line 0 uses Basic style
|
|
|
|
listArea.Draw(state, nil, 10, &hub.DrawOptions{DisableCache: true})
|
|
|
|
// Collect SetCell events for y=0 (our matched line).
|
|
var row []interceptorArgs
|
|
for _, ev := range screen.events["SetCell"] {
|
|
if ev[1].(int) == 0 {
|
|
row = append(row, ev)
|
|
}
|
|
}
|
|
return row
|
|
}
|
|
|
|
t.Run("match at end of line", func(t *testing.T) {
|
|
// "hello world" with "world" matched at end [6,11].
|
|
row := drawAndCollect(t, "hello world", [][]int{{6, 11}})
|
|
|
|
screenWidth := 80
|
|
require.Equal(t, screenWidth, len(row),
|
|
"expected SetCell events to cover the full screen width")
|
|
|
|
for _, ev := range row {
|
|
x := ev[0].(int)
|
|
bg := ev[4].(config.Attribute)
|
|
if x >= 6 && x <= 10 {
|
|
require.Equal(t, matchedBg, bg,
|
|
"cell at x=%d should have matched bg", x)
|
|
} else {
|
|
require.Equal(t, basicBg, bg,
|
|
"cell at x=%d should have basic bg, not matched bg", x)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("match in middle of line", func(t *testing.T) {
|
|
// "hello world, goodbye" with "world" matched at [6,11].
|
|
row := drawAndCollect(t, "hello world, goodbye", [][]int{{6, 11}})
|
|
|
|
screenWidth := 80
|
|
require.Equal(t, screenWidth, len(row),
|
|
"expected SetCell events to cover the full screen width")
|
|
|
|
for _, ev := range row {
|
|
x := ev[0].(int)
|
|
bg := ev[4].(config.Attribute)
|
|
if x >= 6 && x <= 10 {
|
|
require.Equal(t, matchedBg, bg,
|
|
"cell at x=%d should have matched bg", x)
|
|
} else {
|
|
require.Equal(t, basicBg, bg,
|
|
"cell at x=%d should have basic bg, not matched bg", x)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestGHIssue455_DrawScreenForceSync verifies that BasicLayout.DrawScreen
|
|
// calls Sync() (full redraw) instead of Flush() (differential) when
|
|
// DrawOptions.ForceSync is true.
|
|
func TestGHIssue455_DrawScreenForceSync(t *testing.T) {
|
|
setupState := func(t *testing.T) (*Peco, *SimScreen) {
|
|
t.Helper()
|
|
|
|
screen := NewDummyScreen()
|
|
state := New()
|
|
state.screen = screen
|
|
state.Filters().Add(filter.NewIgnoreCase())
|
|
|
|
mb := NewMemoryBuffer(0)
|
|
mb.lines = append(mb.lines, line.NewRaw(0, "line one", false, false))
|
|
state.currentLineBuffer = mb
|
|
|
|
loc := state.Location()
|
|
loc.SetPage(1)
|
|
loc.SetPerPage(10)
|
|
loc.SetLineNumber(0)
|
|
|
|
return state, screen
|
|
}
|
|
|
|
t.Run("ForceSync true calls Sync instead of final Flush", func(t *testing.T) {
|
|
state, screen := setupState(t)
|
|
layout, err := DefaultLayout(state)
|
|
require.NoError(t, err)
|
|
|
|
screen.reset()
|
|
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: true})
|
|
|
|
syncEvents := screen.events["Sync"]
|
|
flushEvents := screen.events["Flush"]
|
|
|
|
require.Len(t, syncEvents, 1, "expected exactly 1 Sync call")
|
|
// DrawPrompt internally calls Flush, but the final DrawScreen
|
|
// Flush should be replaced by Sync.
|
|
for i, ev := range screen.events["Flush"] {
|
|
t.Logf("Flush event %d: %v", i, ev)
|
|
}
|
|
for i, ev := range screen.events["Sync"] {
|
|
t.Logf("Sync event %d: %v", i, ev)
|
|
}
|
|
// The prompt's Flush still fires, but the final screen Flush
|
|
// is replaced by Sync. So Flush count should be 1 less than
|
|
// the non-ForceSync case.
|
|
flushCountWithSync := len(flushEvents)
|
|
|
|
// Compare against the non-ForceSync case
|
|
screen.reset()
|
|
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: false})
|
|
flushCountWithout := len(screen.events["Flush"])
|
|
|
|
require.Equal(t, flushCountWithout-1, flushCountWithSync,
|
|
"ForceSync should replace exactly one Flush call with Sync")
|
|
})
|
|
|
|
t.Run("ForceSync false does not call Sync", func(t *testing.T) {
|
|
state, screen := setupState(t)
|
|
layout, err := DefaultLayout(state)
|
|
require.NoError(t, err)
|
|
|
|
screen.reset()
|
|
layout.DrawScreen(state, &hub.DrawOptions{DisableCache: true, ForceSync: false})
|
|
|
|
syncEvents := screen.events["Sync"]
|
|
require.Empty(t, syncEvents, "expected no Sync calls when ForceSync is false")
|
|
})
|
|
|
|
t.Run("nil options does not call Sync", func(t *testing.T) {
|
|
state, screen := setupState(t)
|
|
layout, err := DefaultLayout(state)
|
|
require.NoError(t, err)
|
|
|
|
screen.reset()
|
|
layout.DrawScreen(state, nil)
|
|
|
|
syncEvents := screen.events["Sync"]
|
|
require.Empty(t, syncEvents, "expected no Sync calls with nil options")
|
|
})
|
|
}
|
|
|
|
// TestNewLayout verifies the layout registry returns correct layout types.
|
|
func TestNewLayout(t *testing.T) {
|
|
makeState := func() *Peco {
|
|
state := New()
|
|
state.screen = NewDummyScreen()
|
|
state.Filters().Add(filter.NewIgnoreCase())
|
|
return state
|
|
}
|
|
|
|
t.Run("top-down", func(t *testing.T) {
|
|
state := makeState()
|
|
layout, err := NewLayout(config.LayoutTypeTopDown, state)
|
|
require.NoError(t, err)
|
|
require.True(t, layout.SortTopDown(), "top-down layout should sort top-down")
|
|
require.Equal(t, AnchorTop, layout.prompt.anchor, "top-down prompt should be anchored at top")
|
|
})
|
|
|
|
t.Run("bottom-up", func(t *testing.T) {
|
|
state := makeState()
|
|
layout, err := NewLayout(config.LayoutTypeBottomUp, state)
|
|
require.NoError(t, err)
|
|
require.False(t, layout.SortTopDown(), "bottom-up layout should not sort top-down")
|
|
require.Equal(t, AnchorBottom, layout.prompt.anchor, "bottom-up prompt should be anchored at bottom")
|
|
})
|
|
|
|
t.Run("top-down-query-bottom", func(t *testing.T) {
|
|
state := makeState()
|
|
layout, err := NewLayout(config.LayoutTypeTopDownQueryBottom, state)
|
|
require.NoError(t, err)
|
|
require.True(t, layout.SortTopDown(), "top-down-query-bottom layout should sort top-down")
|
|
require.Equal(t, AnchorBottom, layout.prompt.anchor, "top-down-query-bottom prompt should be anchored at bottom")
|
|
require.Equal(t, AnchorTop, layout.list.anchor, "top-down-query-bottom list should be anchored at top")
|
|
})
|
|
|
|
t.Run("unknown falls back to top-down", func(t *testing.T) {
|
|
state := makeState()
|
|
layout, err := NewLayout("unknown-layout", state)
|
|
require.NoError(t, err)
|
|
require.True(t, layout.SortTopDown(), "fallback layout should sort top-down")
|
|
require.Equal(t, AnchorTop, layout.prompt.anchor, "fallback prompt should be anchored at top")
|
|
})
|
|
}
|
|
|
|
// TestTopDownQueryBottomLayout verifies the specific properties of the
|
|
// top-down-query-bottom layout.
|
|
func TestTopDownQueryBottomLayout(t *testing.T) {
|
|
state := New()
|
|
state.screen = NewDummyScreen()
|
|
|
|
state.Filters().Add(filter.NewIgnoreCase())
|
|
|
|
layout, err := TopDownQueryBottomLayout(state)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, AnchorBottom, layout.prompt.anchor,
|
|
"prompt should be anchored at bottom")
|
|
require.Equal(t, 1+extraOffset, layout.prompt.anchorOffset,
|
|
"prompt anchor offset should be 1+extraOffset")
|
|
require.Equal(t, AnchorTop, layout.list.anchor,
|
|
"list should be anchored at top")
|
|
require.Equal(t, 0, layout.list.anchorOffset,
|
|
"list anchor offset should be 0")
|
|
require.True(t, layout.list.sortTopDown,
|
|
"list should sort top-down")
|
|
require.True(t, layout.SortTopDown(),
|
|
"SortTopDown() should return true")
|
|
}
|
|
|
|
// TestCursorStyle verifies that UserPrompt.cursorStyle returns the correct
|
|
// fg/bg attributes depending on QueryCursor and Query style configuration.
|
|
func TestCursorStyle(t *testing.T) {
|
|
makePrompt := func(t *testing.T, styles *config.StyleSet) UserPrompt {
|
|
t.Helper()
|
|
screen := NewDummyScreen()
|
|
p, err := NewUserPrompt(screen, AnchorTop, 0, "QUERY>", styles)
|
|
require.NoError(t, err)
|
|
return *p
|
|
}
|
|
|
|
t.Run("both default falls back to config.AttrReverse", func(t *testing.T) {
|
|
styles := config.NewStyleSet()
|
|
// Query and QueryCursor are both config.ColorDefault (zero value)
|
|
p := makePrompt(t, styles)
|
|
fg, bg := p.cursorStyle()
|
|
require.Equal(t, config.ColorDefault|config.AttrReverse, fg)
|
|
require.Equal(t, config.ColorDefault|config.AttrReverse, bg)
|
|
})
|
|
|
|
t.Run("Query has custom colors and QueryCursor is default swaps fg/bg", func(t *testing.T) {
|
|
styles := config.NewStyleSet()
|
|
styles.Query.Fg = config.ColorYellow
|
|
styles.Query.Bg = config.ColorBlue
|
|
p := makePrompt(t, styles)
|
|
fg, bg := p.cursorStyle()
|
|
require.Equal(t, config.ColorBlue, fg, "should use Query.Bg as cursor fg")
|
|
require.Equal(t, config.ColorYellow, bg, "should use Query.Fg as cursor bg")
|
|
})
|
|
|
|
t.Run("QueryCursor explicitly set takes precedence", func(t *testing.T) {
|
|
styles := config.NewStyleSet()
|
|
styles.Query.Fg = config.ColorYellow
|
|
styles.Query.Bg = config.ColorBlue
|
|
styles.QueryCursor.Fg = config.ColorWhite
|
|
styles.QueryCursor.Bg = config.ColorRed
|
|
p := makePrompt(t, styles)
|
|
fg, bg := p.cursorStyle()
|
|
require.Equal(t, config.ColorWhite, fg)
|
|
require.Equal(t, config.ColorRed, bg)
|
|
})
|
|
|
|
t.Run("QueryCursor with only fg set takes precedence", func(t *testing.T) {
|
|
styles := config.NewStyleSet()
|
|
styles.Query.Fg = config.ColorYellow
|
|
styles.Query.Bg = config.ColorBlue
|
|
styles.QueryCursor.Fg = config.ColorGreen
|
|
// bg remains config.ColorDefault
|
|
p := makePrompt(t, styles)
|
|
fg, bg := p.cursorStyle()
|
|
require.Equal(t, config.ColorGreen, fg)
|
|
require.Equal(t, config.ColorDefault, bg)
|
|
})
|
|
|
|
t.Run("Query has only fg set swaps correctly", func(t *testing.T) {
|
|
styles := config.NewStyleSet()
|
|
styles.Query.Fg = config.ColorRed
|
|
// Query.Bg remains config.ColorDefault
|
|
p := makePrompt(t, styles)
|
|
fg, bg := p.cursorStyle()
|
|
require.Equal(t, config.ColorDefault, fg, "should use Query.Bg (default) as cursor fg")
|
|
require.Equal(t, config.ColorRed, bg, "should use Query.Fg as cursor bg")
|
|
})
|
|
}
|
|
|
|
// TestInvalidAnchorReturnsError verifies that invalid vertical anchors
|
|
// produce errors instead of panics.
|
|
func TestInvalidAnchorReturnsError(t *testing.T) {
|
|
screen := NewDummyScreen()
|
|
invalidAnchor := VerticalAnchor(99)
|
|
|
|
t.Run("NewAnchorSettings", func(t *testing.T) {
|
|
_, err := NewAnchorSettings(screen, invalidAnchor, 0)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid vertical anchor")
|
|
})
|
|
|
|
t.Run("NewUserPrompt", func(t *testing.T) {
|
|
_, err := NewUserPrompt(screen, invalidAnchor, 0, "QUERY>", config.NewStyleSet())
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("NewListArea", func(t *testing.T) {
|
|
_, err := NewListArea(screen, invalidAnchor, 0, true, config.NewStyleSet())
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("newScreenStatusBar", func(t *testing.T) {
|
|
_, err := newScreenStatusBar(screen, invalidAnchor, 0, config.NewStyleSet())
|
|
require.Error(t, err)
|
|
})
|
|
}
|