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