main f2d166b

hybrid_test.go RawHistoryFolder

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}

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