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}