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