v0.7.1 ff50ee4

shadow_property_test.go RawHistoryFolder

1package ansi
2
3import (
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.
14type genericSource struct{ f Frame }
15
16func (g genericSource) Columns() int       { return g.f.Columns() }
17func (g genericSource) Rows() int          { return g.f.Rows() }
18func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) }
19
20var 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
28func 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// propertyWrite writes one random cell at (x, y). Some writes are a wide
39// pair, so span widening runs against the reused shadow buffers. A write that
40// lands on half of an existing pair blanks the other half to keep the frame
41// valid.
42func propertyWrite(r *rand.Rand, frame *Frame, w, h int) {
43	x, y := r.IntN(w), r.IntN(h)
44	clearPair := func(x int) {
45		if x < 0 || x >= w {
46			return
47		}
48		cell := frame.Cell(x, y)
49		if cell.Continuation && x > 0 {
50			frame.Set(x-1, y, Cell{Rune: ' '})
51			frame.Set(x, y, Cell{Rune: ' '})
52		} else if core.RuneWidth(cell.Rune) == 2 && x+1 < w {
53			frame.Set(x, y, Cell{Rune: ' '})
54			frame.Set(x+1, y, Cell{Rune: ' '})
55		}
56	}
57	if r.IntN(4) == 0 && x+1 < w {
58		clearPair(x)
59		clearPair(x + 1)
60		style := propertyStyles[r.IntN(len(propertyStyles))]
61		frame.Set(x, y, Cell{Rune: '界', Style: style})
62		frame.Set(x+1, y, Cell{Continuation: true, Style: style})
63		return
64	}
65	clearPair(x)
66	frame.Set(x, y, propertyCell(r))
67}
68
69func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
70	d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
71	d.Width = 1 + r.IntN(w-d.X)
72	d.Height = 1 + r.IntN(h-d.Y)
73	if kind == DamageScrollUp || kind == DamageScrollDown {
74		d.X, d.Width = 0, w
75		d.Count = 1 + r.IntN(max(1, d.Height))
76	}
77	return d
78}
79
80func propertyDamage(r *rand.Rand, w, h int) []Damage {
81	switch r.IntN(6) {
82	case 0:
83		return nil
84	case 1:
85		return []Damage{FullRedraw()}
86	case 2:
87		return []Damage{propertyRect(r, w, h, DamageText)}
88	case 3:
89		return []Damage{propertyRect(r, w, h, DamageClear)}
90	case 4:
91		return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)}
92	default:
93		out := []Damage{propertyRect(r, w, h, DamageScrollDown)}
94		for range r.IntN(4) {
95			out = append(out, propertyRect(r, w, h, DamageText))
96		}
97		return out
98	}
99}
100
101// TestShadowMatchesCloneReference drives one renderer (reused buffers) and a
102// reference built from the public clone-based PlanDelta/Commit pair through
103// random edits, under-reported damage, scrolls, resets, resizes, discarded
104// draws and stale commits. The committed shadows must always agree.
105func TestShadowMatchesCloneReference(t *testing.T) {
106	for seed := range uint64(40) {
107		t.Run(fmt.Sprint(seed), func(t *testing.T) {
108			r := rand.New(rand.NewPCG(seed, 0xa11ce))
109			rend := New(Capabilities{})
110			var ref Frame
111			refHas := false
112			w, h := 3+r.IntN(10), 2+r.IntN(8)
113			frame := NewFrame(w, h)
114			var outstanding *PreparedDraw // last prepared draw, for stale commits
115
116			for step := range 150 {
117				// Mutate the caller's single frame in place.
118				switch r.IntN(8) {
119				case 0:
120					w, h = 3+r.IntN(10), 2+r.IntN(8)
121					frame = NewFrame(w, h)
122				case 1:
123					top := r.IntN(h)
124					bottom := top + r.IntN(h-top)
125					n := 1 + r.IntN(bottom-top+1)
126					if r.IntN(2) == 0 {
127						frame.ScrollUp(top, bottom, n)
128					} else {
129						frame.ScrollDown(top, bottom, n)
130					}
131				}
132				for range r.IntN(12) {
133					propertyWrite(r, &frame, w, h)
134				}
135				damage := propertyDamage(r, w, h)
136				reset := r.IntN(15) == 0
137
138				var source CellSource = frame
139				switch r.IntN(4) {
140				case 0:
141					source = &frame
142				case 1:
143					source = genericSource{frame}
144				}
145
146				// Reference plan against a clone-based committed shadow.
147				refReset := reset || !refHas
148				want, err := PlanDelta(source, damage, ref, refReset)
149				if err != nil {
150					t.Fatal(err)
151				}
152				prepared, err := rend.Prepare(source, damage, reset)
153				if err != nil {
154					t.Fatal(err)
155				}
156				if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) {
157					t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan)
158				}
159				if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) {
160					t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step)
161				}
162
163				// Caller keeps mutating after Prepare, before Commit.
164				snapshotMutation := r.IntN(3) == 0
165				if snapshotMutation {
166					propertyWrite(r, &frame, w, h)
167				}
168
169				switch k := r.IntN(10); {
170				case k < 6: // commit
171					prepared.Commit()
172					want.Commit(&ref)
173					refHas = refHas || want.frame.Width != 0
174					if want.frame.Width == 0 && !refHas {
175						// A no-op candidate with no shadow cannot occur: reset snapshots.
176						t.Fatalf("step %d: empty plan without committed state", step)
177					}
178					outstanding = nil
179				case k < 8: // discard
180					outstanding = &prepared
181				default: // stale commit of the previous discarded draw
182					if outstanding != nil {
183						if hasWork(outstanding.candidate.Plan) {
184							outstanding.Commit()
185							refHas = false
186						} else {
187							outstanding.Commit()
188						}
189						outstanding = nil
190					}
191				}
192				if r.IntN(25) == 0 {
193					rend.Reset()
194					refHas = false
195					ref = Frame{}
196				}
197
198				if rend.hasCommitted != refHas {
199					t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas)
200				}
201				if refHas {
202					if !framesEqual(rend.committed, ref) {
203						t.Fatalf("step %d: committed shadow differs from reference", step)
204					}
205					if err := rend.committed.CheckInvariants(); err != nil {
206						t.Fatalf("step %d: %v", step, err)
207					}
208					if rend.width != ref.Width || rend.height != ref.Height {
209						t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height)
210					}
211				}
212			}
213		})
214	}
215}
216
217func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 }
218
219func framesEqual(a, b Frame) bool {
220	if a.Width != b.Width || a.Height != b.Height {
221		return false
222	}
223	for y := range a.Height {
224		for x := range a.Width {
225			if !a.Cell(x, y).Equal(b.Cell(x, y)) {
226				return false
227			}
228		}
229	}
230	return true
231}

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