1package selection
2
3import (
4 "context"
5 "testing"
6 "testing/synctest"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/nefercap/internal/core"
13 "github.com/bnema/nefercap/internal/ports"
14)
15
16func TestTargetKeysShowToastAndPulse(t *testing.T) {
17 m := newTestModel(t)
18 for _, c := range []struct {
19 key string
20 kind core.PickKind
21 text string
22 }{{"m", core.PickMonitor, toastMonitor}, {"r", core.PickRegion, toastRegion}, {"m", core.PickMonitor, toastMonitor}} {
23 assert.True(t, m.input(key(c.key)), c.key)
24 assert.Equal(t, c.kind, m.picker.Kind())
25 assert.Equal(t, c.text, m.toast.text)
26 assert.False(t, m.toast.warn)
27 assert.True(t, m.pulse.on)
28 }
29}
30
31func TestWorkspaceKeyUsesActiveWorkspace(t *testing.T) {
32 m, _ := newBareModel()
33 m.setWorkspaces([]ports.Workspace{{ID: 11, OutputID: 7, Active: true, Region: ports.Region{Y: 30, Width: 1920, Height: 1050}}})
34 m.resize(1920, 1080, 1)
35 m.input(key("w"))
36 assert.Equal(t, core.PickWorkspace, m.picker.Kind())
37 assert.Equal(t, toastWorkspace, m.toast.text)
38 assert.False(t, m.toast.warn)
39}
40
41func TestWorkspaceKeyWithoutWorkspaceFallsBackToMonitor(t *testing.T) {
42 m := newTestModel(t)
43 assert.True(t, m.input(key("w")))
44 assert.Equal(t, core.PickMonitor, m.picker.Kind())
45 assert.Equal(t, toastNoWS, m.toast.text)
46 assert.True(t, m.toast.warn)
47 m.input(key("Return"))
48 require.Equal(t, endAccepted, m.end)
49 res, ok, err := m.sess.outcome(context.Background())
50 require.NoError(t, err)
51 require.True(t, ok)
52 assert.Equal(t, core.PickMonitor, res.Kind)
53 assert.Equal(t, uint32(7), res.OutputID)
54}
55
56func TestTargetKeysIgnoredWhileDragging(t *testing.T) {
57 m := newTestModel(t)
58 m.input(button(100, 100, true))
59 m.input(motion(300, 300))
60 require.True(t, m.picker.Dragging())
61 for _, k := range []string{"m", "w", "r"} {
62 m.input(key(k))
63 assert.True(t, m.picker.Dragging())
64 assert.False(t, m.toast.on, k)
65 }
66}
67
68func TestTabShowsModeToastOnEveryOverlay(t *testing.T) {
69 sess, _ := testSession()
70 a := newModel(ports.Screenshot, testOutputs, 0, sess, false)
71 b := newModel(ports.Screenshot, testOutputs, 1, sess, false)
72 a.toggle, b.toggle = true, true
73 a.resize(1920, 1080, 1)
74 b.resize(1280, 720, 1)
75 a.input(key("Tab"))
76 assert.Equal(t, toastRec, a.toast.text)
77 b.syncMode() // what b's view does when woken
78 assert.Equal(t, toastRec, b.toast.text)
79 assert.True(t, b.pulse.on)
80 a.input(key("Tab"))
81 assert.Equal(t, toastShot, a.toast.text)
82}
83
84func TestToastHoldsFadesAndEnds(t *testing.T) {
85 m := newTestModel(t)
86 m.input(key("m"))
87 t0 := time.Unix(1000, 0)
88 m.animate(t0)
89 assert.Equal(t, 0, m.toast.step)
90 assert.Greater(t, m.scene().toast.w, 0.0)
91 m.animate(t0.Add(targetHold))
92 assert.Equal(t, 0, m.toast.step, "held at full opacity")
93 m.animate(t0.Add(targetHold + fxStep))
94 assert.Equal(t, 2, m.toast.step)
95 m.animate(t0.Add(targetHold + toastFade - time.Millisecond))
96 assert.Equal(t, len(opacityClasses)-1, m.toast.step)
97 m.animate(t0.Add(targetHold + toastFade))
98 assert.False(t, m.toast.on)
99 assert.Equal(t, frect{}, m.scene().toast)
100}
101
102func TestPulseThickensThenRestoresTheBorder(t *testing.T) {
103 m := newTestModel(t)
104 m.input(key("m"))
105 t0 := time.Unix(1000, 0)
106 m.animate(t0)
107 assert.Equal(t, edge+pulseExtra, m.edgeW)
108 assert.Equal(t, frect{0, 0, 1920, edge + pulseExtra}, m.scene().edges[0])
109 m.animate(t0.Add(pulseSpan / 2))
110 assert.Greater(t, m.edgeW, edge)
111 assert.Less(t, m.edgeW, edge+pulseExtra)
112 m.animate(t0.Add(pulseSpan))
113 assert.Equal(t, edge, m.edgeW)
114 assert.Equal(t, frect{0, 0, 1920, edge}, m.scene().edges[0])
115}
116
117func TestPulseNeverExceedsATinyRegion(t *testing.T) {
118 m := newTestModel(t)
119 m.input(button(100, 100, true))
120 m.input(motion(103, 103))
121 m.pulseBorder()
122 m.animate(time.Unix(1000, 0))
123 for _, e := range m.scene().edges {
124 assert.LessOrEqual(t, e.w, 3.0)
125 assert.LessOrEqual(t, e.h, 3.0)
126 }
127}
128
129func TestChipsShowModeAndTarget(t *testing.T) {
130 m := toggleModel(t, ports.Screenshot, true)
131 s := m.scene()
132 assert.Greater(t, s.modes[0].w, 0.0, "SHOT chip")
133 assert.Greater(t, s.modes[1].w, 0.0, "REC chip offered with Tab")
134 for i := range s.kinds {
135 assert.Greater(t, s.kinds[i].w, 0.0, targetTexts[i])
136 }
137 assert.Equal(t, s.badge.y, s.target.y, "one row on a wide output")
138 assert.False(t, overlaps(s.badge, s.target))
139 assert.False(t, overlaps(s.target, s.header))
140
141 fixed := toggleModel(t, ports.Record, false)
142 s = fixed.scene()
143 assert.Zero(t, s.modes[0].w, "without Tab only the current mode shows")
144 assert.Greater(t, s.modes[1].w, 0.0)
145
146 assert.Equal(t, "shot", modeClass(ports.Screenshot))
147 assert.Equal(t, "rec", modeClass(ports.Record))
148 assert.Contains(t, styleSheet, "box.chip.shot")
149 assert.Contains(t, styleSheet, "box.chip.rec")
150 assert.Contains(t, styleSheet, "box.edge.rec")
151}
152
153func TestChipsStayOnSurfaceAndClearOfHeader(t *testing.T) {
154 for _, w := range []int{3840, 1920, 1024, 640, 320, 200, 100, 40} {
155 m := toggleModel(t, ports.Screenshot, true)
156 m.resize(w, 480, 1)
157 s := m.scene()
158 for _, r := range append(s.modes[:], s.kinds[:]...) {
159 if r.w == 0 {
160 continue
161 }
162 assert.GreaterOrEqual(t, r.x, 0.0)
163 assert.LessOrEqual(t, r.x+r.w, float64(w)+1e-9, "width %d", w)
164 assert.LessOrEqual(t, r.y+r.h, 480.0)
165 }
166 if w >= 640 {
167 assert.False(t, overlaps(s.target, s.header), "target/header at %d", w)
168 }
169 }
170}
171
172func TestEffectsAllocations(t *testing.T) {
173 if raceEnabled {
174 t.Skip("the race detector adds allocations")
175 }
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.
178 m := newTestModel(t)
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}
181 now := time.Unix(1000, 0)
182 n := testing.AllocsPerRun(100, func() {
183 m.input(key("w"))
184 start := now
185 for _, d := range steps {
186 m.animate(start.Add(d))
187 _ = m.scene()
188 }
189 now = now.Add(time.Second)
190 })
191 assert.Zero(t, n, "toasts, pulses and their frames allocate nothing")
192
193 // A full HUD fade out and back in, with a toast fading along, allocates
194 // nothing per frame. The fade restarts every run, so warm-up does not
195 // leave it settled.
196 m.input(button(100, 100, true))
197 m.input(motion(400, 300))
198 n = testing.AllocsPerRun(100, func() {
199 m.hud = hudFade{}
200 m.showToast(toastMonitor, targetHold, false)
201 for i := range hudSteps + 1 {
202 m.animate(now.Add(time.Duration(i) * fxStep))
203 _ = m.scene()
204 _, _ = m.hudClass(), m.toastClass()
205 }
206 m.hud.hiding = false // as after a rejected release
207 for i := range hudSteps + 1 {
208 m.animate(now.Add(time.Second + time.Duration(i)*fxStep))
209 _ = m.scene()
210 _, _ = m.hudClass(), m.toastClass()
211 }
212 m.hud.hiding = true
213 now = now.Add(2 * time.Second)
214 })
215 assert.Zero(t, n, "HUD fade frames allocate nothing")
216 m.input(motion(102, 102))
217 m.input(button(102, 102, false))
218 m.animate(now.Add(time.Minute))
219 m.animate(now.Add(2 * time.Minute))
220 assert.False(t, m.toast.on)
221 assert.False(t, m.pulse.on)
222
223 n = testing.AllocsPerRun(100, func() {
224 m.input(key("r"))
225 m.input(key("m"))
226 })
227 t.Logf("target switch allocs/run: %v", n)
228 // Measured on main before the effects: 3 (two legends and a size label).
229 assert.LessOrEqual(t, n, 3.0, "effects add nothing to a target switch")
230}
231
232func TestAnimatorWakesOnlyWhileEffectsRun(t *testing.T) {
233 synctest.Test(t, func(t *testing.T) {
234 a := newAnimator()
235 wake := make(chan struct{}, 1)
236 ctx, cancel := context.WithCancel(t.Context())
237 done := make(chan struct{})
238 go func() { a.run(ctx, wake); close(done) }()
239
240 time.Sleep(10 * fxStep)
241 synctest.Wait()
242 assert.Empty(t, wake, "an idle animator never wakes the overlay")
243
244 a.extend(time.Now().Add(2 * fxStep))
245 time.Sleep(fxStep)
246 synctest.Wait()
247 require.Len(t, wake, 1, "a running effect wakes the overlay")
248 <-wake
249
250 time.Sleep(5 * fxStep) // past the end and its final frame
251 synctest.Wait()
252 for len(wake) > 0 {
253 <-wake
254 }
255 time.Sleep(10 * fxStep)
256 synctest.Wait()
257 assert.Empty(t, wake, "an ended effect stops waking the overlay")
258
259 cancel()
260 synctest.Wait()
261 select {
262 case <-done:
263 default:
264 t.Fatal("animator did not stop with its context")
265 }
266 })
267}
268
269func TestNilAnimatorIsSafe(t *testing.T) {
270 var a *animator
271 a.extend(time.Now())
272}
273
274func TestChipStatesFollowModeAndTarget(t *testing.T) {
275 assert.Equal(t, "SHOT", badgeText(ports.Screenshot))
276 assert.Equal(t, "REC", badgeText(ports.Record))
277 m := toggleModel(t, ports.Screenshot, true)
278 s := m.scene()
279 assert.Equal(t, [2]string{"shot", "idle"}, s.modeState)
280 assert.Equal(t, [3]string{"shot", "idle", "off"}, s.kindState, "region active, no workspace here")
281 m.input(key("m"))
282 m.input(key("Tab"))
283 s = m.scene()
284 assert.Equal(t, [2]string{"idle", "rec"}, s.modeState)
285 assert.Equal(t, [3]string{"idle", "rec", "off"}, s.kindState)
286}
287
288func TestDragFadesTheHUDOutAndBackIn(t *testing.T) {
289 m := toggleModel(t, ports.Screenshot, true)
290 m.grid = true
291 m.layoutGrid()
292 t0 := time.Unix(1000, 0)
293 m.input(button(100, 100, true))
294 m.animate(t0)
295 assert.Equal(t, 0, m.hud.level, "a press alone keeps the HUD")
296
297 m.input(motion(400, 300))
298 require.True(t, m.picker.Dragging())
299 m.input(key("Tab")) // its toast fades along with the HUD
300 levels := []int{}
301 for i := range hudSteps + 1 {
302 m.animate(t0.Add(time.Duration(i) * fxStep))
303 levels = append(levels, m.hud.level)
304 }
305 assert.Equal(t, []int{1, 2, 3, 4, 4}, levels, "one step per frame, then hidden")
306 s := m.scene()
307 assert.True(t, m.hudHidden())
308 assert.Equal(t, frect{}, s.header)
309 assert.Equal(t, frect{}, s.footer)
310 assert.Equal(t, frect{}, s.toast)
311 assert.Equal(t, [2]frect{}, s.modes)
312 assert.Equal(t, [3]frect{}, s.kinds)
313 assert.Greater(t, s.edges[0].w, 0.0, "the border stays")
314 assert.Greater(t, s.dim[0].w, 0.0, "the dimming stays")
315 assert.Greater(t, s.label.w, 0.0, "the size stays")
316 assert.Greater(t, s.grid[0].h, 0.0, "the guides stay")
317
318 // Releasing a too-small drag keeps picking: the HUD fades back in.
319 m.input(motion(103, 103))
320 m.input(button(103, 103, false))
321 require.Equal(t, endNone, m.end)
322 t1 := t0.Add(time.Second)
323 levels = levels[:0]
324 for i := range hudSteps + 1 {
325 m.animate(t1.Add(time.Duration(i) * fxStep))
326 levels = append(levels, m.hud.level)
327 }
328 assert.Equal(t, []int{3, 2, 1, 0, 0}, levels)
329 assert.Greater(t, m.scene().header.w, 0.0)
330 assert.Equal(t, "o0", m.hudClass())
331}
332
333func TestShortDragReversesTheFadeWithoutAJump(t *testing.T) {
334 m := newTestModel(t)
335 t0 := time.Unix(1000, 0)
336 m.input(button(100, 100, true))
337 m.input(motion(400, 300))
338 m.animate(t0)
339 m.animate(t0.Add(fxStep))
340 require.Equal(t, 2, m.hud.level)
341 m.input(motion(103, 103))
342 m.input(button(103, 103, false))
343 m.animate(t0.Add(2 * fxStep))
344 assert.Equal(t, 1, m.hud.level, "fades back from where it was")
345}
346
347func TestFadingHUDKeepsTheDragLabelInPlace(t *testing.T) {
348 m := toggleModel(t, ports.Screenshot, true)
349 m.input(button(100, 50, true))
350 m.input(motion(300, 250))
351 m.animate(time.Unix(1000, 0)) // level 1: chips still drawn
352 assert.Equal(t, frect{100, 50 - labelH - 4, labelW, labelH}, m.scene().label)
353}
354
355func TestToastFadesWithTheHUD(t *testing.T) {
356 m := newTestModel(t)
357 t0 := time.Unix(1000, 0)
358 m.input(key("m"))
359 m.animate(t0)
360 require.Equal(t, "o0", m.toastClass())
361
362 m.input(button(100, 100, true))
363 m.input(motion(400, 300))
364 classes := []string{}
365 for i := range hudSteps - 1 {
366 m.animate(t0.Add(time.Duration(i) * fxStep))
367 classes = append(classes, m.toastClass())
368 assert.Greater(t, m.scene().toast.w, 0.0, "drawn until the HUD is gone")
369 }
370 assert.Equal(t, []string{"o3", "o5", "o7"}, classes)
371 m.animate(t0.Add((hudSteps - 1) * fxStep))
372 assert.True(t, m.hudHidden())
373 assert.Equal(t, frect{}, m.scene().toast)
374
375 m.input(motion(103, 103))
376 m.input(button(103, 103, false))
377 t1 := t0.Add(5 * fxStep)
378 m.animate(t1)
379 assert.Equal(t, "o7", m.toastClass(), "comes back faint, not at once")
380 m.animate(t1.Add(fxStep))
381 assert.Equal(t, "o5", m.toastClass())
382}