v0.3.0 cf370a0

colorcheck_test.go RawHistoryFolder

1//go:build colorcheck
2
3// On-demand colour check: a real Wayland client (examples/testpattern) shows
4// known colours on a headless NeferWL and the screenshots are compared with
5// what those colours must become. It needs a GPU and, for the PQ client, /dev/udmabuf,
6// so it is excluded from `go test ./...`, `make check` and CI. Run it with
7// `make color-check`.
8package app
9
10import (
11	"context"
12	"fmt"
13	"image"
14	"image/png"
15	"math"
16	"os"
17	"os/exec"
18	"path/filepath"
19	"strconv"
20	"strings"
21	"testing"
22	"time"
23
24	"github.com/bnema/neferwl/examples/testpattern/pattern"
25	"github.com/bnema/neferwl/internal/adapters/config"
26)
27
28const (
29	colorW, colorH = 640, 360
30	// sampleRadius gives the 5×5 box sampled at each patch centre.
31	sampleRadius = 2
32	// cursorRadius is the half size of the box around the output centre that
33	// headless screenshots draw the cursor into.
34	cursorRadius = 48
35	// pqTolerance is the per-channel PQ signal tolerance, the one of
36	// TestHDRWindowedComposition; it covers the fp16 round trip.
37	pqTolerance = 0.012
38	// nitTolerance is the alternative HDR tolerance, in absolute luminance,
39	// for the channels expected below nitFloor.
40	//
41	// Near black PQ is very steep, and the compositor's fp16 linear
42	// intermediate leaves a small residue there: with BT.2020 primaries
43	// outside BT.709 the BT.709 intermediate has a slightly negative or
44	// rounded component, and the BT.2020 green primary patch measures
45	// R=0.0313 PQ instead of 0 on an RX 9070 XT. That is about 0.02 nit,
46	// invisible and not a colour bug, so a channel expected below nitFloor
47	// also passes when the decoded luminance differs by no more than this.
48	nitTolerance = 0.03
49	// nitFloor is the expected luminance (nits) under which the luminance
50	// fallback applies; brighter channels must meet pqTolerance.
51	nitFloor = 0.05
52	// pqNamedTF is the wp_color_manager_v1 transfer function of an HDR output
53	// (st2084_pq).
54	pqNamedTF = 11
55	// colorTimeout bounds each wait for the client and for a matching frame.
56	colorTimeout = 5 * time.Second
57)
58
59// TestColorCheckSDR: an sRGB wl_shm client on an SDR output is read back
60// byte for byte (±1 per channel).
61func TestColorCheckSDR(t *testing.T) {
62	dir := t.TempDir()
63	socket := startColorCompositor(t, Options{ScreenshotDir: dir})
64	client := startPatternClient(t, socket, "sdr")
65	patches := pattern.Patches("sdr", colorW, colorH)
66	assertNoCursorOverlap(t, patches)
67
68	pollImage(t, filepath.Join(dir, "latest.png"), client.ready, "", func(img image.Image) []string {
69		return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string {
70			for c := range 3 {
71				if math.Abs(got[c]*255-float64(p.SRGB[c])) > 1 {
72					return fmt.Sprintf("got %v, want sRGB %v ±1", bytes3(got), p.SRGB)
73				}
74			}
75			return ""
76		})
77	})
78}
79
80// TestColorCheckHDR checks a virtual HDR output through the raw PQ dump
81// (latest-pq.png), with an sRGB client and with a PQ client.
82//
83// Virtual HDR must be on: output-tf other than 11 means it fell back to SDR,
84// which is a failure, not a skip. Since headless exports its targets without
85// a display modifier list, it works on any GPU that exports XR30, and the dev
86// box does. The target requires a GPU: a Vulkan error fails the test. Only a
87// missing /dev/udmabuf or linear XR30 import (PQ client) skips.
88//
89// Expected path: PQ client content is decoded to fp16 linear BT.709 (/203,
90// compose_hdr.frag), then re-encoded (×203, BT.709→BT.2020, PQ, hdr.frag);
91// sRGB content maps to PQ(M·linear·203). There is no dithering, and the
92// cursor and the capture indicator are not in the exported target.
93func TestColorCheckHDR(t *testing.T) {
94	dir := t.TempDir()
95	socket := startColorCompositor(t, Options{ScreenshotDir: dir, HeadlessHDR: true, ScreenshotRaw: true})
96	pq := filepath.Join(dir, "latest-pq.png")
97
98	t.Run("sdr-client", func(t *testing.T) {
99		client := startPatternClient(t, socket, "sdr")
100		patches := pattern.Patches("sdr", colorW, colorH)
101		assertNoCursorOverlap(t, patches)
102		pollImage(t, pq, client.ready, "no raw PQ dump (virtual HDR off?)", func(img image.Image) []string {
103			return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string {
104				var want [3]float64
105				var lin [3]float64
106				for c := range 3 {
107					lin[c] = pattern.SRGBToLinear(float64(p.SRGB[c]) / 255)
108				}
109				for r := range 3 {
110					for c := range 3 {
111						want[r] += pattern.BT709ToBT2020[r][c] * lin[c]
112					}
113					want[r] = pattern.PQEncode(want[r] * pattern.ReferenceWhite)
114				}
115				return comparePQ(got, want)
116			})
117		})
118	})
119
120	t.Run("pq-client", func(t *testing.T) {
121		if _, err := os.Stat("/dev/udmabuf"); err != nil {
122			t.Skipf("no /dev/udmabuf: %v", err)
123		}
124		client := startPatternClient(t, socket, "hdr")
125		if client.tf != pqNamedTF {
126			t.Fatalf("output-tf %d, want %d: virtual HDR fell back to SDR", client.tf, pqNamedTF)
127		}
128		patches := pattern.Patches("hdr", colorW, colorH)
129		assertNoCursorOverlap(t, patches)
130		pollImage(t, pq, client.ready, "no raw PQ dump (virtual HDR off?)", func(img image.Image) []string {
131			return comparePatches(img, patches, func(p pattern.Patch, got [3]float64) string {
132				var want [3]float64
133				for c := range 3 {
134					want[c] = pattern.PQEncode(p.Nits[c])
135				}
136				return comparePQ(got, want)
137			})
138		})
139		// Tone mapping itself is unit-tested: only check the 8-bit
140		// screenshot is not black at the 1000-nit patch.
141		pollImage(t, filepath.Join(dir, "latest.png"), client.ready, "", func(img image.Image) []string {
142			c := patches[5].Centre()
143			r, g, b, _ := img.At(c.X, c.Y).RGBA()
144			if r|g|b == 0 {
145				return []string{fmt.Sprintf("latest.png is black at the 1000-nit patch (%d,%d)", c.X, c.Y)}
146			}
147			return nil
148		})
149	})
150}
151
152// comparePQ compares one patch, per channel: it passes within pqTolerance of
153// signal or, for a channel expected below nitFloor, within nitTolerance of
154// luminance (see nitTolerance).
155func comparePQ(got, want [3]float64) string {
156	for c := range 3 {
157		if math.Abs(got[c]-want[c]) <= pqTolerance {
158			continue
159		}
160		if wantNits := pattern.PQDecode(want[c]); wantNits < nitFloor && math.Abs(pattern.PQDecode(got[c])-wantNits) <= nitTolerance {
161			continue
162		}
163		return fmt.Sprintf("got PQ %.4f %.4f %.4f (%.3f %.3f %.3f nit), want %.4f %.4f %.4f (%.3f %.3f %.3f nit); ±%v PQ, or ±%v nit under %v nit",
164			got[0], got[1], got[2], pattern.PQDecode(got[0]), pattern.PQDecode(got[1]), pattern.PQDecode(got[2]),
165			want[0], want[1], want[2], pattern.PQDecode(want[0]), pattern.PQDecode(want[1]), pattern.PQDecode(want[2]),
166			pqTolerance, nitTolerance, nitFloor)
167	}
168	return ""
169}
170
171func bytes3(v [3]float64) [3]int {
172	return [3]int{int(math.Round(v[0] * 255)), int(math.Round(v[1] * 255)), int(math.Round(v[2] * 255))}
173}
174
175// comparePatches samples the 5×5 box at the centre of every patch; check gets
176// each pixel as unit-range channels and returns a mismatch description or "".
177// It returns one line per mismatching patch, or one for a wrong image size.
178func comparePatches(img image.Image, patches []pattern.Patch, check func(p pattern.Patch, got [3]float64) string) []string {
179	if b := img.Bounds(); b.Dx() != colorW || b.Dy() != colorH {
180		return []string{fmt.Sprintf("image is %v, want %dx%d", b.Size(), colorW, colorH)}
181	}
182	var bad []string
183	for i, p := range patches {
184		c := p.Centre()
185	box:
186		for y := c.Y - sampleRadius; y <= c.Y+sampleRadius; y++ {
187			for x := c.X - sampleRadius; x <= c.X+sampleRadius; x++ {
188				r, g, b, _ := img.At(x, y).RGBA()
189				got := [3]float64{float64(r) / 65535, float64(g) / 65535, float64(b) / 65535}
190				if msg := check(p, got); msg != "" {
191					bad = append(bad, fmt.Sprintf("patch %d at (%d,%d): %s", i, x, y, msg))
192					break box
193				}
194			}
195		}
196	}
197	return bad
198}
199
200// assertNoCursorOverlap checks no sampling box touches the cursor, which
201// headless screenshots draw at the output centre.
202func assertNoCursorOverlap(t *testing.T, patches []pattern.Patch) {
203	t.Helper()
204	cursor := image.Rect(colorW/2-cursorRadius, colorH/2-cursorRadius, colorW/2+cursorRadius, colorH/2+cursorRadius)
205	for i, p := range patches {
206		c := p.Centre()
207		box := image.Rect(c.X-sampleRadius, c.Y-sampleRadius, c.X+sampleRadius+1, c.Y+sampleRadius+1)
208		if box.Overlaps(cursor) {
209			t.Fatalf("patch %d sampling box %v intersects the cursor box %v", i, box, cursor)
210		}
211	}
212}
213
214// pollImage decodes path every 50 ms, up to colorTimeout, until check returns
215// no mismatch. Only files modified after notBefore count, so a frame of a
216// previous client is never judged (files are replaced atomically). On timeout
217// it reports every mismatch of the last attempt; missing, when not empty,
218// replaces the error of a file that does not exist.
219func pollImage(t *testing.T, path string, notBefore time.Time, missing string, check func(image.Image) []string) {
220	t.Helper()
221	var last []string
222	for deadline := time.Now().Add(colorTimeout); ; time.Sleep(50 * time.Millisecond) {
223		last = nil
224		if fi, err := os.Stat(path); err != nil {
225			last = []string{err.Error()}
226			if missing != "" && os.IsNotExist(err) {
227				last = []string{missing}
228			}
229		} else if !fi.ModTime().After(notBefore) {
230			last = []string{fmt.Sprintf("%s not rewritten since the client started", filepath.Base(path))}
231		} else if f, err := os.Open(path); err != nil {
232			last = []string{err.Error()}
233		} else {
234			img, err := png.Decode(f)
235			_ = f.Close()
236			if err != nil {
237				last = []string{err.Error()}
238			} else if last = check(img); len(last) == 0 {
239				return
240			}
241		}
242		if time.Now().After(deadline) {
243			t.Fatalf("%s does not match after %v:\n  %s", filepath.Base(path), colorTimeout, strings.Join(last, "\n  "))
244		}
245	}
246}
247
248// startColorCompositor runs a headless NeferWL with one 640×360 output in
249// temporary runtime and state directories and returns its socket path. A Run
250// error, Vulkan included, fails the test: the target requires a GPU.
251func startColorCompositor(t *testing.T, opts Options) string {
252	t.Helper()
253	runtime := t.TempDir()
254	t.Setenv("XDG_RUNTIME_DIR", runtime)
255	t.Setenv("XDG_STATE_HOME", t.TempDir())
256	opts.Backend = "headless"
257	opts.NoXwayland = true
258	opts.NoTerminal = true
259	opts.Config = config.Defaults()
260	opts.Sizes = [][2]int{{colorW, colorH}}
261
262	ctx, cancel := context.WithCancel(context.Background())
263	done := make(chan error, 1)
264	go func() { done <- Run(ctx, opts) }()
265	stopped := false // Run's result was already received
266	t.Cleanup(func() {
267		cancel()
268		if stopped {
269			return
270		}
271		select {
272		case err := <-done:
273			if err != nil {
274				t.Errorf("Run: %v", err)
275			}
276		case <-time.After(5 * time.Second):
277			t.Error("Run did not stop")
278		}
279	})
280	socket := filepath.Join(runtime, "wayland-1")
281	for deadline := time.Now().Add(colorTimeout); ; time.Sleep(20 * time.Millisecond) {
282		if _, err := os.Stat(socket); err == nil {
283			return socket
284		}
285		select {
286		case err := <-done:
287			stopped = true
288			t.Fatalf("neferwl exited: %v", err)
289		default:
290		}
291		if time.Now().After(deadline) {
292			t.Fatal("no wayland socket")
293		}
294	}
295}
296
297// patternClient is a running examples/testpattern.
298type patternClient struct {
299	ready time.Time // just before the client was started: older files are not judged
300	tf    int       // "output-tf" of the compositor's output (hdr mode)
301}
302
303// startPatternClient builds and starts examples/testpattern with -fullscreen
304// and returns once it printed "ready 640 360". It skips the test when the
305// client reports that linear XR30 is unsupported, and stops the client when
306// the test (or subtest) ends.
307func startPatternClient(t *testing.T, socket, mode string) *patternClient {
308	t.Helper()
309	bin := filepath.Join(t.TempDir(), "testpattern")
310	build := exec.Command("go", "build", "-o", bin, "../../examples/testpattern")
311	build.Env = append(os.Environ(), "CGO_ENABLED=0")
312	if out, err := build.CombinedOutput(); err != nil {
313		t.Fatalf("build testpattern: %v: %s", err, out)
314	}
315	cmd := exec.Command(bin, "-mode", mode, "-fullscreen")
316	cmd.Env = append(os.Environ(), "WAYLAND_DISPLAY="+filepath.Base(socket))
317	stderr := &lockedBuffer{}
318	cmd.Stderr = stderr
319	// Taken before the start so that no frame of this client predates it.
320	// Frames of its windowed state (before fullscreen) are newer, so they
321	// are judged: they just fail the check and are retried.
322	started := time.Now()
323	if err := cmd.Start(); err != nil {
324		t.Fatal(err)
325	}
326	waited := make(chan error, 1)
327	go func() { waited <- cmd.Wait() }()
328	exited := false
329	t.Cleanup(func() {
330		if !exited {
331			_ = cmd.Process.Kill()
332			<-waited
333		}
334	})
335
336	for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(10 * time.Millisecond) { // includes the 1.2 s fullscreen delay
337		log := stderr.String()
338		if strings.Contains(log, fmt.Sprintf("ready %d %d\n", colorW, colorH)) {
339			c := &patternClient{ready: started}
340			for _, l := range strings.Split(log, "\n") {
341				if v, ok := strings.CutPrefix(l, "output-tf "); ok {
342					tf, err := strconv.Atoi(v)
343					if err != nil {
344						t.Fatalf("bad %q", l)
345					}
346					c.tf = tf
347				}
348			}
349			return c
350		}
351		select {
352		case err := <-waited:
353			exited = true
354			log = stderr.String()
355			if strings.Contains(log, "linear XR30 unsupported") {
356				t.Skipf("testpattern: %s", strings.TrimSpace(log))
357			}
358			t.Fatalf("testpattern exited before ready (%v): %s", err, strings.TrimSpace(log))
359		default:
360		}
361		if time.Now().After(deadline) {
362			t.Fatalf("testpattern not ready: %s", strings.TrimSpace(log))
363		}
364	}
365}

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