v0.7.0 4c16dd8

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
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}

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