okami.mom/ultraviolet

git clone https://git.nkp.pet/okami.mom/ultraviolet

Kieran Klukasfix(renderer): erase from the first row an inline frame gave up2c536ce

main
21.2 KiB625 linesraw
1package conformance_test
2
3import (
4	"fmt"
5	"slices"
6	"strings"
7	"testing"
8
9	uv "github.com/charmbracelet/ultraviolet"
10	"github.com/charmbracelet/ultraviolet/internal/conformance"
11	"github.com/charmbracelet/x/ansi"
12)
13
14// runner drives one fuzz program against one emulator.
15type runner struct {
16	prog conformance.Program
17	rend *uv.TerminalRenderer
18	term oracle
19	buf  uv.ScreenBuffer
20
21	// w and h track the live frame dimensions, which an OpResize changes. The
22	// buffer and the draw rectangle follow them so a line drawn after a resize
23	// is valid for the current frame, not the starting one.
24	w, h int
25
26	// termW and termH are the terminal's own dimensions, which the screen
27	// readback follows. A fullscreen frame is the terminal, so they match w and
28	// h. An inline frame is shorter, and the rows below it belong to the
29	// terminal rather than the frame: reading them back is what catches a
30	// shrink that leaves the tail of a taller frame behind.
31	termW, termH int
32}
33
34// newRunner wires a renderer to a fresh emulator sized for the program. The
35// renderer's width model and the emulator's come from the same field, so the two
36// agree unless a test deliberately makes them disagree.
37func newRunner(t *testing.T, p conformance.Program, mk func(*testing.T, int, int, bool) oracle) *runner {
38	t.Helper()
39
40	// An inline frame shares the terminal with whatever came before it, so the
41	// terminal is taller than the frame and the extra rows are part of what is
42	// under test. A fullscreen frame is the terminal.
43	termH := p.Height
44	if p.Inline {
45		termH = conformance.InlineTermHeight
46	}
47
48	term := mk(t, p.Width, termH, p.GraphemeWidth)
49
50	// An inline frame shares the screen, so put something on it first and leave
51	// the cursor below. Written straight to the emulator, since the renderer must
52	// not know these rows exist, and kept inside the narrowest screen a program
53	// can ask for: text that wraps takes more rows than it was given and puts the
54	// frame below where frameTop says it is.
55	if p.Inline {
56		for y := range conformance.InlineRowsAbove {
57			if _, err := fmt.Fprintf(term, "%0*d\r\n", conformance.InlineSeedWidth, y); err != nil {
58				t.Fatalf("seeding the rows above the frame: %v", err)
59			}
60		}
61	}
62
63	rend := uv.NewTerminalRenderer(term, []string{
64		"TERM=xterm-256color",
65		"COLORTERM=truecolor",
66	})
67	if p.Inline {
68		rend.SetRelativeCursor(true)
69		// An inline renderer is told the terminal size, not the frame size; it
70		// learns the frame from the buffer it is handed. Without this it has no
71		// idea how much room it has below the frame.
72		rend.Resize(p.Width, termH)
73	} else {
74		rend.SetFullscreen(true)
75	}
76	rend.SetGraphemeWidth(p.GraphemeWidth)
77	rend.SetScrollOptim(p.ScrollOptim)
78	rend.Erase()
79
80	// The buffer and the renderer must measure width the same way, otherwise
81	// the cells the renderer is asked to draw have widths it did not expect.
82	// The emulator follows the same flag, so all three agree.
83	buf := uv.NewScreenBuffer(p.Width, p.Height)
84	if p.GraphemeWidth {
85		buf.Method = ansi.GraphemeWidth
86	}
87
88	return &runner{
89		prog:  p,
90		rend:  rend,
91		term:  term,
92		buf:   buf,
93		w:     p.Width,
94		h:     p.Height,
95		termW: p.Width,
96		termH: termH,
97	}
98}
99
100func (r *runner) close() { r.term.Close() }
101
102func (r *runner) flush(t *testing.T) {
103	t.Helper()
104	if err := r.rend.Flush(); err != nil {
105		t.Fatalf("Flush: %v", err)
106	}
107}
108
109// step applies a single operation.
110func (r *runner) step(t *testing.T, op conformance.Op) {
111	t.Helper()
112
113	switch op.Kind {
114	case conformance.OpDrawLine:
115		uv.NewStyledString(op.Text).Draw(r.buf, uv.Rect(0, op.Y, r.w, 1))
116	case conformance.OpClear:
117		r.buf.Clear()
118	case conformance.OpRender:
119		r.rend.Render(r.buf.RenderBuffer)
120		r.flush(t)
121	case conformance.OpRedraw:
122		r.rend.Redraw(r.buf.RenderBuffer)
123		r.flush(t)
124	case conformance.OpMoveTo:
125		r.rend.MoveTo(op.N, 0)
126		r.flush(t)
127	case conformance.OpResize:
128		// Resize the buffer, the emulator, and the renderer's tab stops in
129		// lockstep, the way a real SIGWINCH handler would. The renderer's model
130		// of the previous frame keeps its old dimensions, which is exactly the
131		// stale-geometry case under test.
132		//
133		// An inline frame changes height on its own, without the terminal
134		// moving underneath it: a view swaps for a taller or shorter one and
135		// the terminal never hears about it. So the terminal keeps its height
136		// here and only the frame changes, which also means a resize that keeps
137		// the width is a pure change of frame shape.
138		r.w, r.h = op.W, op.H
139		r.termW = op.W
140		if !r.prog.Inline {
141			r.termH = op.H
142		}
143		r.buf.Resize(op.W, op.H)
144		r.term.Resize(r.termW, r.termH)
145		r.rend.Resize(r.termW, r.termH)
146	case conformance.OpErase:
147		r.rend.Erase()
148	}
149}
150
151// frameTop is the terminal row the frame's first row sits on. A fullscreen frame
152// starts at the top of the screen; an inline one starts below the rows that were
153// already there, which is where the cursor was when the renderer first saw it.
154//
155// Anything comparing a frame row against a screen row has to go through this.
156// The differential targets do not, because both of their runs are offset
157// identically and the offset cancels.
158func (r *runner) frameTop() int {
159	if r.prog.Inline {
160		return conformance.InlineRowsAbove
161	}
162	return 0
163}
164
165// screen returns every row of the terminal, for comparison against another
166// runner. Every row, not every row of the frame: an inline frame that shrinks
167// has to clear what it no longer covers, and a row it abandoned is exactly
168// where the residue shows up.
169func (r *runner) screen(t *testing.T) []string {
170	t.Helper()
171
172	rows := make([]string, r.termH)
173	for y := range rows {
174		rows[y] = r.term.Row(t, y)
175	}
176	return rows
177}
178
179// runIncremental replays the whole program through one renderer, so its model
180// accumulates history exactly as it would in a running application.
181func runIncremental(t *testing.T, p conformance.Program, mk func(*testing.T, int, int, bool) oracle) []string {
182	t.Helper()
183
184	r := newRunner(t, p, mk)
185	defer r.close()
186
187	for _, op := range p.Ops {
188		r.step(t, op)
189	}
190
191	// Push the final state out even if the program did not end with a render,
192	// otherwise trailing drawing ops would be dropped and the comparison would
193	// run against a stale screen.
194	r.rend.Render(r.buf.RenderBuffer)
195	r.flush(t)
196
197	return r.screen(t)
198}
199
200// runFullRepaint builds the same final buffer but paints it once onto a fresh
201// terminal with no prior history to diff against. That makes its output an
202// unconditional full paint, which is the ground truth the incremental path has
203// to match.
204func runFullRepaint(t *testing.T, p conformance.Program, mk func(*testing.T, int, int, bool) oracle) []string {
205	t.Helper()
206
207	r := newRunner(t, p, mk)
208	defer r.close()
209
210	// Replay only the operations that shape the buffer. Skipping the renders is
211	// what makes this a reference: the buffer ends up identical, but the
212	// renderer never sees an intermediate frame and so cannot drift. Resizes are
213	// replayed too, so the final buffer has the same dimensions as the
214	// incremental run.
215	for _, op := range p.Ops {
216		switch op.Kind {
217		case conformance.OpDrawLine, conformance.OpClear, conformance.OpResize:
218			r.step(t, op)
219		}
220	}
221
222	r.rend.Render(r.buf.RenderBuffer)
223	r.flush(t)
224
225	return r.screen(t)
226}
227
228// compare reports the first differing row, with the program that produced it.
229// Printing the program is what makes a fuzz failure actionable: the raw input is
230// a byte string that says nothing about what the renderer was asked to do.
231func compare(t *testing.T, p conformance.Program, what string, got, want []string) {
232	t.Helper()
233
234	for y := range want {
235		if got[y] == want[y] {
236			continue
237		}
238		t.Errorf("%s: screen disagrees with a full repaint at row %d\n"+
239			"  got  %q\n"+
240			"  want %q\n"+
241			"program:\n%s",
242			what, y, got[y], want[y], p)
243		return
244	}
245}
246
247// Codepoints an emulator is known to swallow, so [FuzzScreenShowsContent] can
248// assert on everything else instead of giving up on whole clusters. Each entry
249// is a characterised emulator behaviour, not a renderer bug: keep them as tight
250// as the emulator forces and no tighter, since every rune listed here is one the
251// target can no longer catch the renderer dropping.
252const (
253	// VS16 asks for emoji presentation. ghostty's legacy width mode reads it as
254	// a hint and does not store it in the cell, so it never reads back.
255	variationSelector16 = '\ufe0f'
256	// The keycap combining mark. Listed for documentation; x/vt's trouble with
257	// keycaps is handled per cluster rather than per codepoint, see skip below.
258	combiningKeycap = '\u20e3'
259)
260
261// keycap is the cluster x/vt cannot place coherently. It splits the cluster
262// across two cells but advances the cursor by only one, so its own idea of
263// where the row ends disagrees with what it drew. Whether any part of the
264// cluster survives then depends on where in the row it landed, which makes it
265// useless as evidence about the renderer. ghostty places it correctly in both
266// width modes, so the assertion still bites there.
267const keycap = "1\ufe0f\u20e3"
268
269// oracles are the emulators every fuzz target runs against.
270// oracleSpec is one emulator the fuzz targets run against.
271type oracleSpec struct {
272	name string
273	mk   func(*testing.T, int, int, bool) oracle
274	// drops are codepoints this emulator swallows outright, so they never read
275	// back from any cell.
276	drops []rune
277	// skip are whole clusters this emulator cannot place coherently, so where
278	// their codepoints land says nothing about the renderer.
279	skip []string
280}
281
282var oracles = []oracleSpec{
283	{name: "ghostty", mk: newGhostty, drops: []rune{variationSelector16}},
284	// x/vt has no legacy width mode, so its width model is fixed regardless of
285	// what the program asked for.
286	{
287		name:  "vt",
288		mk:    func(t *testing.T, w, h int, _ bool) oracle { return newVT(t, w, h) },
289		drops: []rune{variationSelector16},
290		skip:  []string{keycap},
291	},
292}
293
294// addSeeds seeds a fuzz target from the shared corpus.
295func addSeeds(f *testing.F) {
296	f.Helper()
297
298	for _, seed := range conformance.Seeds() {
299		f.Add(seed)
300	}
301
302	// Short raw inputs too, so the fuzzer has small programs to grow rather
303	// than having to shrink its way down from the structured seeds.
304	f.Add([]byte(nil))
305	f.Add([]byte{0, 0, 0})
306	f.Add([]byte{2, 12, 2, 12, 2, 12})
307}
308
309// FuzzRenderer is the main coverage-guided target. It reads each input as a
310// program of renderer operations, replays it incrementally, and requires the
311// resulting screen to match a full repaint of the same final buffer.
312//
313// This is the property worth fuzzing because it is the renderer's whole
314// contract: however it chooses to reach a frame, the frame must look the same as
315// if it had been painted from scratch. Cursor tracking, dirty regions and scroll
316// optimisation are all implementation details in service of that.
317func FuzzRenderer(f *testing.F) {
318	addSeeds(f)
319
320	f.Fuzz(func(t *testing.T, data []byte) {
321		p := conformance.DecodeProgram(data)
322		if len(p.Ops) == 0 {
323			return
324		}
325
326		for _, o := range oracles {
327			got := runIncremental(t, p, o.mk)
328			want := runFullRepaint(t, p, o.mk)
329			compare(t, p, o.name, got, want)
330		}
331	})
332}
333
334// FuzzRendererIdempotent checks that rendering an unchanged buffer twice leaves
335// the screen alone.
336//
337// This is a weaker property than the one above, but it fails differently and on
338// smaller inputs. A renderer whose model is wrong in a way that cancels out over
339// a whole frame can slip past the differential test and still fail here.
340func FuzzRendererIdempotent(f *testing.F) {
341	addSeeds(f)
342
343	f.Fuzz(func(t *testing.T, data []byte) {
344		p := conformance.DecodeProgram(data)
345		if len(p.Ops) == 0 {
346			return
347		}
348
349		for _, o := range oracles {
350			r := newRunner(t, p, o.mk)
351
352			for _, op := range p.Ops {
353				r.step(t, op)
354			}
355			r.rend.Render(r.buf.RenderBuffer)
356			r.flush(t)
357			before := r.screen(t)
358
359			// A second render of the same buffer should be a no-op.
360			r.rend.Render(r.buf.RenderBuffer)
361			r.flush(t)
362			after := r.screen(t)
363
364			compare(t, p, o.name+", second render of an unchanged buffer", after, before)
365			r.close()
366		}
367	})
368}
369
370// FuzzRedrawResyncs checks that a forced repaint recovers from any state the
371// renderer managed to get itself into.
372//
373// Redraw is the escape hatch the whole design leans on. If it does not reliably
374// resynchronise then no amount of care in the incremental path is enough,
375// because there is no way back from a bad state.
376func FuzzRedrawResyncs(f *testing.F) {
377	addSeeds(f)
378
379	f.Fuzz(func(t *testing.T, data []byte) {
380		p := conformance.DecodeProgram(data)
381		if len(p.Ops) == 0 {
382			return
383		}
384
385		for _, o := range oracles {
386			r := newRunner(t, p, o.mk)
387
388			for _, op := range p.Ops {
389				r.step(t, op)
390			}
391
392			// However confused the renderer now is, a redraw must land on the
393			// same screen as painting this buffer onto a fresh terminal.
394			r.rend.Redraw(r.buf.RenderBuffer)
395			r.flush(t)
396			got := r.screen(t)
397			r.close()
398
399			want := runFullRepaint(t, p, o.mk)
400			compare(t, p, o.name+", after Redraw", got, want)
401		}
402	})
403}
404
405// drawnRow returns the text a buffer row holds that the emulator has room to
406// show: the content of every cell in order, skipping the placeholders that
407// continue a wide cell, and stopping at the first cluster that runs past the
408// right margin.
409//
410// This, and not the string the program asked to draw, is what the renderer was
411// given to paint and could paint. A cluster whose start column falls past the
412// right edge is never placed in the buffer at all. One that starts inside the
413// row but runs off the end cannot be shown whole, and the renderer paints such
414// a row with autowrap off, so it clips rather than spilling onto the next row
415// and everything after it is lost too.
416//
417// The walk stops at the first cluster the emulator measures differently than
418// the renderer does, and that cluster is not included either.
419//
420// Up to that point the two agree on every column, so the renderer put each
421// cluster exactly where the emulator was expecting it and it had to land. The
422// first disagreement ends that. The renderer goes on positioning by its own
423// model, so the very next write lands inside the cluster that disagreed and
424// destroys it, and every write after that is similarly adrift. A codepoint
425// missing from there on says the two disagree about width, which is a given
426// here, not that the renderer dropped it.
427//
428// This still bites where it matters. ghostty measures the drift-prone clusters
429// exactly as the renderer's matching width model does, in both legacy and mode
430// 2027, so the assertion covers all of them there. It is x/vt driven in legacy
431// mode that mostly falls out, and only because x/vt has no legacy mode to
432// drive.
433func drawnRow(buf uv.ScreenBuffer, y int, width func(string) int) string {
434	var b strings.Builder
435	for x := 0; x < buf.Width(); x++ {
436		c := buf.CellAt(x, y)
437		if c == nil || c.Width == 0 {
438			continue
439		}
440		if width(c.Content) != c.Width || x+c.Width > buf.Width() {
441			break
442		}
443		b.WriteString(c.Content)
444	}
445	return b.String()
446}
447
448// FuzzScreenShowsContent checks that everything drawn actually reaches the
449// screen, without reference to any other render.
450//
451// The three targets above are all differential: they compare one render against
452// another. That makes them blind to a renderer that paints the same wrong thing
453// every time, and writing a cluster into a column the terminal has already
454// filled is exactly that kind of consistent wrongness. A first paint and a full
455// repaint agree perfectly while both drop a glyph.
456//
457// So this target asserts something absolute instead. The most recently drawn
458// line must appear on its row, with every codepoint of every drift-prone
459// cluster it contained present at least as many times as it was drawn.
460//
461// Codepoints are counted one at a time rather than as whole clusters because
462// the emulators disagree about how a cluster lands in cells: x/vt splits a
463// keycap across two, so the bytes are on the row but not contiguous. Counting
464// them separately keeps the assertion blind to layout and sharp about loss,
465// which matters, since dropping a combining mark is the bug this target was
466// written to catch. Where an emulator swallows a codepoint outright it is
467// listed in that oracle's drops, so the tolerance is per-emulator and named
468// rather than applied to everyone. Extra occurrences are tolerated as
469// emulator-defined residue from earlier draws. "The glyph reached the screen at
470// all" is a floor that holds under every width model.
471//
472// Only the last draw is checked, not every draw. A resize can clip rows drawn
473// earlier or shrink the screen below them, and whether clipped content should
474// reappear is emulator-defined, so asserting on it would be noise. The last
475// draw is always made at the current width and within the current height, so it
476// is always fully visible and always fair to check.
477func FuzzScreenShowsContent(f *testing.F) {
478	addSeeds(f)
479
480	f.Fuzz(func(t *testing.T, data []byte) {
481		p := conformance.DecodeProgram(data)
482		if len(p.Ops) == 0 {
483			return
484		}
485
486		// Track the row of the last line drawn. A Clear invalidates it, since
487		// nothing is drawn after a clear unless a later DrawLine says so.
488		lastY := -1
489		for _, op := range p.Ops {
490			switch op.Kind {
491			case conformance.OpDrawLine:
492				lastY = op.Y
493			case conformance.OpClear:
494				lastY = -1
495			}
496		}
497		if lastY < 0 {
498			return
499		}
500
501		for _, o := range oracles {
502			r := newRunner(t, p, o.mk)
503			for _, op := range p.Ops {
504				r.step(t, op)
505			}
506			r.rend.Render(r.buf.RenderBuffer)
507			r.flush(t)
508
509			// A resize after the last draw can shrink the screen below that
510			// row, in which case it is off-screen and there is nothing to
511			// assert.
512			if lastY >= r.h {
513				r.close()
514				continue
515			}
516
517			screen := r.screen(t)
518			screenY := lastY + r.frameTop()
519			drawn := drawnRow(r.buf, lastY, func(cluster string) int {
520				return clusterWidth(t, o, p.GraphemeWidth, cluster)
521			})
522			r.close()
523
524			for _, cluster := range conformance.DriftClusters() {
525				want := strings.Count(drawn, cluster)
526				if want == 0 || slices.Contains(o.skip, cluster) {
527					continue
528				}
529				// Count codepoints, not clusters; extras are residue.
530				for _, r := range cluster {
531					if slices.Contains(o.drops, r) {
532						continue
533					}
534					wantRune := strings.Count(drawn, string(r))
535					gotRune := strings.Count(screen[screenY], string(r))
536					if gotRune < wantRune {
537						t.Errorf("%s: row %d shows %q of cluster %q %d times but at least %d were drawn\n"+
538							"  screen %q\n"+
539							"  drawn  %q\n"+
540							"program:\n%s",
541							o.name, lastY, string(r), cluster, gotRune, wantRune, screen[screenY], drawn, p)
542						return
543					}
544				}
545			}
546		}
547	})
548}
549
550// TestInlineSeedOccupiesTheRowsItClaims measures where an inline frame starts
551// instead of trusting the arithmetic.
552//
553// frameTop reports InlineRowsAbove, and the content oracle reads screen rows
554// through it. That is only true while the seeded rows each occupy exactly one
555// row, so it is checked here against a real emulator at the narrowest width a
556// program can ask for: seven columns of "above %d" in a six-column screen used
557// to wrap into four rows, and the oracle then read a seeded row and reported the
558// renderer as having lost content it never drew.
559func TestInlineSeedOccupiesTheRowsItClaims(t *testing.T) {
560	for _, spec := range oracles {
561		t.Run(spec.name, func(t *testing.T) {
562			narrowest := conformance.DecodeProgram(nil)
563			p := conformance.Program{
564				Width:  narrowest.Width,
565				Height: 2,
566				Inline: true,
567			}
568			r := newRunner(t, p, spec.mk)
569			defer r.term.Close()
570
571			for y := range conformance.InlineRowsAbove {
572				if got := r.term.Row(t, y); got == "" {
573					t.Errorf("row %d is blank, so the seeded rows do not reach it", y)
574				}
575			}
576			if got := r.term.Row(t, r.frameTop()); got != "" {
577				t.Errorf("the frame starts at row %d, but the rows above spilled into it: %q",
578					r.frameTop(), got)
579			}
580		})
581	}
582}
583
584// TestInlineShrinkLeavesTheFramesOwnRows checks, against a real emulator, what an
585// inline frame that gives up rows looks like afterwards.
586//
587// The erase starts at the first row the frame gave up, so the rows it still owns
588// are left alone and nothing has to be painted back into them. Asserting on the
589// screen rather than on the escape sequence is the point: there is no single
590// correct sequence, and the byte-level tests in the root package could not tell
591// "left alone" from "erased and repainted".
592func TestInlineShrinkLeavesTheFramesOwnRows(t *testing.T) {
593	for _, spec := range oracles {
594		t.Run(spec.name, func(t *testing.T) {
595			p := conformance.Program{Width: 6, Height: 4, Inline: true}
596			r := newRunner(t, p, spec.mk)
597			defer r.term.Close()
598
599			r.step(t, conformance.Op{Kind: conformance.OpDrawLine, Y: 1, Text: "ab"})
600			r.step(t, conformance.Op{Kind: conformance.OpDrawLine, Y: 3, Text: "zz"})
601			r.step(t, conformance.Op{Kind: conformance.OpRender})
602
603			top := r.frameTop()
604			if got := r.term.Row(t, top+1); got != "ab" {
605				t.Fatalf("row 1 of the frame reads %q before the shrink", got)
606			}
607
608			// Give up the bottom two rows.
609			r.step(t, conformance.Op{Kind: conformance.OpResize, W: 6, H: 2})
610			r.step(t, conformance.Op{Kind: conformance.OpRender})
611
612			if got := r.term.Row(t, top+1); got != "ab" {
613				t.Errorf("the frame still owns row 1, but it reads %q after the shrink", got)
614			}
615			if got := r.term.Row(t, top+3); got != "" {
616				t.Errorf("row 3 was given up, so it should be blank, got %q", got)
617			}
618			for y := range conformance.InlineRowsAbove {
619				if got := r.term.Row(t, y); got == "" {
620					t.Errorf("the erase reached row %d, above the frame", y)
621				}
622			}
623		})
624	}
625}