detached 3328c17

test(renderer): cover canonical damage regressions

commit
3328c176d02242b119c70f6374a8ad505886021c
author
brice · · 2026-07-15 07:03 UTC
parent
f8d0bc7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +124 -0
M pkg/renderer/output_min_test.go +85 -0
@@ -71,6 +71,91 @@ func setRow(f Frame, y int, s string, st Style) {
7171 }
7272 }
7373
74+func hasCUB(out string) bool {
75+ for i := 0; i+2 < len(out); i++ {
76+ if out[i] != '\x1b' || out[i+1] != '[' {
77+ continue
78+ }
79+ j := i + 2
80+ for j < len(out) && out[j] >= '0' && out[j] <= '9' {
81+ j++
82+ }
83+ if j < len(out) && out[j] == 'D' {
84+ return true
85+ }
86+ }
87+ return false
88+}
89+
90+func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) {
91+ r := New(Capabilities{})
92+ base := NewFrame(10, 1)
93+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
94+ t.Fatal(err)
95+ }
96+
97+ frame := NewFrame(10, 1)
98+ setRow(frame, 0, "abcdefghi", DefaultStyle())
99+ got := drawGolden(t, r, frame, []Damage{
100+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
101+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
102+ })
103+ want := "\x1b[1;1Habcdefghi\x1b[0m"
104+ if got != want {
105+ t.Fatalf("output = %q, want %q", got, want)
106+ }
107+}
108+
109+func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ spans []Damage
113+ }{
114+ {
115+ name: "overlapping spans",
116+ spans: []Damage{
117+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
118+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
119+ },
120+ },
121+ {
122+ name: "adjacent spans",
123+ spans: []Damage{
124+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
125+ {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1},
126+ },
127+ },
128+ }
129+
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ render := func(spans []Damage) string {
133+ t.Helper()
134+ r := New(Capabilities{})
135+ if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil {
136+ t.Fatal(err)
137+ }
138+ frame := NewFrame(10, 1)
139+ setRow(frame, 0, "abcdefghi", DefaultStyle())
140+ return drawGolden(t, r, frame, spans)
141+ }
142+
143+ forward := render(tt.spans)
144+ reverse := render([]Damage{tt.spans[1], tt.spans[0]})
145+ if forward != reverse {
146+ t.Fatalf("forward output = %q, reverse output = %q", forward, reverse)
147+ }
148+ if hasCUB(forward) {
149+ t.Fatalf("canonical output contains CUB: %q", forward)
150+ }
151+ want := "\x1b[1;1Habcdefghi\x1b[0m"
152+ if forward != want {
153+ t.Fatalf("output = %q, want canonical output %q", forward, want)
154+ }
155+ })
156+ }
157+}
158+
74159 func TestNativeClearGolden(t *testing.T) {
75160 tests := []struct {
76161 name string
@@ -71,6 +71,91 @@ func setRow(f Frame, y int, s string, st Style) {
71 }71 }
72 }72 }
73 73
74+func hasCUB(out string) bool {
75+ for i := 0; i+2 < len(out); i++ {
76+ if out[i] != '\x1b' || out[i+1] != '[' {
77+ continue
78+ }
79+ j := i + 2
80+ for j < len(out) && out[j] >= '0' && out[j] <= '9' {
81+ j++
82+ }
83+ if j < len(out) && out[j] == 'D' {
84+ return true
85+ }
86+ }
87+ return false
88+}
89+
90+func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) {
91+ r := New(Capabilities{})
92+ base := NewFrame(10, 1)
93+ if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
94+ t.Fatal(err)
95+ }
96+
97+ frame := NewFrame(10, 1)
98+ setRow(frame, 0, "abcdefghi", DefaultStyle())
99+ got := drawGolden(t, r, frame, []Damage{
100+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
101+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
102+ })
103+ want := "\x1b[1;1Habcdefghi\x1b[0m"
104+ if got != want {
105+ t.Fatalf("output = %q, want %q", got, want)
106+ }
107+}
108+
109+func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ spans []Damage
113+ }{
114+ {
115+ name: "overlapping spans",
116+ spans: []Damage{
117+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
118+ {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
119+ },
120+ },
121+ {
122+ name: "adjacent spans",
123+ spans: []Damage{
124+ {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
125+ {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1},
126+ },
127+ },
128+ }
129+
130+ for _, tt := range tests {
131+ t.Run(tt.name, func(t *testing.T) {
132+ render := func(spans []Damage) string {
133+ t.Helper()
134+ r := New(Capabilities{})
135+ if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil {
136+ t.Fatal(err)
137+ }
138+ frame := NewFrame(10, 1)
139+ setRow(frame, 0, "abcdefghi", DefaultStyle())
140+ return drawGolden(t, r, frame, spans)
141+ }
142+
143+ forward := render(tt.spans)
144+ reverse := render([]Damage{tt.spans[1], tt.spans[0]})
145+ if forward != reverse {
146+ t.Fatalf("forward output = %q, reverse output = %q", forward, reverse)
147+ }
148+ if hasCUB(forward) {
149+ t.Fatalf("canonical output contains CUB: %q", forward)
150+ }
151+ want := "\x1b[1;1Habcdefghi\x1b[0m"
152+ if forward != want {
153+ t.Fatalf("output = %q, want canonical output %q", forward, want)
154+ }
155+ })
156+ }
157+}
158+
74 func TestNativeClearGolden(t *testing.T) {159 func TestNativeClearGolden(t *testing.T) {
75 tests := []struct {160 tests := []struct {
76 name string161 name string
M pkg/vt/screen_integration_test.go +39 -0
@@ -6,6 +6,45 @@ import (
66 "github.com/bnema/vev/pkg/renderer"
77 )
88
9+func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
10+ s := NewScreen(20, 2)
11+ r := renderer.New(renderer.Capabilities{})
12+
13+ s.Write([]byte("❯ "))
14+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
15+ t.Fatal(err)
16+ }
17+ s.ClearDamage()
18+
19+ s.Write([]byte("h"))
20+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
21+ t.Fatal(err)
22+ }
23+ s.ClearDamage()
24+
25+ s.Write([]byte("\x1b[31mh\x1b[39m"))
26+ s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m"))
27+
28+ for y := range s.Frame.Height {
29+ for x := range s.Frame.Width {
30+ r := s.Frame.At(x, y).Rune
31+ if r == '\r' || r == '\n' {
32+ t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r)
33+ }
34+ }
35+ }
36+
37+ out, err := r.Draw(s.Frame, s.Damage())
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ for _, b := range out {
42+ if b == '\r' || b == '\n' {
43+ t.Fatalf("incremental output contains raw control byte %q: %q", b, string(out))
44+ }
45+ }
46+}
47+
948 func TestIntegrationScenarios(t *testing.T) {
1049 tests := []struct {
1150 name string
@@ -6,6 +6,45 @@ import (
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9+func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
10+ s := NewScreen(20, 2)
11+ r := renderer.New(renderer.Capabilities{})
12+
13+ s.Write([]byte("❯ "))
14+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
15+ t.Fatal(err)
16+ }
17+ s.ClearDamage()
18+
19+ s.Write([]byte("h"))
20+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
21+ t.Fatal(err)
22+ }
23+ s.ClearDamage()
24+
25+ s.Write([]byte("\x1b[31mh\x1b[39m"))
26+ s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m"))
27+
28+ for y := range s.Frame.Height {
29+ for x := range s.Frame.Width {
30+ r := s.Frame.At(x, y).Rune
31+ if r == '\r' || r == '\n' {
32+ t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r)
33+ }
34+ }
35+ }
36+
37+ out, err := r.Draw(s.Frame, s.Damage())
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ for _, b := range out {
42+ if b == '\r' || b == '\n' {
43+ t.Fatalf("incremental output contains raw control byte %q: %q", b, string(out))
44+ }
45+ }
46+}
47+
9 func TestIntegrationScenarios(t *testing.T) {48 func TestIntegrationScenarios(t *testing.T) {
10 tests := []struct {49 tests := []struct {
11 name string50 name string

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