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) }) }