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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | + | "testing/synctest" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| @@ -172,19 +173,25 @@ func TestEffectsAllocations(t *testing.T) { | |||
| 172 | 173 | if raceEnabled { | |
| 173 | 174 | t.Skip("the race detector adds allocations") | |
| 174 | 175 | } | |
| 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. | |
| 177 | 178 | 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} | |
| 179 | 181 | now := time.Unix(1000, 0) | |
| 180 | 182 | n := testing.AllocsPerRun(100, func() { | |
| 181 | - | m.input(key("m")) | |
| 182 | 183 | 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) | |
| 186 | 190 | }) | |
| 187 | 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 | 195 | n = testing.AllocsPerRun(100, func() { | |
| 189 | 196 | m.input(key("r")) | |
| 190 | 197 | m.input(key("m")) | |
| @@ -195,39 +202,40 @@ func TestEffectsAllocations(t *testing.T) { | |||
| 195 | 202 | } | |
| 196 | 203 | ||
| 197 | 204 | 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") | |
| 218 | 220 | <-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 | + | }) | |
| 231 | 239 | } | |
| 232 | 240 | ||
| 233 | 241 | func TestNilAnimatorIsSafe(t *testing.T) { | |
| @@ -236,6 +244,8 @@ func TestNilAnimatorIsSafe(t *testing.T) { | |||
| 236 | 244 | } | |
| 237 | 245 | ||
| 238 | 246 | func TestChipStatesFollowModeAndTarget(t *testing.T) { | |
| 247 | + | assert.Equal(t, "SHOT", badgeText(ports.Screenshot)) | |
| 248 | + | assert.Equal(t, "REC", badgeText(ports.Record)) | |
| 239 | 249 | m := toggleModel(t, ports.Screenshot, true) | |
| 240 | 250 | s := m.scene() | |
| 241 | 251 | 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 footer | 176 | + | // 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 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} | |||
| 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 animator | 208 | + | 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 | <-wake | 220 | <-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) { | |||
| 68 | 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 | 71 | func TestBadgeTopLeftAndClearOfHeaderAndLabel(t *testing.T) { | |
| 78 | 72 | for _, w := range []int{1920, 640, 320, 200, 100, 40} { | |
| 79 | 73 | 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) { | |||
| 249 | 249 | } | |
| 250 | 250 | s.badge.w = min(s.badge.w, max(w-s.badge.x, 0)) | |
| 251 | 251 | s.target.w = min(s.target.w, max(w-s.target.x, 0)) | |
| 252 | - | s.clearLabelOfBadge(frect{}, h) | |
| 253 | 252 | } | |
| 254 | 253 | ||
| 255 | 254 | // 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) { | |||
| 443 | 443 | assert.Equal(t, frect{100, 249, 200, 1}, s.edges[1]) | |
| 444 | 444 | assert.Equal(t, frect{100, 50, 1, 200}, s.edges[2]) | |
| 445 | 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. | |
| 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) | |
| 448 | 449 | assert.False(t, overlaps(s.label, s.target)) | |
| 449 | 450 | // Dim strips and region tile the output exactly. | |
| 450 | 451 | 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.0 | 451 | area := 200.0 * 200.0 | ||