1package client
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"
11)
12
13var echoTestStart = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
14
15// echoHarness drives one predictor against a 20x3 mirror whose prompt "$ "
16// leaves the cursor at row 0, column 2.
17type echoHarness struct {
18 t *testing.T
19 p *echoPredictor
20 now time.Time
21 seq uint64
22}
23
24func newEchoHarness(t *testing.T, mode domain.EchoPredictMode) *echoHarness {
25 t.Helper()
26 h := &echoHarness{t: t, p: newEchoPredictor(mode), now: echoTestStart}
27 h.p.applyOutput(protocol.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now)
28 return h
29}
30
31// warm sends one empty input and acknowledges it after rtt of network time.
32func (h *echoHarness) warm(rtt time.Duration) {
33 h.seq++
34 h.p.input(h.seq, nil, h.now)
35 h.now = h.now.Add(rtt + echoServerDelay)
36 h.p.applyOutput(protocol.Output{Epoch: 1, Echo: h.seq}, h.now)
37}
38
39func (h *echoHarness) typed(data string) []byte {
40 h.seq++
41 h.p.input(h.seq, []byte(data), h.now)
42 return h.p.render()
43}
44
45// daemon applies a state frame that writes data and acknowledges echo.
46func (h *echoHarness) daemon(data string, echo uint64) []byte {
47 h.now = h.now.Add(time.Millisecond)
48 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Echo: echo, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(data)}, h.now)
49 return h.p.render()
50}
51
52// confirm types a and lets the daemon echo it, which confirms the first epoch.
53func (h *echoHarness) confirm() {
54 h.t.Helper()
55 if got := h.typed("a"); len(got) != 0 {
56 h.t.Fatalf("first guess is tentative, rendered %q", got)
57 }
58 h.daemon("\x1b[1;3Ha", h.seq)
59 if h.p.confEpoch == 0 {
60 h.t.Fatal("echoed guess did not confirm its epoch")
61 }
62}
63
64func TestEchoPredictorDisplay(t *testing.T) {
65 t.Parallel()
66 tests := []struct {
67 name string
68 mode domain.EchoPredictMode
69 rtt time.Duration
70 want string
71 }{
72 {name: "fast link hides guesses", mode: domain.EchoPredictAdaptive, rtt: 10 * time.Millisecond, want: ""},
73 {name: "slow link shows guesses", mode: domain.EchoPredictAdaptive, rtt: 100 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
74 {name: "very slow link underlines guesses", mode: domain.EchoPredictAdaptive, rtt: 200 * time.Millisecond, want: "\x1b[1;4H\x1b[0;4mb\x1b[0m\x1b[1;5H"},
75 {name: "always shows on a fast link", mode: domain.EchoPredictAlways, rtt: 10 * time.Millisecond, want: "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"},
76 {name: "never predicts", mode: domain.EchoPredictNever, rtt: 200 * time.Millisecond, want: ""},
77 }
78 for _, tt := range tests {
79 t.Run(tt.name, func(t *testing.T) {
80 t.Parallel()
81 h := newEchoHarness(t, tt.mode)
82 h.warm(tt.rtt)
83 if tt.mode != domain.EchoPredictNever {
84 h.confirm()
85 }
86 if got := string(h.typed("b")); got != tt.want {
87 t.Fatalf("render = %q, want %q", got, tt.want)
88 }
89 })
90 }
91}
92
93// TestEchoPredictorNeverDrawsStaleGuesses covers daemon frames that arrive
94// before the echo acknowledgement and move or replace the guessed row.
95func TestEchoPredictorNeverDrawsStaleGuesses(t *testing.T) {
96 t.Parallel()
97 tests := []struct {
98 name string
99 input string
100 frame protocol.Output
101 }{
102 {
103 name: "enter then a scrolled prompt before the ack",
104 input: "b\r",
105 frame: protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("b\r\nfoo\r\nbar\r\n$ ")},
106 },
107 {
108 name: "full frame before the ack",
109 input: "b",
110 frame: protocol.Output{Epoch: 1, New: 2, Full: true, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[H\x1b[2Jother")},
111 },
112 }
113 for _, tt := range tests {
114 t.Run(tt.name, func(t *testing.T) {
115 t.Parallel()
116 h := newEchoHarness(t, domain.EchoPredictAlways)
117 h.warm(100 * time.Millisecond)
118 h.confirm()
119 h.typed(tt.input)
120 h.p.undraw()
121 h.p.applyOutput(tt.frame, h.now)
122 if got := h.p.render(); bytes.Contains(got, []byte("b")) {
123 t.Fatalf("render = %q redraws a guess over the new frame", got)
124 }
125 })
126 }
127}
128
129func TestEchoPredictorEnterKeepsDrawnGuessesUntilAFrame(t *testing.T) {
130 t.Parallel()
131 h := newEchoHarness(t, domain.EchoPredictAlways)
132 h.warm(100 * time.Millisecond)
133 h.confirm()
134 if got := h.typed("b"); !bytes.Contains(got, []byte("b")) {
135 t.Fatalf("render = %q, want the guess drawn", got)
136 }
137 if got := h.typed("\r"); bytes.Contains(got, []byte(" ")) {
138 t.Fatalf("render after Enter = %q erases the typed guess before any frame", got)
139 }
140}
141
142func TestEchoPredictorUndrawRestoresMirror(t *testing.T) {
143 t.Parallel()
144 h := newEchoHarness(t, domain.EchoPredictAlways)
145 h.warm(100 * time.Millisecond)
146 h.confirm()
147 if got := h.typed("b"); !bytes.Contains(got, []byte("b")) {
148 t.Fatalf("render = %q, want the guess drawn", got)
149 }
150 if got, want := string(h.p.undraw()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
151 t.Fatalf("undraw = %q, want %q", got, want)
152 }
153 if got := h.p.undraw(); len(got) != 0 {
154 t.Fatalf("second undraw = %q, want nothing", got)
155 }
156}
157
158func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) {
159 t.Parallel()
160 h := newEchoHarness(t, domain.EchoPredictAdaptive)
161 h.warm(200 * time.Millisecond)
162 h.confirm()
163 if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") {
164 t.Fatalf("pending guess is not underlined: %q", got)
165 }
166 got := string(h.daemon("\x1b[1;4Hb", h.seq))
167 if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want {
168 t.Fatalf("confirmed render = %q, want %q", got, want)
169 }
170 if h.p.active() {
171 t.Fatal("confirmed guess is still active")
172 }
173}
174
175func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) {
176 t.Parallel()
177 h := newEchoHarness(t, domain.EchoPredictAdaptive)
178 h.warm(100 * time.Millisecond)
179 h.confirm()
180 if got := string(h.typed("p")); !strings.Contains(got, "p") {
181 t.Fatalf("guess not drawn: %q", got)
182 }
183 // A password prompt echoes nothing.
184 got := string(h.daemon("", h.seq))
185 if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
186 t.Fatalf("render after wrong echo = %q, want %q", got, want)
187 }
188 if h.p.active() {
189 t.Fatal("wrong guess survived")
190 }
191 // The reset makes the next guess tentative again.
192 if got := h.typed("q"); len(got) != 0 {
193 t.Fatalf("guess after reset rendered %q", got)
194 }
195}
196
197func TestEchoPredictorBackspace(t *testing.T) {
198 t.Parallel()
199 h := newEchoHarness(t, domain.EchoPredictAdaptive)
200 h.warm(100 * time.Millisecond)
201 h.confirm()
202 got := string(h.typed("\x7f"))
203 if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
204 t.Fatalf("backspace render = %q, want %q", got, want)
205 }
206 got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq))
207 if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
208 t.Fatalf("confirmed backspace render = %q, want %q", got, want)
209 }
210 if h.p.active() {
211 t.Fatal("confirmed backspace is still active")
212 }
213}
214
215func TestEchoPredictorInsertShiftsText(t *testing.T) {
216 t.Parallel()
217 h := newEchoHarness(t, domain.EchoPredictAlways)
218 h.warm(10 * time.Millisecond)
219 // "$ ac" with the cursor on c.
220 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now)
221 h.p.confEpoch = h.p.predEpoch
222 got := string(h.typed("b"))
223 if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want {
224 t.Fatalf("insert render = %q, want %q", got, want)
225 }
226}
227
228func TestEchoPredictorUnknownInputIsTentative(t *testing.T) {
229 t.Parallel()
230 tests := []struct {
231 name string
232 input string
233 }{
234 {name: "control byte", input: "\x03"},
235 {name: "escape sequence", input: "\x1b[A"},
236 {name: "kitty CSI-u key", input: "\x1b[97;5u"},
237 {name: "carriage return", input: "\r"},
238 {name: "wide rune", input: "界"},
239 {name: "bracketed paste", input: "\x1b[200~text\x1b[201~"},
240 }
241 for _, tt := range tests {
242 t.Run(tt.name, func(t *testing.T) {
243 t.Parallel()
244 h := newEchoHarness(t, domain.EchoPredictAdaptive)
245 h.warm(100 * time.Millisecond)
246 h.confirm()
247 h.typed(tt.input)
248 if got := h.typed("z"); len(got) != 0 {
249 t.Fatalf("guess after %s rendered %q", tt.name, got)
250 }
251 })
252 }
253}
254
255func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) {
256 t.Parallel()
257 h := newEchoHarness(t, domain.EchoPredictAdaptive)
258 h.warm(100 * time.Millisecond)
259 h.confirm()
260 confirmed := h.p.confEpoch
261 h.typed("\x03z")
262 // The daemon cleared the line instead of echoing z.
263 h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq)
264 if h.p.active() {
265 t.Fatal("killed epoch left guesses")
266 }
267 if h.p.confEpoch != confirmed {
268 t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed)
269 }
270}
271
272func TestEchoPredictorHiddenCursorDisables(t *testing.T) {
273 t.Parallel()
274 h := newEchoHarness(t, domain.EchoPredictAlways)
275 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now)
276 h.p.confEpoch = h.p.predEpoch
277 if got := h.typed("b"); len(got) != 0 {
278 t.Fatalf("hidden cursor rendered %q", got)
279 }
280 if h.p.active() {
281 t.Fatal("hidden cursor kept guesses")
282 }
283}
284
285func TestEchoPredictorSRTTHysteresis(t *testing.T) {
286 t.Parallel()
287 h := newEchoHarness(t, domain.EchoPredictAdaptive)
288 h.warm(200 * time.Millisecond)
289 if !h.p.srttOn || !h.p.flagging {
290 t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging)
291 }
292 sawMiddle := false
293 for range 64 {
294 h.warm(0)
295 interval := h.p.sendInterval()
296 if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh {
297 sawMiddle = true
298 if !h.p.srttOn {
299 t.Fatalf("display turned off at send interval %v", interval)
300 }
301 }
302 if interval <= echoFlagTriggerLow && h.p.flagging {
303 t.Fatalf("still flagging at send interval %v", interval)
304 }
305 if interval <= echoSRTTTriggerLow {
306 break
307 }
308 }
309 if !sawMiddle {
310 t.Fatal("never sampled the hysteresis band")
311 }
312 if h.p.srttOn {
313 t.Fatal("display still on after the link recovered")
314 }
315}
316
317func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) {
318 t.Parallel()
319 h := newEchoHarness(t, domain.EchoPredictAdaptive)
320 h.warm(100 * time.Millisecond)
321 h.confirm()
322 h.typed("b")
323 // Fast acknowledgements of other input keep the b guess pending.
324 h.p.srtt = 0
325 h.p.cull(h.now)
326 if !h.p.srttOn {
327 t.Fatal("display turned off while a guess is shown")
328 }
329}
330
331func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) {
332 t.Parallel()
333 h := newEchoHarness(t, domain.EchoPredictAdaptive)
334 h.warm(0)
335 h.confirm()
336 if got := h.typed("b"); len(got) != 0 {
337 t.Fatalf("fast link rendered %q", got)
338 }
339 if !h.p.needsTick() {
340 t.Fatal("pending guess needs no timing tick")
341 }
342 h.now = h.now.Add(echoGlitchThreshold)
343 h.p.cull(h.now)
344 if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" {
345 t.Fatalf("glitch render = %q", got)
346 }
347 h.now = h.now.Add(echoGlitchFlagThreshold)
348 h.p.cull(h.now)
349 h.p.cull(h.now)
350 if !h.p.flagging {
351 t.Fatal("long glitch does not underline")
352 }
353 if h.p.needsTick() {
354 t.Fatal("timing tick still armed once every trigger fired")
355 }
356}
357
358func TestEchoPredictorResizeForgetsGuesses(t *testing.T) {
359 t.Parallel()
360 h := newEchoHarness(t, domain.EchoPredictAlways)
361 h.warm(100 * time.Millisecond)
362 h.confirm()
363 h.typed("b")
364 h.p.applyOutput(protocol.Output{Epoch: 2, New: 1, Full: true, Size: domain.Size{Cols: 30, Rows: 3}, Data: []byte("\x1b[H\x1b[2J$ ")}, h.now)
365 if h.p.active() || len(h.p.drawn) != 0 {
366 t.Fatal("resize kept guesses")
367 }
368 if got := h.p.render(); len(got) != 0 {
369 t.Fatalf("render after resize = %q", got)
370 }
371}
372
373func TestEchoSGR(t *testing.T) {
374 t.Parallel()
375 style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style
376 style.Bold = true
377 style.Foreground = 9
378 style.Background = 200
379 if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want {
380 t.Fatalf("echoSGR = %q, want %q", got, want)
381 }
382 style.HasForegroundRGB = true
383 style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3
384 if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want {
385 t.Fatalf("echoSGR rgb = %q, want %q", got, want)
386 }
387}
388
389func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) {
390 t.Parallel()
391 h := newEchoHarness(t, domain.EchoPredictAlways)
392 h.p.confEpoch = h.p.predEpoch
393 if got := h.typed("b"); len(got) != 0 || h.p.active() {
394 t.Fatalf("predicted before any echo acknowledgement: %q", got)
395 }
396}
397
398func TestEchoPredictorRefusesUnshiftableText(t *testing.T) {
399 t.Parallel()
400 tests := []struct {
401 name string
402 frame string
403 }{
404 {name: "wide rune after the cursor", frame: "a界\x1b[1;3H"},
405 {name: "pane border after the cursor", frame: "ab│\x1b[1;3H"},
406 }
407 for _, tt := range tests {
408 t.Run(tt.name, func(t *testing.T) {
409 t.Parallel()
410 h := newEchoHarness(t, domain.EchoPredictAlways)
411 h.warm(10 * time.Millisecond)
412 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now)
413 h.p.confEpoch = h.p.predEpoch
414 if got := h.typed("x"); len(got) != 0 || h.p.active() {
415 t.Fatalf("shifted unshiftable text: %q", got)
416 }
417 })
418 }
419}
420
421func TestEchoPredictorRestoresWideCellWhole(t *testing.T) {
422 t.Parallel()
423 h := newEchoHarness(t, domain.EchoPredictAlways)
424 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[1;1H\x1b[4:3m界\x1b[0m")}, h.now)
425 var out bytes.Buffer
426 h.p.restoreCell(&out, 0, 1)
427 if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want {
428 t.Fatalf("restoreCell = %q, want %q", got, want)
429 }
430}
431
432func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) {
433 t.Parallel()
434 h := newEchoHarness(t, domain.EchoPredictAlways)
435 h.warm(10 * time.Millisecond)
436 h.confirm()
437 if got := h.typed("b"); len(got) == 0 {
438 t.Fatal("guess not drawn")
439 }
440 h.p.reset()
441 if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
442 t.Fatalf("render after reset = %q, want %q", got, want)
443 }
444}
445
446// TestEchoPredictorTypesAheadInModalInput covers daemon modals (command
447// palette, prompt, copy search): the daemon places the real cursor on their
448// input line inside a bordered box, and guesses stay inside the box.
449func TestEchoPredictorTypesAheadInModalInput(t *testing.T) {
450 t.Parallel()
451 h := newEchoHarness(t, domain.EchoPredictAlways)
452 // Row 2: "│> " then the cursor, with the box's right border at column 15.
453 h.daemon("\x1b[2;1H│> \x1b[2;16H│\x1b[2;4H", 0)
454 h.warm(10 * time.Millisecond)
455 if got := h.typed("s"); len(got) != 0 {
456 t.Fatalf("first guess is tentative, rendered %q", got)
457 }
458 h.daemon("\x1b[2;4Hs", h.seq)
459 got := string(h.typed("p"))
460 if want := "\x1b[2;5H\x1b[0mp\x1b[0m\x1b[2;6H"; got != want {
461 t.Fatalf("render = %q, want %q", got, want)
462 }
463 if cell := h.p.screen.Cell(15, 1); cell.Rune != '│' {
464 t.Fatalf("box border moved: %q", cell.Rune)
465 }
466}
467
468// TestEchoPredictorBackspaceStopsAtBorder holds backspace past an empty
469// modal query: the input's left border is never predicted away.
470func TestEchoPredictorBackspaceStopsAtBorder(t *testing.T) {
471 t.Parallel()
472 h := newEchoHarness(t, domain.EchoPredictAlways)
473 h.daemon("\x1b[2;1H│\x1b[2;16H│\x1b[2;2H", 0)
474 h.warm(10 * time.Millisecond)
475 h.typed("s")
476 h.daemon("\x1b[2;2Hs", h.seq)
477 h.typed("\x7f")
478 got := string(h.typed("\x7f"))
479 if strings.Contains(got, "\x1b[2;1H") {
480 t.Fatalf("backspace predicted over the border: %q", got)
481 }
482 if h.p.cursor.active && h.p.cursor.col < 1 {
483 t.Fatalf("predicted cursor crossed the border: col %d", h.p.cursor.col)
484 }
485}
486
487func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
488 t.Parallel()
489 var f apcFilter
490 chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"}
491 var got []byte
492 for _, chunk := range chunks {
493 got = append(got, f.strip([]byte(chunk))...)
494 }
495 got = append(got, f.strip([]byte("[0m"))...)
496 if want := "ab\x1b[1mc\x1b[0m"; string(got) != want {
497 t.Fatalf("strip = %q, want %q", got, want)
498 }
499}
500
501func TestEscapeLength(t *testing.T) {
502 t.Parallel()
503 tests := []struct {
504 in string
505 want int
506 }{
507 {in: "\x1b[A", want: 3},
508 {in: "\x1bOA", want: 3},
509 {in: "\x1b]11;?\x07x", want: 7},
510 {in: "\x1b]11;?\x1b\\x", want: 8},
511 {in: "\x1b]11;", want: 5},
512 {in: "\x1bx", want: 2},
513 }
514 for _, tt := range tests {
515 if got := escapeLength([]byte(tt.in)); got != tt.want {
516 t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want)
517 }
518 }
519}