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}