detached 0a66bf2

test(mocks): make cursor tests deterministic, harden fakes-check and mock regeneration

commit
0a66bf25abc0d80c03941a52897e4f3ed7a9f4f9
author
brice · · 2026-09-24 16:37 UTC
parent
e9c807e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +57 -15
M AGENTS.md +6 -2
@@ -11,14 +11,18 @@
1111
1212 Test doubles are always generated by Mockery v3 (`testify` template). Never handwrite them.
1313
14-- **No handwritten fakes, stubs or spies.** This includes structs implementing an interface in a test and function fields swapped by tests (`x.ioctl = func...`). `make check` runs `fakes-check`, which rejects `type fake…`/`stub…`/`spy…`/`mock…` in tests.
14+- **No handwritten fakes, stubs or spies.** This covers any test type standing in for a project interface, and function fields swapped by tests (`x.ioctl = func...`). `make check` runs `fakes-check`, which rejects `fake`/`stub`/`spy`/`dummy`/`mock` type names in tests.
1515 - **Every seam is an interface.** When a test needs to replace a dependency, add an interface and generate its mock:
1616 - Cross-layer dependencies go in `internal/ports`. Mocks: `internal/mocks/ports`, package `portsmocks`, type `MockX`.
1717 - Adapter-internal seams (e.g. a kernel ioctl) stay unexported in the adapter. Mocks: `<name>_mock_test.go` in the same package.
1818 - **Add a `.mockery.yml` entry** for each new interface, run `make mocks` and commit the generated files. `make mocks-check` (CI) fails if they are stale or missing.
1919 - Use mocks through `.EXPECT()`. Record calls with `RunAndReturn` when a test inspects arguments.
2020 - Channel ports (sealed message interfaces in `ports/messages.go`) are tested by sending messages, not with mocks.
21-- Real protocol clients in wayland tests (`wlturbo` proxies) are test clients, not doubles.
21+- Not doubles, so allowed:
22+ - real protocol clients in wayland tests (`wlturbo` proxies);
23+ - real in-memory implementations of std interfaces (e.g. a locked `bytes.Buffer` as `io.Writer`);
24+ - production constructor parameters such as `NewRenderer func(w, h) (ports.Renderer, error)`, where tests return a generated mock;
25+ - channels passed to `app.run` to inject input or observe scenes (channel ports).
2226
2327 ## Checks before merge
2428
@@ -11,14 +11,18 @@
11 11
12 Test doubles are always generated by Mockery v3 (`testify` template). Never handwrite them.12 Test doubles are always generated by Mockery v3 (`testify` template). Never handwrite them.
13 13
14-- **No handwritten fakes, stubs or spies.** This includes structs implementing an interface in a test and function fields swapped by tests (`x.ioctl = func...`). `make check` runs `fakes-check`, which rejects `type fake…`/`stub…`/`spy…`/`mock…` in tests.14+- **No handwritten fakes, stubs or spies.** This covers any test type standing in for a project interface, and function fields swapped by tests (`x.ioctl = func...`). `make check` runs `fakes-check`, which rejects `fake`/`stub`/`spy`/`dummy`/`mock` type names in tests.
15 - **Every seam is an interface.** When a test needs to replace a dependency, add an interface and generate its mock:15 - **Every seam is an interface.** When a test needs to replace a dependency, add an interface and generate its mock:
16 - Cross-layer dependencies go in `internal/ports`. Mocks: `internal/mocks/ports`, package `portsmocks`, type `MockX`.16 - Cross-layer dependencies go in `internal/ports`. Mocks: `internal/mocks/ports`, package `portsmocks`, type `MockX`.
17 - Adapter-internal seams (e.g. a kernel ioctl) stay unexported in the adapter. Mocks: `<name>_mock_test.go` in the same package.17 - Adapter-internal seams (e.g. a kernel ioctl) stay unexported in the adapter. Mocks: `<name>_mock_test.go` in the same package.
18 - **Add a `.mockery.yml` entry** for each new interface, run `make mocks` and commit the generated files. `make mocks-check` (CI) fails if they are stale or missing.18 - **Add a `.mockery.yml` entry** for each new interface, run `make mocks` and commit the generated files. `make mocks-check` (CI) fails if they are stale or missing.
19 - Use mocks through `.EXPECT()`. Record calls with `RunAndReturn` when a test inspects arguments.19 - Use mocks through `.EXPECT()`. Record calls with `RunAndReturn` when a test inspects arguments.
20 - Channel ports (sealed message interfaces in `ports/messages.go`) are tested by sending messages, not with mocks.20 - Channel ports (sealed message interfaces in `ports/messages.go`) are tested by sending messages, not with mocks.
21-- Real protocol clients in wayland tests (`wlturbo` proxies) are test clients, not doubles.21+- Not doubles, so allowed:
22+ - real protocol clients in wayland tests (`wlturbo` proxies);
23+ - real in-memory implementations of std interfaces (e.g. a locked `bytes.Buffer` as `io.Writer`);
24+ - production constructor parameters such as `NewRenderer func(w, h) (ports.Renderer, error)`, where tests return a generated mock;
25+ - channels passed to `app.run` to inject input or observe scenes (channel ports).
22 26
23 ## Checks before merge27 ## Checks before merge
24 28
M Makefile +8 -2
@@ -26,16 +26,22 @@ race:
2626 # cgo is enabled only for the race test binary.
2727 CGO_ENABLED=1 go test -race ./...
2828 MOCKS := internal/mocks ':(glob)internal/**/*_mock_test.go'
29+# Regenerate from scratch so mocks of removed interfaces disappear too.
2930 mocks:
31+ rm -rf internal/mocks
32+ find internal -name '*_mock_test.go' -delete
3033 $(HOME)/go/bin/mockery
3134 # Generated mocks must be committed and up to date.
3235 mocks-check: mocks
3336 git diff --exit-code -- $(MOCKS)
3437 @test -z "$$(git ls-files --others --exclude-standard -- $(MOCKS))"
3538 # Test doubles come from Mockery only: no handwritten fake/stub/spy types.
39+# Matches `type fakeX`, `\ttype RendererStub[T any]` and `spyX struct` in `type (` blocks.
40+# grep exit 1 (no match) passes; 0 (match) and 2 (error) fail.
41+FAKES := ^\s*type\s+\w*(fake|stub|spy|dummy|mock)\w*|^\s*\w*(fake|stub|spy|dummy|mock)\w*(\[[^]]*\])?\s+(struct|interface)\b
3642 fakes-check:
37- @! grep -rnE '^type +(fake|stub|spy|dummy|mock)[A-Za-z0-9_]* ' --include='*_test.go' --exclude='*_mock_test.go' internal cmd \
38- || (echo 'handwritten test double: add an interface and a Mockery entry (see AGENTS.md)' >&2; exit 1)
43+ @rc=0; grep -rniE '$(FAKES)' --include='*_test.go' --exclude='*_mock_test.go' internal cmd || rc=$$?; \
44+ [ $$rc -eq 1 ] || { [ $$rc -eq 0 ] && echo 'handwritten test double: add an interface and a Mockery entry (see AGENTS.md)' >&2; exit 1; }
3945 arch:
4046 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
4147 check: vet test arch fakes-check
@@ -26,16 +26,22 @@ race:
26 # cgo is enabled only for the race test binary.26 # cgo is enabled only for the race test binary.
27 CGO_ENABLED=1 go test -race ./...27 CGO_ENABLED=1 go test -race ./...
28 MOCKS := internal/mocks ':(glob)internal/**/*_mock_test.go'28 MOCKS := internal/mocks ':(glob)internal/**/*_mock_test.go'
29+# Regenerate from scratch so mocks of removed interfaces disappear too.
29 mocks:30 mocks:
31+ rm -rf internal/mocks
32+ find internal -name '*_mock_test.go' -delete
30 $(HOME)/go/bin/mockery33 $(HOME)/go/bin/mockery
31 # Generated mocks must be committed and up to date.34 # Generated mocks must be committed and up to date.
32 mocks-check: mocks35 mocks-check: mocks
33 git diff --exit-code -- $(MOCKS)36 git diff --exit-code -- $(MOCKS)
34 @test -z "$$(git ls-files --others --exclude-standard -- $(MOCKS))"37 @test -z "$$(git ls-files --others --exclude-standard -- $(MOCKS))"
35 # Test doubles come from Mockery only: no handwritten fake/stub/spy types.38 # Test doubles come from Mockery only: no handwritten fake/stub/spy types.
39+# Matches `type fakeX`, `\ttype RendererStub[T any]` and `spyX struct` in `type (` blocks.
40+# grep exit 1 (no match) passes; 0 (match) and 2 (error) fail.
41+FAKES := ^\s*type\s+\w*(fake|stub|spy|dummy|mock)\w*|^\s*\w*(fake|stub|spy|dummy|mock)\w*(\[[^]]*\])?\s+(struct|interface)\b
36 fakes-check:42 fakes-check:
37- @! grep -rnE '^type +(fake|stub|spy|dummy|mock)[A-Za-z0-9_]* ' --include='*_test.go' --exclude='*_mock_test.go' internal cmd \43+ @rc=0; grep -rniE '$(FAKES)' --include='*_test.go' --exclude='*_mock_test.go' internal cmd || rc=$$?; \
38- || (echo 'handwritten test double: add an interface and a Mockery entry (see AGENTS.md)' >&2; exit 1)44+ [ $$rc -eq 1 ] || { [ $$rc -eq 0 ] && echo 'handwritten test double: add an interface and a Mockery entry (see AGENTS.md)' >&2; exit 1; }
39 arch:45 arch:
40 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...46 $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
41 check: vet test arch fakes-check47 check: vet test arch fakes-check
M internal/adapters/drm/cursor_test.go +40 -10
@@ -1,6 +1,7 @@
11 package drm
22
33 import (
4+ "sync"
45 "testing"
56 "time"
67 "unsafe"
@@ -58,19 +59,47 @@ func TestCursorMovesOncePerVblank(t *testing.T) {
5859 }
5960
6061 // Move must never wait for the plane, or libinput stops reading and the
61-// kernel drops mouse reports.
62+// kernel drops mouse reports. Moves made meanwhile are coalesced.
6263 func TestCursorMoveDoesNotBlockOnPlane(t *testing.T) {
6364 c, plane := testCursor(t)
64- release := make(chan struct{})
65- plane.EXPECT().WaitVblank().RunAndReturn(func() error { <-release; return nil })
66- plane.EXPECT().Set(mock.Anything).Return(nil).Maybe()
65+ c.hotX, c.hotY = 3, 4
66+ started, release := make(chan struct{}), make(chan struct{})
67+ var once sync.Once
68+ moved := make(chan modeCursor2, 1000)
69+ plane.EXPECT().WaitVblank().RunAndReturn(func() error {
70+ once.Do(func() { close(started) })
71+ <-release
72+ return nil
73+ })
74+ plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { moved <- *v; return nil })
6775 c.start()
68- defer func() { close(release); c.close() }()
69- for i := range 1000 { // the worker is stuck in WaitVblank the whole time
70- c.Move(float64(i), 0)
76+ defer c.close()
77+ c.Move(0, 0)
78+ <-started // the worker is now held in WaitVblank
79+ done := make(chan struct{})
80+ go func() {
81+ for i := range 1000 {
82+ c.Move(float64(i), float64(2*i))
83+ }
84+ close(done)
85+ }()
86+ select {
87+ case <-done:
88+ case <-time.After(2 * time.Second):
89+ t.Fatal("Move blocks while the plane waits for vblank")
7190 }
72- if s := c.TakeStats(); s.Moves != 1000 {
73- t.Fatalf("stats %+v", s)
91+ close(release)
92+ for n := 1; ; n++ {
93+ v := <-moved
94+ if v.flags != cursorMove {
95+ t.Fatalf("flags %#x", v.flags)
96+ }
97+ if v.x == 999-3 && v.y == 1998-4 {
98+ if n >= 1000 {
99+ t.Fatalf("%d moves applied; want coalesced", n)
100+ }
101+ return
102+ }
74103 }
75104 }
76105
@@ -101,8 +130,9 @@ func TestCursorStats(t *testing.T) {
101130 <-applied
102131 c.Move(2, 2)
103132 <-applied
133+ // Set signals before the worker counts the ioctl.
104134 s := c.TakeStats()
105- if s.Moves != 2 || s.Ioctls != 2 {
135+ if s.Moves != 2 || s.Ioctls < 1 || s.Ioctls > 2 {
106136 t.Fatalf("stats %+v", s)
107137 }
108138 if s := c.TakeStats(); s.Moves != 0 {
@@ -1,6 +1,7 @@
1 package drm1 package drm
2 2
3 import (3 import (
4+ "sync"
4 "testing"5 "testing"
5 "time"6 "time"
6 "unsafe"7 "unsafe"
@@ -58,19 +59,47 @@ func TestCursorMovesOncePerVblank(t *testing.T) {
58 }59 }
59 60
60 // Move must never wait for the plane, or libinput stops reading and the61 // Move must never wait for the plane, or libinput stops reading and the
61-// kernel drops mouse reports.62+// kernel drops mouse reports. Moves made meanwhile are coalesced.
62 func TestCursorMoveDoesNotBlockOnPlane(t *testing.T) {63 func TestCursorMoveDoesNotBlockOnPlane(t *testing.T) {
63 c, plane := testCursor(t)64 c, plane := testCursor(t)
64- release := make(chan struct{})65+ c.hotX, c.hotY = 3, 4
65- plane.EXPECT().WaitVblank().RunAndReturn(func() error { <-release; return nil })66+ started, release := make(chan struct{}), make(chan struct{})
66- plane.EXPECT().Set(mock.Anything).Return(nil).Maybe()67+ var once sync.Once
68+ moved := make(chan modeCursor2, 1000)
69+ plane.EXPECT().WaitVblank().RunAndReturn(func() error {
70+ once.Do(func() { close(started) })
71+ <-release
72+ return nil
73+ })
74+ plane.EXPECT().Set(mock.Anything).RunAndReturn(func(v *modeCursor2) error { moved <- *v; return nil })
67 c.start()75 c.start()
68- defer func() { close(release); c.close() }()76+ defer c.close()
69- for i := range 1000 { // the worker is stuck in WaitVblank the whole time77+ c.Move(0, 0)
70- c.Move(float64(i), 0)78+ <-started // the worker is now held in WaitVblank
79+ done := make(chan struct{})
80+ go func() {
81+ for i := range 1000 {
82+ c.Move(float64(i), float64(2*i))
83+ }
84+ close(done)
85+ }()
86+ select {
87+ case <-done:
88+ case <-time.After(2 * time.Second):
89+ t.Fatal("Move blocks while the plane waits for vblank")
71 }90 }
72- if s := c.TakeStats(); s.Moves != 1000 {91+ close(release)
73- t.Fatalf("stats %+v", s)92+ for n := 1; ; n++ {
93+ v := <-moved
94+ if v.flags != cursorMove {
95+ t.Fatalf("flags %#x", v.flags)
96+ }
97+ if v.x == 999-3 && v.y == 1998-4 {
98+ if n >= 1000 {
99+ t.Fatalf("%d moves applied; want coalesced", n)
100+ }
101+ return
102+ }
74 }103 }
75 }104 }
76 105
@@ -101,8 +130,9 @@ func TestCursorStats(t *testing.T) {
101 <-applied130 <-applied
102 c.Move(2, 2)131 c.Move(2, 2)
103 <-applied132 <-applied
133+ // Set signals before the worker counts the ioctl.
104 s := c.TakeStats()134 s := c.TakeStats()
105- if s.Moves != 2 || s.Ioctls != 2 {135+ if s.Moves != 2 || s.Ioctls < 1 || s.Ioctls > 2 {
106 t.Fatalf("stats %+v", s)136 t.Fatalf("stats %+v", s)
107 }137 }
108 if s := c.TakeStats(); s.Moves != 0 {138 if s := c.TakeStats(); s.Moves != 0 {
M internal/adapters/headless/output_test.go +3 -1
@@ -83,7 +83,9 @@ func TestRun(t *testing.T) {
8383 }
8484 contents <- ports.SurfaceContent{ID: 1}
8585 waitFrames(t, f, 2)
86- if _, c := f.snapshot(); c[1][1].Pixels != nil {
86+ if _, c := f.snapshot(); len(c) < 2 {
87+ t.Fatal("no second frame")
88+ } else if _, ok := c[1][1]; ok {
8789 t.Error("content not deleted")
8890 }
8991 cancel()
@@ -83,7 +83,9 @@ func TestRun(t *testing.T) {
83 }83 }
84 contents <- ports.SurfaceContent{ID: 1}84 contents <- ports.SurfaceContent{ID: 1}
85 waitFrames(t, f, 2)85 waitFrames(t, f, 2)
86- if _, c := f.snapshot(); c[1][1].Pixels != nil {86+ if _, c := f.snapshot(); len(c) < 2 {
87+ t.Fatal("no second frame")
88+ } else if _, ok := c[1][1]; ok {
87 t.Error("content not deleted")89 t.Error("content not deleted")
88 }90 }
89 cancel()91 cancel()

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