1package hints
2
3import (
4 "context"
5 "hash/maphash"
6 "image"
7 "image/draw"
8 "image/png"
9 "os"
10 "os/exec"
11 "runtime"
12 "testing"
13
14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/stretchr/testify/require"
16)
17
18func TestWindowCrop(t *testing.T) {
19 for _, tc := range []struct {
20 name string
21 r ports.Rect
22 scale float64
23 want image.Rectangle
24 }{
25 {"unit", ports.Rect{X: 10, Y: 20, W: 100, H: 50}, 1, image.Rect(10, 20, 110, 70)},
26 {"fractional", ports.Rect{X: 11, Y: 21, W: 100, H: 50}, 1.2, image.Rect(13, 25, 134, 86)},
27 {"off output", ports.Rect{X: -10, Y: 20, W: 100, H: 100}, 1.2, image.Rect(0, 24, 108, 100)},
28 {"outside", ports.Rect{X: 1000, Y: 20, W: 100, H: 100}, 1, image.Rectangle{}},
29 } {
30 t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, windowCrop(tc.r, tc.scale, 200, 100)) })
31 }
32}
33
34func sourceResult(ar atspiResult) *pendingSource {
35 p := &pendingSource{done: make(chan struct{}), res: ar}
36 close(p.done)
37 return p
38}
39
40func TestHybridCacheAndMerge(t *testing.T) {
41 t.Setenv("PATH", t.TempDir()) // Cached OCR must not need tesseract.
42 buf := make([]byte, 200*100*4)
43 wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}, {ID: 2, Rect: ports.Rect{X: 100, W: 100, H: 100}}}
44 req := ports.HintScanRequest{Output: "o", Width: 200, Height: 100, Scale: 1, Windows: wins}
45 seed := maphash.MakeSeed()
46 r := windowCrop(wins[1].Rect, 1, 200, 100)
47 oldWorkers := ocrWorkers
48 ocrWorkers = 1 // one band covers the crop
49 t.Cleanup(func() { ocrWorkers = oldWorkers })
50 ext := image.Rect(0, 0, 100, 100)
51 entry := bandCache{hash: (&worker{cacheSeed: seed}).cropHash(buf, 800, r), ext: ext, words: []OCRWord{{Text: "example.org", Rect: image.Rect(10, 20, 90, 40)}}}
52 button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 1, Y: 1, W: 10, H: 10}}
53 link := ports.HintTarget{Kind: ports.HintLink, Text: true, Rect: ports.Rect{X: 110, Y: 20, W: 80, H: 20}}
54 uncoveredButton := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{X: 101, Y: 1, W: 10, H: 10}}
55 ar := atspiResult{targets: []ports.HintTarget{button, uncoveredButton}, covered: map[ports.WindowID]bool{1: true}}
56 w := worker{ocr: ocrState{done: true, binary: "tesseract", lang: "eng"}, cacheSeed: seed, cache: map[bandKey]bandCache{{output: "o", window: 2, band: 0, bands: 1}: entry}}
57 got := w.scan(context.Background(), req, frame{pix: buf, stride: 800}, 0, ports.HintScanResult{}, sourceResult(ar))
58 require.Equal(t, []ports.HintTarget{button, uncoveredButton, link}, got.Targets)
59 req.LinksOnly = true
60 got = w.scan(context.Background(), req, frame{pix: buf, stride: 800}, 0, ports.HintScanResult{}, sourceResult(ar))
61 require.Equal(t, []ports.HintTarget{link}, got.Targets)
62 // Changing one pixel invalidates only the crop, never leaving stale links.
63 buf[100*4]++
64 got = w.scan(context.Background(), req, frame{pix: buf, stride: 800}, 0, ports.HintScanResult{}, sourceResult(ar))
65 require.Empty(t, got.Targets)
66}
67
68func TestMissingTesseractKeepsAccessibilityButtons(t *testing.T) {
69 t.Setenv("PATH", t.TempDir())
70 w := worker{}
71 button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{W: 5, H: 5}}
72 req := ports.HintScanRequest{Width: 20, Height: 20, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}}}
73 got := w.scan(context.Background(), req, frame{pix: make([]byte, 1600), stride: 80}, 0, ports.HintScanResult{}, sourceResult(atspiResult{targets: []ports.HintTarget{button}}))
74 require.NoError(t, got.Err)
75 require.Equal(t, []ports.HintTarget{button}, got.Targets)
76}
77
78func TestNoWindowsProducesNoPixelTargets(t *testing.T) {
79 pix, width, height := loadBGRA(t)
80 w := worker{}
81 got := w.scan(context.Background(), ports.HintScanRequest{Width: width, Height: height}, frame{pix: pix, stride: width * 4}, 0, ports.HintScanResult{}, sourceResult(atspiResult{}))
82 require.Empty(t, got.Targets)
83}
84
85func TestStripMappingAndPolarity(t *testing.T) {
86 img := image.NewRGBA(image.Rect(0, 0, 200, 200))
87 strips := packLines(img, []image.Rectangle{image.Rect(20, 20, 160, 40), image.Rect(20, 80, 160, 112)}, 2, true)
88 require.Len(t, strips, 2)
89 for _, s := range strips {
90 for _, off := range s.offsets {
91 require.Equal(t, 1, off.factor)
92 require.Equal(t, uint8(255), s.img.GrayAt(off.packed.Min.X, off.packed.Min.Y).Y)
93 }
94 }
95 small := packLines(img, []image.Rectangle{image.Rect(20, 20, 100, 30)}, 1, true)
96 require.Equal(t, 2, small[0].offsets[0].factor)
97}
98
99func TestCropGrayAndHash(t *testing.T) {
100 buf := make([]byte, 4*4*4)
101 for y := 0; y < 4; y++ {
102 for x := 0; x < 4; x++ {
103 copy(buf[y*16+x*4:], []byte{0, 0, 255, 255}) // BGRA red
104 }
105 }
106 g := cropGray(buf, 16, image.Rect(1, 1, 3, 3), make([]byte, 4))
107 require.Equal(t, image.Rect(0, 0, 2, 2), g.Bounds())
108 require.Equal(t, uint8(76), g.GrayAt(1, 1).Y)
109 w := worker{cacheSeed: maphash.MakeSeed()}
110 a := w.cropHash(buf, 16, image.Rect(1, 1, 3, 3))
111 buf[0]++ // outside the crop
112 require.Equal(t, a, w.cropHash(buf, 16, image.Rect(1, 1, 3, 3)))
113 buf[16+4]++
114 require.NotEqual(t, a, w.cropHash(buf, 16, image.Rect(1, 1, 3, 3)))
115}
116
117func TestUpscaleBandGray(t *testing.T) {
118 g := image.NewGray(image.Rect(0, 0, 3, 2))
119 for i := range g.Pix {
120 g.Pix[i] = uint8(i + 1)
121 }
122 sub := g.SubImage(image.Rect(1, 1, 3, 2)).(*image.Gray)
123 up := upscaleBand(sub)
124 require.Equal(t, image.Rect(0, 0, 4, 2), up.Bounds())
125 require.Equal(t, []uint8{5, 5, 6, 6, 5, 5, 6, 6}, up.Pix)
126}
127
128// A blinking cursor changes one band: the other bands keep their cached
129// words, and only the changed one is recognized again.
130func TestBandCacheSurvivesCursorBlink(t *testing.T) {
131 binary, err := exec.LookPath("tesseract")
132 if err != nil {
133 t.Skip("tesseract not on PATH")
134 }
135 old := ocrWorkers
136 ocrWorkers = 4
137 t.Cleanup(func() { ocrWorkers = old })
138 f, err := os.Open("testdata/terminal.png")
139 require.NoError(t, err)
140 defer f.Close()
141 src, err := png.Decode(f)
142 require.NoError(t, err)
143 w, h := src.Bounds().Dx(), src.Bounds().Dy()
144 rgba := image.NewRGBA(image.Rect(0, 0, w, h))
145 draw.Draw(rgba, rgba.Bounds(), src, src.Bounds().Min, draw.Src)
146 buf := make([]byte, w*h*4)
147 for i := 0; i < len(buf); i += 4 {
148 buf[i], buf[i+1], buf[i+2], buf[i+3] = rgba.Pix[i+2], rgba.Pix[i+1], rgba.Pix[i], 0xff
149 }
150 _ = binary
151 wk := &worker{}
152 req := ports.HintScanRequest{Output: "o", Width: w, Height: h, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}}}
153 first := wk.scan(context.Background(), req, frame{pix: buf, stride: w * 4}, 0, ports.HintScanResult{}, sourceResult(atspiResult{}))
154 require.NotEmpty(t, first.Targets)
155 require.Len(t, wk.cache, min(runtime.GOMAXPROCS(0), 4))
156
157 // The same frame: every band hits.
158 again := wk.scan(context.Background(), req, frame{pix: buf, stride: w * 4}, 0, ports.HintScanResult{}, sourceResult(atspiResult{}))
159 require.Equal(t, first.Targets, again.Targets)
160
161 // One pixel at the bottom blinks: only the last band (and the context of
162 // none other) is invalidated.
163 before := map[bandKey]uint64{}
164 for k, v := range wk.cache {
165 before[k] = v.hash
166 }
167 buf[((h-1)*w+3)*4] ^= 0xff
168 blink := wk.scan(context.Background(), req, frame{pix: buf, stride: w * 4}, 0, ports.HintScanResult{}, sourceResult(atspiResult{}))
169 require.Equal(t, first.Targets, blink.Targets)
170 changed := 0
171 for k, v := range wk.cache {
172 if before[k] != v.hash {
173 changed++
174 }
175 }
176 require.Equal(t, 1, changed)
177}