okami.mom/ultraviolet

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

Kieran Klukastest(conformance): put someone else's output above every inline frameab6f6cb

upstream
7.9 KiB231 linesraw
1package conformance_test
2
3import (
4	"strings"
5	"testing"
6
7	"github.com/charmbracelet/ultraviolet/internal/conformance"
8	"github.com/charmbracelet/x/ansi"
9)
10
11// Tests for the fuzzing machinery itself.
12//
13// A fuzzer is only as good as its input decoder and its seeds, and both can rot
14// silently. A decoder that panics turns every fuzz run into a false positive; a
15// seed corpus that no longer decodes into anything interesting leaves the
16// fuzzer exploring plain ASCII forever while still reporting success. Neither
17// failure is visible from the fuzz targets, so they are pinned down here.
18
19// TestDriftPremise guards the assumption the whole suite rests on. If a future
20// width table lines legacy widths up with grapheme widths for these clusters,
21// the emulators stop reproducing drift and every conformance test quietly
22// starts proving nothing. Fail loudly instead.
23func TestDriftPremise(t *testing.T) {
24	clusters := conformance.DriftClusters()
25	if len(clusters) == 0 {
26		t.Fatal("no drift clusters defined")
27	}
28
29	for _, cluster := range clusters {
30		wc, grapheme := ansi.StringWidthWc(cluster), ansi.StringWidth(cluster)
31		if wc == grapheme {
32			t.Errorf("%q measures %d columns under both width methods, so it no "+
33				"longer exercises column drift and should be replaced", cluster, wc)
34		}
35	}
36}
37
38// TestDecodeProgramTotal checks that decoding never panics and always yields an
39// in-range program.
40//
41// The decoder is the fuzzer's only interface to the renderer. A panic or an
42// out-of-range row here would surface as a fuzz failure that has nothing to do
43// with the renderer, so it is worth ruling out separately.
44func TestDecodeProgramTotal(t *testing.T) {
45	inputs := [][]byte{
46		nil,
47		{},
48		{0},
49		{255},
50		[]byte(strings.Repeat("\xff", 512)),
51		[]byte(strings.Repeat("\x00", 512)),
52	}
53	// A spread of short inputs, since those are the ones most likely to run the
54	// decoder off the end of its buffer partway through an operation.
55	for i := range 256 {
56		inputs = append(inputs, []byte{byte(i), byte(i * 7), byte(i * 13), byte(i * 31)})
57	}
58	inputs = append(inputs, conformance.Seeds()...)
59
60	for _, in := range inputs {
61		p := conformance.DecodeProgram(in)
62
63		if p.Width < 1 || p.Height < 1 {
64			t.Fatalf("DecodeProgram(%x) gave a nonsensical size of %dx%d", in, p.Width, p.Height)
65		}
66
67		// The live dimensions, which an OpResize changes. Draw and move bounds
68		// are checked against these, mirroring how the decoder computes them.
69		curW, curH := p.Width, p.Height
70		for i, op := range p.Ops {
71			switch op.Kind {
72			case conformance.OpDrawLine:
73				if op.Y < 0 || op.Y >= curH {
74					t.Fatalf("DecodeProgram(%x) op %d draws to row %d, outside the current %d-row screen",
75						in, i, op.Y, curH)
76				}
77				if w := ansi.StringWidth(op.Text); w >= curW {
78					t.Fatalf("DecodeProgram(%x) op %d draws %d columns into the current %d-column screen, "+
79						"which would wrap and make failures ambiguous", in, i, w, curW)
80				}
81			case conformance.OpMoveTo:
82				if op.N < 0 || op.N >= curW {
83					t.Fatalf("DecodeProgram(%x) op %d moves to column %d, outside the current %d-column screen",
84						in, i, op.N, curW)
85				}
86			case conformance.OpResize:
87				if op.W < conformance.MinResizeW || op.W > conformance.MaxResizeW {
88					t.Fatalf("DecodeProgram(%x) op %d resizes to width %d, outside [%d,%d]",
89						in, i, op.W, conformance.MinResizeW, conformance.MaxResizeW)
90				}
91				// An inline frame may collapse to nothing; a fullscreen one
92				// may not, since the renderer owns every row of the screen.
93				minH := conformance.MinResizeH
94				if p.Inline {
95					minH = 0
96				}
97				if op.H < minH || op.H > conformance.MaxResizeH {
98					t.Fatalf("DecodeProgram(%x) op %d resizes to height %d, outside [%d,%d]",
99						in, i, op.H, minH, conformance.MaxResizeH)
100				}
101				if p.Inline && op.W < curW {
102					t.Fatalf("DecodeProgram(%x) op %d narrows an inline screen from %d to %d columns, "+
103						"which rewraps rows the renderer then has no way to find",
104						in, i, curW, op.W)
105				}
106				curW, curH = op.W, op.H
107			}
108		}
109	}
110}
111
112// TestDecodeProgramIsDeterministic checks that one input always decodes to one
113// program. Go's fuzzer reruns a failing input to reproduce it, which only works
114// if decoding is a pure function of the bytes.
115func TestDecodeProgramIsDeterministic(t *testing.T) {
116	for _, seed := range conformance.Seeds() {
117		a, b := conformance.DecodeProgram(seed), conformance.DecodeProgram(seed)
118		if a.String() != b.String() {
119			t.Fatalf("DecodeProgram(%x) is not deterministic:\n%s\nvs\n%s", seed, a, b)
120		}
121	}
122}
123
124// TestSeedCorpusIsInteresting guards the seeds against quietly going stale.
125//
126// The seeds are hand-encoded bytes that address clusters by index, so a change
127// to the alphabet or the decoder could turn them into noise with nothing
128// failing. A seed that never renders, or that draws no text, costs the fuzzer
129// nothing but also teaches it nothing.
130func TestSeedCorpusIsInteresting(t *testing.T) {
131	seeds := conformance.Seeds()
132	if len(seeds) == 0 {
133		t.Fatal("seed corpus is empty")
134	}
135
136	drift := conformance.DriftClusters()
137	seenDrift := map[string]bool{}
138	var withText int
139	seenMode := map[bool]bool{}
140
141	for i, seed := range seeds {
142		p := conformance.DecodeProgram(seed)
143		seenMode[p.Inline] = true
144
145		var renders, draws int
146		for _, op := range p.Ops {
147			switch op.Kind {
148			case conformance.OpRender, conformance.OpRedraw:
149				renders++
150			case conformance.OpDrawLine:
151				if op.Text == "" {
152					continue
153				}
154				draws++
155				for _, c := range drift {
156					if strings.Contains(op.Text, c) {
157						seenDrift[c] = true
158					}
159				}
160			}
161		}
162
163		// One render paints; the bugs this suite exists for need a second frame
164		// to diff against the first.
165		if renders < 2 {
166			t.Errorf("seed %d renders %d times, too few to exercise the incremental path:\n%s",
167				i, renders, p)
168		}
169		if draws > 0 {
170			withText++
171		}
172	}
173
174	if withText == 0 {
175		t.Error("no seed draws any text, so the corpus has gone stale")
176	}
177	for _, c := range drift {
178		if !seenDrift[c] {
179			t.Errorf("no seed draws %q, so the fuzzer does not start anywhere near it", c)
180		}
181	}
182	for _, inline := range []bool{false, true} {
183		if !seenMode[inline] {
184			t.Errorf("no seed runs with inline=%v, so half the renderer starts unreached", inline)
185		}
186	}
187}
188
189// An inline frame has to leave the terminal a row to spare. A frame that
190// reaches the last row scrolls the screen on the next newline, and content that
191// has scrolled sits at a different absolute row in every run, so the
192// differential targets would report the scroll as a disagreement.
193//
194// The decoder cannot produce a frame that tall today. This is here so that
195// widening the resize bounds fails loudly rather than turning the inline
196// targets into a source of false failures.
197func TestInlineFramesFitTheTerminal(t *testing.T) {
198	tallest := conformance.InlineRowsAbove + max(conformance.MaxResizeH, decodedMaxHeight(t))
199	if tallest >= conformance.InlineTermHeight {
200		t.Errorf("a program can reach %d rows in a terminal of %d, leaving no room below the frame",
201			tallest, conformance.InlineTermHeight)
202	}
203}
204
205// decodedMaxHeight is the tallest starting frame the decoder will produce, found
206// by asking it, since the size mapping lives in the decoder rather than in a
207// constant this test could read.
208func decodedMaxHeight(t *testing.T) int {
209	t.Helper()
210
211	var tallest int
212	for b := range 256 {
213		p := conformance.DecodeProgram([]byte{0, byte(b)})
214		tallest = max(tallest, p.Height)
215	}
216	return tallest
217}
218
219// TestFuzzTargetsRunSeeds runs every fuzz target over its seed corpus, which is
220// what `go test` does for a FuzzXxx function without -fuzz.
221//
222// This is here as documentation as much as verification: it is the reason the
223// fuzz targets are useful in ordinary CI, not just during a dedicated fuzzing
224// run. The seeds alone make a decent regression suite.
225func TestFuzzTargetsRunSeeds(t *testing.T) {
226	if testing.Short() {
227		t.Skip("exercises every seed against both emulators")
228	}
229	t.Log("go test runs each FuzzXxx target over its seeds; " +
230		"use -fuzz to search for new inputs")
231}