transition_unmap_internal_test.go RawHistoryFolder
1package core
2
3import (
4 "context"
5 "math"
6 "slices"
7 "testing"
8 "time"
9
10 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11 "github.com/bnema/neferwl/internal/ports"
12)
13
14// twoSettled maps two tiles with animations on and settles them.
15func twoSettled(t *testing.T) (*Core, *indicatorClock, chan ports.ClientCommand, *screen) {
16 t.Helper()
17 c, ic, cmds := fadeCore(t)
18 c.cfg.Animations.On = true
19 sc := c.cur()
20 c.mapWindow(ports.WindowMapped{ID: 1})
21 c.mapWindow(ports.WindowMapped{ID: 2})
22 indicatorScene(t, c)
23 ic.now = ic.now.Add(5 * time.Second)
24 c.animate(ic.now, nil)
25 indicatorScene(t, c)
26 if len(sc.rects) != 0 {
27 t.Fatalf("motions left after the settle: %d", len(sc.rects))
28 }
29 drainConfigures(cmds)
30 return c, ic, cmds, sc
31}
32
33// A tiled unmap: the closed window stays drawn as a leaving entry from its
34// settled rect, fading out and shrinking, hidden to input and configures;
35// its neighbour slides from where it was to the whole frame; the client is
36// configured once; everything settles exactly and no motion is left.
37func TestUnmapTiledLeavesAndReflows(t *testing.T) {
38 c, ic, cmds, sc := twoSettled(t)
39 old1 := sceneWindow(t, indicatorScene(t, c), 1).Rect
40 left := settledOf(t, sc, 2)
41
42 c.unmapWindow(ports.WindowUnmapped{ID: 2})
43 s := indicatorScene(t, c)
44 w2 := sceneWindow(t, s, 2)
45 if w2.Hidden || w2.Fade != 0 || w2.Rect != left.Rect || w2.Zoom != 0 {
46 t.Fatalf("closed window right after the unmap: %+v, want drawn opaque at %+v", w2, left.Rect)
47 }
48 if got := sceneWindow(t, s, 1).Rect; got != old1 {
49 t.Fatalf("neighbour right after the unmap at %+v, want the old %+v", got, old1)
50 }
51 settled1 := settledOf(t, sc, 1)
52 if settled1.Rect == old1 {
53 t.Fatal("the closed column did not move the neighbour: the case checks nothing")
54 }
55 if ids := drainConfigures(cmds); !slices.Equal(ids, []WindowID{1}) {
56 t.Fatalf("configures %v, want one for 1 only", ids)
57 }
58 if _, w := c.screenOf(2); w != nil {
59 t.Fatal("the unmapped window is still in a workspace")
60 }
61 cx, cy := float64(left.Rect.X+left.Rect.W/2), float64(left.Rect.Y+left.Rect.H/2)
62 if id, _, _ := c.hit(cx, cy); id == 2 {
63 t.Fatal("the leaving window is hit")
64 }
65
66 ic.now = ic.now.Add(40 * time.Millisecond)
67 c.animate(ic.now, nil)
68 s = indicatorScene(t, c)
69 w2 = sceneWindow(t, s, 2)
70 if !(w2.Fade > 0 && w2.Fade < 1) || w2.Rect.W >= left.Rect.W || !(w2.Zoom > appearScale && w2.Zoom < 1) {
71 t.Fatalf("first frame of the leaving window: %+v", w2)
72 }
73 if r := sceneWindow(t, s, 1).Rect; !(r.W > old1.W && r.W < settled1.Rect.W) {
74 t.Fatalf("neighbour on the first frame %+v, want between %+v and %+v", r, old1, settled1.Rect)
75 }
76 if ids := drainConfigures(cmds); len(ids) != 0 {
77 t.Fatalf("frames reconfigured %v", ids)
78 }
79
80 ic.now = ic.now.Add(5 * time.Second)
81 c.animate(ic.now, nil)
82 s = indicatorScene(t, c)
83 if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
84 t.Fatalf("the closed window is still in the scene: %+v", s.Windows)
85 }
86 if w := sceneWindow(t, s, 1); w.Rect != settled1.Rect {
87 t.Fatalf("neighbour settled at %+v, want %+v", w.Rect, settled1.Rect)
88 }
89 if len(sc.rects) != 0 || c.animating() {
90 t.Fatal("still animating after the settle")
91 }
92 // The destroyed window got no configure at any point, and its last one
93 // is forgotten once it left the scene.
94 if ids := drainConfigures(cmds); slices.Contains(ids, 2) {
95 t.Fatalf("the destroyed window was configured: %v", ids)
96 }
97 if _, ok := c.configures.sent[2]; ok {
98 t.Fatal("configures still remember the destroyed window")
99 }
100}
101
102// A float leaves too, from its settled rect.
103func TestUnmapFloatingLeaves(t *testing.T) {
104 c, ic, _ := fadeCore(t)
105 c.cfg.Animations.On = true
106 sc := c.cur()
107 c.mapWindow(ports.WindowMapped{ID: 1})
108 c.mapWindow(ports.WindowMapped{ID: 2, Floating: true, Width: 80, Height: 60})
109 indicatorScene(t, c)
110 ic.now = ic.now.Add(5 * time.Second)
111 c.animate(ic.now, nil)
112 indicatorScene(t, c)
113 left := settledOf(t, sc, 2)
114 c.unmapWindow(ports.WindowUnmapped{ID: 2})
115 s := indicatorScene(t, c)
116 if w := sceneWindow(t, s, 2); !w.Floating || w.Fade != 0 || w.Rect != left.Rect {
117 t.Fatalf("float right after the unmap: %+v, settled %+v", w, left.Rect)
118 }
119 ic.now = ic.now.Add(5 * time.Second)
120 c.animate(ic.now, nil)
121 if s := indicatorScene(t, c); slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
122 t.Fatalf("the closed float is still in the scene: %+v", s.Windows)
123 }
124}
125
126// A window closed while its own rect motion runs (its entrance here)
127// leaves from what was drawn: the first leaving frame shows the rect and
128// fade it had, not its settled rect, and the leaving motion replaces the
129// running one.
130func TestUnmapMidMotionLeavesFromDrawn(t *testing.T) {
131 c, ic, _, sc := twoSettled(t)
132 c.mapWindow(ports.WindowMapped{ID: 3})
133 indicatorScene(t, c)
134 ic.now = ic.now.Add(30 * time.Millisecond)
135 c.animate(ic.now, nil)
136 before := sceneWindow(t, indicatorScene(t, c), 3)
137 settled3 := settledOf(t, sc, 3)
138 if before.Rect == settled3.Rect || !(before.Fade > 0 && before.Fade < 1) {
139 t.Fatalf("window 3 is not in flight (%+v): the case checks nothing", before)
140 }
141 c.unmapWindow(ports.WindowUnmapped{ID: 3})
142 s := indicatorScene(t, c)
143 w := sceneWindow(t, s, 3)
144 if w.Rect != before.Rect || math.Abs(w.Fade-before.Fade) > 1e-9 {
145 t.Fatalf("leaving window right after the unmap: %+v, want drawn as before %+v", w, before)
146 }
147 if rm, ok := sc.rects[3]; !ok || !rm.leaving {
148 t.Fatalf("no leaving motion for window 3: %+v", rm)
149 }
150 ic.now = ic.now.Add(5 * time.Second)
151 c.animate(ic.now, nil)
152 if s := indicatorScene(t, c); slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 3 }) || len(sc.rects) != 0 {
153 t.Fatalf("the closed window is still there: %+v", s.Windows)
154 }
155}
156
157// A tile closed while the stash hides: the absent leaving tile goes by
158// withLeaving's two-pass rule (absent tiles by ID before the floats, the
159// hidden stash windows replaced in place), the same on every refresh.
160func TestUnmapWhileStashHidesOrdersDeterministically(t *testing.T) {
161 c, ic, sc, cmds := stashRig(t, true)
162 sc.mon.AddWindow(5)
163 sc.mon.AddWindow(6)
164 settleShown(t, c)
165 drainConfigures(cmds)
166 act(c, ic, ActionToggleStashVisible) // 2 and 3 hide, leaving in place
167 c.unmapWindow(ports.WindowUnmapped{ID: 5})
168 var want []WindowID
169 for i := range 20 {
170 s := indicatorScene(t, c)
171 var ids []WindowID
172 for _, w := range s.Windows {
173 ids = append(ids, w.ID)
174 }
175 if i == 0 {
176 want = ids
177 k := slices.Index(sc.settledLayout, settledOf(t, sc, 5))
178 if k < 0 || !sc.settledLayout[k].Leaving || !sc.shown[k].Leaving {
179 t.Fatalf("the closed tile is not a leaving entry: %+v", sc.settledLayout)
180 }
181 for _, id := range []WindowID{2, 3} {
182 if j := slices.Index(ids, id); j < 0 || !sc.settledLayout[j].Leaving {
183 t.Fatalf("stash window %d not replaced in place: %v", id, ids)
184 }
185 }
186 // The absent tile sits before any drawn float (the leaving
187 // stash windows); a hidden, undrawn one may precede it.
188 i5 := slices.Index(ids, 5)
189 for j, w := range s.Windows {
190 if w.Floating && !w.Hidden && j < i5 {
191 t.Fatalf("order %v: float %d before the leaving tile 5", ids, w.ID)
192 }
193 }
194 continue
195 }
196 if !slices.Equal(ids, want) {
197 t.Fatalf("publish %d: order %v, first %v", i, ids, want)
198 }
199 }
200 if ids := drainConfigures(cmds); slices.Contains(ids, 5) {
201 t.Fatalf("the closed window was configured: %v", ids)
202 }
203 ic.now = ic.now.Add(5 * time.Second)
204 c.animate(ic.now, nil)
205 s := indicatorScene(t, c)
206 if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 5 }) || len(sc.rects) != 0 {
207 t.Fatalf("after the settle: %+v", s.Windows)
208 }
209 if ids := drainConfigures(cmds); slices.Contains(ids, 5) {
210 t.Fatalf("the destroyed window was configured after the fade: %v", ids)
211 }
212}
213
214// With animations off an unmap is instant: the window is gone, no motion.
215func TestUnmapOffIsInstant(t *testing.T) {
216 c, _, _ := fadeCore(t)
217 sc := c.cur()
218 c.mapWindow(ports.WindowMapped{ID: 1})
219 c.mapWindow(ports.WindowMapped{ID: 2})
220 indicatorScene(t, c)
221 c.unmapWindow(ports.WindowUnmapped{ID: 2})
222 s := indicatorScene(t, c)
223 if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 || c.animating() {
224 t.Fatalf("an unmap animated with animations off: %+v", s.Windows)
225 }
226 if w := sceneWindow(t, s, 1); w.Rect != settledOf(t, sc, 1).Rect {
227 t.Fatalf("neighbour not at its settled rect: %+v", w)
228 }
229}
230
231// Windows that are not drawn as a tile or an ordinary float do not leave:
232// fullscreen, on another workspace, under a session lock, with the overview
233// open. Nothing is left of them.
234func TestUnmapExceptions(t *testing.T) {
235 gone := func(t *testing.T, c *Core, sc *screen, id WindowID) {
236 t.Helper()
237 s := indicatorScene(t, c)
238 if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == id }) || len(sc.rects) != 0 {
239 t.Fatalf("window %d left something behind: %+v rects %+v", id, s.Windows, sc.rects)
240 }
241 if _, w := c.screenOf(id); w != nil {
242 t.Fatalf("window %d still placed", id)
243 }
244 }
245 t.Run("fullscreen", func(t *testing.T) {
246 c, _, _, sc := twoSettled(t)
247 sc.mon.SetFullscreen(2, true)
248 indicatorScene(t, c)
249 c.unmapWindow(ports.WindowUnmapped{ID: 2})
250 gone(t, c, sc, 2)
251 })
252 t.Run("other workspace", func(t *testing.T) {
253 c, _, _, sc := twoSettled(t)
254 sc.mon.Focus(1)
255 indicatorScene(t, c)
256 c.stopAnimations()
257 c.unmapWindow(ports.WindowUnmapped{ID: 2})
258 gone(t, c, sc, 2)
259 })
260 t.Run("session lock", func(t *testing.T) {
261 c, _, _, sc := twoSettled(t)
262 c.security = ports.SecurityState{Generation: 1, Protected: true}
263 c.unmapWindow(ports.WindowUnmapped{ID: 2})
264 if len(sc.rects) != 0 || c.animating() {
265 t.Fatalf("an unmap under a session lock animated: %+v", sc.rects)
266 }
267 if _, w := c.screenOf(2); w != nil {
268 t.Fatal("window 2 still placed")
269 }
270 })
271 t.Run("overview open", func(t *testing.T) {
272 c, _, _, sc := twoSettled(t)
273 sc.mon.ToggleOverview()
274 indicatorScene(t, c)
275 c.unmapWindow(ports.WindowUnmapped{ID: 2})
276 gone(t, c, sc, 2)
277 })
278 t.Run("never placed", func(t *testing.T) {
279 c, _, _, sc := twoSettled(t)
280 c.unmapWindow(ports.WindowUnmapped{ID: 9})
281 if len(sc.rects) != 0 {
282 t.Fatalf("an unknown window animated: %+v", sc.rects)
283 }
284 })
285}
286
287// An interruption during the fade drops the leaving entry with the other
288// motions (stopRects): a real session lock, an output unplug, an overview
289// toggle and a workspace switch. Nothing of the closed window is left.
290func TestUnmapLeavingDroppedByInterruptions(t *testing.T) {
291 leaving := func(t *testing.T) (*Core, *indicatorClock, *screen) {
292 t.Helper()
293 c, ic, _, sc := twoSettled(t)
294 c.unmapWindow(ports.WindowUnmapped{ID: 2})
295 if s := indicatorScene(t, c); !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
296 t.Fatal("no leaving entry")
297 }
298 return c, ic, sc
299 }
300 gone := func(t *testing.T, c *Core, sc *screen, s ports.Scene) {
301 t.Helper()
302 if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) {
303 t.Fatalf("the leaving entry survived: %+v", s.Windows)
304 }
305 // Other motions may run (the overview's cards): none for 2.
306 if _, ok := sc.rects[2]; ok || slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) {
307 t.Fatalf("rects %+v shown %+v", sc.rects, sc.shown)
308 }
309 }
310 t.Run("session lock", func(t *testing.T) {
311 c, _, sc := leaving(t)
312 state := ports.SecurityState{Generation: 1, Protected: true}
313 gate := portsmocks.NewMockSessionSecurity(t)
314 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
315 c.ch.Security = gate
316 if err := c.publish(context.Background()); err != nil {
317 t.Fatal(err)
318 }
319 locked := (<-c.ch.Scenes)[0]
320 if len(locked.Windows) != 0 || len(sc.rects) != 0 || c.animating() {
321 t.Fatalf("locked scene %+v rects %d animating %t", locked.Windows, len(sc.rects), c.animating())
322 }
323 state = ports.SecurityState{Generation: 2}
324 gone(t, c, sc, indicatorScene(t, c))
325 })
326 t.Run("unplug", func(t *testing.T) {
327 c, _, sc := leaving(t)
328 c.removeScreen("A")
329 if c.animating() || len(sc.rects) != 0 {
330 t.Fatalf("motions survived the unplug: %d", len(sc.rects))
331 }
332 c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
333 gone(t, c, c.cur(), indicatorScene(t, c))
334 })
335 t.Run("overview toggle", func(t *testing.T) {
336 c, ic, sc := leaving(t)
337 act(c, ic, ActionToggleOverview)
338 gone(t, c, sc, indicatorScene(t, c))
339 })
340 t.Run("workspace switch", func(t *testing.T) {
341 c, ic, sc := leaving(t)
342 act(c, ic, ActionFocusWorkspaceDown)
343 gone(t, c, sc, indicatorScene(t, c))
344 })
345}
346
347// A window ID reused by a new toplevel while the old one fades out (xdg
348// reuses IDs): the leaving entry goes, the new window appears from where the
349// old one was drawn (appearFrom), is listed once, and gets its tile
350// configure (the destroyed window's last configure is forgotten).
351func TestUnmapThenRemapDuringFade(t *testing.T) {
352 c, ic, cmds, sc := twoSettled(t)
353 c.unmapWindow(ports.WindowUnmapped{ID: 2})
354 indicatorScene(t, c)
355 ic.now = ic.now.Add(40 * time.Millisecond)
356 c.animate(ic.now, nil)
357 mid := sceneWindow(t, indicatorScene(t, c), 2)
358 if !(mid.Fade > 0 && mid.Fade < 1) {
359 t.Fatalf("not fading: %+v", mid)
360 }
361 drainConfigures(cmds)
362 if _, ok := c.configures.sent[2]; ok {
363 t.Fatal("the destroyed window's configure is remembered")
364 }
365
366 c.mapWindow(ports.WindowMapped{ID: 2})
367 s := indicatorScene(t, c)
368 n := 0
369 for _, w := range s.Windows {
370 if w.ID == 2 {
371 n++
372 }
373 }
374 if n != 1 {
375 t.Fatalf("window 2 listed %d times: %+v", n, s.Windows)
376 }
377 if slices.ContainsFunc(sc.shown, func(p Placement) bool { return p.Leaving }) || slices.ContainsFunc(sc.settledLayout, func(p Placement) bool { return p.Leaving }) {
378 t.Fatalf("leaving entry left: %+v", sc.shown)
379 }
380 rm, ok := sc.rects[2]
381 if !ok || rm.leaving {
382 t.Fatalf("no entrance for the remapped window: %+v", rm)
383 }
384 w := sceneWindow(t, s, 2)
385 // The entrance chains over the exit: drawn as the fade left it, not
386 // invisible at 90 %.
387 if w.Hidden || math.Abs(w.Fade-mid.Fade) > 1e-9 || w.Rect != mid.Rect {
388 t.Fatalf("remapped window right after the map: %+v, want as drawn before %+v", w, mid)
389 }
390 settled := settledOf(t, sc, 2)
391 var got []ports.ConfigureWindow
392 for len(cmds) > 0 {
393 if v, ok := (<-cmds).(ports.ConfigureWindow); ok && v.ID == 2 {
394 got = append(got, v)
395 }
396 }
397 if len(got) != 1 || got[0].Width != settled.Rect.W || !got[0].Visible {
398 t.Fatalf("configures of the remapped window: %+v, want one to %+v", got, settled.Rect)
399 }
400 ic.now = ic.now.Add(5 * time.Second)
401 c.animate(ic.now, nil)
402 if w := sceneWindow(t, indicatorScene(t, c), 2); w.Fade != 0 || w.Rect != settled.Rect || len(sc.rects) != 0 {
403 t.Fatalf("remapped window settled: %+v", w)
404 }
405}
406
407// A stash window that closes while its hide still fades keeps that fade:
408// the motion runs on from where it was (the renderer draws it from the
409// content it kept), and it is not restarted as a fresh exit. Its configure
410// record is forgotten at the unmap and not kept while it fades, so a
411// remapped window with the same ID starts from nothing.
412func TestUnmapDuringStashHideKeepsTheFade(t *testing.T) {
413 c, ic, sc, _ := stashRig(t, true)
414 act(c, ic, ActionToggleStashVisible) // 2 and 3 hide, leaving in place
415 indicatorScene(t, c)
416 ic.now = ic.now.Add(40 * time.Millisecond)
417 c.animate(ic.now, nil)
418 s := indicatorScene(t, c)
419 before := sceneWindow(t, s, 2)
420 rm, ok := sc.rects[2]
421 if !ok || !rm.leaving || !(before.Fade > 0 && before.Fade < 1) {
422 t.Fatalf("setup: leaving %v fade %v", ok && rm.leaving, before.Fade)
423 }
424 c.configures.sent[2] = ports.ConfigureWindow{ID: 2, Width: 10}
425 c.unmapWindow(ports.WindowUnmapped{ID: 2})
426 after, ok := sc.rects[2]
427 if !ok || !after.leaving || after.fade != rm.fade || after.x != rm.x {
428 t.Fatalf("the hide's motion was replaced: before %+v after %+v", rm, after)
429 }
430 s = indicatorScene(t, c)
431 if w := sceneWindow(t, s, 2); w.Fade < before.Fade || w.Hidden {
432 t.Fatalf("after the unmap: %+v, want the fade to go on from %v", w, before.Fade)
433 }
434 if _, kept := c.configures.sent[2]; kept {
435 t.Fatal("a closed window's configure survived the publish")
436 }
437 ic.now = ic.now.Add(5 * time.Second)
438 c.animate(ic.now, nil)
439 s = indicatorScene(t, c)
440 if slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == 2 }) || len(sc.rects) != 0 {
441 t.Fatalf("after the settle: %+v", s.Windows)
442 }
443}
444
445// A stash window that hides keeps its configure while it fades: the
446// workspace still holds it, and its next show must not start from nothing.
447func TestStashHideKeepsConfigureWhileFading(t *testing.T) {
448 c, ic, sc, _ := stashRig(t, true)
449 act(c, ic, ActionToggleStashVisible)
450 for range 3 {
451 ic.now = ic.now.Add(20 * time.Millisecond)
452 c.animate(ic.now, nil)
453 indicatorScene(t, c)
454 }
455 if _, ok := sc.rects[2]; !ok {
456 t.Fatal("setup: window 2 is not fading")
457 }
458 if _, ok := c.configures.sent[2]; !ok {
459 t.Fatal("the hidden stash window's configure was pruned while it fades")
460 }
461}