返回 DeepSeek-Reasonix
scroll_state_test.go
根目录 / internal / cli / scroll_state_test.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6 "testing"
7 "time"
8
9 tea "charm.land/bubbletea/v2"
10
11 "reasonix/internal/control"
12 "reasonix/internal/event"
13 )
14
15 func TestUseLegacyViewportScrollClear(t *testing.T) {
16 for _, tt := range []struct {
17 name string
18 goos string
19 environ []string
20 want bool
21 }{
22 {name: "windows", goos: "windows", environ: []string{"TERM_PROGRAM=Windows_Terminal"}},
23 {name: "windows Warp", goos: "windows", environ: []string{"TERM_PROGRAM=WarpTerminal"}},
24 {name: "macOS Warp", goos: "darwin", environ: []string{"TERM_PROGRAM=WarpTerminal"}, want: true},
25 {name: "Linux Warp case and whitespace", goos: "linux", environ: []string{"TERM_PROGRAM= warPterminal "}, want: true},
26 {name: "SSH forwarded Warp", goos: "linux", environ: []string{"SSH_CONNECTION=client", "TERM_PROGRAM=WarpTerminal"}, want: true},
27 {name: "Apple Terminal", goos: "darwin", environ: []string{"TERM_PROGRAM=Apple_Terminal"}},
28 {name: "iTerm", goos: "darwin", environ: []string{"TERM_PROGRAM=iTerm.app"}},
29 {name: "Ghostty", goos: "darwin", environ: []string{"TERM_PROGRAM=ghostty"}},
30 {name: "Kitty", goos: "linux", environ: []string{"TERM=xterm-kitty"}},
31 {name: "empty environment", goos: "linux"},
32 {name: "lookalike variable", goos: "linux", environ: []string{"OTHER_TERM_PROGRAM=WarpTerminal"}},
33 } {
34 t.Run(tt.name, func(t *testing.T) {
35 got := useLegacyViewportScrollClear(tt.goos, tt.environ)
36 if got != tt.want {
37 t.Fatalf("useLegacyViewportScrollClear(%q, %q) = %v, want %v", tt.goos, tt.environ, got, tt.want)
38 }
39 })
40 }
41 }
42
43 func assertLegacyViewportClearCmd(t *testing.T, cmd tea.Cmd, want bool) {
44 t.Helper()
45 if got := cmd != nil; got != want {
46 t.Fatalf("viewport ClearScreen command = %v, want %v", got, want)
47 }
48 }
49
50 func TestModalOpenDoesNotDisableTailFollow(t *testing.T) {
51 // Opening an approval banner shrinks the transcript viewport. Without an
52 // explicit scroll state machine that used to make AtBottom() flip false and
53 // permanently stop tail-follow (#6430).
54 ctrl := newOwnedTestController(t, control.Options{})
55 adv := func(m chatTUI, msg tea.Msg) chatTUI {
56 n, _ := m.Update(msg)
57 return n.(chatTUI)
58 }
59 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
60
61 cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 12})
62 for range 20 {
63 cur = adv(cur, notice)
64 }
65 if !cur.shouldFollowTail() || !cur.viewport.AtBottom() {
66 t.Fatal("expected followTail at bottom after streaming")
67 }
68
69 // Simulate approval popup (height-only change, no user scroll).
70 cur.pendingApproval = &event.Approval{Tool: "bash", Subject: "echo hi"}
71 if !cur.modalOpen() {
72 t.Fatal("pendingApproval must report modalOpen")
73 }
74 cur = adv(cur, tea.WindowSizeMsg{Width: 80, Height: 12})
75 if !cur.shouldFollowTail() {
76 t.Fatal("modal open must not mark userScrolled")
77 }
78
79 cur = adv(cur, notice)
80 if !cur.viewport.AtBottom() {
81 t.Fatalf("new output while modal open must keep followTail pin, YOffset=%d", cur.viewport.YOffset())
82 }
83 }
84
85 func TestUserScrollBreaksAndEmptyEnterRestoresFollow(t *testing.T) {
86 ctrl := newOwnedTestController(t, control.Options{})
87 adv := func(m chatTUI, msg tea.Msg) chatTUI {
88 n, _ := m.Update(msg)
89 return n.(chatTUI)
90 }
91 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
92
93 cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 10})
94 for range 30 {
95 cur = adv(cur, notice)
96 }
97 cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
98 if cur.shouldFollowTail() {
99 t.Fatal("wheel-up must mark userScrolled")
100 }
101 cur = adv(cur, notice)
102 if cur.viewport.AtBottom() {
103 t.Fatal("output while userScrolled must not yank to bottom")
104 }
105 cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})
106 if !cur.shouldFollowTail() || !cur.viewport.AtBottom() {
107 t.Fatal("empty Enter must restore followTail and pin bottom")
108 }
109 }
110
111 func TestScrollbarDragMotionSyncsBeforeRelease(t *testing.T) {
112 // Drag motion must leave followTail immediately so an interleaved agent
113 // event cannot GotoBottom before MouseRelease.
114 ctrl := newOwnedTestController(t, control.Options{})
115 adv := func(m chatTUI, msg tea.Msg) chatTUI {
116 n, _ := m.Update(msg)
117 return n.(chatTUI)
118 }
119 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
120 cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 10})
121 for range 40 {
122 cur = adv(cur, notice)
123 }
124 if !cur.shouldFollowTail() {
125 t.Fatal("expected followTail before drag")
126 }
127 barX := cur.viewport.Width()
128 // Click thumb region near the top of the scrollbar.
129 cur = adv(cur, tea.MouseClickMsg{X: barX, Y: 0, Button: tea.MouseLeft})
130 if !cur.scrollbarDrag {
131 t.Fatal("expected scrollbar drag")
132 }
133 // First motion moves the offset — must mark userScrolled before release.
134 cur = adv(cur, tea.MouseMotionMsg{X: barX, Y: 0, Button: tea.MouseLeft})
135 if cur.shouldFollowTail() {
136 t.Fatal("drag motion must mark userScrolled before mouse release")
137 }
138 readOffset := cur.viewport.YOffset()
139 // Interleaved streaming event must not yank to bottom.
140 cur = adv(cur, notice)
141 if cur.viewport.AtBottom() {
142 t.Fatal("streaming during scrollbar drag must not jump to bottom")
143 }
144 if got := cur.viewport.YOffset(); got != readOffset {
145 // Offset may grow if content above expands; must not equal bottom.
146 if cur.viewport.AtBottom() {
147 t.Fatalf("YOffset jumped to bottom during drag: %d", got)
148 }
149 }
150 }
151
152 func TestWrapCacheAppendOnlyMatchesFullRebuild(t *testing.T) {
153 m := newTestChatTUI()
154 m.width = 40
155 cw := 40
156 for i := range 5 {
157 m.transcript = append(m.transcript, fmt.Sprintf("block-%d %s", i, strings.Repeat("word ", 20)))
158 }
159 m.rebuildWrappedLinesFull(cw)
160 full := append([]string(nil), m.wrappedLines...)
161
162 m2 := newTestChatTUI()
163 m2.width = 40
164 for i := range 5 {
165 m2.transcript = append(m2.transcript, m.transcript[i])
166 m2.syncWrappedLines(cw, i == 0)
167 }
168 if len(m2.wrappedLines) != len(full) {
169 t.Fatalf("incremental lines=%d full=%d", len(m2.wrappedLines), len(full))
170 }
171 for i := range full {
172 if m2.wrappedLines[i] != full[i] {
173 t.Fatalf("line %d mismatch\ninc=%q\nfull=%q", i, m2.wrappedLines[i], full[i])
174 }
175 }
176 }
177
178 func TestStreamAnswerSuffixInvalidationNotFullRebuild(t *testing.T) {
179 // Real event.Text → streamAnswer path must only re-wrap from answerIdx,
180 // leaving wrapBlockCount of the prefix intact between flushes.
181 ctrl := newOwnedTestController(t, control.Options{})
182 adv := func(m chatTUI, msg tea.Msg) chatTUI {
183 n, _ := m.Update(msg)
184 return n.(chatTUI)
185 }
186 cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 20})
187 // Seed history blocks.
188 for i := range 50 {
189 cur = adv(cur, agentEventMsg(event.Event{
190 Kind: event.Notice, Level: event.LevelInfo,
191 Text: fmt.Sprintf("hist-%d %s", i, strings.Repeat("x", 30)),
192 }))
193 }
194 prefixBlocks := cur.wrapBlockCount
195 if prefixBlocks < 50 {
196 t.Fatalf("prefix wrapBlockCount=%d, want >= 50", prefixBlocks)
197 }
198 // Stream a multi-paragraph answer that flushes more than once.
199 cur.state = tuiRunning
200 paras := []string{
201 "First paragraph of the answer that is long enough to wrap.\n\n",
202 "Second paragraph arrives later and rewrites the same block.\n\n",
203 "Third paragraph extends the live answer block again.\n\n",
204 }
205 for _, p := range paras {
206 cur = adv(cur, agentEventMsg(event.Event{Kind: event.Text, Text: p}))
207 // After each flush, the wrap cache should cover all blocks; the live
208 // answer is the last block so wrapBlockCount == len(transcript).
209 if cur.wrapBlockCount != len(cur.transcript) {
210 t.Fatalf("after stream wrapBlockCount=%d transcript=%d", cur.wrapBlockCount, len(cur.transcript))
211 }
212 // Prefix history blocks must still exist; answer is typically one block.
213 if len(cur.transcript) < prefixBlocks {
214 t.Fatal("history blocks disappeared during stream")
215 }
216 }
217 if cur.answerIdx < 0 {
218 // Paragraphs end with \n\n so streamAnswer must open a live answer block.
219 t.Fatal("streamAnswer never opened an answer block after flushable paragraphs")
220 }
221 // Prefix wrap lines must still match the joined content of history blocks.
222 if got := cur.wrappedContentString(); got == "" {
223 t.Fatal("wrappedContentString empty after streamed answer")
224 }
225 }
226
227 func TestClampCursorToTerminal(t *testing.T) {
228 cur := tea.NewCursor(100, -3)
229 got := clampCursorToTerminal(cur, 80, 24)
230 if got.X != 79 || got.Y != 0 {
231 t.Fatalf("clamp = (%d,%d), want (79,0)", got.X, got.Y)
232 }
233 }
234
235 func TestMouseReenableTrailingEdge(t *testing.T) {
236 // Controllable clock: first request emits; second inside the window arms
237 // trailing pending; timer msg after window emits again.
238 fakeNow := time.Unix(1_700_000_000, 0)
239 mouseNowFn = func() time.Time { return fakeNow }
240 t.Cleanup(func() { mouseNowFn = nil })
241
242 m := newTestChatTUI()
243 m.nativeScrollback = false
244 m.mouseCaptureOff = false
245
246 cmd1 := m.maybeReenableMouse()
247 if cmd1 == nil {
248 t.Fatal("first re-enable should emit")
249 }
250 if m.mouseReenablePending {
251 t.Fatal("first fire must clear pending")
252 }
253
254 // Immediate second request inside window → pending + timer.
255 cmd2 := m.maybeReenableMouse()
256 if !m.mouseReenablePending {
257 t.Fatal("second request inside window must set pending")
258 }
259 if !m.mouseReenableTimerArmed {
260 t.Fatal("second request must arm trailing timer")
261 }
262 // tea.Tick cmd is non-nil (schedule).
263 if cmd2 == nil {
264 t.Fatal("expected timer cmd for trailing edge")
265 }
266
267 // Third request should not arm another timer.
268 cmd3 := m.maybeReenableMouse()
269 if cmd3 != nil {
270 t.Fatal("already-armed timer must not schedule again")
271 }
272 if !m.mouseReenablePending {
273 t.Fatal("pending must stay set")
274 }
275
276 // Advance clock past the interval and deliver the timer msg.
277 fakeNow = fakeNow.Add(mouseReenableMinInterval + time.Millisecond)
278 cmd4 := m.handleMouseReenableMsg(mouseReenableMsg{})
279 if cmd4 == nil {
280 t.Fatal("trailing timer must emit enable when pending")
281 }
282 if m.mouseReenablePending {
283 t.Fatal("trailing fire must clear pending")
284 }
285 }
286
287 func TestMouseReenableRateLimit(t *testing.T) {
288 fakeNow := time.Unix(1_700_000_000, 0)
289 mouseNowFn = func() time.Time { return fakeNow }
290 t.Cleanup(func() { mouseNowFn = nil })
291
292 m := newTestChatTUI()
293 m.nativeScrollback = false
294 m.mouseCaptureOff = false
295 if m.maybeReenableMouse() == nil {
296 t.Fatal("first re-enable should emit a raw cmd")
297 }
298 // Second call is trailing schedule — pending path, not a silent drop.
299 if m.maybeReenableMouse() == nil && !m.mouseReenablePending {
300 t.Fatal("in-window call must arm trailing pending, not silently drop")
301 }
302 if !m.mouseReenablePending {
303 t.Fatal("in-window call must be pending, not silently dropped")
304 }
305 }
306
307 func TestLongTranscriptStreamAnswerScalesSuffixOnly(t *testing.T) {
308 // 1k history + many streaming flushes: wrapBlockCount tracks transcript and
309 // the answer stays a single rewritten block (no full-history force).
310 ctrl := newOwnedTestController(t, control.Options{})
311 adv := func(m chatTUI, msg tea.Msg) chatTUI {
312 n, _ := m.Update(msg)
313 return n.(chatTUI)
314 }
315 cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 20})
316 const hist = 1000
317 for i := range hist {
318 cur = adv(cur, agentEventMsg(event.Event{
319 Kind: event.Notice, Level: event.LevelInfo,
320 Text: fmt.Sprintf("h-%d-%s", i, strings.Repeat("y", 40)),
321 }))
322 }
323 cur.state = tuiRunning
324 for i := range 30 {
325 cur = adv(cur, agentEventMsg(event.Event{
326 Kind: event.Text,
327 Text: fmt.Sprintf("stream paragraph %d with enough text to flush.\n\n", i),
328 }))
329 if cur.wrapBlockCount != len(cur.transcript) {
330 t.Fatalf("iter %d wrapBlockCount=%d len=%d", i, cur.wrapBlockCount, len(cur.transcript))
331 }
332 }
333 if !cur.viewport.AtBottom() {
334 t.Fatal("followTail stream must stay at bottom")
335 }
336 }
337
338 func BenchmarkStreamAnswerSuffixWrap(b *testing.B) {
339 // Benchmark real event.Text flushes against a large history. Suffix-only
340 // invalidation should keep per-op cost independent of history length.
341 for _, hist := range []int{500, 1000, 2000} {
342 b.Run(fmt.Sprintf("hist=%d", hist), func(b *testing.B) {
343 ctrl := newOwnedTestController(b, control.Options{})
344 adv := func(m chatTUI, msg tea.Msg) chatTUI {
345 n, _ := m.Update(msg)
346 return n.(chatTUI)
347 }
348 base := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 24})
349 for i := range hist {
350 base = adv(base, agentEventMsg(event.Event{
351 Kind: event.Notice, Level: event.LevelInfo,
352 Text: fmt.Sprintf("h-%d-%s", i, strings.Repeat("z", 48)),
353 }))
354 }
355 b.ReportAllocs()
356 b.ResetTimer()
357 for i := range b.N {
358 m := base // copy model value (shallow; OK for bench of Update path)
359 m.state = tuiRunning
360 m.pending = &strings.Builder{}
361 m.answerIdx = -1
362 m.answerFlushed = 0
363 // One flushable paragraph per iteration.
364 m = adv(m, agentEventMsg(event.Event{
365 Kind: event.Text,
366 Text: fmt.Sprintf("bench paragraph %d with body text that closes.\n\n", i),
367 }))
368 if m.wrapBlockCount != len(m.transcript) {
369 b.Fatalf("wrapBlockCount=%d len=%d", m.wrapBlockCount, len(m.transcript))
370 }
371 }
372 })
373 }
374 }
375
375 lines GO