detached 64a7a45

test(ansi): property test comparing renderer shadow against clone-based reference

Random edits of one in-place-mutated frame, under-reported damage, scrolls,
resets, resizes, discarded and stale-committed draws, with Frame, *Frame and
generic CellSource inputs. Compares the plan, prepared snapshot and committed
shadow with the public PlanDelta/Commit reference.
commit
64a7a45dc9c745e1c578e718980080573afde520
author
brice · · 2026-10-02 09:47 UTC
parent
1bbdfc6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +200 -0
A ansi/shadow_property_test.go +200 -0
@@ -0,0 +1,200 @@
1+package ansi
2+
3+import (
4+ "fmt"
5+ "math/rand/v2"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev-vt/core"
10+)
11+
12+// genericSource hides the concrete core.Frame type so the renderer must use
13+// the generic CellSource paths.
14+type genericSource struct{ f Frame }
15+
16+func (g genericSource) Columns() int { return g.f.Columns() }
17+func (g genericSource) Rows() int { return g.f.Rows() }
18+func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) }
19+
20+var propertyStyles = []Style{
21+ DefaultStyle(),
22+ {Bold: true, Foreground: 1, Background: -1},
23+ {Italic: true, Foreground: -1, Background: 4},
24+ {Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: core.RGB{R: 9, G: 8, B: 7}},
25+ {Inverse: true, Foreground: 2, Background: 3},
26+}
27+
28+func propertyCell(r *rand.Rand) Cell {
29+ c := Cell{Rune: rune('a' + r.IntN(6)), Style: propertyStyles[r.IntN(len(propertyStyles))]}
30+ if r.IntN(12) == 0 {
31+ if p, err := core.NewCellPayload("e\u0301", "https://example.test/"+string(rune('a'+r.IntN(3)))); err == nil {
32+ c.Payload = p
33+ }
34+ }
35+ return c
36+}
37+
38+func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
39+ d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
40+ d.Width = 1 + r.IntN(w-d.X)
41+ d.Height = 1 + r.IntN(h-d.Y)
42+ if kind == DamageScrollUp || kind == DamageScrollDown {
43+ d.X, d.Width = 0, w
44+ d.Count = 1 + r.IntN(max(1, d.Height))
45+ }
46+ return d
47+}
48+
49+func propertyDamage(r *rand.Rand, w, h int) []Damage {
50+ switch r.IntN(6) {
51+ case 0:
52+ return nil
53+ case 1:
54+ return []Damage{FullRedraw()}
55+ case 2:
56+ return []Damage{propertyRect(r, w, h, DamageText)}
57+ case 3:
58+ return []Damage{propertyRect(r, w, h, DamageClear)}
59+ case 4:
60+ return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)}
61+ default:
62+ out := []Damage{propertyRect(r, w, h, DamageScrollDown)}
63+ for range r.IntN(4) {
64+ out = append(out, propertyRect(r, w, h, DamageText))
65+ }
66+ return out
67+ }
68+}
69+
70+// TestShadowMatchesCloneReference drives one renderer (reused buffers) and a
71+// reference built from the public clone-based PlanDelta/Commit pair through
72+// random edits, under-reported damage, scrolls, resets, resizes, discarded
73+// draws and stale commits. The committed shadows must always agree.
74+func TestShadowMatchesCloneReference(t *testing.T) {
75+ for seed := range uint64(40) {
76+ t.Run(fmt.Sprint(seed), func(t *testing.T) {
77+ r := rand.New(rand.NewPCG(seed, 0xa11ce))
78+ rend := New(Capabilities{})
79+ var ref Frame
80+ refHas := false
81+ w, h := 3+r.IntN(10), 2+r.IntN(8)
82+ frame := NewFrame(w, h)
83+ var outstanding *PreparedDraw // last prepared draw, for stale commits
84+
85+ for step := range 150 {
86+ // Mutate the caller's single frame in place.
87+ switch r.IntN(8) {
88+ case 0:
89+ w, h = 3+r.IntN(10), 2+r.IntN(8)
90+ frame = NewFrame(w, h)
91+ case 1:
92+ top := r.IntN(h)
93+ bottom := top + r.IntN(h-top)
94+ n := 1 + r.IntN(bottom-top+1)
95+ if r.IntN(2) == 0 {
96+ frame.ScrollUp(top, bottom, n)
97+ } else {
98+ frame.ScrollDown(top, bottom, n)
99+ }
100+ }
101+ for range r.IntN(12) {
102+ frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
103+ }
104+ damage := propertyDamage(r, w, h)
105+ reset := r.IntN(15) == 0
106+
107+ var source CellSource = frame
108+ switch r.IntN(4) {
109+ case 0:
110+ source = &frame
111+ case 1:
112+ source = genericSource{frame}
113+ }
114+
115+ // Reference plan against a clone-based committed shadow.
116+ refReset := reset || !refHas
117+ want, err := PlanDelta(source, damage, ref, refReset)
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ prepared, err := rend.Prepare(source, damage, reset)
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) {
126+ t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan)
127+ }
128+ if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) {
129+ t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step)
130+ }
131+
132+ // Caller keeps mutating after Prepare, before Commit.
133+ snapshotMutation := r.IntN(3) == 0
134+ if snapshotMutation {
135+ frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
136+ }
137+
138+ switch k := r.IntN(10); {
139+ case k < 6: // commit
140+ prepared.Commit()
141+ want.Commit(&ref)
142+ refHas = refHas || want.frame.Width != 0
143+ if want.frame.Width == 0 && !refHas {
144+ // A no-op candidate with no shadow cannot occur: reset snapshots.
145+ t.Fatalf("step %d: empty plan without committed state", step)
146+ }
147+ outstanding = nil
148+ case k < 8: // discard
149+ outstanding = &prepared
150+ default: // stale commit of the previous discarded draw
151+ if outstanding != nil {
152+ if hasWork(outstanding.candidate.Plan) {
153+ outstanding.Commit()
154+ refHas = false
155+ } else {
156+ outstanding.Commit()
157+ }
158+ outstanding = nil
159+ }
160+ }
161+ if r.IntN(25) == 0 {
162+ rend.Reset()
163+ refHas = false
164+ ref = Frame{}
165+ }
166+
167+ if rend.hasCommitted != refHas {
168+ t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas)
169+ }
170+ if refHas {
171+ if !framesEqual(rend.committed, ref) {
172+ t.Fatalf("step %d: committed shadow differs from reference", step)
173+ }
174+ if err := rend.committed.CheckInvariants(); err != nil {
175+ t.Fatalf("step %d: %v", step, err)
176+ }
177+ if rend.width != ref.Width || rend.height != ref.Height {
178+ t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height)
179+ }
180+ }
181+ }
182+ })
183+ }
184+}
185+
186+func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 }
187+
188+func framesEqual(a, b Frame) bool {
189+ if a.Width != b.Width || a.Height != b.Height {
190+ return false
191+ }
192+ for y := range a.Height {
193+ for x := range a.Width {
194+ if !a.Cell(x, y).Equal(b.Cell(x, y)) {
195+ return false
196+ }
197+ }
198+ }
199+ return true
200+}
@@ -0,0 +1,200 @@
1+package ansi
2+
3+import (
4+ "fmt"
5+ "math/rand/v2"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev-vt/core"
10+)
11+
12+// genericSource hides the concrete core.Frame type so the renderer must use
13+// the generic CellSource paths.
14+type genericSource struct{ f Frame }
15+
16+func (g genericSource) Columns() int { return g.f.Columns() }
17+func (g genericSource) Rows() int { return g.f.Rows() }
18+func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) }
19+
20+var propertyStyles = []Style{
21+ DefaultStyle(),
22+ {Bold: true, Foreground: 1, Background: -1},
23+ {Italic: true, Foreground: -1, Background: 4},
24+ {Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: core.RGB{R: 9, G: 8, B: 7}},
25+ {Inverse: true, Foreground: 2, Background: 3},
26+}
27+
28+func propertyCell(r *rand.Rand) Cell {
29+ c := Cell{Rune: rune('a' + r.IntN(6)), Style: propertyStyles[r.IntN(len(propertyStyles))]}
30+ if r.IntN(12) == 0 {
31+ if p, err := core.NewCellPayload("e\u0301", "https://example.test/"+string(rune('a'+r.IntN(3)))); err == nil {
32+ c.Payload = p
33+ }
34+ }
35+ return c
36+}
37+
38+func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
39+ d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
40+ d.Width = 1 + r.IntN(w-d.X)
41+ d.Height = 1 + r.IntN(h-d.Y)
42+ if kind == DamageScrollUp || kind == DamageScrollDown {
43+ d.X, d.Width = 0, w
44+ d.Count = 1 + r.IntN(max(1, d.Height))
45+ }
46+ return d
47+}
48+
49+func propertyDamage(r *rand.Rand, w, h int) []Damage {
50+ switch r.IntN(6) {
51+ case 0:
52+ return nil
53+ case 1:
54+ return []Damage{FullRedraw()}
55+ case 2:
56+ return []Damage{propertyRect(r, w, h, DamageText)}
57+ case 3:
58+ return []Damage{propertyRect(r, w, h, DamageClear)}
59+ case 4:
60+ return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)}
61+ default:
62+ out := []Damage{propertyRect(r, w, h, DamageScrollDown)}
63+ for range r.IntN(4) {
64+ out = append(out, propertyRect(r, w, h, DamageText))
65+ }
66+ return out
67+ }
68+}
69+
70+// TestShadowMatchesCloneReference drives one renderer (reused buffers) and a
71+// reference built from the public clone-based PlanDelta/Commit pair through
72+// random edits, under-reported damage, scrolls, resets, resizes, discarded
73+// draws and stale commits. The committed shadows must always agree.
74+func TestShadowMatchesCloneReference(t *testing.T) {
75+ for seed := range uint64(40) {
76+ t.Run(fmt.Sprint(seed), func(t *testing.T) {
77+ r := rand.New(rand.NewPCG(seed, 0xa11ce))
78+ rend := New(Capabilities{})
79+ var ref Frame
80+ refHas := false
81+ w, h := 3+r.IntN(10), 2+r.IntN(8)
82+ frame := NewFrame(w, h)
83+ var outstanding *PreparedDraw // last prepared draw, for stale commits
84+
85+ for step := range 150 {
86+ // Mutate the caller's single frame in place.
87+ switch r.IntN(8) {
88+ case 0:
89+ w, h = 3+r.IntN(10), 2+r.IntN(8)
90+ frame = NewFrame(w, h)
91+ case 1:
92+ top := r.IntN(h)
93+ bottom := top + r.IntN(h-top)
94+ n := 1 + r.IntN(bottom-top+1)
95+ if r.IntN(2) == 0 {
96+ frame.ScrollUp(top, bottom, n)
97+ } else {
98+ frame.ScrollDown(top, bottom, n)
99+ }
100+ }
101+ for range r.IntN(12) {
102+ frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
103+ }
104+ damage := propertyDamage(r, w, h)
105+ reset := r.IntN(15) == 0
106+
107+ var source CellSource = frame
108+ switch r.IntN(4) {
109+ case 0:
110+ source = &frame
111+ case 1:
112+ source = genericSource{frame}
113+ }
114+
115+ // Reference plan against a clone-based committed shadow.
116+ refReset := reset || !refHas
117+ want, err := PlanDelta(source, damage, ref, refReset)
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ prepared, err := rend.Prepare(source, damage, reset)
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) {
126+ t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan)
127+ }
128+ if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) {
129+ t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step)
130+ }
131+
132+ // Caller keeps mutating after Prepare, before Commit.
133+ snapshotMutation := r.IntN(3) == 0
134+ if snapshotMutation {
135+ frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
136+ }
137+
138+ switch k := r.IntN(10); {
139+ case k < 6: // commit
140+ prepared.Commit()
141+ want.Commit(&ref)
142+ refHas = refHas || want.frame.Width != 0
143+ if want.frame.Width == 0 && !refHas {
144+ // A no-op candidate with no shadow cannot occur: reset snapshots.
145+ t.Fatalf("step %d: empty plan without committed state", step)
146+ }
147+ outstanding = nil
148+ case k < 8: // discard
149+ outstanding = &prepared
150+ default: // stale commit of the previous discarded draw
151+ if outstanding != nil {
152+ if hasWork(outstanding.candidate.Plan) {
153+ outstanding.Commit()
154+ refHas = false
155+ } else {
156+ outstanding.Commit()
157+ }
158+ outstanding = nil
159+ }
160+ }
161+ if r.IntN(25) == 0 {
162+ rend.Reset()
163+ refHas = false
164+ ref = Frame{}
165+ }
166+
167+ if rend.hasCommitted != refHas {
168+ t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas)
169+ }
170+ if refHas {
171+ if !framesEqual(rend.committed, ref) {
172+ t.Fatalf("step %d: committed shadow differs from reference", step)
173+ }
174+ if err := rend.committed.CheckInvariants(); err != nil {
175+ t.Fatalf("step %d: %v", step, err)
176+ }
177+ if rend.width != ref.Width || rend.height != ref.Height {
178+ t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height)
179+ }
180+ }
181+ }
182+ })
183+ }
184+}
185+
186+func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 }
187+
188+func framesEqual(a, b Frame) bool {
189+ if a.Width != b.Width || a.Height != b.Height {
190+ return false
191+ }
192+ for y := range a.Height {
193+ for x := range a.Width {
194+ if !a.Cell(x, y).Equal(b.Cell(x, y)) {
195+ return false
196+ }
197+ }
198+ }
199+ return true
200+}

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 2

Tags 13

↑↓ moveenter openesc close