detached 431292a

test(core): animations-on variants of the border and old animations-off tests

Border scene invariants with animations on (weak invariants mid-transition, full
ones once settled), On variants that land every spring on a mock clock for the
workspace, drag, state, unplug, free float and workspace-size tests, and an unmap
mid rect motion test.
commit
431292a4d8d8a1e1239c04ef65b628a3fbf74daa
author
brice · · 2026-10-04 20:34 UTC
parent
b6a8e33
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +613 -176
M internal/core/border_invariants_test.go +136 -33
@@ -2,7 +2,9 @@ package core_test
22
33 import (
44 "math/rand"
5+ "slices"
56 "testing"
7+ "time"
68
79 "github.com/bnema/neferwl/internal/ports"
810 )
@@ -13,7 +15,6 @@ func TestBorderSceneInvariants(t *testing.T) {
1315 for _, overflow := range []string{"fixed", "scroll"} {
1416 for _, gap := range []int{0, 6} {
1517 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
16- rng := rand.New(rand.NewSource(20260928))
1718 r := startMulti(t, func(c *ports.Config) {
1819 c.Animations.On = false
1920 c.Border.Width = 2
@@ -21,45 +22,147 @@ func TestBorderSceneInvariants(t *testing.T) {
2122 c.Layout.Overflow = overflow
2223 c.Layout.Gaps = gap
2324 }, left, right)
24- var ids []ports.WindowID
25- next := ports.WindowID(1)
26- // A single keypress may publish a scene even when the action is
27- // inapplicable; check every resulting scene independently.
28- keys := []struct {
29- sym string
30- mods ports.Mods
31- }{
32- {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift},
33- {"Left", ports.ModAlt}, {"Right", ports.ModAlt},
34- {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl},
35- {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift},
36- {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt},
37- {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift},
38- {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt},
39- }
40- for step := range 180 {
41- var scenes []ports.Scene
42- switch {
43- case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12:
44- scenes = r.mapWindow(t, next)
45- ids = append(ids, next)
46- next++
47- case rng.Intn(9) == 0:
48- i := rng.Intn(len(ids))
49- r.client <- ports.WindowUnmapped{ID: ids[i]}
50- scenes = receive(t, r.scenes)
51- ids = append(ids[:i], ids[i+1:]...)
52- default:
53- k := keys[rng.Intn(len(keys))]
54- scenes = r.key(t, k.sym, k.mods)
25+ borderScenario(t, r, gap, nil)
26+ })
27+ }
28+ }
29+}
30+
31+// With animations on, the scene right after an action shows the old layout
32+// moving: only the weak invariants hold there. Once every spring settled
33+// (the test moves the clock and sends the page flips) the full ones do,
34+// whatever the actions that ran mid-transition. Insets and lit lines come
35+// from the settled topology by design: separators follow the shown layout.
36+func TestBorderSceneInvariantsAnimated(t *testing.T) {
37+ for _, overflow := range []string{"fixed", "scroll"} {
38+ for _, gap := range []int{0, 6} {
39+ t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
40+ r := startSwipeOn(t, func(c *ports.Config) {
41+ c.Animations.On = true
42+ c.Border.Width = 2
43+ c.Layout.MaxColumns = 3
44+ c.Layout.Overflow = overflow
45+ c.Layout.Gaps = gap
46+ }, left, right)
47+ moved := 0
48+ borderScenario(t, r.multiRig, gap, func(set []ports.Scene) []ports.Scene {
49+ settled, ok := r.settleAll(t, left.Name, right.Name)
50+ if !ok {
51+ return set
52+ }
53+ if !sameRects(set, settled) {
54+ moved++
5555 }
56- checkBorderScenes(t, step, gap, scenes)
56+ // Settled means no spring runs: a further flip has nothing to do.
57+ if _, again := r.flipAll(t, 16*time.Millisecond, left.Name, right.Name); again {
58+ t.Fatal("a flip after the settle still published: a spring runs")
59+ }
60+ return settled
61+ })
62+ if moved == 0 {
63+ t.Fatal("no scene was ever mid-transition: the case checks nothing")
5764 }
65+ t.Logf("%d steps checked mid-transition and settled", moved)
5866 })
5967 }
6068 }
6169 }
6270
71+// borderScenario drives 180 random steps and checks every scene set. With
72+// settle set the steps are animated: the set a step publishes is checked
73+// with the weak invariants, then the settled set (settle returns the set
74+// itself when nothing animated) with the full ones. One step in four skips
75+// the settling, so the next action starts mid-transition; the last step
76+// always settles.
77+func borderScenario(t *testing.T, r *multiRig, gap int, settle func([]ports.Scene) []ports.Scene) {
78+ t.Helper()
79+ rng := rand.New(rand.NewSource(20260928))
80+ var ids []ports.WindowID
81+ next := ports.WindowID(1)
82+ // A single keypress may publish a scene even when the action is
83+ // inapplicable; check every resulting scene independently.
84+ keys := []struct {
85+ sym string
86+ mods ports.Mods
87+ }{
88+ {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift},
89+ {"Left", ports.ModAlt}, {"Right", ports.ModAlt},
90+ {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl},
91+ {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift},
92+ {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt},
93+ {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift},
94+ {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt},
95+ }
96+ const steps = 180
97+ for step := range steps {
98+ var scenes []ports.Scene
99+ switch {
100+ case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12:
101+ scenes = r.mapWindow(t, next)
102+ ids = append(ids, next)
103+ next++
104+ case rng.Intn(9) == 0:
105+ i := rng.Intn(len(ids))
106+ r.client <- ports.WindowUnmapped{ID: ids[i]}
107+ scenes = receive(t, r.scenes)
108+ ids = append(ids[:i], ids[i+1:]...)
109+ default:
110+ k := keys[rng.Intn(len(keys))]
111+ scenes = r.key(t, k.sym, k.mods)
112+ }
113+ if settle == nil {
114+ checkBorderScenes(t, step, gap, scenes)
115+ continue
116+ }
117+ checkBorderScenesMoving(t, step, scenes)
118+ if rng.Intn(4) != 0 || step == steps-1 {
119+ checkBorderScenes(t, step, gap, settle(scenes))
120+ }
121+ }
122+}
123+
124+// sameRects reports whether two scene sets draw every window at the same
125+// rect.
126+func sameRects(a, b []ports.Scene) bool {
127+ if len(a) != len(b) {
128+ return false
129+ }
130+ for i := range a {
131+ if !slices.EqualFunc(a[i].Windows, b[i].Windows, func(x, y ports.SceneWindow) bool { return x.ID == y.ID && x.Rect == y.Rect }) {
132+ return false
133+ }
134+ }
135+ return true
136+}
137+
138+// checkBorderScenesMoving is what holds for a scene published while
139+// windows still move: every separator lies inside its output and belongs
140+// to a window that is shown, and at most one window is focused.
141+func checkBorderScenesMoving(t *testing.T, step int, scenes []ports.Scene) {
142+ t.Helper()
143+ focused := 0
144+ for _, s := range scenes {
145+ o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight}
146+ for _, sep := range s.Separators {
147+ r := sep.Rect
148+ if r.W <= 0 || r.H <= 0 || r.X < 0 || r.Y < 0 || r.X+r.W > o.W || r.Y+r.H > o.H {
149+ t.Fatalf("step %d %s (moving): line outside output: %+v; scene %+v", step, s.Output, sep, s)
150+ }
151+ if sep.Window != 0 && !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == sep.Window && !w.Hidden }) {
152+ t.Fatalf("step %d %s (moving): line belongs to a hidden or absent window: %+v; scene %+v", step, s.Output, sep, s)
153+ }
154+ }
155+ for _, w := range s.Windows {
156+ if w.Focused && !w.Hidden && !w.Popup {
157+ focused++
158+ }
159+ }
160+ }
161+ if focused > 1 {
162+ t.Fatalf("step %d (moving): several focused windows: %+v", step, scenes)
163+ }
164+}
165+
63166 func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) {
64167 t.Helper()
65168 focused := 0
@@ -2,7 +2,9 @@ package core_test
2 2
3 import (3 import (
4 "math/rand"4 "math/rand"
5+ "slices"
5 "testing"6 "testing"
7+ "time"
6 8
7 "github.com/bnema/neferwl/internal/ports"9 "github.com/bnema/neferwl/internal/ports"
8 )10 )
@@ -13,7 +15,6 @@ func TestBorderSceneInvariants(t *testing.T) {
13 for _, overflow := range []string{"fixed", "scroll"} {15 for _, overflow := range []string{"fixed", "scroll"} {
14 for _, gap := range []int{0, 6} {16 for _, gap := range []int{0, 6} {
15 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {17 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
16- rng := rand.New(rand.NewSource(20260928))
17 r := startMulti(t, func(c *ports.Config) {18 r := startMulti(t, func(c *ports.Config) {
18 c.Animations.On = false19 c.Animations.On = false
19 c.Border.Width = 220 c.Border.Width = 2
@@ -21,45 +22,147 @@ func TestBorderSceneInvariants(t *testing.T) {
21 c.Layout.Overflow = overflow22 c.Layout.Overflow = overflow
22 c.Layout.Gaps = gap23 c.Layout.Gaps = gap
23 }, left, right)24 }, left, right)
24- var ids []ports.WindowID25+ borderScenario(t, r, gap, nil)
25- next := ports.WindowID(1)26+ })
26- // A single keypress may publish a scene even when the action is27+ }
27- // inapplicable; check every resulting scene independently.28+ }
28- keys := []struct {29+}
29- sym string30+
30- mods ports.Mods31+// With animations on, the scene right after an action shows the old layout
31- }{32+// moving: only the weak invariants hold there. Once every spring settled
32- {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift},33+// (the test moves the clock and sends the page flips) the full ones do,
33- {"Left", ports.ModAlt}, {"Right", ports.ModAlt},34+// whatever the actions that ran mid-transition. Insets and lit lines come
34- {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl},35+// from the settled topology by design: separators follow the shown layout.
35- {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift},36+func TestBorderSceneInvariantsAnimated(t *testing.T) {
36- {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt},37+ for _, overflow := range []string{"fixed", "scroll"} {
37- {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift},38+ for _, gap := range []int{0, 6} {
38- {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt},39+ t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
39- }40+ r := startSwipeOn(t, func(c *ports.Config) {
40- for step := range 180 {41+ c.Animations.On = true
41- var scenes []ports.Scene42+ c.Border.Width = 2
42- switch {43+ c.Layout.MaxColumns = 3
43- case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12:44+ c.Layout.Overflow = overflow
44- scenes = r.mapWindow(t, next)45+ c.Layout.Gaps = gap
45- ids = append(ids, next)46+ }, left, right)
46- next++47+ moved := 0
47- case rng.Intn(9) == 0:48+ borderScenario(t, r.multiRig, gap, func(set []ports.Scene) []ports.Scene {
48- i := rng.Intn(len(ids))49+ settled, ok := r.settleAll(t, left.Name, right.Name)
49- r.client <- ports.WindowUnmapped{ID: ids[i]}50+ if !ok {
50- scenes = receive(t, r.scenes)51+ return set
51- ids = append(ids[:i], ids[i+1:]...)52+ }
52- default:53+ if !sameRects(set, settled) {
53- k := keys[rng.Intn(len(keys))]54+ moved++
54- scenes = r.key(t, k.sym, k.mods)
55 }55 }
56- checkBorderScenes(t, step, gap, scenes)56+ // Settled means no spring runs: a further flip has nothing to do.
57+ if _, again := r.flipAll(t, 16*time.Millisecond, left.Name, right.Name); again {
58+ t.Fatal("a flip after the settle still published: a spring runs")
59+ }
60+ return settled
61+ })
62+ if moved == 0 {
63+ t.Fatal("no scene was ever mid-transition: the case checks nothing")
57 }64 }
65+ t.Logf("%d steps checked mid-transition and settled", moved)
58 })66 })
59 }67 }
60 }68 }
61 }69 }
62 70
71+// borderScenario drives 180 random steps and checks every scene set. With
72+// settle set the steps are animated: the set a step publishes is checked
73+// with the weak invariants, then the settled set (settle returns the set
74+// itself when nothing animated) with the full ones. One step in four skips
75+// the settling, so the next action starts mid-transition; the last step
76+// always settles.
77+func borderScenario(t *testing.T, r *multiRig, gap int, settle func([]ports.Scene) []ports.Scene) {
78+ t.Helper()
79+ rng := rand.New(rand.NewSource(20260928))
80+ var ids []ports.WindowID
81+ next := ports.WindowID(1)
82+ // A single keypress may publish a scene even when the action is
83+ // inapplicable; check every resulting scene independently.
84+ keys := []struct {
85+ sym string
86+ mods ports.Mods
87+ }{
88+ {"Left", ports.ModAlt | ports.ModShift}, {"Right", ports.ModAlt | ports.ModShift},
89+ {"Left", ports.ModAlt}, {"Right", ports.ModAlt},
90+ {"Left", ports.ModAlt | ports.ModCtrl}, {"Right", ports.ModAlt | ports.ModCtrl},
91+ {"Left", ports.ModAlt | ports.ModCtrl | ports.ModShift}, {"Right", ports.ModAlt | ports.ModCtrl | ports.ModShift},
92+ {"bracketleft", ports.ModAlt}, {"bracketright", ports.ModAlt},
93+ {"f", ports.ModAlt}, {"f", ports.ModAlt | ports.ModShift},
94+ {"s", ports.ModAlt}, {"S", ports.ModAlt | ports.ModShift}, {"Up", ports.ModAlt}, {"Down", ports.ModAlt},
95+ }
96+ const steps = 180
97+ for step := range steps {
98+ var scenes []ports.Scene
99+ switch {
100+ case len(ids) < 2 || rng.Intn(5) == 0 && len(ids) < 12:
101+ scenes = r.mapWindow(t, next)
102+ ids = append(ids, next)
103+ next++
104+ case rng.Intn(9) == 0:
105+ i := rng.Intn(len(ids))
106+ r.client <- ports.WindowUnmapped{ID: ids[i]}
107+ scenes = receive(t, r.scenes)
108+ ids = append(ids[:i], ids[i+1:]...)
109+ default:
110+ k := keys[rng.Intn(len(keys))]
111+ scenes = r.key(t, k.sym, k.mods)
112+ }
113+ if settle == nil {
114+ checkBorderScenes(t, step, gap, scenes)
115+ continue
116+ }
117+ checkBorderScenesMoving(t, step, scenes)
118+ if rng.Intn(4) != 0 || step == steps-1 {
119+ checkBorderScenes(t, step, gap, settle(scenes))
120+ }
121+ }
122+}
123+
124+// sameRects reports whether two scene sets draw every window at the same
125+// rect.
126+func sameRects(a, b []ports.Scene) bool {
127+ if len(a) != len(b) {
128+ return false
129+ }
130+ for i := range a {
131+ if !slices.EqualFunc(a[i].Windows, b[i].Windows, func(x, y ports.SceneWindow) bool { return x.ID == y.ID && x.Rect == y.Rect }) {
132+ return false
133+ }
134+ }
135+ return true
136+}
137+
138+// checkBorderScenesMoving is what holds for a scene published while
139+// windows still move: every separator lies inside its output and belongs
140+// to a window that is shown, and at most one window is focused.
141+func checkBorderScenesMoving(t *testing.T, step int, scenes []ports.Scene) {
142+ t.Helper()
143+ focused := 0
144+ for _, s := range scenes {
145+ o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight}
146+ for _, sep := range s.Separators {
147+ r := sep.Rect
148+ if r.W <= 0 || r.H <= 0 || r.X < 0 || r.Y < 0 || r.X+r.W > o.W || r.Y+r.H > o.H {
149+ t.Fatalf("step %d %s (moving): line outside output: %+v; scene %+v", step, s.Output, sep, s)
150+ }
151+ if sep.Window != 0 && !slices.ContainsFunc(s.Windows, func(w ports.SceneWindow) bool { return w.ID == sep.Window && !w.Hidden }) {
152+ t.Fatalf("step %d %s (moving): line belongs to a hidden or absent window: %+v; scene %+v", step, s.Output, sep, s)
153+ }
154+ }
155+ for _, w := range s.Windows {
156+ if w.Focused && !w.Hidden && !w.Popup {
157+ focused++
158+ }
159+ }
160+ }
161+ if focused > 1 {
162+ t.Fatalf("step %d (moving): several focused windows: %+v", step, scenes)
163+ }
164+}
165+
63 func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) {166 func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) {
64 t.Helper()167 t.Helper()
65 focused := 0168 focused := 0
M internal/core/core_test.go +66 -22
@@ -383,15 +383,46 @@ func TestLayerKeyboardFocus(t *testing.T) {
383383 }
384384 }
385385
386-func TestWorkspaceSwitch(t *testing.T) {
386+func TestWorkspaceSwitch(t *testing.T) { workspaceSwitch(t, false) }
387+
388+// With animations on the scene right after the key shows the slide's start:
389+// the test lands it before it asserts.
390+func TestWorkspaceSwitchAnimated(t *testing.T) { workspaceSwitch(t, true) }
391+
392+// settledScene lands the springs an action started on the single output
393+// OUT-1 of a rig with a stepClock and returns its scene; animations off:
394+// the scene itself.
395+func settledScene(t *testing.T, sc *stepClock, frames chan ports.OutputFrame, scenes chan []ports.Scene, s ports.Scene, mustAnimate bool) ports.Scene {
396+ t.Helper()
397+ if sc == nil {
398+ return s
399+ }
400+ set, ok := sc.settle(t, frames, scenes, "OUT-1")
401+ if !ok {
402+ if mustAnimate {
403+ t.Fatal("the action started no animation: the case checks nothing")
404+ }
405+ return s
406+ }
407+ return set[0]
408+}
409+
410+func workspaceSwitch(t *testing.T, animated bool) {
387411 cfg := config.Defaults()
388- cfg.Animations.On = false // the test reads the settled scene right after the key
412+ cfg.Animations.On = animated
389413 client := make(chan ports.ClientEvent, 8)
390414 input := make(chan ports.InputEvent, 8)
391415 output := make(chan ports.OutputEvent, 8)
392416 commands := make(chan ports.ClientCommand, 64)
393417 scenes := make(chan []ports.Scene, 1)
394- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
418+ ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
419+ var sc *stepClock
420+ frames := make(chan ports.OutputFrame)
421+ if animated {
422+ sc = newStepClock(t)
423+ ch.Clock, ch.Frames = sc.clock, frames
424+ }
425+ c, err := core.New(cfg, ch)
395426 if err != nil {
396427 t.Fatal(err)
397428 }
@@ -407,7 +438,7 @@ func TestWorkspaceSwitch(t *testing.T) {
407438 }
408439 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).
409440 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
410- s := scene(t, scenes)
441+ s := settledScene(t, sc, frames, scenes, scene(t, scenes), animated)
411442 if len(s.Windows) != 1 || !s.Windows[0].Hidden {
412443 t.Fatal(s)
413444 }
@@ -426,21 +457,32 @@ func TestWorkspaceSwitch(t *testing.T) {
426457 }
427458 // A new window opens on workspace 2, alone and borderless.
428459 client <- ports.WindowMapped{ID: 2}
429- s = scene(t, scenes)
460+ s = settledScene(t, sc, frames, scenes, scene(t, scenes), false)
430461 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
431462 t.Fatal(s)
432463 }
433464 }
434465
435-func TestClickAfterWorkspaceSwitch(t *testing.T) {
466+func TestClickAfterWorkspaceSwitch(t *testing.T) { clickAfterWorkspaceSwitch(t, false) }
467+
468+func TestClickAfterWorkspaceSwitchAnimated(t *testing.T) { clickAfterWorkspaceSwitch(t, true) }
469+
470+func clickAfterWorkspaceSwitch(t *testing.T, animated bool) {
436471 cfg := config.Defaults()
437- cfg.Animations.On = false // the test reads the settled scene right after the key
472+ cfg.Animations.On = animated
438473 client := make(chan ports.ClientEvent, 8)
439474 input := make(chan ports.InputEvent, 8)
440475 output := make(chan ports.OutputEvent, 8)
441476 commands := make(chan ports.ClientCommand, 64)
442477 scenes := make(chan []ports.Scene, 1)
443- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})
478+ ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
479+ var sc *stepClock
480+ frames := make(chan ports.OutputFrame)
481+ if animated {
482+ sc = newStepClock(t)
483+ ch.Clock, ch.Frames = sc.clock, frames
484+ }
485+ c, err := core.New(cfg, ch)
444486 if err != nil {
445487 t.Fatal(err)
446488 }
@@ -460,7 +502,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
460502 }
461503 // Cmd+2 hides window 1; the pointer must leave it.
462504 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
463- scene(t, scenes)
505+ settledScene(t, sc, frames, scenes, scene(t, scenes), animated)
464506 var cleared bool
465507 for len(commands) > 0 {
466508 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {
@@ -474,7 +516,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
474516 input <- ports.PointerButton{Button: 0x110, Pressed: true}
475517 input <- ports.PointerButton{Button: 0x110}
476518 client <- ports.WindowMapped{ID: 2}
477- s := scene(t, scenes)
519+ s := settledScene(t, sc, frames, scenes, scene(t, scenes), false)
478520 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {
479521 t.Fatal(s)
480522 }
@@ -1024,18 +1066,20 @@ func TestExternalFullscreenInOverview(t *testing.T) {
10241066 // focus-window-down from a fullscreen tile at the bottom of its column
10251067 // goes to the next workspace and fullscreen stays.
10261068 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {
1027- r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed"; c.Animations.On = false }, left)
1028- r.mapWindow(t, 1)
1029- r.mapWindow(t, 2)
1030- r.key(t, "f", ports.ModAlt|ports.ModShift)
1031- set := r.key(t, "Down", ports.ModAlt)
1032- if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {
1033- t.Fatal("still on the first workspace:", got)
1034- }
1035- set = r.key(t, "Up", ports.ModAlt)
1036- if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1037- t.Fatal("fullscreen lost:", got, focused)
1038- }
1069+ both(t, func(t *testing.T, animated bool) {
1070+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1071+ r.mapLanded(t, 1)
1072+ r.mapLanded(t, 2)
1073+ r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)
1074+ set := r.keyLanded(t, "Down", ports.ModAlt)
1075+ if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {
1076+ t.Fatal("still on the first workspace:", got)
1077+ }
1078+ set = r.keyLanded(t, "Up", ports.ModAlt)
1079+ if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1080+ t.Fatal("fullscreen lost:", got, focused)
1081+ }
1082+ })
10391083 }
10401084
10411085 // A refused fullscreen request still gets a configure, unchanged: clients
@@ -383,15 +383,46 @@ func TestLayerKeyboardFocus(t *testing.T) {
383 }383 }
384 }384 }
385 385
386-func TestWorkspaceSwitch(t *testing.T) {386+func TestWorkspaceSwitch(t *testing.T) { workspaceSwitch(t, false) }
387+
388+// With animations on the scene right after the key shows the slide's start:
389+// the test lands it before it asserts.
390+func TestWorkspaceSwitchAnimated(t *testing.T) { workspaceSwitch(t, true) }
391+
392+// settledScene lands the springs an action started on the single output
393+// OUT-1 of a rig with a stepClock and returns its scene; animations off:
394+// the scene itself.
395+func settledScene(t *testing.T, sc *stepClock, frames chan ports.OutputFrame, scenes chan []ports.Scene, s ports.Scene, mustAnimate bool) ports.Scene {
396+ t.Helper()
397+ if sc == nil {
398+ return s
399+ }
400+ set, ok := sc.settle(t, frames, scenes, "OUT-1")
401+ if !ok {
402+ if mustAnimate {
403+ t.Fatal("the action started no animation: the case checks nothing")
404+ }
405+ return s
406+ }
407+ return set[0]
408+}
409+
410+func workspaceSwitch(t *testing.T, animated bool) {
387 cfg := config.Defaults()411 cfg := config.Defaults()
388- cfg.Animations.On = false // the test reads the settled scene right after the key412+ cfg.Animations.On = animated
389 client := make(chan ports.ClientEvent, 8)413 client := make(chan ports.ClientEvent, 8)
390 input := make(chan ports.InputEvent, 8)414 input := make(chan ports.InputEvent, 8)
391 output := make(chan ports.OutputEvent, 8)415 output := make(chan ports.OutputEvent, 8)
392 commands := make(chan ports.ClientCommand, 64)416 commands := make(chan ports.ClientCommand, 64)
393 scenes := make(chan []ports.Scene, 1)417 scenes := make(chan []ports.Scene, 1)
394- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})418+ ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
419+ var sc *stepClock
420+ frames := make(chan ports.OutputFrame)
421+ if animated {
422+ sc = newStepClock(t)
423+ ch.Clock, ch.Frames = sc.clock, frames
424+ }
425+ c, err := core.New(cfg, ch)
395 if err != nil {426 if err != nil {
396 t.Fatal(err)427 t.Fatal(err)
397 }428 }
@@ -407,7 +438,7 @@ func TestWorkspaceSwitch(t *testing.T) {
407 }438 }
408 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).439 // AZERTY: the 2 key prints eacute; the default bind is on its physical key (code 3).
409 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}440 input <- ports.KeyEvent{Keysym: "eacute", Base: "eacute", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
410- s := scene(t, scenes)441+ s := settledScene(t, sc, frames, scenes, scene(t, scenes), animated)
411 if len(s.Windows) != 1 || !s.Windows[0].Hidden {442 if len(s.Windows) != 1 || !s.Windows[0].Hidden {
412 t.Fatal(s)443 t.Fatal(s)
413 }444 }
@@ -426,21 +457,32 @@ func TestWorkspaceSwitch(t *testing.T) {
426 }457 }
427 // A new window opens on workspace 2, alone and borderless.458 // A new window opens on workspace 2, alone and borderless.
428 client <- ports.WindowMapped{ID: 2}459 client <- ports.WindowMapped{ID: 2}
429- s = scene(t, scenes)460+ s = settledScene(t, sc, frames, scenes, scene(t, scenes), false)
430 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {461 if len(s.Windows) != 2 || s.Windows[1].ID != 2 || s.Windows[1].Hidden || s.Windows[1].Inset != 0 {
431 t.Fatal(s)462 t.Fatal(s)
432 }463 }
433 }464 }
434 465
435-func TestClickAfterWorkspaceSwitch(t *testing.T) {466+func TestClickAfterWorkspaceSwitch(t *testing.T) { clickAfterWorkspaceSwitch(t, false) }
467+
468+func TestClickAfterWorkspaceSwitchAnimated(t *testing.T) { clickAfterWorkspaceSwitch(t, true) }
469+
470+func clickAfterWorkspaceSwitch(t *testing.T, animated bool) {
436 cfg := config.Defaults()471 cfg := config.Defaults()
437- cfg.Animations.On = false // the test reads the settled scene right after the key472+ cfg.Animations.On = animated
438 client := make(chan ports.ClientEvent, 8)473 client := make(chan ports.ClientEvent, 8)
439 input := make(chan ports.InputEvent, 8)474 input := make(chan ports.InputEvent, 8)
440 output := make(chan ports.OutputEvent, 8)475 output := make(chan ports.OutputEvent, 8)
441 commands := make(chan ports.ClientCommand, 64)476 commands := make(chan ports.ClientCommand, 64)
442 scenes := make(chan []ports.Scene, 1)477 scenes := make(chan []ports.Scene, 1)
443- c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes})478+ ch := core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}
479+ var sc *stepClock
480+ frames := make(chan ports.OutputFrame)
481+ if animated {
482+ sc = newStepClock(t)
483+ ch.Clock, ch.Frames = sc.clock, frames
484+ }
485+ c, err := core.New(cfg, ch)
444 if err != nil {486 if err != nil {
445 t.Fatal(err)487 t.Fatal(err)
446 }488 }
@@ -460,7 +502,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
460 }502 }
461 // Cmd+2 hides window 1; the pointer must leave it.503 // Cmd+2 hides window 1; the pointer must leave it.
462 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}504 input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true}
463- scene(t, scenes)505+ settledScene(t, sc, frames, scenes, scene(t, scenes), animated)
464 var cleared bool506 var cleared bool
465 for len(commands) > 0 {507 for len(commands) > 0 {
466 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {508 if v, ok := (<-commands).(ports.PointerFocus); ok && v.ID == 0 {
@@ -474,7 +516,7 @@ func TestClickAfterWorkspaceSwitch(t *testing.T) {
474 input <- ports.PointerButton{Button: 0x110, Pressed: true}516 input <- ports.PointerButton{Button: 0x110, Pressed: true}
475 input <- ports.PointerButton{Button: 0x110}517 input <- ports.PointerButton{Button: 0x110}
476 client <- ports.WindowMapped{ID: 2}518 client <- ports.WindowMapped{ID: 2}
477- s := scene(t, scenes)519+ s := settledScene(t, sc, frames, scenes, scene(t, scenes), false)
478 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {520 if s.Windows[0].ID != 1 || !s.Windows[0].Hidden || s.Windows[1].Hidden {
479 t.Fatal(s)521 t.Fatal(s)
480 }522 }
@@ -1024,18 +1066,20 @@ func TestExternalFullscreenInOverview(t *testing.T) {
1024 // focus-window-down from a fullscreen tile at the bottom of its column1066 // focus-window-down from a fullscreen tile at the bottom of its column
1025 // goes to the next workspace and fullscreen stays.1067 // goes to the next workspace and fullscreen stays.
1026 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {1068 func TestFocusWindowDownKeepsFullscreen(t *testing.T) {
1027- r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed"; c.Animations.On = false }, left)1069+ both(t, func(t *testing.T, animated bool) {
1028- r.mapWindow(t, 1)1070+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left)
1029- r.mapWindow(t, 2)1071+ r.mapLanded(t, 1)
1030- r.key(t, "f", ports.ModAlt|ports.ModShift)1072+ r.mapLanded(t, 2)
1031- set := r.key(t, "Down", ports.ModAlt)1073+ r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)
1032- if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {1074+ set := r.keyLanded(t, "Down", ports.ModAlt)
1033- t.Fatal("still on the first workspace:", got)1075+ if got, _ := windowsOf(set, "DP-1"); len(got) != 0 {
1034- }1076+ t.Fatal("still on the first workspace:", got)
1035- set = r.key(t, "Up", ports.ModAlt)1077+ }
1036- if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {1078+ set = r.keyLanded(t, "Up", ports.ModAlt)
1037- t.Fatal("fullscreen lost:", got, focused)1079+ if got, focused := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{2}) || focused != 2 {
1038- }1080+ t.Fatal("fullscreen lost:", got, focused)
1081+ }
1082+ })
1039 }1083 }
1040 1084
1041 // A refused fullscreen request still gets a configure, unchanged: clients1085 // A refused fullscreen request still gets a configure, unchanged: clients
M internal/core/drag_test.go +71 -5
@@ -21,11 +21,35 @@ type dragRig struct {
2121 scenes chan []ports.Scene
2222 last []ports.Scene
2323 epoch *atomic.Value
24+ // With a clock the test moves (animated rigs), frames carries the page
25+ // flips and outputs lists the plugged ones: settle lands every spring
26+ // before it reads the scene.
27+ frames chan ports.OutputFrame
28+ clock *stepClock
29+ outputs []string
2430 }
2531
2632 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and
2733 // the given outputs. edit may change the config.
2834 func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
35+ t.Helper()
36+ return newDragRig(t, false, edit, outs...)
37+}
38+
39+// startAnimatedDragRig is startDragRig with animations on and a clock the
40+// test moves: settle moves it past every spring and sends the page flips,
41+// so the scene it returns is the settled one, deterministically.
42+func startAnimatedDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
43+ t.Helper()
44+ return newDragRig(t, true, func(c *ports.Config) {
45+ c.Animations.On = true
46+ if edit != nil {
47+ edit(c)
48+ }
49+ }, outs...)
50+}
51+
52+func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
2953 t.Helper()
3054 cfg := config.Defaults()
3155 cfg.Border.Width = 0
@@ -45,7 +69,12 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
4569 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
4670 scenes: make(chan []ports.Scene, 1), epoch: epoch,
4771 }
48- c, err := core.New(cfg, core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes})
72+ ch := core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}
73+ if animated {
74+ r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
75+ ch.Frames, ch.Clock = r.frames, r.clock.clock
76+ }
77+ c, err := core.New(cfg, ch)
4978 if err != nil {
5079 t.Fatal(err)
5180 }
@@ -55,6 +84,7 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
5584 t.Cleanup(func() { cancel(); <-done })
5685 for _, o := range outs {
5786 r.output <- ports.OutputAdded{Info: o}
87+ r.outputs = append(r.outputs, o.Name)
5888 r.settle(t)
5989 }
6090 return r
@@ -79,9 +109,17 @@ func send[T any](ch chan T, v T) {
79109 const barrier ports.WindowID = 1 << 30
80110
81111 // settle returns the scene set once core handled every event sent so
82-// far. The second barrier is taken only after the first one published.
112+// far. The second barrier is taken only after the first one published. An
113+// animated rig first moves its clock past every spring and flips each
114+// output, so the set is the settled one.
83115 func (r *dragRig) settle(t *testing.T) []ports.Scene {
84116 t.Helper()
117+ if r.frames != nil {
118+ r.clock.advance(5 * time.Second)
119+ for _, o := range r.outputs {
120+ send(r.frames, ports.OutputFrame{Output: o})
121+ }
122+ }
85123 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
86124 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
87125 select {
@@ -332,8 +370,28 @@ func TestDragTileZonesAndHints(t *testing.T) {
332370 }
333371 }
334372
373+// startDragMode is the drag rig with animations off, or on and landed by
374+// settle.
375+func startDragMode(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
376+ t.Helper()
377+ if animated {
378+ return startAnimatedDragRig(t, edit, outs...)
379+ }
380+ return startDragRig(t, func(c *ports.Config) {
381+ if edit != nil {
382+ edit(c)
383+ }
384+ c.Animations.On = false
385+ }, outs...)
386+}
387+
335388 func TestDragTileGapInsert(t *testing.T) {
336- r := startDragRig(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.MaxColumns = 3 }, dragOut)
389+ both(t, func(t *testing.T, animated bool) {
390+ dragTileGapInsert(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
391+ })
392+}
393+
394+func dragTileGapInsert(t *testing.T, r *dragRig) {
337395 for id := ports.WindowID(1); id <= 3; id++ {
338396 r.client <- ports.WindowMapped{ID: id}
339397 r.settle(t)
@@ -484,7 +542,10 @@ func TestLockClearsDrag(t *testing.T) {
484542 }
485543
486544 func TestDragEdgeScroll(t *testing.T) {
487- r := startDragRig(t, animationsOff, dragOut)
545+ both(t, func(t *testing.T, animated bool) { dragEdgeScroll(t, startDragMode(t, animated, nil, dragOut)) })
546+}
547+
548+func dragEdgeScroll(t *testing.T, r *dragRig) {
488549 for id := ports.WindowID(1); id <= 4; id++ {
489550 r.client <- ports.WindowMapped{ID: id}
490551 r.settle(t)
@@ -623,7 +684,12 @@ func TestClientDragUsesLastPress(t *testing.T) {
623684 }
624685
625686 func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
626- r := startDragRig(t, animationsOff, dragOut)
687+ both(t, func(t *testing.T, animated bool) {
688+ edgeScrollPublishesWithoutTarget(t, startDragMode(t, animated, nil, dragOut))
689+ })
690+}
691+
692+func edgeScrollPublishesWithoutTarget(t *testing.T, r *dragRig) {
627693 for id := ports.WindowID(1); id <= 4; id++ {
628694 r.client <- ports.WindowMapped{ID: id}
629695 r.settle(t)
@@ -21,11 +21,35 @@ type dragRig struct {
21 scenes chan []ports.Scene21 scenes chan []ports.Scene
22 last []ports.Scene22 last []ports.Scene
23 epoch *atomic.Value23 epoch *atomic.Value
24+ // With a clock the test moves (animated rigs), frames carries the page
25+ // flips and outputs lists the plugged ones: settle lands every spring
26+ // before it reads the scene.
27+ frames chan ports.OutputFrame
28+ clock *stepClock
29+ outputs []string
24 }30 }
25 31
26 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and32 // startDrag runs core with Super as Cmd, no border, 10 px gaps, and
27 // the given outputs. edit may change the config.33 // the given outputs. edit may change the config.
28 func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {34 func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
35+ t.Helper()
36+ return newDragRig(t, false, edit, outs...)
37+}
38+
39+// startAnimatedDragRig is startDragRig with animations on and a clock the
40+// test moves: settle moves it past every spring and sends the page flips,
41+// so the scene it returns is the settled one, deterministically.
42+func startAnimatedDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
43+ t.Helper()
44+ return newDragRig(t, true, func(c *ports.Config) {
45+ c.Animations.On = true
46+ if edit != nil {
47+ edit(c)
48+ }
49+ }, outs...)
50+}
51+
52+func newDragRig(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
29 t.Helper()53 t.Helper()
30 cfg := config.Defaults()54 cfg := config.Defaults()
31 cfg.Border.Width = 055 cfg.Border.Width = 0
@@ -45,7 +69,12 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
45 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),69 output: make(chan ports.OutputEvent), commands: make(chan ports.ClientCommand, 4096),
46 scenes: make(chan []ports.Scene, 1), epoch: epoch,70 scenes: make(chan []ports.Scene, 1), epoch: epoch,
47 }71 }
48- c, err := core.New(cfg, core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes})72+ ch := core.Channels{Security: gate, Client: r.client, Input: r.input, Output: r.output, Commands: r.commands, Scenes: r.scenes}
73+ if animated {
74+ r.frames, r.clock = make(chan ports.OutputFrame), newStepClock(t)
75+ ch.Frames, ch.Clock = r.frames, r.clock.clock
76+ }
77+ c, err := core.New(cfg, ch)
49 if err != nil {78 if err != nil {
50 t.Fatal(err)79 t.Fatal(err)
51 }80 }
@@ -55,6 +84,7 @@ func startDragRig(t *testing.T, edit func(*ports.Config), outs ...ports.OutputIn
55 t.Cleanup(func() { cancel(); <-done })84 t.Cleanup(func() { cancel(); <-done })
56 for _, o := range outs {85 for _, o := range outs {
57 r.output <- ports.OutputAdded{Info: o}86 r.output <- ports.OutputAdded{Info: o}
87+ r.outputs = append(r.outputs, o.Name)
58 r.settle(t)88 r.settle(t)
59 }89 }
60 return r90 return r
@@ -79,9 +109,17 @@ func send[T any](ch chan T, v T) {
79 const barrier ports.WindowID = 1 << 30109 const barrier ports.WindowID = 1 << 30
80 110
81 // settle returns the scene set once core handled every event sent so111 // settle returns the scene set once core handled every event sent so
82-// far. The second barrier is taken only after the first one published.112+// far. The second barrier is taken only after the first one published. An
113+// animated rig first moves its clock past every spring and flips each
114+// output, so the set is the settled one.
83 func (r *dragRig) settle(t *testing.T) []ports.Scene {115 func (r *dragRig) settle(t *testing.T) []ports.Scene {
84 t.Helper()116 t.Helper()
117+ if r.frames != nil {
118+ r.clock.advance(5 * time.Second)
119+ for _, o := range r.outputs {
120+ send(r.frames, ports.OutputFrame{Output: o})
121+ }
122+ }
85 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))123 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
86 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))124 send(r.client, ports.ClientEvent(ports.WindowResized{ID: barrier}))
87 select {125 select {
@@ -332,8 +370,28 @@ func TestDragTileZonesAndHints(t *testing.T) {
332 }370 }
333 }371 }
334 372
373+// startDragMode is the drag rig with animations off, or on and landed by
374+// settle.
375+func startDragMode(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *dragRig {
376+ t.Helper()
377+ if animated {
378+ return startAnimatedDragRig(t, edit, outs...)
379+ }
380+ return startDragRig(t, func(c *ports.Config) {
381+ if edit != nil {
382+ edit(c)
383+ }
384+ c.Animations.On = false
385+ }, outs...)
386+}
387+
335 func TestDragTileGapInsert(t *testing.T) {388 func TestDragTileGapInsert(t *testing.T) {
336- r := startDragRig(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.MaxColumns = 3 }, dragOut)389+ both(t, func(t *testing.T, animated bool) {
390+ dragTileGapInsert(t, startDragMode(t, animated, func(c *ports.Config) { c.Layout.MaxColumns = 3 }, dragOut))
391+ })
392+}
393+
394+func dragTileGapInsert(t *testing.T, r *dragRig) {
337 for id := ports.WindowID(1); id <= 3; id++ {395 for id := ports.WindowID(1); id <= 3; id++ {
338 r.client <- ports.WindowMapped{ID: id}396 r.client <- ports.WindowMapped{ID: id}
339 r.settle(t)397 r.settle(t)
@@ -484,7 +542,10 @@ func TestLockClearsDrag(t *testing.T) {
484 }542 }
485 543
486 func TestDragEdgeScroll(t *testing.T) {544 func TestDragEdgeScroll(t *testing.T) {
487- r := startDragRig(t, animationsOff, dragOut)545+ both(t, func(t *testing.T, animated bool) { dragEdgeScroll(t, startDragMode(t, animated, nil, dragOut)) })
546+}
547+
548+func dragEdgeScroll(t *testing.T, r *dragRig) {
488 for id := ports.WindowID(1); id <= 4; id++ {549 for id := ports.WindowID(1); id <= 4; id++ {
489 r.client <- ports.WindowMapped{ID: id}550 r.client <- ports.WindowMapped{ID: id}
490 r.settle(t)551 r.settle(t)
@@ -623,7 +684,12 @@ func TestClientDragUsesLastPress(t *testing.T) {
623 }684 }
624 685
625 func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {686 func TestEdgeScrollPublishesWithoutTarget(t *testing.T) {
626- r := startDragRig(t, animationsOff, dragOut)687+ both(t, func(t *testing.T, animated bool) {
688+ edgeScrollPublishesWithoutTarget(t, startDragMode(t, animated, nil, dragOut))
689+ })
690+}
691+
692+func edgeScrollPublishesWithoutTarget(t *testing.T, r *dragRig) {
627 for id := ports.WindowID(1); id <= 4; id++ {693 for id := ports.WindowID(1); id <= 4; id++ {
628 r.client <- ports.WindowMapped{ID: id}694 r.client <- ports.WindowMapped{ID: id}
629 r.settle(t)695 r.settle(t)
M internal/core/free_core_test.go +21 -19
@@ -21,23 +21,25 @@ func windowIn(set []ports.Scene, output string, id ports.WindowID) (ports.SceneW
2121 }
2222
2323 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {
24- r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 0 }, left, right)
25- r.mapWindow(t, 1)
26- r.mapWindow(t, 2)
27- r.key(t, "space", ports.ModAlt|ports.ModShift)
28- // Nudge left twice: 100 px left of centre on a 200 px output stops at
29- // the edge.
30- r.key(t, "Left", ports.ModAlt|ports.ModShift)
31- set := r.key(t, "Left", ports.ModAlt|ports.ModShift)
32- before, ok := windowIn(set, "DP-1", 2)
33- if !ok || !before.Floating || before.Rect.X != 0 {
34- t.Fatalf("%+v", before)
35- }
36- // The workspace moves to the 400x200 output: the centre keeps its
37- // fractions of the usable area (1/4, 1/2), the size stays.
38- set = r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
39- after, ok := windowIn(set, "DP-2", 2)
40- if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 {
41- t.Fatalf("%+v", after)
42- }
24+ both(t, func(t *testing.T, animated bool) {
25+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right)
26+ r.mapLanded(t, 1)
27+ r.mapLanded(t, 2)
28+ r.keyLanded(t, "space", ports.ModAlt|ports.ModShift)
29+ // Nudge left twice: 100 px left of centre on a 200 px output stops at
30+ // the edge.
31+ r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift)
32+ set := r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift)
33+ before, ok := windowIn(set, "DP-1", 2)
34+ if !ok || !before.Floating || before.Rect.X != 0 {
35+ t.Fatalf("%+v", before)
36+ }
37+ // The workspace moves to the 400x200 output: the centre keeps its
38+ // fractions of the usable area (1/4, 1/2), the size stays.
39+ set = r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
40+ after, ok := windowIn(set, "DP-2", 2)
41+ if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 {
42+ t.Fatalf("%+v", after)
43+ }
44+ })
4345 }
@@ -21,23 +21,25 @@ func windowIn(set []ports.Scene, output string, id ports.WindowID) (ports.SceneW
21 }21 }
22 22
23 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {23 func TestFreeFloatKeepsPlaceAcrossMonitors(t *testing.T) {
24- r := startMulti(t, func(c *ports.Config) { c.Animations.On = false; c.Layout.Gaps = 0 }, left, right)24+ both(t, func(t *testing.T, animated bool) {
25- r.mapWindow(t, 1)25+ r := startLanding(t, animated, func(c *ports.Config) { c.Layout.Gaps = 0 }, left, right)
26- r.mapWindow(t, 2)26+ r.mapLanded(t, 1)
27- r.key(t, "space", ports.ModAlt|ports.ModShift)27+ r.mapLanded(t, 2)
28- // Nudge left twice: 100 px left of centre on a 200 px output stops at28+ r.keyLanded(t, "space", ports.ModAlt|ports.ModShift)
29- // the edge.29+ // Nudge left twice: 100 px left of centre on a 200 px output stops at
30- r.key(t, "Left", ports.ModAlt|ports.ModShift)30+ // the edge.
31- set := r.key(t, "Left", ports.ModAlt|ports.ModShift)31+ r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift)
32- before, ok := windowIn(set, "DP-1", 2)32+ set := r.keyLanded(t, "Left", ports.ModAlt|ports.ModShift)
33- if !ok || !before.Floating || before.Rect.X != 0 {33+ before, ok := windowIn(set, "DP-1", 2)
34- t.Fatalf("%+v", before)34+ if !ok || !before.Floating || before.Rect.X != 0 {
35- }35+ t.Fatalf("%+v", before)
36- // The workspace moves to the 400x200 output: the centre keeps its36+ }
37- // fractions of the usable area (1/4, 1/2), the size stays.37+ // The workspace moves to the 400x200 output: the centre keeps its
38- set = r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)38+ // fractions of the usable area (1/4, 1/2), the size stays.
39- after, ok := windowIn(set, "DP-2", 2)39+ set = r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
40- if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 {40+ after, ok := windowIn(set, "DP-2", 2)
41- t.Fatalf("%+v", after)41+ if !ok || after.Rect.W != before.Rect.W || after.Rect.X+after.Rect.W/2 != 100 || after.Rect.Y+after.Rect.H/2 != 100 {
42- }42+ t.Fatalf("%+v", after)
43+ }
44+ })
43 }45 }
M internal/core/screens_test.go +62 -58
@@ -400,33 +400,35 @@ func TestMonitorActionsFollowGeometry(t *testing.T) {
400400 }
401401
402402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
403- r := startMulti(t, animationsOff, left, right)
404- r.mapWindow(t, 1)
405- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
406- r.mapWindow(t, 2)
407- // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
408- r.output <- ports.OutputRemoved{Name: "DP-2"}
409- set := receive(t, r.scenes)
410- if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
411- t.Fatal(shown(set))
412- }
413- // Cmd+2 shows the guest.
414- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
415- set = receive(t, r.scenes)
416- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
417- if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
418- t.Fatal(got)
419- }
420- // Replugged while on screen: the guest stays until the user leaves it.
421- set = r.plug(t, right)
422- if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
423- t.Fatal(got)
424- }
425- r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
426- set = receive(t, r.scenes)
427- if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
428- t.Fatal(got)
429- }
403+ both(t, func(t *testing.T, animated bool) {
404+ r := startLanding(t, animated, nil, left, right)
405+ r.mapLanded(t, 1)
406+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
407+ r.mapLanded(t, 2)
408+ // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
409+ r.output <- ports.OutputRemoved{Name: "DP-2"}
410+ set := r.land(receive(t, r.scenes))
411+ if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
412+ t.Fatal(shown(set))
413+ }
414+ // Cmd+2 shows the guest.
415+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
416+ set = r.land(receive(t, r.scenes))
417+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
418+ if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
419+ t.Fatal(got)
420+ }
421+ // Replugged while on screen: the guest stays until the user leaves it.
422+ set = r.land(r.plug(t, right))
423+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
424+ t.Fatal(got)
425+ }
426+ r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
427+ set = r.land(receive(t, r.scenes))
428+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
429+ t.Fatal(got)
430+ }
431+ })
430432 }
431433
432434 func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
@@ -763,38 +765,40 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
763765 }
764766
765767 func TestStateSnapshot(t *testing.T) {
766- r := startMulti(t, animationsOff, left, right)
767- r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
768- receive(t, r.scenes)
769- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
770- <-r.state // the channel holds the latest snapshot only
771- r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
772- receive(t, r.scenes)
773- // Snapshots from before window 2 may still be queued: wait for it.
774- st := receive(t, r.state)
775- for len(st.Windows) < 2 {
768+ both(t, func(t *testing.T, animated bool) {
769+ r := startLanding(t, animated, nil, left, right)
770+ r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
771+ r.land(receive(t, r.scenes))
772+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
773+ <-r.state // the channel holds the latest snapshot only
774+ r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
775+ r.land(receive(t, r.scenes))
776+ // Snapshots from before window 2 may still be queued: wait for it.
777+ st := receive(t, r.state)
778+ for len(st.Windows) < 2 {
779+ st = receive(t, r.state)
780+ }
781+ want := ports.State{
782+ Output: "DP-2",
783+ Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
784+ Windows: []ports.WindowState{
785+ {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
786+ {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
787+ },
788+ }
789+ want.Window = &want.Windows[1]
790+ if !reflect.DeepEqual(st, want) {
791+ t.Fatalf("got %+v\nwant %+v", st, want)
792+ }
793+ // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
794+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
795+ r.land(receive(t, r.scenes))
776796 st = receive(t, r.state)
777- }
778- want := ports.State{
779- Output: "DP-2",
780- Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
781- Windows: []ports.WindowState{
782- {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
783- {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
784- },
785- }
786- want.Window = &want.Windows[1]
787- if !reflect.DeepEqual(st, want) {
788- t.Fatalf("got %+v\nwant %+v", st, want)
789- }
790- // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
791- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
792- receive(t, r.scenes)
793- st = receive(t, r.state)
794- // The window keeps its workspace ID; the output now shows the new one.
795- if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil {
796- t.Fatalf("%+v", st)
797- }
797+ // The window keeps its workspace ID; the output now shows the new one.
798+ if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil {
799+ t.Fatalf("%+v", st)
800+ }
801+ })
798802 }
799803
800804 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
@@ -400,33 +400,35 @@ func TestMonitorActionsFollowGeometry(t *testing.T) {
400 }400 }
401 401
402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {402 func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
403- r := startMulti(t, animationsOff, left, right)403+ both(t, func(t *testing.T, animated bool) {
404- r.mapWindow(t, 1)404+ r := startLanding(t, animated, nil, left, right)
405- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)405+ r.mapLanded(t, 1)
406- r.mapWindow(t, 2)406+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
407- // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.407+ r.mapLanded(t, 2)
408- r.output <- ports.OutputRemoved{Name: "DP-2"}408+ // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
409- set := receive(t, r.scenes)409+ r.output <- ports.OutputRemoved{Name: "DP-2"}
410- if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {410+ set := r.land(receive(t, r.scenes))
411- t.Fatal(shown(set))411+ if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
412- }412+ t.Fatal(shown(set))
413- // Cmd+2 shows the guest.413+ }
414- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}414+ // Cmd+2 shows the guest.
415- set = receive(t, r.scenes)415+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
416- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}416+ set = r.land(receive(t, r.scenes))
417- if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {417+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
418- t.Fatal(got)418+ if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
419- }419+ t.Fatal(got)
420- // Replugged while on screen: the guest stays until the user leaves it.420+ }
421- set = r.plug(t, right)421+ // Replugged while on screen: the guest stays until the user leaves it.
422- if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {422+ set = r.land(r.plug(t, right))
423- t.Fatal(got)423+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
424- }424+ t.Fatal(got)
425- r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}425+ }
426- set = receive(t, r.scenes)426+ r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
427- if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {427+ set = r.land(receive(t, r.scenes))
428- t.Fatal(got)428+ if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
429- }429+ t.Fatal(got)
430+ }
431+ })
430 }432 }
431 433
432 func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {434 func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
@@ -763,38 +765,40 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
763 }765 }
764 766
765 func TestStateSnapshot(t *testing.T) {767 func TestStateSnapshot(t *testing.T) {
766- r := startMulti(t, animationsOff, left, right)768+ both(t, func(t *testing.T, animated bool) {
767- r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}769+ r := startLanding(t, animated, nil, left, right)
768- receive(t, r.scenes)770+ r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
769- r.key(t, "Right", ports.ModAlt|ports.ModCtrl)771+ r.land(receive(t, r.scenes))
770- <-r.state // the channel holds the latest snapshot only772+ r.keyLanded(t, "Right", ports.ModAlt|ports.ModCtrl)
771- r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}773+ <-r.state // the channel holds the latest snapshot only
772- receive(t, r.scenes)774+ r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
773- // Snapshots from before window 2 may still be queued: wait for it.775+ r.land(receive(t, r.scenes))
774- st := receive(t, r.state)776+ // Snapshots from before window 2 may still be queued: wait for it.
775- for len(st.Windows) < 2 {777+ st := receive(t, r.state)
778+ for len(st.Windows) < 2 {
779+ st = receive(t, r.state)
780+ }
781+ want := ports.State{
782+ Output: "DP-2",
783+ Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},
784+ Windows: []ports.WindowState{
785+ {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
786+ {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
787+ },
788+ }
789+ want.Window = &want.Windows[1]
790+ if !reflect.DeepEqual(st, want) {
791+ t.Fatalf("got %+v\nwant %+v", st, want)
792+ }
793+ // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
794+ r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
795+ r.land(receive(t, r.scenes))
776 st = receive(t, r.state)796 st = receive(t, r.state)
777- }797+ // The window keeps its workspace ID; the output now shows the new one.
778- want := ports.State{798+ if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil {
779- Output: "DP-2",799+ t.Fatalf("%+v", st)
780- Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1, WorkspaceID: 1}, {Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 2}},800+ }
781- Windows: []ports.WindowState{801+ })
782- {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, WorkspaceID: 1, Visible: true},
783- {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 2, Visible: true},
784- },
785- }
786- want.Window = &want.Windows[1]
787- if !reflect.DeepEqual(st, want) {
788- t.Fatalf("got %+v\nwant %+v", st, want)
789- }
790- // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
791- r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
792- receive(t, r.scenes)
793- st = receive(t, r.state)
794- // The window keeps its workspace ID; the output now shows the new one.
795- if o := st.Outputs[1]; o.Name != "DP-2" || o.Active != 2 || o.Count != 2 || o.WorkspaceID == 0 || o.WorkspaceID == 2 || st.Windows[1].WorkspaceID != 2 || st.Windows[1].Visible || st.Window != nil {
796- t.Fatalf("%+v", st)
797- }
798 }802 }
799 803
800 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {804 func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
A internal/core/step_clock_test.go +117 -0
@@ -0,0 +1,117 @@
1+package core_test
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+// stepClock is a clock the test moves: springs sample it, so a test lands
14+// them by advancing it and sending page flips, never by sleeping. Its
15+// timers never fire: frames come from the test.
16+type stepClock struct {
17+ mu sync.Mutex
18+ now time.Time
19+ clock ports.Clock
20+}
21+
22+func newStepClock(t *testing.T) *stepClock {
23+ t.Helper()
24+ s := &stepClock{now: time.Unix(100, 0)}
25+ clock := portsmocks.NewMockClock(t)
26+ clock.EXPECT().Now().RunAndReturn(func() time.Time {
27+ s.mu.Lock()
28+ defer s.mu.Unlock()
29+ return s.now
30+ }).Maybe()
31+ // One timer built before core runs: a mock created by core while the
32+ // test ends would race its own cleanup check.
33+ timer := portsmocks.NewMockTimer(t)
34+ timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
35+ timer.EXPECT().Stop().Return(true).Maybe()
36+ clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
37+ s.clock = clock
38+ return s
39+}
40+
41+func (s *stepClock) advance(d time.Duration) {
42+ s.mu.Lock()
43+ s.now = s.now.Add(d)
44+ s.mu.Unlock()
45+}
46+
47+// settle moves the clock past every spring, flips each output and returns
48+// the set core published for them (ok false: no spring ran, so nothing
49+// moved). The last flip names no output: core takes it only after it
50+// handled the previous ones, so the scene channel then holds their result.
51+func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, outputs ...string) ([]ports.Scene, bool) {
52+ t.Helper()
53+ s.advance(5 * time.Second)
54+ for _, o := range outputs {
55+ send(frames, ports.OutputFrame{Output: o})
56+ }
57+ send(frames, ports.OutputFrame{Output: "sync"})
58+ select {
59+ case set := <-scenes:
60+ return set, true
61+ default:
62+ return nil, false
63+ }
64+}
65+
66+// landRig is a multiRig whose settle lands every running spring: with
67+// animations on it runs on a clock the test moves (swipeRig) and sends the
68+// page flips; with animations off it is the plain rig and land is the
69+// identity. Each test body runs once per mode, so the off variant keeps
70+// its meaning and the on variant asserts the same settled facts.
71+type landRig struct {
72+ *multiRig
73+ land func([]ports.Scene) []ports.Scene
74+}
75+
76+// startLanding runs core on the given outputs (gaps and terminal policy as
77+// in startMulti) with animations on or off.
78+func startLanding(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *landRig {
79+ t.Helper()
80+ if !animated {
81+ r := startMulti(t, func(c *ports.Config) {
82+ if edit != nil {
83+ edit(c)
84+ }
85+ c.Animations.On = false
86+ }, outs...)
87+ return &landRig{multiRig: r, land: func(set []ports.Scene) []ports.Scene { return set }}
88+ }
89+ defaults := altCmdDefaults()
90+ r := startSwipeOn(t, func(c *ports.Config) {
91+ c.Layout.Gaps, c.Terminal.AutoOpen = defaults.Layout.Gaps, defaults.Terminal.AutoOpen
92+ if edit != nil {
93+ edit(c)
94+ }
95+ c.Animations.On = true
96+ }, outs...)
97+ return &landRig{multiRig: r.multiRig, land: func(set []ports.Scene) []ports.Scene { return r.landed(t, set) }}
98+}
99+
100+// both runs body with animations off, then on.
101+func both(t *testing.T, body func(t *testing.T, animated bool)) {
102+ t.Helper()
103+ t.Run("off", func(t *testing.T) { body(t, false) })
104+ t.Run("on", func(t *testing.T) { body(t, true) })
105+}
106+
107+// keyLanded presses a key and returns the settled scene set.
108+func (r *landRig) keyLanded(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
109+ t.Helper()
110+ return r.land(r.key(t, sym, mods))
111+}
112+
113+// mapLanded maps a window and returns the settled scene set.
114+func (r *landRig) mapLanded(t *testing.T, id ports.WindowID) []ports.Scene {
115+ t.Helper()
116+ return r.land(r.mapWindow(t, id))
117+}
@@ -0,0 +1,117 @@
1+package core_test
2+
3+import (
4+ "sync"
5+ "testing"
6+ "time"
7+
8+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9+ "github.com/bnema/neferwl/internal/ports"
10+ "github.com/stretchr/testify/mock"
11+)
12+
13+// stepClock is a clock the test moves: springs sample it, so a test lands
14+// them by advancing it and sending page flips, never by sleeping. Its
15+// timers never fire: frames come from the test.
16+type stepClock struct {
17+ mu sync.Mutex
18+ now time.Time
19+ clock ports.Clock
20+}
21+
22+func newStepClock(t *testing.T) *stepClock {
23+ t.Helper()
24+ s := &stepClock{now: time.Unix(100, 0)}
25+ clock := portsmocks.NewMockClock(t)
26+ clock.EXPECT().Now().RunAndReturn(func() time.Time {
27+ s.mu.Lock()
28+ defer s.mu.Unlock()
29+ return s.now
30+ }).Maybe()
31+ // One timer built before core runs: a mock created by core while the
32+ // test ends would race its own cleanup check.
33+ timer := portsmocks.NewMockTimer(t)
34+ timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
35+ timer.EXPECT().Stop().Return(true).Maybe()
36+ clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
37+ s.clock = clock
38+ return s
39+}
40+
41+func (s *stepClock) advance(d time.Duration) {
42+ s.mu.Lock()
43+ s.now = s.now.Add(d)
44+ s.mu.Unlock()
45+}
46+
47+// settle moves the clock past every spring, flips each output and returns
48+// the set core published for them (ok false: no spring ran, so nothing
49+// moved). The last flip names no output: core takes it only after it
50+// handled the previous ones, so the scene channel then holds their result.
51+func (s *stepClock) settle(t *testing.T, frames chan ports.OutputFrame, scenes chan []ports.Scene, outputs ...string) ([]ports.Scene, bool) {
52+ t.Helper()
53+ s.advance(5 * time.Second)
54+ for _, o := range outputs {
55+ send(frames, ports.OutputFrame{Output: o})
56+ }
57+ send(frames, ports.OutputFrame{Output: "sync"})
58+ select {
59+ case set := <-scenes:
60+ return set, true
61+ default:
62+ return nil, false
63+ }
64+}
65+
66+// landRig is a multiRig whose settle lands every running spring: with
67+// animations on it runs on a clock the test moves (swipeRig) and sends the
68+// page flips; with animations off it is the plain rig and land is the
69+// identity. Each test body runs once per mode, so the off variant keeps
70+// its meaning and the on variant asserts the same settled facts.
71+type landRig struct {
72+ *multiRig
73+ land func([]ports.Scene) []ports.Scene
74+}
75+
76+// startLanding runs core on the given outputs (gaps and terminal policy as
77+// in startMulti) with animations on or off.
78+func startLanding(t *testing.T, animated bool, edit func(*ports.Config), outs ...ports.OutputInfo) *landRig {
79+ t.Helper()
80+ if !animated {
81+ r := startMulti(t, func(c *ports.Config) {
82+ if edit != nil {
83+ edit(c)
84+ }
85+ c.Animations.On = false
86+ }, outs...)
87+ return &landRig{multiRig: r, land: func(set []ports.Scene) []ports.Scene { return set }}
88+ }
89+ defaults := altCmdDefaults()
90+ r := startSwipeOn(t, func(c *ports.Config) {
91+ c.Layout.Gaps, c.Terminal.AutoOpen = defaults.Layout.Gaps, defaults.Terminal.AutoOpen
92+ if edit != nil {
93+ edit(c)
94+ }
95+ c.Animations.On = true
96+ }, outs...)
97+ return &landRig{multiRig: r.multiRig, land: func(set []ports.Scene) []ports.Scene { return r.landed(t, set) }}
98+}
99+
100+// both runs body with animations off, then on.
101+func both(t *testing.T, body func(t *testing.T, animated bool)) {
102+ t.Helper()
103+ t.Run("off", func(t *testing.T) { body(t, false) })
104+ t.Run("on", func(t *testing.T) { body(t, true) })
105+}
106+
107+// keyLanded presses a key and returns the settled scene set.
108+func (r *landRig) keyLanded(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
109+ t.Helper()
110+ return r.land(r.key(t, sym, mods))
111+}
112+
113+// mapLanded maps a window and returns the settled scene set.
114+func (r *landRig) mapLanded(t *testing.T, id ports.WindowID) []ports.Scene {
115+ t.Helper()
116+ return r.land(r.mapWindow(t, id))
117+}
M internal/core/swipe_test.go +49 -1
@@ -21,11 +21,19 @@ type swipeRig struct {
2121 now time.Time
2222 // at is the device time of the next swipe event.
2323 at time.Duration
24+ // outs are the plugged outputs, for landed.
25+ outs []string
2426 }
2527
2628 var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000}
2729
2830 func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
31+ t.Helper()
32+ return startSwipeOn(t, edit, wide)
33+}
34+
35+// startSwipeOn is startSwipe with the given outputs plugged in order.
36+func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *swipeRig {
2937 t.Helper()
3038 cfg := altCmdDefaults()
3139 cfg.Border.Width = 0
@@ -61,10 +69,24 @@ func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
6169 done := make(chan error, 1)
6270 go func() { done <- c.Run(ctx) }()
6371 t.Cleanup(func() { cancel(); <-done })
64- r.plug(t, wide)
72+ for _, o := range outs {
73+ r.plug(t, o)
74+ r.outs = append(r.outs, o.Name)
75+ }
6576 return r
6677 }
6778
79+// landed is the scene set once every spring an action started on any output
80+// landed: the set itself when nothing animates (animations off, or an
81+// action that moves nothing).
82+func (r *swipeRig) landed(t *testing.T, set []ports.Scene) []ports.Scene {
83+ t.Helper()
84+ if landed, ok := r.settleAll(t, r.outs...); ok {
85+ return landed
86+ }
87+ return set
88+}
89+
6890 func (r *swipeRig) advance(d time.Duration) {
6991 r.mu.Lock()
7092 r.now = r.now.Add(d)
@@ -105,6 +127,32 @@ func (r *swipeRig) settle(t *testing.T) ports.Scene {
105127 return r.frame(t, 5*time.Second)
106128 }
107129
130+// flipAll moves the clock by d, sends a page flip for each output and
131+// returns the latest scene set they published (ok false: none, nothing
132+// animated). The last flip names no output: core takes it only after it
133+// handled the previous ones, so the scene channel then holds their result.
134+func (r *swipeRig) flipAll(t *testing.T, d time.Duration, outputs ...string) (set []ports.Scene, ok bool) {
135+ t.Helper()
136+ r.advance(d)
137+ for _, o := range outputs {
138+ send(r.frames, ports.OutputFrame{Output: o})
139+ }
140+ send(r.frames, ports.OutputFrame{Output: "sync"})
141+ select {
142+ case set = <-r.scenes:
143+ return set, true
144+ default:
145+ return nil, false
146+ }
147+}
148+
149+// settleAll moves the clock past every spring on the outputs and returns
150+// the scene set that results (ok false: nothing was animating).
151+func (r *swipeRig) settleAll(t *testing.T, outputs ...string) ([]ports.Scene, bool) {
152+ t.Helper()
153+ return r.flipAll(t, 5*time.Second, outputs...)
154+}
155+
108156 // flick sends a quick swipe of n updates of (dx, dy) and lifts the
109157 // fingers; it returns the scene right after the lift.
110158 func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {
@@ -21,11 +21,19 @@ type swipeRig struct {
21 now time.Time21 now time.Time
22 // at is the device time of the next swipe event.22 // at is the device time of the next swipe event.
23 at time.Duration23 at time.Duration
24+ // outs are the plugged outputs, for landed.
25+ outs []string
24 }26 }
25 27
26 var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000}28 var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000}
27 29
28 func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {30 func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
31+ t.Helper()
32+ return startSwipeOn(t, edit, wide)
33+}
34+
35+// startSwipeOn is startSwipe with the given outputs plugged in order.
36+func startSwipeOn(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *swipeRig {
29 t.Helper()37 t.Helper()
30 cfg := altCmdDefaults()38 cfg := altCmdDefaults()
31 cfg.Border.Width = 039 cfg.Border.Width = 0
@@ -61,10 +69,24 @@ func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig {
61 done := make(chan error, 1)69 done := make(chan error, 1)
62 go func() { done <- c.Run(ctx) }()70 go func() { done <- c.Run(ctx) }()
63 t.Cleanup(func() { cancel(); <-done })71 t.Cleanup(func() { cancel(); <-done })
64- r.plug(t, wide)72+ for _, o := range outs {
73+ r.plug(t, o)
74+ r.outs = append(r.outs, o.Name)
75+ }
65 return r76 return r
66 }77 }
67 78
79+// landed is the scene set once every spring an action started on any output
80+// landed: the set itself when nothing animates (animations off, or an
81+// action that moves nothing).
82+func (r *swipeRig) landed(t *testing.T, set []ports.Scene) []ports.Scene {
83+ t.Helper()
84+ if landed, ok := r.settleAll(t, r.outs...); ok {
85+ return landed
86+ }
87+ return set
88+}
89+
68 func (r *swipeRig) advance(d time.Duration) {90 func (r *swipeRig) advance(d time.Duration) {
69 r.mu.Lock()91 r.mu.Lock()
70 r.now = r.now.Add(d)92 r.now = r.now.Add(d)
@@ -105,6 +127,32 @@ func (r *swipeRig) settle(t *testing.T) ports.Scene {
105 return r.frame(t, 5*time.Second)127 return r.frame(t, 5*time.Second)
106 }128 }
107 129
130+// flipAll moves the clock by d, sends a page flip for each output and
131+// returns the latest scene set they published (ok false: none, nothing
132+// animated). The last flip names no output: core takes it only after it
133+// handled the previous ones, so the scene channel then holds their result.
134+func (r *swipeRig) flipAll(t *testing.T, d time.Duration, outputs ...string) (set []ports.Scene, ok bool) {
135+ t.Helper()
136+ r.advance(d)
137+ for _, o := range outputs {
138+ send(r.frames, ports.OutputFrame{Output: o})
139+ }
140+ send(r.frames, ports.OutputFrame{Output: "sync"})
141+ select {
142+ case set = <-r.scenes:
143+ return set, true
144+ default:
145+ return nil, false
146+ }
147+}
148+
149+// settleAll moves the clock past every spring on the outputs and returns
150+// the scene set that results (ok false: nothing was animating).
151+func (r *swipeRig) settleAll(t *testing.T, outputs ...string) ([]ports.Scene, bool) {
152+ t.Helper()
153+ return r.flipAll(t, 5*time.Second, outputs...)
154+}
155+
108 // flick sends a quick swipe of n updates of (dx, dy) and lifts the156 // flick sends a quick swipe of n updates of (dx, dy) and lifts the
109 // fingers; it returns the scene right after the lift.157 // fingers; it returns the scene right after the lift.
110 func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {158 func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene {
M internal/core/transition_test.go +41 -0
@@ -421,3 +421,44 @@ func TestDiscreteSwipeTransition(t *testing.T) {
421421 t.Fatalf("settled 1 at %+v and 2 at %+v, want 1 beside 2 on halves", got1, got2)
422422 }
423423 }
424+
425+// A window unmapped while its rect motion runs leaves the scene at once, its
426+// motion goes with it, the others keep theirs, and once those settle no
427+// frame is wanted: a motion kept for a gone window would ask for frames with
428+// nothing to move.
429+func TestUnmapMidRectMotion(t *testing.T) {
430+ r := startSwipe(t, nil)
431+ threeColumns(t, r)
432+ // 3, focused, swaps with 2: both move.
433+ r.key(t, "Left", ports.ModAlt|ports.ModShift)
434+ mid := r.frame(t, 16*time.Millisecond)
435+ if x := rectNow(t, mid, 3).X; x <= 0 || x >= 400 {
436+ t.Fatalf("window 3 at %d on the first frame, want it moving", x)
437+ }
438+ moving := rectNow(t, mid, 2).X
439+ r.client <- ports.WindowUnmapped{ID: 3}
440+ s := scene(t, r.scenes)
441+ if _, ok := rectOf(s, 3); ok {
442+ t.Fatal("the unmapped window is still shown")
443+ }
444+ for _, w := range s.Windows {
445+ if w.ID == 3 {
446+ t.Fatalf("window 3 still in the scene: %+v", w)
447+ }
448+ }
449+ // The neighbour's own motion carries on from where it was drawn.
450+ if got := rectNow(t, s, 2).X; got != moving {
451+ t.Fatalf("window 2 jumped from %d to %d on the unmap", moving, got)
452+ }
453+ if got := rectNow(t, r.frame(t, 16*time.Millisecond), 2).X; got == moving {
454+ t.Fatalf("window 2 stopped at %d: its motion died with the unmapped window", got)
455+ }
456+ s = r.settle(t)
457+ if _, ok := rectOf(s, 3); ok {
458+ t.Fatal("the unmapped window came back")
459+ }
460+ if got := rectNow(t, s, 2).X; got != 400 {
461+ t.Fatalf("window 2 settled at %d, want 400", got)
462+ }
463+ r.noFrameScene(t)
464+}
@@ -421,3 +421,44 @@ func TestDiscreteSwipeTransition(t *testing.T) {
421 t.Fatalf("settled 1 at %+v and 2 at %+v, want 1 beside 2 on halves", got1, got2)421 t.Fatalf("settled 1 at %+v and 2 at %+v, want 1 beside 2 on halves", got1, got2)
422 }422 }
423 }423 }
424+
425+// A window unmapped while its rect motion runs leaves the scene at once, its
426+// motion goes with it, the others keep theirs, and once those settle no
427+// frame is wanted: a motion kept for a gone window would ask for frames with
428+// nothing to move.
429+func TestUnmapMidRectMotion(t *testing.T) {
430+ r := startSwipe(t, nil)
431+ threeColumns(t, r)
432+ // 3, focused, swaps with 2: both move.
433+ r.key(t, "Left", ports.ModAlt|ports.ModShift)
434+ mid := r.frame(t, 16*time.Millisecond)
435+ if x := rectNow(t, mid, 3).X; x <= 0 || x >= 400 {
436+ t.Fatalf("window 3 at %d on the first frame, want it moving", x)
437+ }
438+ moving := rectNow(t, mid, 2).X
439+ r.client <- ports.WindowUnmapped{ID: 3}
440+ s := scene(t, r.scenes)
441+ if _, ok := rectOf(s, 3); ok {
442+ t.Fatal("the unmapped window is still shown")
443+ }
444+ for _, w := range s.Windows {
445+ if w.ID == 3 {
446+ t.Fatalf("window 3 still in the scene: %+v", w)
447+ }
448+ }
449+ // The neighbour's own motion carries on from where it was drawn.
450+ if got := rectNow(t, s, 2).X; got != moving {
451+ t.Fatalf("window 2 jumped from %d to %d on the unmap", moving, got)
452+ }
453+ if got := rectNow(t, r.frame(t, 16*time.Millisecond), 2).X; got == moving {
454+ t.Fatalf("window 2 stopped at %d: its motion died with the unmapped window", got)
455+ }
456+ s = r.settle(t)
457+ if _, ok := rectOf(s, 3); ok {
458+ t.Fatal("the unmapped window came back")
459+ }
460+ if got := rectNow(t, s, 2).X; got != 400 {
461+ t.Fatalf("window 2 settled at %d, want 400", got)
462+ }
463+ r.noFrameScene(t)
464+}
M internal/core/workspace_size_test.go +50 -38
@@ -12,7 +12,15 @@ var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial:
1212 // bound to Alt+w, and shows it.
1313 func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
1414 t.Helper()
15- r := startMulti(t, func(c *ports.Config) {
15+ r, s := sizedLanding(t, false, overflow, edit)
16+ return r.multiRig, s
17+}
18+
19+// sizedLanding is sizedRig with animations on or off; the scene it returns
20+// and every one of its keyLanded and mapLanded are settled.
21+func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports.Config)) (*landRig, ports.Scene) {
22+ t.Helper()
23+ r := startLanding(t, animated, func(c *ports.Config) {
1624 c.Layout.Gaps = 0
1725 c.Layout.MaxColumns = 2
1826 c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}
@@ -21,7 +29,7 @@ func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRi
2129 edit(c)
2230 }
2331 }, ultrawide)
24- return r, r.key(t, "w", ports.ModAlt)[0]
32+ return r, r.keyLanded(t, "w", ports.ModAlt)[0]
2533 }
2634
2735 var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080}
@@ -73,48 +81,52 @@ func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
7381 }
7482
7583 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
76- r, _ := sizedRig(t, "scroll", animationsOff)
77- r.mapWindow(t, 1)
78- r.mapWindow(t, 2)
79- s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
80- full := 0
81- for _, w := range s.Windows {
82- if w.Fullscreen {
83- full++
84- if w.Rect != frame {
85- t.Fatalf("%+v", w)
84+ both(t, func(t *testing.T, animated bool) {
85+ r, _ := sizedLanding(t, animated, "scroll", nil)
86+ r.mapLanded(t, 1)
87+ r.mapLanded(t, 2)
88+ s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0]
89+ full := 0
90+ for _, w := range s.Windows {
91+ if w.Fullscreen {
92+ full++
93+ if w.Rect != frame {
94+ t.Fatalf("%+v", w)
95+ }
8696 }
8797 }
88- }
89- if full != 1 {
90- t.Fatal(s.Windows)
91- }
98+ if full != 1 {
99+ t.Fatal(s.Windows)
100+ }
101+ })
92102 }
93103
94104 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {
95- r, _ := sizedRig(t, "fixed", animationsOff)
96- r.mapWindow(t, 1)
97- r.mapWindow(t, 2)
98- s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]
99- if s.WorkspaceClip != frame {
100- t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)
101- }
102- for _, w := range s.Windows {
103- if w.Fullscreen && w.Rect != frame {
104- t.Fatalf("%+v", w)
105+ both(t, func(t *testing.T, animated bool) {
106+ r, _ := sizedLanding(t, animated, "fixed", nil)
107+ r.mapLanded(t, 1)
108+ r.mapLanded(t, 2)
109+ s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0]
110+ if s.WorkspaceClip != frame {
111+ t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)
105112 }
106- }
107- // Reload keeps the size; removing it frees the workspace.
108- cfg := r.cfg
109- r.reload <- ports.ConfigChanged{Config: cfg}
110- if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame {
111- t.Fatalf("after reload %+v", s.WorkspaceClip)
112- }
113- cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}
114- r.reload <- ports.ConfigChanged{Config: cfg}
115- if s := receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) {
116- t.Fatalf("after size removal %+v", s.WorkspaceClip)
117- }
113+ for _, w := range s.Windows {
114+ if w.Fullscreen && w.Rect != frame {
115+ t.Fatalf("%+v", w)
116+ }
117+ }
118+ // Reload keeps the size; removing it frees the workspace.
119+ cfg := r.cfg
120+ r.reload <- ports.ConfigChanged{Config: cfg}
121+ if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != frame {
122+ t.Fatalf("after reload %+v", s.WorkspaceClip)
123+ }
124+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}
125+ r.reload <- ports.ConfigChanged{Config: cfg}
126+ if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != (ports.Rect{}) {
127+ t.Fatalf("after size removal %+v", s.WorkspaceClip)
128+ }
129+ })
118130 }
119131
120132 func TestWorkspaceSizeFloatsContained(t *testing.T) {
@@ -12,7 +12,15 @@ var ultrawide = ports.OutputInfo{Name: "DP-3", Make: "Acme", Model: "W", Serial:
12 // bound to Alt+w, and shows it.12 // bound to Alt+w, and shows it.
13 func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {13 func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRig, ports.Scene) {
14 t.Helper()14 t.Helper()
15- r := startMulti(t, func(c *ports.Config) {15+ r, s := sizedLanding(t, false, overflow, edit)
16+ return r.multiRig, s
17+}
18+
19+// sizedLanding is sizedRig with animations on or off; the scene it returns
20+// and every one of its keyLanded and mapLanded are settled.
21+func sizedLanding(t *testing.T, animated bool, overflow string, edit func(*ports.Config)) (*landRig, ports.Scene) {
22+ t.Helper()
23+ r := startLanding(t, animated, func(c *ports.Config) {
16 c.Layout.Gaps = 024 c.Layout.Gaps = 0
17 c.Layout.MaxColumns = 225 c.Layout.MaxColumns = 2
18 c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}26 c.Workspaces = []ports.WorkspaceConfig{{Name: "focus", Size: [2]int{1920, 1080}, LayoutRules: ports.LayoutRules{Overflow: overflow}}}
@@ -21,7 +29,7 @@ func sizedRig(t *testing.T, overflow string, edit func(*ports.Config)) (*multiRi
21 edit(c)29 edit(c)
22 }30 }
23 }, ultrawide)31 }, ultrawide)
24- return r, r.key(t, "w", ports.ModAlt)[0]32+ return r, r.keyLanded(t, "w", ports.ModAlt)[0]
25 }33 }
26 34
27 var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080}35 var frame = ports.Rect{X: 760, Y: 180, W: 1920, H: 1080}
@@ -73,48 +81,52 @@ func TestWorkspaceSizeFixedColumnsFillFrame(t *testing.T) {
73 }81 }
74 82
75 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {83 func TestWorkspaceSizeFullscreenFillsFrame(t *testing.T) {
76- r, _ := sizedRig(t, "scroll", animationsOff)84+ both(t, func(t *testing.T, animated bool) {
77- r.mapWindow(t, 1)85+ r, _ := sizedLanding(t, animated, "scroll", nil)
78- r.mapWindow(t, 2)86+ r.mapLanded(t, 1)
79- s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]87+ r.mapLanded(t, 2)
80- full := 088+ s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0]
81- for _, w := range s.Windows {89+ full := 0
82- if w.Fullscreen {90+ for _, w := range s.Windows {
83- full++91+ if w.Fullscreen {
84- if w.Rect != frame {92+ full++
85- t.Fatalf("%+v", w)93+ if w.Rect != frame {
94+ t.Fatalf("%+v", w)
95+ }
86 }96 }
87 }97 }
88- }98+ if full != 1 {
89- if full != 1 {99+ t.Fatal(s.Windows)
90- t.Fatal(s.Windows)100+ }
91- }101+ })
92 }102 }
93 103
94 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {104 func TestWorkspaceSizeFixedFullscreenKeepsFrame(t *testing.T) {
95- r, _ := sizedRig(t, "fixed", animationsOff)105+ both(t, func(t *testing.T, animated bool) {
96- r.mapWindow(t, 1)106+ r, _ := sizedLanding(t, animated, "fixed", nil)
97- r.mapWindow(t, 2)107+ r.mapLanded(t, 1)
98- s := r.key(t, "f", ports.ModAlt|ports.ModShift)[0]108+ r.mapLanded(t, 2)
99- if s.WorkspaceClip != frame {109+ s := r.keyLanded(t, "f", ports.ModAlt|ports.ModShift)[0]
100- t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)110+ if s.WorkspaceClip != frame {
101- }111+ t.Fatalf("fullscreen clip %+v", s.WorkspaceClip)
102- for _, w := range s.Windows {
103- if w.Fullscreen && w.Rect != frame {
104- t.Fatalf("%+v", w)
105 }112 }
106- }113+ for _, w := range s.Windows {
107- // Reload keeps the size; removing it frees the workspace.114+ if w.Fullscreen && w.Rect != frame {
108- cfg := r.cfg115+ t.Fatalf("%+v", w)
109- r.reload <- ports.ConfigChanged{Config: cfg}116+ }
110- if s := receive(t, r.scenes)[0]; s.WorkspaceClip != frame {117+ }
111- t.Fatalf("after reload %+v", s.WorkspaceClip)118+ // Reload keeps the size; removing it frees the workspace.
112- }119+ cfg := r.cfg
113- cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}120+ r.reload <- ports.ConfigChanged{Config: cfg}
114- r.reload <- ports.ConfigChanged{Config: cfg}121+ if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != frame {
115- if s := receive(t, r.scenes)[0]; s.WorkspaceClip != (ports.Rect{}) {122+ t.Fatalf("after reload %+v", s.WorkspaceClip)
116- t.Fatalf("after size removal %+v", s.WorkspaceClip)123+ }
117- }124+ cfg.Workspaces = []ports.WorkspaceConfig{{Name: "focus", LayoutRules: ports.LayoutRules{Overflow: "fixed"}}}
125+ r.reload <- ports.ConfigChanged{Config: cfg}
126+ if s := r.land(receive(t, r.scenes))[0]; s.WorkspaceClip != (ports.Rect{}) {
127+ t.Fatalf("after size removal %+v", s.WorkspaceClip)
128+ }
129+ })
118 }130 }
119 131
120 func TestWorkspaceSizeFloatsContained(t *testing.T) {132 func TestWorkspaceSizeFloatsContained(t *testing.T) {

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 23

Tags 10

↑↓ moveenter openesc close