detached aed7ca4

test(hints): assert underline detection on real renders

commit
aed7ca486b217bb4654273bf6ff237d08a6cdcab
author
brice · · 2026-10-19 15:57 UTC
parent
73f9772
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
feat/hint-hybrid
2 files · +77 -10
A internal/adapters/hints/testdata/underline.png +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/hints/underline_test.go +77 -10
@@ -143,11 +143,13 @@ func TestUnderlinedURLIsOneTextTarget(t *testing.T) {
143143 require.True(t, got[0].Text)
144144 }
145145
146-func TestUnderlineOnBrowserTestdata(t *testing.T) {
146+// ocrTestdata reads the words of a testdata image with the real tesseract.
147+func ocrTestdata(t *testing.T, name string) (*image.Gray, []OCRWord) {
148+ t.Helper()
147149 if _, err := exec.LookPath("tesseract"); err != nil {
148150 t.Skip("tesseract not on PATH")
149151 }
150- f, err := os.Open("testdata/browser.png")
152+ f, err := os.Open("testdata/" + name)
151153 require.NoError(t, err)
152154 defer f.Close()
153155 img, _, err := image.Decode(f)
@@ -160,19 +162,84 @@ func TestUnderlineOnBrowserTestdata(t *testing.T) {
160162 }
161163 words, _, err := readWords(t.Context(), g)
162164 require.NoError(t, err)
163- var under, urls int
164- for _, w := range words {
165- if w.Underlined {
166- under++
167- t.Logf("underlined: %q %v", w.Text, w.Rect)
165+ return g, words
166+}
167+
168+// headingRule finds the thin horizontal rule under the heading of
169+// browser.png: the first row, from y=150, with a 900 px run of non-white
170+// pixels. It returns the rule as a rect.
171+func headingRule(t *testing.T, g *image.Gray) image.Rectangle {
172+ t.Helper()
173+ for y := 150; y < 220; y++ {
174+ run, start := 0, 0
175+ for x := 0; x < g.Rect.Dx(); x++ {
176+ if g.GrayAt(x, y).Y < 240 {
177+ if run == 0 {
178+ start = x
179+ }
180+ run++
181+ if run >= 900 {
182+ return image.Rect(start, y, start+run, y+1)
183+ }
184+ } else {
185+ run = 0
186+ }
168187 }
169188 }
189+ t.Fatal("heading rule not found")
190+ return image.Rectangle{}
191+}
192+
193+// browser.png has blue links and no underlined link: the only underline-only
194+// target is a known false positive, and the heading rule is not taken for an
195+// underline.
196+func TestUnderlineOnBrowserTestdata(t *testing.T) {
197+ g, words := ocrTestdata(t, "browser.png")
198+ rule := headingRule(t, g)
199+ // Known false positive: the word "as" (flat glyph bottoms, ~(878,353)) is
200+ // flagged underlined by the detector. Anything else is a regression.
201+ var under []ports.HintTarget
170202 for _, tg := range textLinks(words, 1) {
171- if tg.Text {
172- urls++
203+ if !tg.Text {
204+ under = append(under, tg)
173205 }
174206 }
175- t.Logf("words=%d underlined=%d url targets=%d total targets=%d", len(words), under, urls, len(textLinks(words, 1)))
207+ require.Len(t, under, 1, "underline-only targets: %v", under)
208+ require.True(t, under[0].Rect.X > 860 && under[0].Rect.X < 900 && under[0].Rect.Y > 340 && under[0].Rect.Y < 370, "unexpected target %v", under[0].Rect)
209+ for _, tg := range textLinks(words, 1) {
210+ r := image.Rect(tg.Rect.X, tg.Rect.Y, tg.Rect.X+tg.Rect.W, tg.Rect.Y+tg.Rect.H)
211+ require.False(t, r.Overlaps(rule), "target %v overlaps the heading rule %v", tg.Rect, rule)
212+ }
213+}
214+
215+// underline.png is a real render (Chromium) of a generic page: underlined
216+// links `demo`, `source` and `ermite-runner hosts`, a table with row rules
217+// and an input box, which must yield nothing.
218+func TestUnderlineOnRealRender(t *testing.T) {
219+ _, words := ocrTestdata(t, "underline.png")
220+ near := func(r ports.Rect, x0, y0, x1, y1 int) bool {
221+ const tol = 8
222+ abs := func(v int) int { return max(v, -v) }
223+ return abs(r.X-x0) <= tol && abs(r.Y-y0) <= tol && abs(r.X+r.W-x1) <= tol && abs(r.Y+r.H-y1) <= tol
224+ }
225+ var got []ports.HintTarget
226+ for _, tg := range textLinks(words, 1) {
227+ if !tg.Text {
228+ got = append(got, tg)
229+ }
230+ }
231+ require.Len(t, got, 3, "targets: %v", got)
232+ for _, want := range [][4]int{{148, 122, 196, 137}, {306, 126, 364, 137}, {122, 165, 294, 180}} {
233+ found := false
234+ for _, tg := range got {
235+ found = found || near(tg.Rect, want[0], want[1], want[2], want[3])
236+ }
237+ require.True(t, found, "no target near %v in %v", want, got)
238+ }
239+ // The table (y 215..335) and the input box (y 360..410) give nothing.
240+ for _, tg := range textLinks(words, 1) {
241+ require.False(t, tg.Rect.Y > 200 && tg.Rect.Y < 420, "unexpected target %v", tg.Rect)
242+ }
176243 }
177244
178245 // A single glyph with a flat bottom stroke (8, L) is not underlined text.
@@ -143,11 +143,13 @@ func TestUnderlinedURLIsOneTextTarget(t *testing.T) {
143 require.True(t, got[0].Text)143 require.True(t, got[0].Text)
144 }144 }
145 145
146-func TestUnderlineOnBrowserTestdata(t *testing.T) {146+// ocrTestdata reads the words of a testdata image with the real tesseract.
147+func ocrTestdata(t *testing.T, name string) (*image.Gray, []OCRWord) {
148+ t.Helper()
147 if _, err := exec.LookPath("tesseract"); err != nil {149 if _, err := exec.LookPath("tesseract"); err != nil {
148 t.Skip("tesseract not on PATH")150 t.Skip("tesseract not on PATH")
149 }151 }
150- f, err := os.Open("testdata/browser.png")152+ f, err := os.Open("testdata/" + name)
151 require.NoError(t, err)153 require.NoError(t, err)
152 defer f.Close()154 defer f.Close()
153 img, _, err := image.Decode(f)155 img, _, err := image.Decode(f)
@@ -160,19 +162,84 @@ func TestUnderlineOnBrowserTestdata(t *testing.T) {
160 }162 }
161 words, _, err := readWords(t.Context(), g)163 words, _, err := readWords(t.Context(), g)
162 require.NoError(t, err)164 require.NoError(t, err)
163- var under, urls int165+ return g, words
164- for _, w := range words {166+}
165- if w.Underlined {167+
166- under++168+// headingRule finds the thin horizontal rule under the heading of
167- t.Logf("underlined: %q %v", w.Text, w.Rect)169+// browser.png: the first row, from y=150, with a 900 px run of non-white
170+// pixels. It returns the rule as a rect.
171+func headingRule(t *testing.T, g *image.Gray) image.Rectangle {
172+ t.Helper()
173+ for y := 150; y < 220; y++ {
174+ run, start := 0, 0
175+ for x := 0; x < g.Rect.Dx(); x++ {
176+ if g.GrayAt(x, y).Y < 240 {
177+ if run == 0 {
178+ start = x
179+ }
180+ run++
181+ if run >= 900 {
182+ return image.Rect(start, y, start+run, y+1)
183+ }
184+ } else {
185+ run = 0
186+ }
168 }187 }
169 }188 }
189+ t.Fatal("heading rule not found")
190+ return image.Rectangle{}
191+}
192+
193+// browser.png has blue links and no underlined link: the only underline-only
194+// target is a known false positive, and the heading rule is not taken for an
195+// underline.
196+func TestUnderlineOnBrowserTestdata(t *testing.T) {
197+ g, words := ocrTestdata(t, "browser.png")
198+ rule := headingRule(t, g)
199+ // Known false positive: the word "as" (flat glyph bottoms, ~(878,353)) is
200+ // flagged underlined by the detector. Anything else is a regression.
201+ var under []ports.HintTarget
170 for _, tg := range textLinks(words, 1) {202 for _, tg := range textLinks(words, 1) {
171- if tg.Text {203+ if !tg.Text {
172- urls++204+ under = append(under, tg)
173 }205 }
174 }206 }
175- t.Logf("words=%d underlined=%d url targets=%d total targets=%d", len(words), under, urls, len(textLinks(words, 1)))207+ require.Len(t, under, 1, "underline-only targets: %v", under)
208+ require.True(t, under[0].Rect.X > 860 && under[0].Rect.X < 900 && under[0].Rect.Y > 340 && under[0].Rect.Y < 370, "unexpected target %v", under[0].Rect)
209+ for _, tg := range textLinks(words, 1) {
210+ r := image.Rect(tg.Rect.X, tg.Rect.Y, tg.Rect.X+tg.Rect.W, tg.Rect.Y+tg.Rect.H)
211+ require.False(t, r.Overlaps(rule), "target %v overlaps the heading rule %v", tg.Rect, rule)
212+ }
213+}
214+
215+// underline.png is a real render (Chromium) of a generic page: underlined
216+// links `demo`, `source` and `ermite-runner hosts`, a table with row rules
217+// and an input box, which must yield nothing.
218+func TestUnderlineOnRealRender(t *testing.T) {
219+ _, words := ocrTestdata(t, "underline.png")
220+ near := func(r ports.Rect, x0, y0, x1, y1 int) bool {
221+ const tol = 8
222+ abs := func(v int) int { return max(v, -v) }
223+ return abs(r.X-x0) <= tol && abs(r.Y-y0) <= tol && abs(r.X+r.W-x1) <= tol && abs(r.Y+r.H-y1) <= tol
224+ }
225+ var got []ports.HintTarget
226+ for _, tg := range textLinks(words, 1) {
227+ if !tg.Text {
228+ got = append(got, tg)
229+ }
230+ }
231+ require.Len(t, got, 3, "targets: %v", got)
232+ for _, want := range [][4]int{{148, 122, 196, 137}, {306, 126, 364, 137}, {122, 165, 294, 180}} {
233+ found := false
234+ for _, tg := range got {
235+ found = found || near(tg.Rect, want[0], want[1], want[2], want[3])
236+ }
237+ require.True(t, found, "no target near %v in %v", want, got)
238+ }
239+ // The table (y 215..335) and the input box (y 360..410) give nothing.
240+ for _, tg := range textLinks(words, 1) {
241+ require.False(t, tg.Rect.Y > 200 && tg.Rect.Y < 420, "unexpected target %v", tg.Rect)
242+ }
176 }243 }
177 244
178 // A single glyph with a flat bottom stroke (8, L) is not underlined text.245 // A single glyph with a flat bottom stroke (8, L) is not underlined text.

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