peco.peco/follow_test.go
2026-06-04 11:16:23 +09:00

195 lines
5.8 KiB
Go

package peco
import (
"fmt"
"strings"
"testing"
"github.com/peco/peco/filter"
"github.com/peco/peco/hub"
"github.com/peco/peco/line"
"github.com/stretchr/testify/require"
)
// makeFollowBuffer builds a MemoryBuffer with n lines labelled "lineN".
func makeFollowBuffer(n int) *MemoryBuffer {
mb := NewMemoryBuffer(0)
for i := range n {
mb.lines = append(mb.lines, line.NewRaw(uint64(i), fmt.Sprintf("line%d", i), false, false))
}
return mb
}
// readScreenRow returns the text rendered on row y of the simulation screen,
// trailing blanks trimmed.
func readScreenRow(t *testing.T, s *SimScreen, y int) string {
t.Helper()
cells, w, _ := s.screen.GetContents()
var b strings.Builder
for x := range w {
c := cells[y*w+x]
if len(c.Runes) == 0 || c.Runes[0] == 0 {
b.WriteRune(' ')
continue
}
b.WriteRune(c.Runes[0])
}
return strings.TrimRight(b.String(), " ")
}
func newFollowState(t *testing.T, buf Buffer) (*Peco, *SimScreen) {
t.Helper()
screen := NewDummyScreen()
state := New()
state.screen = screen
state.Filters().Add(filter.NewIgnoreCase())
state.currentLineBuffer = buf
return state, screen
}
func TestWindowCrop(t *testing.T) {
buf := makeFollowBuffer(100)
t.Run("tail window", func(t *testing.T) {
fb := WindowCrop{offset: 90, perPage: 10}.Crop(buf)
require.Equal(t, 10, fb.Size())
first, err := fb.LineAt(0)
require.NoError(t, err)
require.Equal(t, "line90", first.DisplayString())
last, err := fb.LineAt(9)
require.NoError(t, err)
require.Equal(t, "line99", last.DisplayString())
})
t.Run("partial window smaller than perPage", func(t *testing.T) {
small := makeFollowBuffer(5)
fb := WindowCrop{offset: 0, perPage: 10}.Crop(small)
require.Equal(t, 5, fb.Size())
})
t.Run("negative offset clamps to zero", func(t *testing.T) {
fb := WindowCrop{offset: -5, perPage: 3}.Crop(buf)
require.Equal(t, 3, fb.Size())
first, err := fb.LineAt(0)
require.NoError(t, err)
require.Equal(t, "line0", first.DisplayString())
})
t.Run("offset past end yields empty", func(t *testing.T) {
fb := WindowCrop{offset: 200, perPage: 10}.Crop(buf)
require.Equal(t, 0, fb.Size())
})
}
// TestGHIssue820_FollowKeepsNewestVisible verifies that with follow mode on,
// DrawScreen pins the viewport to the tail of the buffer so the newest line
// is rendered on the last visible row (like tail -f), regardless of page
// alignment.
func TestGHIssue820_FollowKeepsNewestVisible(t *testing.T) {
buf := makeFollowBuffer(50)
state, screen := newFollowState(t, buf)
state.Follow().Set(true)
// top-down-query-bottom is the layout from the issue: list anchored to
// the top (row 0), query at the bottom.
layout, err := TopDownQueryBottomLayout(state)
require.NoError(t, err)
layout.DrawScreen(state, nil)
loc := state.Location()
perPage := layout.linesPerPage()
require.Equal(t, 49, loc.LineNumber(), "cursor should be pinned to the newest line")
require.Equal(t, 50, loc.Total())
require.Equal(t, max(50-perPage, 0), loc.Offset(),
"offset should be a sliding window over the tail, not page-aligned")
// The list area is anchored at row 0; the newest line sits on the last
// visible row.
lastRow := perPage - 1
require.Equal(t, "line49", readScreenRow(t, screen, lastRow),
"newest line should be on the bottom row of the list area")
}
// TestFollowDefaultShowsHead verifies that without follow mode the viewport
// stays at the head of the buffer (unchanged behavior).
func TestFollowDefaultShowsHead(t *testing.T) {
buf := makeFollowBuffer(50)
state, screen := newFollowState(t, buf)
require.False(t, state.IsFollowing())
layout, err := TopDownQueryBottomLayout(state)
require.NoError(t, err)
layout.DrawScreen(state, nil)
loc := state.Location()
require.Equal(t, 0, loc.LineNumber())
require.Equal(t, 0, loc.Offset())
require.Equal(t, "line0", readScreenRow(t, screen, 0),
"oldest line should be on the top row when not following")
}
// TestFollowDisabledByManualScroll verifies that any manual vertical
// navigation turns follow mode off.
func TestFollowDisabledByManualScroll(t *testing.T) {
buf := makeFollowBuffer(50)
state, _ := newFollowState(t, buf)
state.Follow().Set(true)
layout, err := TopDownQueryBottomLayout(state)
require.NoError(t, err)
// Pin to the tail first.
layout.DrawScreen(state, nil)
require.True(t, state.IsFollowing())
moved := layout.MovePage(state, hub.ToLineAbove)
require.True(t, moved)
require.False(t, state.IsFollowing(),
"manual vertical scroll should disable follow mode")
}
// TestToggleFollowAction verifies the ToggleFollow action flips follow state.
func TestToggleFollowAction(t *testing.T) {
state := New()
state.hub = nullHub{}
require.False(t, state.IsFollowing())
doToggleFollow(t.Context(), state, Event{})
require.True(t, state.IsFollowing(), "follow should be on after first toggle")
doToggleFollow(t.Context(), state, Event{})
require.False(t, state.IsFollowing(), "follow should be off after second toggle")
}
// TestFollowConfigAndFlag verifies that --follow and the Follow config field
// both enable follow mode, with the CLI flag taking precedence.
func TestFollowConfigAndFlag(t *testing.T) {
t.Run("CLI flag", func(t *testing.T) {
p := newPeco()
require.NoError(t, p.ApplyConfig(CLIOptions{OptFollow: true}))
require.True(t, p.IsFollowing())
})
t.Run("config field", func(t *testing.T) {
p := newPeco()
p.config.Follow = true
require.NoError(t, p.ApplyConfig(CLIOptions{}))
require.True(t, p.IsFollowing())
})
t.Run("default off", func(t *testing.T) {
p := newPeco()
require.NoError(t, p.ApplyConfig(CLIOptions{}))
require.False(t, p.IsFollowing())
})
}
// TestToggleFollowRegistered verifies the action is wired into the registry so
// it can be bound from a keymap.
func TestToggleFollowRegistered(t *testing.T) {
_, ok := nameToActions["peco.ToggleFollow"]
require.True(t, ok, "peco.ToggleFollow should be registered")
}