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 tests := []struct {
145 name, typed, want string
146 }{
147 {name: "drawn guess", typed: "b", want: "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"},
148 // A blank guess matches the mirror cell, so only the cursor moved.
149 {name: "cursor-only guess", typed: " ", want: "\x1b[0m\x1b[1;4H"},
150 }
151 for _, tt := range tests {
152 t.Run(tt.name, func(t *testing.T) {
153 t.Parallel()
154 h := newEchoHarness(t, domain.EchoPredictAlways)
155 h.warm(100 * time.Millisecond)
156 h.confirm()
157 if got := h.typed(tt.typed); len(got) == 0 {
158 t.Fatal("guess rendered nothing")
159 }
160 if got := string(h.p.undraw()); got != tt.want {
161 t.Fatalf("undraw = %q, want %q", got, tt.want)
162 }
163 if got := h.p.undraw(); len(got) != 0 {
164 t.Fatalf("second undraw = %q, want nothing", got)
165 }
166 })
167 }
168}
169
170func TestEchoPredictorConfirmationRepaintsFromMirror(t *testing.T) {
171 t.Parallel()
172 h := newEchoHarness(t, domain.EchoPredictAdaptive)
173 h.warm(200 * time.Millisecond)
174 h.confirm()
175 if got := string(h.typed("b")); !strings.Contains(got, "\x1b[0;4mb") {
176 t.Fatalf("pending guess is not underlined: %q", got)
177 }
178 got := string(h.daemon("\x1b[1;4Hb", h.seq))
179 if want := "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H"; got != want {
180 t.Fatalf("confirmed render = %q, want %q", got, want)
181 }
182 if h.p.active() {
183 t.Fatal("confirmed guess is still active")
184 }
185}
186
187func TestEchoPredictorWrongEchoErasesGuess(t *testing.T) {
188 t.Parallel()
189 h := newEchoHarness(t, domain.EchoPredictAdaptive)
190 h.warm(100 * time.Millisecond)
191 h.confirm()
192 if got := string(h.typed("p")); !strings.Contains(got, "p") {
193 t.Fatalf("guess not drawn: %q", got)
194 }
195 // A password prompt echoes nothing.
196 got := string(h.daemon("", h.seq))
197 if want := "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
198 t.Fatalf("render after wrong echo = %q, want %q", got, want)
199 }
200 if h.p.active() {
201 t.Fatal("wrong guess survived")
202 }
203 // The reset makes the next guess tentative again.
204 if got := h.typed("q"); len(got) != 0 {
205 t.Fatalf("guess after reset rendered %q", got)
206 }
207}
208
209func TestEchoPredictorBackspace(t *testing.T) {
210 t.Parallel()
211 h := newEchoHarness(t, domain.EchoPredictAdaptive)
212 h.warm(100 * time.Millisecond)
213 h.confirm()
214 got := string(h.typed("\x7f"))
215 if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
216 t.Fatalf("backspace render = %q, want %q", got, want)
217 }
218 got = string(h.daemon("\x1b[1;3H \x1b[1;3H", h.seq))
219 if want := "\x1b[1;3H\x1b[0m \x1b[0m\x1b[1;3H"; got != want {
220 t.Fatalf("confirmed backspace render = %q, want %q", got, want)
221 }
222 if h.p.active() {
223 t.Fatal("confirmed backspace is still active")
224 }
225}
226
227func TestEchoPredictorInsertShiftsText(t *testing.T) {
228 t.Parallel()
229 h := newEchoHarness(t, domain.EchoPredictAlways)
230 h.warm(10 * time.Millisecond)
231 // "$ ac" with the cursor on c.
232 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("ac\x1b[1;4H")}, h.now)
233 h.p.confEpoch = h.p.predEpoch
234 got := string(h.typed("b"))
235 if want := "\x1b[1;4H\x1b[0mb\x1b[1;5H\x1b[0mc\x1b[0m\x1b[1;5H"; got != want {
236 t.Fatalf("insert render = %q, want %q", got, want)
237 }
238}
239
240func TestEchoPredictorUnknownInputIsTentative(t *testing.T) {
241 t.Parallel()
242 tests := []struct {
243 name string
244 input string
245 }{
246 {name: "control byte", input: "\x03"},
247 {name: "escape sequence", input: "\x1b[A"},
248 {name: "kitty CSI-u key", input: "\x1b[97;5u"},
249 {name: "carriage return", input: "\r"},
250 {name: "wide rune", input: "界"},
251 {name: "bracketed paste", input: "\x1b[200~text\x1b[201~"},
252 }
253 for _, tt := range tests {
254 t.Run(tt.name, func(t *testing.T) {
255 t.Parallel()
256 h := newEchoHarness(t, domain.EchoPredictAdaptive)
257 h.warm(100 * time.Millisecond)
258 h.confirm()
259 h.typed(tt.input)
260 if got := h.typed("z"); len(got) != 0 {
261 t.Fatalf("guess after %s rendered %q", tt.name, got)
262 }
263 })
264 }
265}
266
267func TestEchoPredictorWrongTentativeGuessKillsItsEpoch(t *testing.T) {
268 t.Parallel()
269 h := newEchoHarness(t, domain.EchoPredictAdaptive)
270 h.warm(100 * time.Millisecond)
271 h.confirm()
272 confirmed := h.p.confEpoch
273 h.typed("\x03z")
274 // The daemon cleared the line instead of echoing z.
275 h.daemon("\x1b[1;1H\x1b[2K$ ", h.seq)
276 if h.p.active() {
277 t.Fatal("killed epoch left guesses")
278 }
279 if h.p.confEpoch != confirmed {
280 t.Fatalf("confirmed epoch = %d, want %d", h.p.confEpoch, confirmed)
281 }
282}
283
284func TestEchoPredictorHiddenCursorDisables(t *testing.T) {
285 t.Parallel()
286 h := newEchoHarness(t, domain.EchoPredictAlways)
287 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte("\x1b[?25l")}, h.now)
288 h.p.confEpoch = h.p.predEpoch
289 if got := h.typed("b"); len(got) != 0 {
290 t.Fatalf("hidden cursor rendered %q", got)
291 }
292 if h.p.active() {
293 t.Fatal("hidden cursor kept guesses")
294 }
295}
296
297func TestEchoPredictorSRTTHysteresis(t *testing.T) {
298 t.Parallel()
299 h := newEchoHarness(t, domain.EchoPredictAdaptive)
300 h.warm(200 * time.Millisecond)
301 if !h.p.srttOn || !h.p.flagging {
302 t.Fatalf("slow link: srttOn=%v flagging=%v, want both on", h.p.srttOn, h.p.flagging)
303 }
304 sawMiddle := false
305 for range 64 {
306 h.warm(0)
307 interval := h.p.sendInterval()
308 if interval > echoSRTTTriggerLow && interval <= echoSRTTTriggerHigh {
309 sawMiddle = true
310 if !h.p.srttOn {
311 t.Fatalf("display turned off at send interval %v", interval)
312 }
313 }
314 if interval <= echoFlagTriggerLow && h.p.flagging {
315 t.Fatalf("still flagging at send interval %v", interval)
316 }
317 if interval <= echoSRTTTriggerLow {
318 break
319 }
320 }
321 if !sawMiddle {
322 t.Fatal("never sampled the hysteresis band")
323 }
324 if h.p.srttOn {
325 t.Fatal("display still on after the link recovered")
326 }
327}
328
329func TestEchoPredictorSRTTStaysOnWhileGuessesShow(t *testing.T) {
330 t.Parallel()
331 h := newEchoHarness(t, domain.EchoPredictAdaptive)
332 h.warm(100 * time.Millisecond)
333 h.confirm()
334 h.typed("b")
335 // Fast acknowledgements of other input keep the b guess pending.
336 h.p.srtt = 0
337 h.p.cull(h.now)
338 if !h.p.srttOn {
339 t.Fatal("display turned off while a guess is shown")
340 }
341}
342
343func TestEchoPredictorGlitchShowsAndFlags(t *testing.T) {
344 t.Parallel()
345 h := newEchoHarness(t, domain.EchoPredictAdaptive)
346 h.warm(0)
347 h.confirm()
348 if got := h.typed("b"); len(got) != 0 {
349 t.Fatalf("fast link rendered %q", got)
350 }
351 if !h.p.needsTick() {
352 t.Fatal("pending guess needs no timing tick")
353 }
354 h.now = h.now.Add(echoGlitchThreshold)
355 h.p.cull(h.now)
356 if got := string(h.p.render()); got != "\x1b[1;4H\x1b[0mb\x1b[0m\x1b[1;5H" {
357 t.Fatalf("glitch render = %q", got)
358 }
359 h.now = h.now.Add(echoGlitchFlagThreshold)
360 h.p.cull(h.now)
361 h.p.cull(h.now)
362 if !h.p.flagging {
363 t.Fatal("long glitch does not underline")
364 }
365 if h.p.needsTick() {
366 t.Fatal("timing tick still armed once every trigger fired")
367 }
368}
369
370func TestEchoPredictorResizeForgetsGuesses(t *testing.T) {
371 t.Parallel()
372 h := newEchoHarness(t, domain.EchoPredictAlways)
373 h.warm(100 * time.Millisecond)
374 h.confirm()
375 h.typed("b")
376 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)
377 if h.p.active() || len(h.p.drawn) != 0 {
378 t.Fatal("resize kept guesses")
379 }
380 if got := h.p.render(); len(got) != 0 {
381 t.Fatalf("render after resize = %q", got)
382 }
383}
384
385func TestEchoSGR(t *testing.T) {
386 t.Parallel()
387 style := newEchoHarness(t, domain.EchoPredictAlways).p.screen.Cell(0, 0).Style
388 style.Bold = true
389 style.Foreground = 9
390 style.Background = 200
391 if got, want := echoSGR(style, true), "\x1b[0;1;4;91;48;5;200m"; got != want {
392 t.Fatalf("echoSGR = %q, want %q", got, want)
393 }
394 style.HasForegroundRGB = true
395 style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B = 1, 2, 3
396 if got, want := echoSGR(style, false), "\x1b[0;1;38;2;1;2;3;48;5;200m"; got != want {
397 t.Fatalf("echoSGR rgb = %q, want %q", got, want)
398 }
399}
400
401func TestEchoPredictorWaitsForFirstAcknowledgement(t *testing.T) {
402 t.Parallel()
403 h := newEchoHarness(t, domain.EchoPredictAlways)
404 h.p.confEpoch = h.p.predEpoch
405 if got := h.typed("b"); len(got) != 0 || h.p.active() {
406 t.Fatalf("predicted before any echo acknowledgement: %q", got)
407 }
408}
409
410func TestEchoPredictorRefusesUnshiftableText(t *testing.T) {
411 t.Parallel()
412 tests := []struct {
413 name string
414 frame string
415 }{
416 {name: "wide rune after the cursor", frame: "a界\x1b[1;3H"},
417 {name: "pane border after the cursor", frame: "ab│\x1b[1;3H"},
418 }
419 for _, tt := range tests {
420 t.Run(tt.name, func(t *testing.T) {
421 t.Parallel()
422 h := newEchoHarness(t, domain.EchoPredictAlways)
423 h.warm(10 * time.Millisecond)
424 h.p.applyOutput(protocol.Output{Epoch: 1, New: 2, Size: domain.Size{Cols: 20, Rows: 3}, Data: []byte(tt.frame)}, h.now)
425 h.p.confEpoch = h.p.predEpoch
426 if got := h.typed("x"); len(got) != 0 || h.p.active() {
427 t.Fatalf("shifted unshiftable text: %q", got)
428 }
429 })
430 }
431}
432
433func TestEchoPredictorRestoresWideCellWhole(t *testing.T) {
434 t.Parallel()
435 h := newEchoHarness(t, domain.EchoPredictAlways)
436 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)
437 var out bytes.Buffer
438 h.p.restoreCell(&out, 0, 1)
439 if got, want := out.String(), "\x1b[1;1H\x1b[0;4:3m界"; got != want {
440 t.Fatalf("restoreCell = %q, want %q", got, want)
441 }
442}
443
444func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) {
445 t.Parallel()
446 h := newEchoHarness(t, domain.EchoPredictAlways)
447 h.warm(10 * time.Millisecond)
448 h.confirm()
449 if got := h.typed("b"); len(got) == 0 {
450 t.Fatal("guess not drawn")
451 }
452 h.p.reset()
453 if got, want := string(h.p.render()), "\x1b[1;4H\x1b[0m \x1b[0m\x1b[1;4H"; got != want {
454 t.Fatalf("render after reset = %q, want %q", got, want)
455 }
456}
457
458// TestEchoPredictorTypesAheadInModalInput covers daemon modals (command
459// palette, prompt, copy search): the daemon places the real cursor on their
460// input line inside a bordered box, and guesses stay inside the box.
461func TestEchoPredictorTypesAheadInModalInput(t *testing.T) {
462 t.Parallel()
463 h := newEchoHarness(t, domain.EchoPredictAlways)
464 // Row 2: "│> " then the cursor, with the box's right border at column 15.
465 h.daemon("\x1b[2;1H│> \x1b[2;16H│\x1b[2;4H", 0)
466 h.warm(10 * time.Millisecond)
467 if got := h.typed("s"); len(got) != 0 {
468 t.Fatalf("first guess is tentative, rendered %q", got)
469 }
470 h.daemon("\x1b[2;4Hs", h.seq)
471 got := string(h.typed("p"))
472 if want := "\x1b[2;5H\x1b[0mp\x1b[0m\x1b[2;6H"; got != want {
473 t.Fatalf("render = %q, want %q", got, want)
474 }
475 if cell := h.p.screen.Cell(15, 1); cell.Rune != '│' {
476 t.Fatalf("box border moved: %q", cell.Rune)
477 }
478}
479
480// TestEchoPredictorBackspaceStopsAtBorder holds backspace past an empty
481// modal query: the input's left border is never predicted away.
482func TestEchoPredictorBackspaceStopsAtBorder(t *testing.T) {
483 t.Parallel()
484 h := newEchoHarness(t, domain.EchoPredictAlways)
485 h.daemon("\x1b[2;1H│\x1b[2;16H│\x1b[2;2H", 0)
486 h.warm(10 * time.Millisecond)
487 h.typed("s")
488 h.daemon("\x1b[2;2Hs", h.seq)
489 h.typed("\x7f")
490 got := string(h.typed("\x7f"))
491 if strings.Contains(got, "\x1b[2;1H") {
492 t.Fatalf("backspace predicted over the border: %q", got)
493 }
494 if h.p.cursor.active && h.p.cursor.col < 1 {
495 t.Fatalf("predicted cursor crossed the border: col %d", h.p.cursor.col)
496 }
497}
498
499func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
500 t.Parallel()
501 var f apcFilter
502 chunks := []string{"a\x1b_Gf=100;AAA", "A\x1b", "\\b\x1b[1mc\x1b"}
503 var got []byte
504 for _, chunk := range chunks {
505 got = append(got, f.strip([]byte(chunk))...)
506 }
507 got = append(got, f.strip([]byte("[0m"))...)
508 if want := "ab\x1b[1mc\x1b[0m"; string(got) != want {
509 t.Fatalf("strip = %q, want %q", got, want)
510 }
511}
512
513func TestEscapeLength(t *testing.T) {
514 t.Parallel()
515 tests := []struct {
516 in string
517 want int
518 }{
519 {in: "\x1b[A", want: 3},
520 {in: "\x1bOA", want: 3},
521 {in: "\x1b]11;?\x07x", want: 7},
522 {in: "\x1b]11;?\x1b\\x", want: 8},
523 {in: "\x1b]11;", want: 5},
524 {in: "\x1bx", want: 2},
525 }
526 for _, tt := range tests {
527 if got := escapeLength([]byte(tt.in)); got != tt.want {
528 t.Errorf("escapeLength(%q) = %d, want %d", tt.in, got, tt.want)
529 }
530 }
531}