okami.mom/ultraviolet

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

Kieran Klukastest(conformance): read an erased cell back as a space0f20d9f

main
6.8 KiB225 linesraw
1package conformance_test
2
3import (
4	"strings"
5	"sync"
6	"testing"
7
8	"github.com/charmbracelet/x/vt"
9	"go.mitchellh.com/libghostty"
10)
11
12// Two reference emulators, because they disagree about how wide a grapheme
13// cluster is and that disagreement is the whole subject of these tests.
14//
15// libghostty is ghostty's real VT engine. It models legacy widths per
16// codepoint, which is what stock terminals actually do, and it implements DEC
17// mode 2027 so both width models can be exercised. x/vt always measures whole
18// clusters, so it reproduces a mode-2027 terminal only.
19//
20// Neither is redundant. A renderer bug that only shows up under legacy widths
21// is invisible to x/vt, and keeping both means a disagreement between them is
22// itself a signal worth investigating.
23
24// oracle is a terminal emulator the renderer can write to and whose screen can
25// be read back column by column.
26//
27// Reading by column is the point. A formatter that returns the screen as a
28// string collapses the spacer cells that follow a wide glyph, which is exactly
29// the information these tests need: it cannot distinguish a cluster at column 0
30// with a marker at column 2 from the same cluster with a marker at column 1.
31type oracle interface {
32	// Write feeds renderer output into the emulator.
33	Write(p []byte) (n int, err error)
34
35	// Row returns the visible text of a row, trailing blanks removed.
36	Row(t *testing.T, y int) string
37
38	// CellAt returns the content of one cell. A cell that holds nothing, or
39	// that continues a wide glyph starting to its left, returns "".
40	CellAt(t *testing.T, x, y int) string
41
42	// Size reports the emulator's dimensions.
43	Size() (w, h int)
44
45	// Resize changes the emulator's dimensions, mirroring a SIGWINCH.
46	Resize(w, h int)
47
48	// Close releases emulator resources.
49	Close()
50
51	// Name identifies the emulator in failure messages.
52	Name() string
53}
54
55// ghosttyOracle wraps ghostty's VT engine.
56type ghosttyOracle struct {
57	term *libghostty.Terminal
58	w, h int
59}
60
61// newGhostty creates a ghostty emulator. When grapheme is false it measures
62// clusters with legacy per-codepoint widths, the way an ordinary terminal that
63// has not negotiated mode 2027 does.
64func newGhostty(t *testing.T, w, h int, grapheme bool) oracle {
65	t.Helper()
66
67	term, err := libghostty.NewTerminal(libghostty.WithSize(uint16(w), uint16(h)))
68	if err != nil {
69		t.Fatalf("libghostty.NewTerminal(%d, %d): %v", w, h, err)
70	}
71	if err := term.ModeSet(libghostty.ModeGraphemeCluster, grapheme); err != nil {
72		term.Close()
73		t.Fatalf("ModeSet(ModeGraphemeCluster, %v): %v", grapheme, err)
74	}
75	return &ghosttyOracle{term: term, w: w, h: h}
76}
77
78func (g *ghosttyOracle) Write(p []byte) (int, error) { return g.term.Write(p) }
79func (g *ghosttyOracle) Size() (int, int)            { return g.w, g.h }
80func (g *ghosttyOracle) Close()                      { g.term.Close() }
81func (g *ghosttyOracle) Name() string                { return "ghostty" }
82
83func (g *ghosttyOracle) Resize(w, h int) {
84	g.w, g.h = w, h
85	// Cell pixel dimensions are irrelevant to these tests, so pass zero.
86	_ = g.term.Resize(uint16(w), uint16(h), 0, 0)
87}
88
89func (g *ghosttyOracle) CellAt(t *testing.T, x, y int) string {
90	t.Helper()
91
92	ref, err := g.term.GridRef(libghostty.Point{
93		Tag: libghostty.PointTagActive,
94		X:   uint16(x),
95		Y:   uint32(y),
96	})
97	if err != nil {
98		t.Fatalf("GridRef(%d, %d): %v", x, y, err)
99	}
100	cell, err := ref.Cell()
101	if err != nil {
102		t.Fatalf("Cell(%d, %d): %v", x, y, err)
103	}
104
105	// Graphemes returns the whole cluster including its base codepoint, so
106	// reading Codepoint as well would duplicate the base. Fall back to
107	// Codepoint only for cells that hold a single rune.
108	var b strings.Builder
109	if cps, err := ref.Graphemes(); err == nil && len(cps) > 0 {
110		for _, cp := range cps {
111			b.WriteRune(rune(cp))
112		}
113		return b.String()
114	}
115	if cp, err := cell.Codepoint(); err == nil && cp != 0 {
116		b.WriteRune(rune(cp))
117	}
118	return b.String()
119}
120
121func (g *ghosttyOracle) Row(t *testing.T, y int) string {
122	t.Helper()
123
124	var b strings.Builder
125	for x := range g.w {
126		cell := g.CellAt(t, x, y)
127		if cell == "" {
128			// A cell that was erased reports no codepoint, while a cell
129			// someone wrote a space into reports one. They look the same on
130			// screen and mean the same thing, so read them back the same way.
131			// Otherwise a row painted by erasing compares unequal to the same
132			// row painted with spaces.
133			cell = " "
134		}
135		b.WriteString(cell)
136	}
137
138	// Empty cells read back as NUL rather than a space, so trim both.
139	return strings.TrimRight(b.String(), " \x00")
140}
141
142// clusterWidth reports how many columns an emulator advances when it prints one
143// grapheme cluster.
144//
145// Measured rather than modelled. How wide a cluster is happens to be the exact
146// thing these tests are about, so a width table here would be a second opinion
147// standing in for the emulator's, and wrong in the cases that matter: ghostty's
148// legacy mode paints a regional indicator pair in four columns where wcwidth
149// says two.
150//
151// The measurement writes the cluster followed by a marker and reports the
152// column the marker landed in, which is the advance width by definition. The
153// answers are cached because a fuzz target asks for the same handful of
154// clusters millions of times.
155var clusterWidths sync.Map
156
157func clusterWidth(t *testing.T, o oracleSpec, grapheme bool, cluster string) int {
158	t.Helper()
159
160	type key struct {
161		name     string
162		grapheme bool
163		cluster  string
164	}
165	k := key{o.name, grapheme, cluster}
166	if w, ok := clusterWidths.Load(k); ok {
167		return w.(int)
168	}
169
170	// Wide enough for any cluster in the corpus, with room for the marker.
171	const scratch = 64
172	const marker = "|"
173
174	term := o.mk(t, scratch, 1, grapheme)
175	defer term.Close()
176	if _, err := term.Write([]byte(cluster + marker)); err != nil {
177		t.Fatalf("measuring %q: %v", cluster, err)
178	}
179
180	w := 0
181	for x := range scratch {
182		if term.CellAt(t, x, 0) == marker {
183			w = x
184			break
185		}
186	}
187	clusterWidths.Store(k, w)
188	return w
189}
190
191// vtOracle wraps x/vt, which always measures whole grapheme clusters and so
192// behaves like a terminal with mode 2027 enabled.
193type vtOracle struct {
194	em *vt.Emulator
195}
196
197func newVT(t *testing.T, w, h int) oracle {
198	t.Helper()
199	return &vtOracle{em: vt.NewEmulator(w, h)}
200}
201
202func (v *vtOracle) Write(p []byte) (int, error) { return v.em.Write(p) }
203func (v *vtOracle) Size() (int, int)            { return v.em.Width(), v.em.Height() }
204func (v *vtOracle) Close()                      {}
205func (v *vtOracle) Name() string                { return "vt" }
206func (v *vtOracle) Resize(w, h int)             { v.em.Resize(w, h) }
207
208func (v *vtOracle) CellAt(t *testing.T, x, y int) string {
209	t.Helper()
210
211	if cell := v.em.CellAt(x, y); cell != nil {
212		return cell.Content
213	}
214	return ""
215}
216
217func (v *vtOracle) Row(t *testing.T, y int) string {
218	t.Helper()
219
220	var b strings.Builder
221	for x := range v.em.Width() {
222		b.WriteString(v.CellAt(t, x, y))
223	}
224	return strings.TrimRight(b.String(), " ")
225}