v0.9.0 e5e4ded

predictive_echo_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close