1package core_test
2
3import (
4 "math/rand"
5 "slices"
6 "testing"
7 "time"
8
9 "github.com/bnema/neferwl/internal/ports"
10)
11
12// Published scenes, not just layouts: exercise the real owner loop, binds,
13// monitor transfers, unmaps, and both kinds of overflow with a fixed seed.
14func TestBorderSceneInvariants(t *testing.T) {
15 for _, overflow := range []string{"fixed", "scroll"} {
16 for _, gap := range []int{0, 6} {
17 t.Run(overflow+map[int]string{0: "-flush", 6: "-gaps"}[gap], func(t *testing.T) {
18 r := startMulti(t, func(c *ports.Config) {
19 c.Animations.On = false
20 c.Border.Width = 2
21 c.Layout.MaxColumns = 3
22 c.Layout.Overflow = overflow
23 c.Layout.Gaps = gap
24 }, left, right)
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.
36func 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++
55 }
56 // Settled means no spring runs: a further flip has nothing to do.
57 if _, again := r.clk.flip(t, r.frames, r.scenes, 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")
64 }
65 t.Logf("%d steps checked mid-transition and settled", moved)
66 })
67 }
68 }
69}
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.
77func 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.
126func 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.
141func 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
166func checkBorderScenes(t *testing.T, step, gap int, scenes []ports.Scene) {
167 t.Helper()
168 focused := 0
169 for _, s := range scenes {
170 checkBorderScene(t, step, gap, s)
171 for _, w := range s.Windows {
172 if w.Focused && !w.Hidden && !w.Popup && w.Rect.Overlaps(ports.Rect{W: s.OutputWidth, H: s.OutputHeight}) {
173 focused++
174 }
175 }
176 }
177 if focused > 1 {
178 t.Fatalf("step %d: focused windows on multiple outputs: %+v", step, scenes)
179 }
180}
181
182func checkBorderScene(t *testing.T, step, gap int, s ports.Scene) {
183 t.Helper()
184 o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight}
185 var focus *ports.SceneWindow
186 tiles := 0
187 for i := range s.Windows {
188 w := &s.Windows[i]
189 if w.Popup || w.Hidden || !w.Rect.Overlaps(o) {
190 continue
191 }
192 if w.Focused {
193 if focus != nil {
194 t.Fatalf("step %d %s: two focused windows: %+v", step, s.Output, s.Windows)
195 }
196 focus = w
197 }
198 if !w.Floating && !w.Fullscreen {
199 tiles++
200 }
201 if w.Fullscreen && w.Inset != 0 {
202 t.Fatalf("step %d %s: fullscreen inset: %+v", step, s.Output, w)
203 }
204 if !w.Floating && !w.Fullscreen {
205 if got, want := w.Inset, wantInset(s, *w, gap); got != want {
206 t.Fatalf("step %d %s: tile %d inset %v, want %v; scene %+v", step, s.Output, w.ID, got, want, s)
207 }
208 }
209 }
210 if tiles <= 1 {
211 for _, w := range s.Windows {
212 if !w.Floating && !w.Popup && !w.Hidden && w.Rect.Overlaps(o) && w.Inset != 0 {
213 t.Fatalf("step %d %s: lone tile reserves a border: %+v; scene %+v", step, s.Output, w, s)
214 }
215 }
216 }
217 for _, sep := range s.Separators {
218 r := sep.Rect
219 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 {
220 t.Fatalf("step %d %s: line outside output: %+v; scene %+v", step, s.Output, sep, s)
221 }
222 if sep.Window != 0 {
223 found := false
224 for _, w := range s.Windows {
225 if w.ID == sep.Window && w.Floating && !w.Fullscreen && !w.Hidden && w.Rect.Overlaps(r) {
226 found = true
227 }
228 }
229 if !found {
230 t.Fatalf("step %d %s: line belongs to invisible float: %+v; scene %+v", step, s.Output, sep, s)
231 }
232 } else if tiles < 2 {
233 t.Fatalf("step %d %s: lone tile has shared line: %+v; scene %+v", step, s.Output, sep, s)
234 }
235 if sep.Active && (focus == nil || focus.Fullscreen || sep.Window != 0 && sep.Window != focus.ID || sep.Window == 0 && focus.Floating) {
236 t.Fatalf("step %d %s: lit line without matching focused tile/float: %+v; scene %+v", step, s.Output, sep, s)
237 }
238 }
239 // A focused tile with a visible neighbor lights at least one line
240 // touching it.
241 if focus == nil || focus.Floating || focus.Fullscreen || neighborSides(s, *focus, gap) == 0 {
242 return
243 }
244 near := ports.Rect{X: focus.Rect.X - 2, Y: focus.Rect.Y - 2, W: focus.Rect.W + 4, H: focus.Rect.H + 4}
245 for _, sep := range s.Separators {
246 if sep.Active && sep.Window == 0 && sep.Rect.Overlaps(near) {
247 return
248 }
249 }
250 t.Fatalf("step %d %s: focused tile %d has neighbors but no lit line; scene %+v", step, s.Output, focus.ID, s)
251}
252
253// neighborSides are the sides of tile w facing another visible tile across
254// gap, with the shared edge strictly inside the output.
255func neighborSides(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
256 o := ports.Rect{W: s.OutputWidth, H: s.OutputHeight}
257 var sides ports.Sides
258 for _, other := range s.Windows {
259 if other.ID == w.ID || other.Popup || other.Floating || other.Fullscreen || other.Hidden || !other.Rect.Overlaps(o) {
260 continue
261 }
262 a, b := w.Rect, other.Rect
263 rows := a.Y < b.Y+b.H && b.Y < a.Y+a.H
264 cols := a.X < b.X+b.W && b.X < a.X+a.W
265 switch {
266 case rows && a.X+a.W+gap == b.X && a.X+a.W > 0 && a.X+a.W < o.W:
267 sides |= ports.SideRight
268 case rows && b.X+b.W+gap == a.X && a.X > 0 && a.X < o.W:
269 sides |= ports.SideLeft
270 case cols && a.Y+a.H+gap == b.Y && a.Y+a.H > 0 && a.Y+a.H < o.H:
271 sides |= ports.SideBottom
272 case cols && b.Y+b.H+gap == a.Y && a.Y > 0 && a.Y < o.H:
273 sides |= ports.SideTop
274 }
275 }
276 return sides
277}
278
279// wantInset is the exact inset of a visible tile: every neighbor side with
280// gaps, only the right and bottom (shared-line owner) sides without.
281func wantInset(s ports.Scene, w ports.SceneWindow, gap int) ports.Sides {
282 sides := neighborSides(s, w, gap)
283 if gap == 0 {
284 sides &= ports.SideRight | ports.SideBottom
285 }
286 return sides
287}
288
289// Regression for the original two-monitor transfer: when the last tile
290// comes back, neither the old output nor the new one has a shared line.
291func TestBorderMoveBackAlone(t *testing.T) {
292 r := startMulti(t, func(c *ports.Config) {
293 c.Border.Width = 2
294 c.Layout.MaxColumns = 3
295 c.Layout.Overflow = "fixed"
296 }, left, right)
297 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
298 r.mapWindow(t, 1)
299 for _, sym := range []string{"Left", "Right"} {
300 scenes := r.key(t, sym, ports.ModAlt|ports.ModShift)
301 checkBorderScenes(t, 0, 0, scenes)
302 if got := shown(scenes); len(got["DP-1"]) != map[string]int{"Left": 1, "Right": 0}[sym] || len(got["DP-2"]) != map[string]int{"Left": 0, "Right": 1}[sym] {
303 t.Fatalf("%s: window did not move: %v", sym, got)
304 }
305 }
306}