main f2d166b

ocr_test.go RawHistoryFolder

1package hints
2
3import (
4	"context"
5	"errors"
6	"image"
7	"image/draw"
8	"image/png"
9	"io"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"reflect"
14	"strings"
15	"testing"
16	"time"
17
18	"github.com/stretchr/testify/mock"
19
20	"github.com/bnema/neferwl/internal/ports"
21)
22
23func TestTextLink(t *testing.T) {
24	yes := []string{"ermite.bnema.dev", "google.fr", "example.org/path?q=1", "https.google.fr", "https://example.org", "file:///tmp/main.go", "ftp://host/file", "www.main.go", "user@example.org", "127.0.0.1", "192.168.1.1:8080", "(https://example.org),", "htps://google.fr", "https:/example.org"}
25	no := []string{"ermite-runner", "main.go", "README.md", "v1.2.3", "1.5", "e.g.", "report.txt", "source.rs", "alpha", "999.999.999.999"}
26	for _, s := range yes {
27		t.Run(s, func(t *testing.T) {
28			if !isTextLink(s) {
29				t.Errorf("not accepted: %s", s)
30			}
31		})
32	}
33	for _, s := range no {
34		t.Run(s, func(t *testing.T) {
35			if isTextLink(s) {
36				t.Errorf("incorrectly accepted: %s", s)
37			}
38		})
39	}
40}
41func TestParseTSV(t *testing.T) {
42	data := "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext\n5\t1\t1\t1\t1\t1\t10\t20\t30\t12\t91.5\texample.org\n"
43	words, err := parseTSV([]byte(data), image.Pt(0, 40))
44	if err != nil || len(words) != 1 {
45		t.Fatalf("%v %v", words, err)
46	}
47	if words[0].Rect != image.Rect(10, 60, 40, 72) || words[0].Confidence != 91.5 {
48		t.Fatal(words)
49	}
50	_, err = parseTSV([]byte(strings.ReplaceAll(data, "\t10\t", "\tbad\t")), image.Point{})
51	if err == nil {
52		t.Fatal("expected malformed coordinate error")
53	}
54}
55func TestJoinedOCRLink(t *testing.T) {
56	words := []OCRWord{{Text: "https:/", Rect: image.Rect(20, 20, 90, 40)}, {Text: "/example.org", Rect: image.Rect(91, 20, 200, 40)}}
57	got := textLinks(words, 2)
58	if len(got) != 1 || got[0].Rect != (ports.Rect{X: 10, Y: 10, W: 90, H: 10}) {
59		t.Fatal(got)
60	}
61}
62func TestRealOCR(t *testing.T) {
63	if _, err := exec.LookPath("tesseract"); err != nil {
64		t.Skip("tesseract not on PATH")
65	}
66	for _, name := range []string{"browser", "terminal"} {
67		t.Run(name, func(t *testing.T) {
68			f, err := os.Open("testdata/" + name + ".png")
69			if err != nil {
70				t.Fatal(err)
71			}
72			defer f.Close()
73			src, err := png.Decode(f)
74			if err != nil {
75				t.Fatal(err)
76			}
77			img := image.NewRGBA(src.Bounds())
78			draw.Draw(img, img.Bounds(), src, src.Bounds().Min, draw.Src)
79			start := time.Now()
80			words, bands, err := readWords(context.Background(), toGray(img))
81			if err != nil {
82				t.Fatal(err)
83			}
84			links := textLinks(words, 1)
85			t.Logf("%s %dx%d ocr_ms=%.1f words=%d links=%d bands=%d", name, img.Bounds().Dx(), img.Bounds().Dy(), ms(time.Since(start)), len(words), len(links), bands)
86			for _, w := range words {
87				t.Logf("%s %v", w.Text, w.Rect)
88			}
89			if name == "terminal" {
90				for _, r := range []image.Rectangle{image.Rect(160, 65, 380, 85), image.Rect(140, 90, 490, 110), image.Rect(95, 115, 630, 138), image.Rect(120, 140, 330, 163), image.Rect(385, 140, 740, 163)} {
91					found := false
92					for _, l := range links {
93						if overlap(targetRect(l), r) > .5 {
94							found = true
95						}
96					}
97					if !found {
98						t.Errorf("missing terminal URL near %v", r)
99					}
100				}
101				if len(links) != 5 {
102					t.Errorf("want 5 terminal links, got %d: %v", len(links), links)
103				}
104			} else if len(links) == 0 {
105				t.Error("no browser links")
106			}
107		})
108	}
109}
110
111func TestSmallGlyphOCR(t *testing.T) {
112	binary, err := exec.LookPath("tesseract")
113	if err != nil {
114		t.Skip("tesseract not on PATH")
115	}
116	f, err := os.Open("testdata/terminal.png")
117	if err != nil {
118		t.Fatal(err)
119	}
120	defer f.Close()
121	src, err := png.Decode(f)
122	if err != nil {
123		t.Fatal(err)
124	}
125	small := image.NewGray(image.Rect(0, 0, 640, 200))
126	for y := 0; y < 200; y++ {
127		for x := 0; x < 640; x++ {
128			small.Set(x, y, src.At(x*2, y*2))
129		}
130	}
131	lang, err := ocrLanguage(context.Background(), binary)
132	if err != nil {
133		t.Fatal(err)
134	}
135	for _, factor := range []int{1, 2} {
136		var img image.Image = small
137		if factor == 2 {
138			img = upscaleBand(small)
139		}
140		start := time.Now()
141		words, err := recognizeBand(context.Background(), binary, lang, img)
142		if err != nil {
143			t.Fatal(err)
144		}
145		t.Logf("glyph experiment %dx: %.1f ms, words=%d links=%d", factor, ms(time.Since(start)), len(words), len(textLinks(words, 1)))
146	}
147}
148
149func TestPGMReader(t *testing.T) {
150	g := image.NewGray(image.Rect(0, 0, 3, 3))
151	for i := range g.Pix {
152		g.Pix[i] = uint8(i + 1)
153	}
154	sub := g.SubImage(image.Rect(1, 1, 3, 3)).(*image.Gray)
155	got, err := io.ReadAll(newPGMReader(sub))
156	if err != nil {
157		t.Fatal(err)
158	}
159	if want := "P5\n2 2\n255\n" + string([]byte{5, 6, 8, 9}); string(got) != want {
160		t.Fatalf("%q", got)
161	}
162}
163
164// Tesseract must read the raw PGM exactly as it reads the PNG.
165func TestPGMMatchesPNG(t *testing.T) {
166	binary, err := exec.LookPath("tesseract")
167	if err != nil {
168		t.Skip("tesseract not on PATH")
169	}
170	f, err := os.Open("testdata/terminal.png")
171	if err != nil {
172		t.Fatal(err)
173	}
174	defer f.Close()
175	src, err := png.Decode(f)
176	if err != nil {
177		t.Fatal(err)
178	}
179	rgba := image.NewRGBA(src.Bounds())
180	draw.Draw(rgba, rgba.Bounds(), src, src.Bounds().Min, draw.Src)
181	gray := toGray(rgba)
182	lang, err := ocrLanguage(context.Background(), binary)
183	if err != nil {
184		t.Fatal(err)
185	}
186	defer func(v bool) { rawPGM = v }(rawPGM)
187	var res [2][]OCRWord
188	for i, raw := range []bool{false, true} {
189		rawPGM = raw
190		if res[i], err = recognizeBand(context.Background(), binary, lang, gray); err != nil {
191			t.Fatal(err)
192		}
193	}
194	if len(res[0]) == 0 || !reflect.DeepEqual(res[0], res[1]) {
195		t.Fatalf("PNG %d words, PGM %d words", len(res[0]), len(res[1]))
196	}
197}
198
199// TestRecognizeCapsProcesses runs the bands of several windows through a
200// stand-in tesseract that logs when each process starts and ends.
201func TestRecognizeCapsProcesses(t *testing.T) {
202	dir := t.TempDir()
203	logPath := filepath.Join(dir, "log")
204	script := filepath.Join(dir, "tesseract")
205	body := "#!/bin/sh\ncat >/dev/null\necho s >> " + logPath + "\nsleep 0.1\necho e >> " + logPath + "\nprintf 'level\\tpage_num\\tblock_num\\tpar_num\\tline_num\\tword_num\\tleft\\ttop\\twidth\\theight\\tconf\\ttext\\n'\n"
206	if err := os.WriteFile(script, []byte(body), 0o700); err != nil {
207		t.Fatal(err)
208	}
209	old := ocrWorkers
210	ocrWorkers = 2
211	t.Cleanup(func() { ocrWorkers = old })
212	w := &worker{ocr: ocrState{done: true, binary: script, lang: "eng"}}
213	var jobs []*bandJob
214	for range 8 { // several windows' bands
215		g := image.NewGray(image.Rect(0, 0, 8, 8))
216		jobs = append(jobs, &bandJob{ext: g.Rect, own: [2]int{0, 8}, gray: g})
217	}
218	w.recognize(context.Background(), jobs)
219	data, err := os.ReadFile(logPath)
220	if err != nil {
221		t.Fatal(err)
222	}
223	cur, peak, starts := 0, 0, 0
224	for _, l := range strings.Fields(string(data)) {
225		if l == "s" {
226			cur++
227			starts++
228			peak = max(peak, cur)
229		} else {
230			cur--
231		}
232	}
233	if starts != 8 || peak > 2 {
234		t.Fatalf("starts=%d peak=%d", starts, peak)
235	}
236	for _, j := range jobs {
237		if j.err != nil {
238			t.Fatal(j.err)
239		}
240	}
241}
242
243func TestLanguageDiscoveryTimeout(t *testing.T) {
244	t.Setenv("TESSDATA_PREFIX", t.TempDir())
245	runner := newMocklanguageRunner(t)
246	parent := context.Background()
247	runner.EXPECT().Output(mock.Anything, "tesseract", []string{"--list-langs"}).RunAndReturn(
248		func(ctx context.Context, _ string, _ ...string) ([]byte, error) {
249			deadline, ok := ctx.Deadline()
250			if !ok || time.Until(deadline) > languageTimeout {
251				t.Fatal("language discovery has no bounded deadline")
252			}
253			<-ctx.Done()
254			return nil, ctx.Err()
255		}).Once()
256	lang, err := discoverLanguage(parent, "tesseract", runner, 0)
257	if lang != "" || !errors.Is(err, context.DeadlineExceeded) || parent.Err() != nil {
258		t.Fatalf("language=%q error=%v parent=%v", lang, err, parent.Err())
259	}
260}
261
262func TestLanguageDiscoveryFailureKeepsAccessibilityOnly(t *testing.T) {
263	w := &worker{ocr: ocrState{done: true, err: context.DeadlineExceeded}}
264	ar := atspiResult{targets: []ports.HintTarget{{Window: 1, Rect: ports.Rect{W: 20, H: 10}}}}
265	req := ports.HintScanRequest{Width: 100, Height: 100, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}}}
266	if w.needFrame(context.Background(), req, ar) {
267		t.Fatal("timed out discovery must not require an OCR capture")
268	}
269	if err := w.ocr.resolve(context.Background()); !errors.Is(err, context.DeadlineExceeded) {
270		t.Fatalf("discovery failure was not retained: %v", err)
271	}
272}

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 25

Tags 10

↑↓ moveenter openesc close