v0.4.0 b6be3a8

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}

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