v0.7.2 71ebe61

font_lazy_index_test.go RawHistoryFolder

1package text
2
3import (
4	"bytes"
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"path/filepath"
10	"slices"
11	"testing"
12
13	"github.com/stretchr/testify/mock"
14)
15
16const fixtureDir = "../../testdata/fonts"
17
18// lazySource is a LazySource over paths. Each path indexes and opens as the
19// fixture file named by files (a missing entry indexes to nothing), so a test
20// controls file names independently of font families. indexed records every
21// path handed to IndexFiles.
22func lazySource(t *testing.T, paths []string, files map[string]string) (*MockLazySource, *[]string) {
23	t.Helper()
24	var indexed []string
25	src := NewMockLazySource(t)
26	src.EXPECT().Paths().Return(paths, nil)
27	src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(func(ps []string) ([]FontFile, error) {
28		indexed = append(indexed, ps...)
29		var out []FontFile
30		for _, p := range ps {
31			file, ok := files[p]
32			if !ok {
33				continue
34			}
35			entries, err := SystemSource{}.IndexFiles([]string{filepath.Join(fixtureDir, file)})
36			if err != nil {
37				return nil, err
38			}
39			for _, e := range entries {
40				e.Path = p
41				out = append(out, e)
42			}
43		}
44		return out, nil
45	}).Maybe()
46	src.EXPECT().Open(mock.Anything).RunAndReturn(func(p string) (io.ReadCloser, error) {
47		b, err := os.ReadFile(filepath.Join(fixtureDir, files[p]))
48		return io.NopCloser(bytes.NewReader(b)), err
49	}).Maybe()
50	return src, &indexed
51}
52
53// fixtureSource lists the fixture fonts under their own names, after extra
54// paths that index to nothing.
55func fixtureSource(t *testing.T, extra ...string) (*MockLazySource, *[]string) {
56	t.Helper()
57	fonts, err := filepath.Glob(filepath.Join(fixtureDir, "*.ttf"))
58	if err != nil || len(fonts) == 0 {
59		t.Fatalf("fixture fonts: %v", err)
60	}
61	paths := slices.Clone(extra)
62	files := map[string]string{}
63	for _, f := range fonts {
64		p := "/fonts/" + filepath.Base(f)
65		paths = append(paths, p)
66		files[p] = filepath.Base(f)
67	}
68	return lazySource(t, paths, files)
69}
70
71func bases(paths []string) []string {
72	out := make([]string, len(paths))
73	for i, p := range paths {
74		out[i] = filepath.Base(p)
75	}
76	slices.Sort(out)
77	return out
78}
79
80func TestLazyIndexReadsOnlyNamedFamilies(t *testing.T) {
81	src, indexed := fixtureSource(t, "/fonts/Unrelated-Regular.ttf")
82	c, err := Load(src)
83	if err != nil {
84		t.Fatal(err)
85	}
86	l, err := NewEngine(c).Measure("Hello", Request{Families: []string{"monospace"}, Size: 16}, 0)
87	if err != nil || l.Width == 0 {
88		t.Fatalf("measure: %v %v", err, c.Diagnostics)
89	}
90	// monospace names no fixture family; the sans-serif and script fallbacks do.
91	want := []string{"AdwaitaSans-Regular.ttf", "IBMPlexSansJP-Regular.ttf", "NotoSans-Regular.ttf", "NotoSansArabic-Regular.ttf", "NotoSansDevanagari-Regular.ttf", "NotoSansSymbols2-Regular.ttf"}
92	if got := bases(*indexed); !slices.Equal(got, want) {
93		t.Fatalf("indexed %v, want %v", got, want)
94	}
95	if c.complete {
96		t.Fatal("catalog fully indexed for a covered request")
97	}
98}
99
100func TestLazyIndexScansAllForUnknownFamily(t *testing.T) {
101	src, indexed := fixtureSource(t, "/fonts/Unrelated-Regular.ttf")
102	c, err := Load(src)
103	if err != nil {
104		t.Fatal(err)
105	}
106	// The file name does not reveal the family: only a full scan can find it.
107	if _, err := NewEngine(c).Measure("Hello", Request{Families: []string{"Some Family"}, Size: 16}, 0); err != nil {
108		t.Fatal(err)
109	}
110	if !c.complete || !slices.Contains(*indexed, "/fonts/Unrelated-Regular.ttf") {
111		t.Fatalf("unknown family did not index every file: %v", *indexed)
112	}
113}
114
115func TestLazyIndexFallbackGlyph(t *testing.T) {
116	src, _ := fixtureSource(t)
117	c, err := Load(src)
118	if err != nil {
119		t.Fatal(err)
120	}
121	l, err := NewEngine(c).Measure("日本語", Request{Families: []string{"Adwaita Sans"}, Size: 20}, 0)
122	if err != nil {
123		t.Fatal(err)
124	}
125	run := l.Lines[0].Runs[0]
126	if run.Face.Family != "IBM Plex Sans JP" || run.Glyphs[0].Missing {
127		t.Fatalf("fallback face %q", run.Face.Family)
128	}
129}
130
131// A named script fallback family stored under an unrelated file name is found
132// on the first measure, not only after another request indexed every file.
133func TestLazyIndexFirstMeasureFindsHiddenNamedFamily(t *testing.T) {
134	src, _ := lazySource(t, []string{"/fonts/AdwaitaSans-Regular.ttf", "/fonts/Arabic.ttf"},
135		map[string]string{"/fonts/AdwaitaSans-Regular.ttf": "AdwaitaSans-Regular.ttf", "/fonts/Arabic.ttf": "NotoSansArabic-Regular.ttf"})
136	c, err := Load(src)
137	if err != nil {
138		t.Fatal(err)
139	}
140	e := NewEngine(c)
141	for i := range 2 {
142		l, err := e.Measure("سلام", Request{Families: []string{"Adwaita Sans"}, Size: 20}, 0)
143		if err != nil {
144			t.Fatal(err)
145		}
146		run := l.Lines[0].Runs[0]
147		if run.Face.Family != "Noto Sans Arabic" || run.Glyphs[0].Missing {
148			t.Fatalf("measure %d: face %q missing %v", i, run.Face.Family, run.Glyphs[0].Missing)
149		}
150		e.EndFrame()
151	}
152}
153
154// A file whose stem adds style words to the family name belongs to it.
155func TestLazyIndexFindsStyleSuffixedFiles(t *testing.T) {
156	src, indexed := lazySource(t, []string{"/fonts/NotoSans-Regular.ttf", "/fonts/NotoSansCondensed.ttf", "/fonts/NotoSansArabic-Regular.ttf"},
157		map[string]string{"/fonts/NotoSans-Regular.ttf": "NotoSans-Regular.ttf", "/fonts/NotoSansCondensed.ttf": "NotoSans-Regular.ttf", "/fonts/NotoSansArabic-Regular.ttf": "NotoSansArabic-Regular.ttf"})
158	c, err := Load(src)
159	if err != nil {
160		t.Fatal(err)
161	}
162	if !slices.Contains(*indexed, "/fonts/NotoSansCondensed.ttf") || slices.Contains(*indexed, "/fonts/NotoSansArabic-Regular.ttf") {
163		t.Fatalf("indexed %v", *indexed)
164	}
165	if c.complete {
166		t.Fatal("catalog fully indexed at load")
167	}
168}
169
170func TestStemMatches(t *testing.T) {
171	for _, tc := range []struct {
172		stem, key string
173		want      bool
174	}{
175		{"notosans", "notosans", true},
176		{"dejavusanscondensed", "dejavusans", true},
177		{"dejavusansboldoblique", "dejavusans", true},
178		{"dejavusansmono", "dejavusans", false},
179		{"notosansarabic", "notosans", false},
180		{"noto", "notosans", false},
181	} {
182		if got := stemMatches(tc.stem, tc.key); got != tc.want {
183			t.Errorf("stemMatches(%q, %q) = %v", tc.stem, tc.key, got)
184		}
185	}
186}
187
188// Indexing in steps keeps the catalog order of a full index.
189func TestLazyIndexKeepsPathOrder(t *testing.T) {
190	fonts, err := filepath.Glob(filepath.Join(fixtureDir, "*.ttf"))
191	if err != nil {
192		t.Fatal(err)
193	}
194	src := NewMockLazySource(t)
195	src.EXPECT().Paths().Return(fonts, nil)
196	src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(SystemSource{}.IndexFiles)
197	c, err := Load(src)
198	if err != nil {
199		t.Fatal(err)
200	}
201	c.indexAll()
202	full, err := SystemSource{}.IndexFiles(fonts)
203	if err != nil {
204		t.Fatal(err)
205	}
206	if len(full) != len(c.Faces) {
207		t.Fatalf("%d faces, want %d", len(c.Faces), len(full))
208	}
209	for i, f := range c.Faces {
210		if f.path != full[i].Path || f.index != full[i].Index {
211			t.Fatalf("face %d is %s/%d, want %s/%d", i, f.path, f.index, full[i].Path, full[i].Index)
212		}
213	}
214}
215
216func TestLazyLoadReportsPathsError(t *testing.T) {
217	fonts, err := filepath.Glob(filepath.Join(fixtureDir, "NotoSans-*.ttf"))
218	if err != nil {
219		t.Fatal(err)
220	}
221	listing := errors.New("listing failed")
222	src := NewMockLazySource(t)
223	src.EXPECT().Paths().Return(fonts, listing)
224	src.EXPECT().IndexFiles(mock.Anything).RunAndReturn(SystemSource{}.IndexFiles)
225	c, err := Load(src)
226	if err != nil {
227		t.Fatal(err)
228	}
229	if len(c.Diagnostics) == 0 || !errors.Is(c.Diagnostics[0], listing) {
230		t.Fatalf("diagnostics %v", c.Diagnostics)
231	}
232}
233
234// More files than workers come back in path order, and failures are joined.
235func TestIndexFilesOrderAndErrors(t *testing.T) {
236	data, err := os.ReadFile(filepath.Join(fixtureDir, "NotoSans-Regular.ttf"))
237	if err != nil {
238		t.Fatal(err)
239	}
240	dir := t.TempDir()
241	var paths []string
242	for i := range indexWorkers + 8 {
243		p := filepath.Join(dir, fmt.Sprintf("font%02d.ttf", i))
244		if err := os.WriteFile(p, data, 0o600); err != nil {
245			t.Fatal(err)
246		}
247		paths = append(paths, p)
248	}
249	missing := filepath.Join(dir, "missing.ttf")
250	paths = slices.Insert(paths, 3, missing)
251	files, err := SystemSource{}.IndexFiles(paths)
252	if err == nil || !errors.Is(err, os.ErrNotExist) {
253		t.Fatalf("error %v", err)
254	}
255	if len(files) != len(paths)-1 {
256		t.Fatalf("%d entries, want %d", len(files), len(paths)-1)
257	}
258	want := slices.DeleteFunc(slices.Clone(paths), func(p string) bool { return p == missing })
259	for i, f := range files {
260		if f.Path != want[i] {
261			t.Fatalf("entry %d is %s, want %s", i, f.Path, want[i])
262		}
263	}
264}
265
266func TestFileStem(t *testing.T) {
267	for path, want := range map[string]string{
268		"/f/NotoSans-Bold.ttf":       "notosans",
269		"/f/NotoSans[wght].ttf":      "notosans",
270		"/f/DejaVuSansMono.ttf":      "dejavusansmono",
271		"/f/IBMPlexMono_Regular.otf": "ibmplexmono",
272	} {
273		if got := fileStem(path); got != want {
274			t.Errorf("%s: %q, want %q", path, got, want)
275		}
276	}
277}

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 1

Tags 12

↑↓ moveenter openesc close