detached ffd1332

test(selection): drive the animator on a virtual clock and measure whole effect frames

Run the animator test under synctest instead of wall-clock windows,
measure allocations across hold, fade, pulse decay and end, drop the
obsolete neutral-badge stylesheet assertion, and relocate the size label
once, after the header, with the highlighted rectangle.
commit
ffd1332e94f45eab348a40c511bb9a65b6ffa005
author
brice · · 2026-10-17 17:48 UTC
parent
e2eda52
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +52 -48
M internal/adapters/selection/fx_test.go +49 -39
@@ -3,6 +3,7 @@ package selection
33 import (
44 "context"
55 "testing"
6+ "testing/synctest"
67 "time"
78
89 "github.com/stretchr/testify/assert"
@@ -172,19 +173,25 @@ func TestEffectsAllocations(t *testing.T) {
172173 if raceEnabled {
173174 t.Skip("the race detector adds allocations")
174175 }
175- // A target change rebuilds the footer legend once (covered by the footer
176- // tests); confirming the same target only replays the effects.
176+ // Each run triggers both effects once, then draws them through the hold,
177+ // the fade, the pulse decay and the end, like the frames of one key press.
177178 m := newTestModel(t)
178- m.input(key("w")) // falls back to the monitor
179+ m.input(key("w")) // falls back to the monitor: later presses keep the legend
180+ steps := [...]time.Duration{0, fxStep, pulseSpan / 2, pulseSpan, warnHold, warnHold + 4*fxStep, warnHold + toastFade}
179181 now := time.Unix(1000, 0)
180182 n := testing.AllocsPerRun(100, func() {
181- m.input(key("m"))
182183 m.input(key("w"))
183- now = now.Add(fxStep)
184- m.animate(now)
185- _ = m.scene()
184+ start := now
185+ for _, d := range steps {
186+ m.animate(start.Add(d))
187+ _ = m.scene()
188+ }
189+ now = now.Add(time.Second)
186190 })
187191 assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192+ assert.False(t, m.toast.on)
193+ assert.False(t, m.pulse.on)
194+
188195 n = testing.AllocsPerRun(100, func() {
189196 m.input(key("r"))
190197 m.input(key("m"))
@@ -195,39 +202,40 @@ func TestEffectsAllocations(t *testing.T) {
195202 }
196203
197204 func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {
198- a := newAnimator()
199- wake := make(chan struct{}, 1)
200- ctx, cancel := context.WithCancel(context.Background())
201- defer cancel() // a failed assertion must not leak the animator
202- done := make(chan struct{})
203- go func() { a.run(ctx, wake); close(done) }()
204-
205- select {
206- case <-wake:
207- t.Fatal("idle animator woke the overlay")
208- case <-time.After(3 * fxStep):
209- }
210- a.extend(time.Now().Add(2 * fxStep))
211- select {
212- case <-wake:
213- case <-time.After(time.Second):
214- t.Fatal("running effect did not wake the overlay")
215- }
216- time.Sleep(5 * fxStep)
217- for len(wake) > 0 {
205+ synctest.Test(t, func(t *testing.T) {
206+ a := newAnimator()
207+ wake := make(chan struct{}, 1)
208+ ctx, cancel := context.WithCancel(t.Context())
209+ done := make(chan struct{})
210+ go func() { a.run(ctx, wake); close(done) }()
211+
212+ time.Sleep(10 * fxStep)
213+ synctest.Wait()
214+ assert.Empty(t, wake, "an idle animator never wakes the overlay")
215+
216+ a.extend(time.Now().Add(2 * fxStep))
217+ time.Sleep(fxStep)
218+ synctest.Wait()
219+ require.Len(t, wake, 1, "a running effect wakes the overlay")
218220 <-wake
219- }
220- select {
221- case <-wake:
222- t.Fatal("ended effect kept waking the overlay")
223- case <-time.After(3 * fxStep):
224- }
225- cancel()
226- select {
227- case <-done:
228- case <-time.After(time.Second):
229- t.Fatal("animator did not stop with its context")
230- }
221+
222+ time.Sleep(5 * fxStep) // past the end and its final frame
223+ synctest.Wait()
224+ for len(wake) > 0 {
225+ <-wake
226+ }
227+ time.Sleep(10 * fxStep)
228+ synctest.Wait()
229+ assert.Empty(t, wake, "an ended effect stops waking the overlay")
230+
231+ cancel()
232+ synctest.Wait()
233+ select {
234+ case <-done:
235+ default:
236+ t.Fatal("animator did not stop with its context")
237+ }
238+ })
231239 }
232240
233241 func TestNilAnimatorIsSafe(t *testing.T) {
@@ -236,6 +244,8 @@ func TestNilAnimatorIsSafe(t *testing.T) {
236244 }
237245
238246 func TestChipStatesFollowModeAndTarget(t *testing.T) {
247+ assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
248+ assert.Equal(t, "REC", badgeText(ports.Record))
239249 m := toggleModel(t, ports.Screenshot, true)
240250 s := m.scene()
241251 assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)
@@ -3,6 +3,7 @@ package selection
3 import (3 import (
4 "context"4 "context"
5 "testing"5 "testing"
6+ "testing/synctest"
6 "time"7 "time"
7 8
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
@@ -172,19 +173,25 @@ func TestEffectsAllocations(t *testing.T) {
172 if raceEnabled {173 if raceEnabled {
173 t.Skip("the race detector adds allocations")174 t.Skip("the race detector adds allocations")
174 }175 }
175- // A target change rebuilds the footer legend once (covered by the footer176+ // Each run triggers both effects once, then draws them through the hold,
176- // tests); confirming the same target only replays the effects.177+ // the fade, the pulse decay and the end, like the frames of one key press.
177 m := newTestModel(t)178 m := newTestModel(t)
178- m.input(key("w")) // falls back to the monitor179+ m.input(key("w")) // falls back to the monitor: later presses keep the legend
180+ steps := [...]time.Duration{0, fxStep, pulseSpan / 2, pulseSpan, warnHold, warnHold + 4*fxStep, warnHold + toastFade}
179 now := time.Unix(1000, 0)181 now := time.Unix(1000, 0)
180 n := testing.AllocsPerRun(100, func() {182 n := testing.AllocsPerRun(100, func() {
181- m.input(key("m"))
182 m.input(key("w"))183 m.input(key("w"))
183- now = now.Add(fxStep)184+ start := now
184- m.animate(now)185+ for _, d := range steps {
185- _ = m.scene()186+ m.animate(start.Add(d))
187+ _ = m.scene()
188+ }
189+ now = now.Add(time.Second)
186 })190 })
187 assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")191 assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192+ assert.False(t, m.toast.on)
193+ assert.False(t, m.pulse.on)
194+
188 n = testing.AllocsPerRun(100, func() {195 n = testing.AllocsPerRun(100, func() {
189 m.input(key("r"))196 m.input(key("r"))
190 m.input(key("m"))197 m.input(key("m"))
@@ -195,39 +202,40 @@ func TestEffectsAllocations(t *testing.T) {
195 }202 }
196 203
197 func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {204 func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {
198- a := newAnimator()205+ synctest.Test(t, func(t *testing.T) {
199- wake := make(chan struct{}, 1)206+ a := newAnimator()
200- ctx, cancel := context.WithCancel(context.Background())207+ wake := make(chan struct{}, 1)
201- defer cancel() // a failed assertion must not leak the animator208+ ctx, cancel := context.WithCancel(t.Context())
202- done := make(chan struct{})209+ done := make(chan struct{})
203- go func() { a.run(ctx, wake); close(done) }()210+ go func() { a.run(ctx, wake); close(done) }()
204-211+
205- select {212+ time.Sleep(10 * fxStep)
206- case <-wake:213+ synctest.Wait()
207- t.Fatal("idle animator woke the overlay")214+ assert.Empty(t, wake, "an idle animator never wakes the overlay")
208- case <-time.After(3 * fxStep):215+
209- }216+ a.extend(time.Now().Add(2 * fxStep))
210- a.extend(time.Now().Add(2 * fxStep))217+ time.Sleep(fxStep)
211- select {218+ synctest.Wait()
212- case <-wake:219+ require.Len(t, wake, 1, "a running effect wakes the overlay")
213- case <-time.After(time.Second):
214- t.Fatal("running effect did not wake the overlay")
215- }
216- time.Sleep(5 * fxStep)
217- for len(wake) > 0 {
218 <-wake220 <-wake
219- }221+
220- select {222+ time.Sleep(5 * fxStep) // past the end and its final frame
221- case <-wake:223+ synctest.Wait()
222- t.Fatal("ended effect kept waking the overlay")224+ for len(wake) > 0 {
223- case <-time.After(3 * fxStep):225+ <-wake
224- }226+ }
225- cancel()227+ time.Sleep(10 * fxStep)
226- select {228+ synctest.Wait()
227- case <-done:229+ assert.Empty(t, wake, "an ended effect stops waking the overlay")
228- case <-time.After(time.Second):230+
229- t.Fatal("animator did not stop with its context")231+ cancel()
230- }232+ synctest.Wait()
233+ select {
234+ case <-done:
235+ default:
236+ t.Fatal("animator did not stop with its context")
237+ }
238+ })
231 }239 }
232 240
233 func TestNilAnimatorIsSafe(t *testing.T) {241 func TestNilAnimatorIsSafe(t *testing.T) {
@@ -236,6 +244,8 @@ func TestNilAnimatorIsSafe(t *testing.T) {
236 }244 }
237 245
238 func TestChipStatesFollowModeAndTarget(t *testing.T) {246 func TestChipStatesFollowModeAndTarget(t *testing.T) {
247+ assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
248+ assert.Equal(t, "REC", badgeText(ports.Record))
239 m := toggleModel(t, ports.Screenshot, true)249 m := toggleModel(t, ports.Screenshot, true)
240 s := m.scene()250 s := m.scene()
241 assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)251 assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)
M internal/adapters/selection/mode_test.go +0 -6
@@ -68,12 +68,6 @@ func TestTabSharedAcrossOverlaysAndResult(t *testing.T) {
6868 assert.Equal(t, ports.VideoSettings{}, sel.Video)
6969 }
7070
71-func TestBadgeClassAndText(t *testing.T) {
72- assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
73- assert.Equal(t, "REC", badgeText(ports.Record))
74- assert.NotContains(t, styleSheet, "box.rec", "the rec badge keeps the neutral tag style")
75-}
76-
7771 func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) {
7872 for _, w := range []int{1920, 640, 320, 200, 100, 40} {
7973 for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {
@@ -68,12 +68,6 @@ func TestTabSharedAcrossOverlaysAndResult(t *testing.T) {
68 assert.Equal(t, ports.VideoSettings{}, sel.Video)68 assert.Equal(t, ports.VideoSettings{}, sel.Video)
69 }69 }
70 70
71-func TestBadgeClassAndText(t *testing.T) {
72- assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
73- assert.Equal(t, "REC", badgeText(ports.Record))
74- assert.NotContains(t, styleSheet, "box.rec", "the rec badge keeps the neutral tag style")
75-}
76-
77 func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) {71 func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) {
78 for _, w := range []int{1920, 640, 320, 200, 100, 40} {72 for _, w := range []int{1920, 640, 320, 200, 100, 40} {
79 for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {73 for _, mode := range []ports.Mode{ports.Screenshot, ports.Record} {
M internal/adapters/selection/scene.go +0 -1
@@ -249,7 +249,6 @@ func (s *scene) placeChips(m *model, w, h float64) {
249249 }
250250 s.badge.w = min(s.badge.w, max(w-s.badge.x, 0))
251251 s.target.w = min(s.target.w, max(w-s.target.x, 0))
252- s.clearLabelOfBadge(frect{}, h)
253252 }
254253
255254 // chipStates colors the current mode and target with the mode class.
@@ -249,7 +249,6 @@ func (s *scene) placeChips(m *model, w, h float64) {
249 }249 }
250 s.badge.w = min(s.badge.w, max(w-s.badge.x, 0))250 s.badge.w = min(s.badge.w, max(w-s.badge.x, 0))
251 s.target.w = min(s.target.w, max(w-s.target.x, 0))251 s.target.w = min(s.target.w, max(w-s.target.x, 0))
252- s.clearLabelOfBadge(frect{}, h)
253 }252 }
254 253
255 // chipStates colors the current mode and target with the mode class.254 // chipStates colors the current mode and target with the mode class.
M internal/adapters/selection/selection_test.go +3 -2
@@ -443,8 +443,9 @@ func TestSceneGeometry(t *testing.T) {
443443 assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])
444444 assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])
445445 assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])
446- // Above the region the label would meet the chips, so it moves below them.
447- assert.Equal(t, frect{100, s.target.y + s.target.h + tagPadY, labelW, labelH}, s.label)
446+ // Above the region the label would meet the chips, so it moves below them
447+ // and inside the region.
448+ assert.Equal(t, frect{100, max(50+tagPadY, s.target.y+s.target.h+tagPadY), labelW, labelH}, s.label)
448449 assert.False(t, overlaps(s.label, s.target))
449450 // Dim strips and region tile the output exactly.
450451 area := 200.0 * 200.0
@@ -443,8 +443,9 @@ func TestSceneGeometry(t *testing.T) {
443 assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])443 assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1])
444 assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])444 assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2])
445 assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])445 assert.Equal(t, frect{299, 50, 1, 200}, s.edges[3])
446- // Above the region the label would meet the chips, so it moves below them.446+ // Above the region the label would meet the chips, so it moves below them
447- assert.Equal(t, frect{100, s.target.y + s.target.h + tagPadY, labelW, labelH}, s.label)447+ // and inside the region.
448+ assert.Equal(t, frect{100, max(50+tagPadY, s.target.y+s.target.h+tagPadY), labelW, labelH}, s.label)
448 assert.False(t, overlaps(s.label, s.target))449 assert.False(t, overlaps(s.label, s.target))
449 // Dim strips and region tile the output exactly.450 // Dim strips and region tile the output exactly.
450 area := 200.0 * 200.0451 area := 200.0 * 200.0

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 2

Tags 9

↑↓ moveenter openesc close