hints_test.go RawHistoryFolder

1package hints
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"github.com/bnema/zerowrap"
10	"image"
11	_ "image/png"
12	"math"
13	"os"
14	"path/filepath"
15	"strings"
16	"testing"
17	"time"
18
19	"github.com/stretchr/testify/mock"
20	"github.com/stretchr/testify/require"
21
22	"github.com/bnema/neferwl/internal/ports"
23)
24
25type harness struct {
26	requests chan ports.HintScanRequest
27	captures chan ports.CaptureRequest
28	captured chan ports.CaptureDone
29	results  chan ports.HintScanResult
30}
31
32func start(t *testing.T, timeout time.Duration) *harness {
33	return startWith(t, timeout, Options{})
34}
35
36// startCapturing starts a worker that always captures: debug dumps are on.
37func startCapturing(t *testing.T, timeout time.Duration) *harness {
38	return startWith(t, timeout, Options{DumpDir: filepath.Join(t.TempDir(), "hints")})
39}
40
41func startWith(t *testing.T, timeout time.Duration, opts Options) *harness {
42	t.Helper()
43	if opts.detector == nil {
44		// Tests of the capture flow do not care what the detector finds.
45		det := newMockdetector(t)
46		det.EXPECT().Detect(mock.Anything, mock.Anything).Return(nil, nil).Maybe()
47		det.EXPECT().Close().Return(nil).Maybe()
48		opts.detector = det
49	}
50	h := &harness{
51		requests: make(chan ports.HintScanRequest, 1),
52		captures: make(chan ports.CaptureRequest, 4),
53		captured: make(chan ports.CaptureDone, 1),
54		results:  make(chan ports.HintScanResult, 4),
55	}
56	ctx, cancel := context.WithCancel(context.Background())
57	exited := make(chan struct{})
58	go func() {
59		defer close(exited)
60		_ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout, opts)
61	}()
62	t.Cleanup(func() { cancel(); <-exited })
63	return h
64}
65
66func TestHintScan(t *testing.T) {
67	det := newMockdetector(t)
68	det.EXPECT().Close().Return(nil).Once()
69	var got detFrame
70	det.EXPECT().Detect(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f detFrame) ([]detection, error) {
71		got = f
72		return []detection{{x1: 10, y1: 20, x2: 50, y2: 30, score: 0.8}}, nil
73	}).Once()
74	h := startWith(t, 5*time.Second, Options{detector: det})
75	go func() {
76		creq := <-h.captures
77		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
78	}()
79	h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: 64, Height: 32, Scale: 1}
80	res := <-h.results
81	require.NoError(t, res.Err)
82	require.Equal(t, uint64(7), res.Seq)
83	require.Equal(t, "HEADLESS-1", res.Output)
84	require.Equal(t, []ports.HintTarget{{Rect: ports.Rect{X: 10, Y: 20, W: 40, H: 10}, Score: 0.8}}, res.Targets)
85	require.Equal(t, 64, got.width)
86	require.Equal(t, 32, got.height)
87	require.Equal(t, 64*4, got.stride)
88}
89
90func TestTargetsOf(t *testing.T) {
91	nan := float32(math.NaN())
92	inf := float32(math.Inf(1))
93	r := func(x, y, w, h int, score float32) []ports.HintTarget {
94		return []ports.HintTarget{{Rect: ports.Rect{X: x, Y: y, W: w, H: h}, Score: score}}
95	}
96	for _, tc := range []struct {
97		name  string
98		det   detection
99		scale float64
100		want  []ports.HintTarget
101	}{
102		{"inside", detection{10, 20, 50, 40, 0.5, 0}, 1, r(10, 20, 40, 20, 0.5)},
103		{"scale 2 rounds outwards, clamped at 100", detection{11, 21, 100.2, 61, 0.5, 0}, 2, r(5, 10, 45, 21, 0.5)},
104		{"zero scale reads as 1", detection{0, 0, 10, 10, 0.5, 0}, 0, r(0, 0, 10, 10, 0.5)},
105		{"exactly 4x4 kept", detection{0, 0, 8, 8, 0.4, 0}, 2, r(0, 0, 4, 4, 0.4)},
106		{"3 logical wide dropped", detection{0, 0, 5, 40, 0.9, 0}, 2, nil},
107		{"3 logical high dropped", detection{0, 0, 40, 5, 0.9, 0}, 2, nil},
108		{"fully outside", detection{200, 10, 300, 50, 0.5, 0}, 1, nil},
109		{"fully before origin", detection{-50, -50, -10, -10, 0.5, 0}, 1, nil},
110		{"partly outside is clamped", detection{-20, 70, 150, 120, 0.5, 0}, 1, r(0, 70, 100, 30, 0.5)},
111		{"partly outside, scale 2", detection{-20, 70, 150, 120, 0.5, 0}, 2, r(0, 35, 50, 15, 0.5)},
112		{"inverted x", detection{50, 10, 20, 40, 0.5, 0}, 1, nil},
113		{"inverted y", detection{10, 40, 50, 10, 0.5, 0}, 1, nil},
114		{"empty x", detection{10, 10, 10, 40, 0.5, 0}, 1, nil},
115		{"empty y", detection{10, 10, 40, 10, 0.5, 0}, 1, nil},
116		{"NaN coordinate", detection{nan, 0, 50, 50, 0.5, 0}, 1, nil},
117		{"Inf coordinate", detection{0, 0, inf, 50, 0.5, 0}, 1, nil},
118		{"NaN score", detection{0, 0, 50, 50, nan, 0}, 1, nil},
119		{"Inf score", detection{0, 0, 50, 50, inf, 0}, 1, nil},
120		{"score above 1", detection{0, 0, 50, 50, 1.5, 0}, 1, nil},
121		{"negative score", detection{0, 0, 50, 50, -0.1, 0}, 1, nil},
122		{"score 0 and 1 kept", detection{0, 0, 50, 50, 1, 0}, 1, r(0, 0, 50, 50, 1)},
123		{"huge coordinates", detection{-1e30, -1e30, 1e30, 1e30, 0.5, 0}, 1, r(0, 0, 100, 100, 0.5)},
124		{"huge positive only", detection{10, 10, 1e30, 1e30, 0.5, 0}, 2, r(5, 5, 45, 45, 0.5)},
125		{"huge outside", detection{1e30, 1e30, 3e30, 3e30, 0.5, 0}, 1, nil},
126	} {
127		t.Run(tc.name, func(t *testing.T) {
128			got := targetsOf([]detection{tc.det}, tc.scale, 100, 100)
129			if tc.want == nil {
130				require.Empty(t, got)
131				return
132			}
133			require.Equal(t, tc.want, got)
134		})
135	}
136}
137
138// A detector error fails the scan; the worker goes on and the buffer is released.
139func TestHintDetectorErrorFailsScan(t *testing.T) {
140	boom := errors.New("detector gone")
141	det := newMockdetector(t)
142	det.EXPECT().Close().Return(nil).Once()
143	det.EXPECT().Detect(mock.Anything, mock.Anything).Return(nil, boom).Once()
144	det.EXPECT().Detect(mock.Anything, mock.Anything).Return(nil, nil).Once()
145	h := startWith(t, 5*time.Second, Options{detector: det})
146	for i, want := range []error{boom, nil} {
147		go func() {
148			creq := <-h.captures
149			h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
150		}()
151		h.requests <- ports.HintScanRequest{Seq: uint64(i + 1), Output: "o", Width: 16, Height: 16, Scale: 1}
152		res := <-h.results
153		if want != nil {
154			require.ErrorIs(t, res.Err, want)
155		} else {
156			require.NoError(t, res.Err)
157		}
158	}
159}
160
161func TestHintRequestShape(t *testing.T) {
162	h := startCapturing(t, 5*time.Second)
163	got := make(chan ports.CaptureRequest, 1)
164	go func() {
165		creq := <-h.captures
166		got <- creq
167		h.captured <- ports.CaptureDone{ID: creq.ID + 1}                  // another ID: ignored
168		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} // blank frame
169	}()
170	h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
171	res := <-h.results
172	require.NoError(t, res.Err)
173	creq := <-got
174	require.NotZero(t, creq.ID&ports.CaptureIDInternal)
175	require.False(t, creq.Indicate)
176	require.Equal(t, image.Rect(0, 0, 64, 32), creq.Region)
177	require.Len(t, creq.Mapped, 64*32*4)
178	require.Equal(t, 64*4, creq.Stride)
179	require.Equal(t, uint32(1), creq.Format)
180}
181
182func TestHintCaptureError(t *testing.T) {
183	h := startCapturing(t, 5*time.Second)
184	boom := errors.New("boom")
185	go func() {
186		creq := <-h.captures
187		h.captured <- ports.CaptureDone{ID: creq.ID, Err: boom}
188	}()
189	h.requests <- ports.HintScanRequest{Seq: 3, Output: "o", Width: 64, Height: 32, Scale: 1}
190	res := <-h.results
191	require.ErrorIs(t, res.Err, boom)
192	require.Equal(t, uint64(3), res.Seq)
193	require.Empty(t, res.Targets)
194}
195
196func TestHintTimeout(t *testing.T) {
197	h := startCapturing(t, 50*time.Millisecond)
198	h.requests <- ports.HintScanRequest{Seq: 4, Output: "o", Width: 64, Height: 32, Scale: 1}
199	res := <-h.results
200	require.ErrorIs(t, res.Err, ErrTimeout)
201	require.Equal(t, uint64(4), res.Seq)
202}
203
204func TestHintBusy(t *testing.T) {
205	h := startCapturing(t, 5*time.Second)
206	h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
207	creq := <-h.captures
208	h.requests <- ports.HintScanRequest{Seq: 2, Output: "o", Width: 64, Height: 32, Scale: 1}
209	res := <-h.results
210	require.ErrorIs(t, res.Err, ErrBusy)
211	require.Equal(t, uint64(2), res.Seq)
212	h.captured <- ports.CaptureDone{ID: creq.ID}
213	res = <-h.results
214	require.NoError(t, res.Err)
215	require.Equal(t, uint64(1), res.Seq)
216}
217
218func TestHintBadSize(t *testing.T) {
219	h := start(t, time.Second)
220	h.requests <- ports.HintScanRequest{Seq: 5, Output: "o"}
221	res := <-h.results
222	require.ErrorIs(t, res.Err, ErrBadRequest)
223}
224
225// waitFiles waits for the dump the worker writes after sending the result.
226func waitFiles(t *testing.T, dir string, n int) []os.DirEntry {
227	t.Helper()
228	var entries []os.DirEntry
229	require.Eventually(t, func() bool {
230		entries, _ = os.ReadDir(dir)
231		return len(entries) >= n
232	}, 5*time.Second, 10*time.Millisecond)
233	return entries
234}
235
236// scanOnce runs one scan on a 64x32 frame and waits for its result.
237func scanOnce(t *testing.T, h *harness, seq uint64) ports.HintScanResult {
238	t.Helper()
239	go func() {
240		creq := <-h.captures
241		for i := 0; i < len(creq.Mapped); i += 4 {
242			creq.Mapped[i], creq.Mapped[i+1], creq.Mapped[i+2] = 10, 20, 30
243		}
244		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
245	}()
246	h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: 64, Height: 32, Scale: 1}
247	res := <-h.results
248	require.NoError(t, res.Err)
249	return res
250}
251
252func TestHintDump(t *testing.T) {
253	det := newMockdetector(t)
254	det.EXPECT().Close().Return(nil).Once()
255	det.EXPECT().Detect(mock.Anything, mock.Anything).Return([]detection{{x1: 8, y1: 4, x2: 40, y2: 20, score: 0.75}}, nil).Once()
256	dir := filepath.Join(t.TempDir(), "hints")
257	h := startWith(t, 5*time.Second, Options{DumpDir: dir, detector: det})
258	res := scanOnce(t, h, 1)
259	require.Len(t, res.Targets, 1)
260
261	entries := waitFiles(t, dir, 3)
262	var png, ann, js string
263	for _, e := range entries {
264		info, err := e.Info()
265		require.NoError(t, err)
266		require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
267		switch {
268		case strings.HasSuffix(e.Name(), ".annotated.png"):
269			ann = e.Name()
270		case strings.HasSuffix(e.Name(), ".png"):
271			png = e.Name()
272		case strings.HasSuffix(e.Name(), ".json"):
273			js = e.Name()
274		}
275	}
276	require.NotEmpty(t, png)
277	require.NotEmpty(t, ann)
278	require.Regexp(t, `^\d{8}-\d{6}\.\d{3}-HEADLESS-1\.json$`, js)
279
280	st, err := os.Stat(dir)
281	require.NoError(t, err)
282	require.Equal(t, os.FileMode(0o700), st.Mode().Perm())
283
284	data, err := os.ReadFile(filepath.Join(dir, js))
285	require.NoError(t, err)
286	var meta struct {
287		Output   string  `json:"output"`
288		Width    int     `json:"width"`
289		Height   int     `json:"height"`
290		Stride   int     `json:"stride"`
291		Scale    float64 `json:"scale"`
292		DetectMS float64 `json:"detect_ms"`
293		TotalMS  float64 `json:"total_ms"`
294		Targets  []struct {
295			Rect  [4]int  `json:"rect"`
296			Score float32 `json:"score"`
297		} `json:"targets"`
298	}
299	require.NoError(t, json.Unmarshal(data, &meta))
300	require.Equal(t, "HEADLESS-1", meta.Output)
301	require.Equal(t, 64, meta.Width)
302	require.Equal(t, 32, meta.Height)
303	require.Equal(t, 64*4, meta.Stride)
304	require.Equal(t, 1.0, meta.Scale)
305	require.GreaterOrEqual(t, meta.TotalMS, meta.DetectMS)
306	require.Len(t, meta.Targets, 1)
307	require.Equal(t, [4]int{8, 4, 40, 20}, meta.Targets[0].Rect)
308	require.Equal(t, float32(0.75), meta.Targets[0].Score)
309
310	f, err := os.Open(filepath.Join(dir, png))
311	require.NoError(t, err)
312	defer f.Close()
313	img, _, err := image.Decode(f)
314	require.NoError(t, err)
315	require.Equal(t, image.Rect(0, 0, 64, 32), img.Bounds())
316	r, g, b, _ := img.At(0, 0).RGBA()
317	require.Equal(t, uint32(30)*0x101, r)
318	require.Equal(t, uint32(20)*0x101, g)
319	require.Equal(t, uint32(10)*0x101, b)
320}
321
322func TestHintDumpDisabled(t *testing.T) {
323	dir := filepath.Join(t.TempDir(), "hints")
324	h := start(t, 5*time.Second)
325	scanOnce(t, h, 1)
326	_, err := os.Stat(dir)
327	require.ErrorIs(t, err, os.ErrNotExist)
328}
329
330func TestHintDumpRetention(t *testing.T) {
331	dir := t.TempDir()
332	for i := 0; i < 60; i++ {
333		for _, ext := range []string{".png", ".annotated.png", ".json"} {
334			name := fmt.Sprintf("20260101-000000.%03d-o%s", i, ext)
335			require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o600))
336		}
337	}
338	require.NoError(t, prune(dir, maxDumps))
339	entries, err := os.ReadDir(dir)
340	require.NoError(t, err)
341	require.Len(t, entries, maxDumps*3)
342	require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())
343}
344
345// The worker keeps no frame: the mapped buffer belongs to the scan, and to the
346// dump job until it is written; every mapping is released afterwards.
347func TestWorkerHoldsNoFrameAfterScan(t *testing.T) {
348	for _, dumpDir := range []string{"", filepath.Join(t.TempDir(), "hints")} {
349		det := newMockdetector(t)
350		det.EXPECT().Detect(mock.Anything, mock.Anything).Return(nil, nil).Once()
351		w := &worker{dumpDir: dumpDir, det: det}
352		captures := make(chan ports.CaptureRequest, 1)
353		captured := make(chan ports.CaptureDone, 1)
354		go func() {
355			creq := <-captures
356			captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
357		}()
358		req := ports.HintScanRequest{Output: "o", Width: 64, Height: 32, Scale: 1}
359		res := w.serve(context.Background(), req, make(chan ports.HintScanRequest), captures, captured, make(chan ports.HintScanResult, 1), 5*time.Second)
360		require.NoError(t, res.Err)
361		if dumpDir == "" {
362			require.Nil(t, w.pending)
363			require.Zero(t, w.live, "the frame is released before the result")
364		} else {
365			require.NotNil(t, w.pending)
366			require.Equal(t, 1, w.live, "only the frame, kept for the dump")
367		}
368		w.dump(context.Background())
369		require.Nil(t, w.pending)
370		require.Zero(t, w.live)
371	}
372}
373
374// A capture that timed out stays mapped until the output answers it.
375func TestTimedOutCaptureIsReleasedWhenDone(t *testing.T) {
376	w := &worker{dumpDir: t.TempDir()}
377	captures := make(chan ports.CaptureRequest, 1)
378	captured := make(chan ports.CaptureDone, 1)
379	req := ports.HintScanRequest{Output: "o", Width: 64, Height: 32, Scale: 1}
380	res := w.serve(context.Background(), req, make(chan ports.HintScanRequest), captures, captured, make(chan ports.HintScanResult, 1), 20*time.Millisecond)
381	require.ErrorIs(t, res.Err, ErrTimeout)
382	creq := <-captures
383	require.Equal(t, 1, w.live)
384	creq.Mapped[0] = 1 // still valid: the output may write until CaptureDone
385	w.reap(creq.ID)
386	require.Zero(t, w.live)
387}
388
389// A capture that completes after its scan timed out is released while the
390// worker is idle, without another scan, and its reply does not linger.
391func TestHintLateCaptureReaped(t *testing.T) {
392	h := startCapturing(t, 50*time.Millisecond)
393	h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
394	creq := <-h.captures
395	res := <-h.results
396	require.ErrorIs(t, res.Err, ErrTimeout)
397	// The worker is idle; the output finally answers.
398	h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
399	require.Eventually(t, func() bool { return len(h.captured) == 0 }, time.Second, time.Millisecond)
400}
401
402func TestScanLogsRawAndPreparedCounts(t *testing.T) {
403	dets := make([]detection, maxWorkBoxes+5)
404	for i := range dets {
405		dets[i] = detection{x1: 10, y1: 10, x2: 30, y2: 30, score: .9}
406	}
407	det := newMockdetector(t)
408	det.EXPECT().Detect(mock.Anything, mock.Anything).Return(dets, nil).Once()
409	var out bytes.Buffer // scan logs synchronously on this goroutine
410	ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &out}))
411	w := &worker{det: det, dumpDir: "enabled"}
412	w.scan(ctx, ports.HintScanRequest{Width: 100, Height: 100, Scale: 1}, frame{m: &mapping{}}, 0, ports.HintScanResult{})
413	var fields map[string]any
414	require.NoError(t, json.Unmarshal(out.Bytes(), &fields))
415	require.Equal(t, float64(maxWorkBoxes+5), fields["raw_boxes"])
416	require.Equal(t, float64(maxWorkBoxes), fields["prepared_boxes"])
417	require.Equal(t, "hints", fields["component"])
418}

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 23

Tags 10

↑↓ moveenter openesc close