1package core_test
2
3import (
4 "context"
5 "reflect"
6 "slices"
7 "testing"
8
9 "github.com/bnema/neferwl/internal/adapters/config"
10 "github.com/bnema/neferwl/internal/core"
11 "github.com/bnema/neferwl/internal/ports"
12)
13
14type multiRig struct {
15 client chan ports.ClientEvent
16 input chan ports.InputEvent
17 output chan ports.OutputEvent
18 reload chan ports.ConfigChanged
19 commands chan ports.ClientCommand
20 scenes chan []ports.Scene
21 spawn chan ports.SpawnRequest
22 state chan ports.State
23 cfg ports.Config
24}
25
26var (
27 left = ports.OutputInfo{Name: "DP-1", Make: "Acme", Model: "A", Serial: "1", Width: 200, Height: 100}
28 right = ports.OutputInfo{Name: "DP-2", Make: "Acme", Model: "B", Serial: "2", Width: 400, Height: 200}
29)
30
31// startMulti runs core with Alt as Cmd, no border and no gaps, and plugs
32// the given outputs in order.
33func startMulti(t *testing.T, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
34 t.Helper()
35 return startRig(t, false, edit, outs...)
36}
37
38// startRig is startMulti; with terminal set, core keeps a terminal on every
39// empty workspace on screen.
40func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...ports.OutputInfo) *multiRig {
41 t.Helper()
42 cfg := config.Defaults()
43 cfg.Keyboard.CmdKey = "alt"
44 cfg.Border.Width = 0
45 if edit != nil {
46 edit(&cfg)
47 }
48 r := &multiRig{
49 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
50 output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4),
51 commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg,
52 }
53 c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal})
54 if err != nil {
55 t.Fatal(err)
56 }
57 ctx, cancel := context.WithCancel(context.Background())
58 done := make(chan error, 1)
59 go func() { done <- c.Run(ctx) }()
60 t.Cleanup(func() { cancel(); <-done })
61 for _, o := range outs {
62 r.plug(t, o)
63 }
64 return r
65}
66
67func (r *multiRig) plug(t *testing.T, o ports.OutputInfo) []ports.Scene {
68 t.Helper()
69 r.output <- ports.OutputAdded{Info: o}
70 return receive(t, r.scenes)
71}
72
73func (r *multiRig) key(t *testing.T, sym string, mods ports.Mods) []ports.Scene {
74 t.Helper()
75 r.input <- ports.KeyEvent{Keysym: sym, Mods: mods, Pressed: true}
76 s := receive(t, r.scenes)
77 r.input <- ports.KeyEvent{Keysym: sym, Mods: mods}
78 return s
79}
80
81func (r *multiRig) mapWindow(t *testing.T, id ports.WindowID) []ports.Scene {
82 t.Helper()
83 r.client <- ports.WindowMapped{ID: id}
84 return receive(t, r.scenes)
85}
86
87// shown returns the visible window IDs of each output, by connector.
88func shown(set []ports.Scene) map[string][]ports.WindowID {
89 out := map[string][]ports.WindowID{}
90 for _, s := range set {
91 out[s.Output] = []ports.WindowID{}
92 for _, w := range s.Windows {
93 if !w.Hidden {
94 out[s.Output] = append(out[s.Output], w.ID)
95 }
96 }
97 }
98 return out
99}
100
101func lastOutputs(t *testing.T, ch chan ports.ClientCommand) ports.SetOutputs {
102 t.Helper()
103 var last ports.SetOutputs
104 found := false
105 for len(ch) > 0 {
106 if v, ok := (<-ch).(ports.SetOutputs); ok {
107 last, found = v, true
108 }
109 }
110 if !found {
111 t.Fatal("no SetOutputs")
112 }
113 return last
114}
115
116func TestOutputsLeftToRight(t *testing.T) {
117 r := startMulti(t, func(c *ports.Config) {
118 c.Outputs = []ports.OutputConfig{{Name: "DP-2", Scale: 2}, {Name: "DP-1"}}
119 }, left, right)
120 out := lastOutputs(t, r.commands)
121 // Config order wins: DP-2 (200x100 logical at scale 2), then DP-1.
122 if len(out.Outputs) != 2 || out.Outputs[0].Info.Name != "DP-2" || out.Outputs[0].X != 0 || out.Outputs[0].Width != 200 ||
123 out.Outputs[1].Info.Name != "DP-1" || out.Outputs[1].X != 200 || out.Focused != "DP-1" {
124 t.Fatalf("%+v", out)
125 }
126}
127
128func TestWindowsOpenOnFocusedOutput(t *testing.T) {
129 r := startMulti(t, nil, left, right)
130 r.mapWindow(t, 1)
131 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
132 set := r.mapWindow(t, 2)
133 if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
134 t.Fatal(got)
135 }
136 // Only the focused output draws a focused window.
137 for _, s := range set {
138 for _, w := range s.Windows {
139 if w.Focused != (s.Output == "DP-2") {
140 t.Fatalf("%s: %+v", s.Output, w)
141 }
142 }
143 }
144}
145
146func TestFocusColumnCrossesOutputs(t *testing.T) {
147 r := startMulti(t, nil, left, right)
148 r.mapWindow(t, 1)
149 // The only column is the edge: focus moves to the right output.
150 r.key(t, "Right", ports.ModAlt)
151 r.mapWindow(t, 2)
152 for len(r.commands) > 0 {
153 <-r.commands
154 }
155 r.key(t, "Left", ports.ModAlt)
156 var focus ports.WindowID
157 for len(r.commands) > 0 {
158 if v, ok := (<-r.commands).(ports.FocusWindow); ok {
159 focus = v.ID
160 }
161 }
162 if focus != 1 {
163 t.Fatal(focus)
164 }
165}
166
167func TestPointerCrossesOutputs(t *testing.T) {
168 r := startMulti(t, nil, left, right)
169 r.mapWindow(t, 1)
170 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
171 r.mapWindow(t, 2)
172 for len(r.commands) > 0 {
173 <-r.commands
174 }
175 // DP-2 starts at x 200: global (250, 50) is (50, 50) on window 2.
176 r.input <- ports.PointerMotion{X: 250, Y: 50}
177 if v := command(t, r.commands); v != (ports.PointerFocus{ID: 2, X: 50, Y: 50}) {
178 t.Fatal(v)
179 }
180 // Back on DP-1, then below it (100 high): off every output, y stays on DP-1.
181 r.input <- ports.PointerMotion{X: 10, Y: 50}
182 r.input <- ports.PointerMotion{X: 10, Y: 150}
183 for {
184 if v, ok := command(t, r.commands).(ports.PointerMotionTo); ok && v.Y == 99 {
185 if v.ID != 1 || v.X != 10 {
186 t.Fatal(v)
187 }
188 break
189 }
190 }
191 // A click focuses the output under the pointer.
192 r.input <- ports.PointerButton{Button: 0x110, Pressed: true}
193 set := receive(t, r.scenes)
194 for _, s := range set {
195 for _, w := range s.Windows {
196 if w.Focused != (w.ID == 1) {
197 t.Fatalf("%s: %+v", s.Output, w)
198 }
199 }
200 }
201}
202
203func TestMoveWorkspaceToMonitor(t *testing.T) {
204 r := startMulti(t, nil, left, right)
205 r.mapWindow(t, 1)
206 set := r.key(t, "Right", ports.ModAlt|ports.ModCtrl|ports.ModShift)
207 if got := shown(set); len(got["DP-1"]) != 0 || len(got["DP-2"]) != 1 {
208 t.Fatal(got)
209 }
210 // It is DP-2's now: unplugging DP-1 does not move it.
211 r.output <- ports.OutputRemoved{Name: "DP-1"}
212 set = receive(t, r.scenes)
213 if len(set) != 1 || set[0].Output != "DP-2" || len(shown(set)["DP-2"]) != 1 {
214 t.Fatal(shown(set))
215 }
216}
217
218func TestUnplugMovesWorkspacesAndReplugReturnsThem(t *testing.T) {
219 r := startMulti(t, nil, left, right)
220 r.mapWindow(t, 1)
221 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
222 r.mapWindow(t, 2)
223 // Unplug DP-2 while it is focused: its workspace joins DP-1 below its own.
224 r.output <- ports.OutputRemoved{Name: "DP-2"}
225 set := receive(t, r.scenes)
226 if len(set) != 1 || len(shown(set)["DP-1"]) != 1 || shown(set)["DP-1"][0] != 1 {
227 t.Fatal(shown(set))
228 }
229 // Cmd+2 shows the guest.
230 r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
231 set = receive(t, r.scenes)
232 r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt}
233 if got := shown(set)["DP-1"]; len(got) != 1 || got[0] != 2 {
234 t.Fatal(got)
235 }
236 // Replugged while on screen: the guest stays until the user leaves it.
237 set = r.plug(t, right)
238 if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 2 || len(got["DP-2"]) != 0 {
239 t.Fatal(got)
240 }
241 r.input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModAlt, Pressed: true}
242 set = receive(t, r.scenes)
243 if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
244 t.Fatal(got)
245 }
246}
247
248func TestReplugReturnsHiddenGuestAtOnce(t *testing.T) {
249 r := startMulti(t, nil, left, right)
250 r.mapWindow(t, 1)
251 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
252 r.mapWindow(t, 2)
253 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
254 r.output <- ports.OutputRemoved{Name: "DP-2"}
255 receive(t, r.scenes)
256 // Not on screen: it goes home as soon as DP-2 is back.
257 set := r.plug(t, right)
258 if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
259 t.Fatal(got)
260 }
261}
262
263func TestNamedWorkspaceOnItsMonitor(t *testing.T) {
264 r := startMulti(t, func(c *ports.Config) {
265 c.Workspaces = []ports.WorkspaceConfig{{Name: "web", Monitor: "DP-2", Hidden: true}}
266 c.Binds["Alt+w"] = "workspace web"
267 }, left)
268 // DP-2 is not plugged: web lives on DP-1 as a guest.
269 r.key(t, "w", ports.ModAlt)
270 r.mapWindow(t, 1)
271 r.key(t, "w", ports.ModAlt)
272 // DP-2 arrives: web goes home (not on screen).
273 r.plug(t, right)
274 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
275 set := r.key(t, "w", ports.ModAlt)
276 if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
277 t.Fatal(got)
278 }
279}
280
281func TestLayersStayOnTheirOutput(t *testing.T) {
282 r := startMulti(t, nil, left, right)
283 bar := ports.LayerSurface{ID: 9, Layer: ports.LayerTop, Anchor: ports.AnchorTop | ports.AnchorLeft | ports.AnchorRight, Height: 10, ExclusiveZone: 10, Output: "DP-2"}
284 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{bar}}
285 set := receive(t, r.scenes)
286 for _, s := range set {
287 if (len(s.Layers) == 1) != (s.Output == "DP-2") {
288 t.Fatalf("%s: %+v", s.Output, s.Layers)
289 }
290 }
291 if set[1].Layers[0].Rect != (ports.Rect{W: 400, H: 10}) {
292 t.Fatal(set[1].Layers)
293 }
294}
295
296func TestScaleBindTargetsFocusedOutput(t *testing.T) {
297 r := startMulti(t, func(c *ports.Config) { c.Binds["Alt+equal"] = "scale-up" }, left, right)
298 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
299 set := r.key(t, "equal", ports.ModAlt)
300 if set[0].Scale != 1 || set[1].Scale == 1 {
301 t.Fatal(set[0].Scale, set[1].Scale)
302 }
303}
304
305func TestLastOutputUnpluggedKeepsWindows(t *testing.T) {
306 r := startMulti(t, nil, left)
307 r.mapWindow(t, 1)
308 r.output <- ports.OutputRemoved{Name: "DP-1"}
309 if set := receive(t, r.scenes); len(set) != 1 || set[0].Output != "" {
310 t.Fatal(shown(set))
311 }
312 if out := lastOutputs(t, r.commands); len(out.Outputs) != 0 {
313 t.Fatalf("%+v", out)
314 }
315 // Another monitor takes the windows over.
316 set := r.plug(t, right)
317 if got := shown(set); len(got) != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 1 {
318 t.Fatal(got)
319 }
320}
321
322func TestModeChangeKeepsScreen(t *testing.T) {
323 r := startMulti(t, nil, left, right)
324 r.mapWindow(t, 1)
325 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
326 r.mapWindow(t, 2)
327 // A new mode on DP-2 is only a new size: nothing moves, focus stays.
328 bigger := right
329 bigger.Width, bigger.Height = 800, 400
330 set := r.plug(t, bigger)
331 if got := shown(set); len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 || len(got["DP-1"]) != 1 {
332 t.Fatal(got)
333 }
334 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" || out.Outputs[1].Width != 800 {
335 t.Fatalf("%+v", out)
336 }
337}
338
339func TestPrimaryOutputGetsFocusAtStartup(t *testing.T) {
340 r := startMulti(t, func(c *ports.Config) {
341 c.Outputs = []ports.OutputConfig{{Name: "DP-1"}, {Name: "DP-2", Primary: true}}
342 }, left, right)
343 out := lastOutputs(t, r.commands)
344 // Placed right of DP-1, but focused.
345 if out.Focused != "DP-2" || out.Outputs[1].Info.Name != "DP-2" || !out.Outputs[1].Primary || out.Outputs[0].Primary {
346 t.Fatalf("%+v", out)
347 }
348 set := r.mapWindow(t, 1)
349 if got := shown(set); len(got["DP-2"]) != 1 {
350 t.Fatal(got)
351 }
352}
353
354func TestMoveColumnCrossesOutputs(t *testing.T) {
355 r := startMulti(t, nil, left, right)
356 r.mapWindow(t, 1)
357 r.mapWindow(t, 2)
358 // Window 2 is the right column of DP-1: moving right first hits the
359 // edge, then the column goes to DP-2 and focus follows it.
360 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
361 if got := shown(set); len(got["DP-1"]) != 1 || got["DP-1"][0] != 1 || len(got["DP-2"]) != 1 || got["DP-2"][0] != 2 {
362 t.Fatal(got)
363 }
364 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
365 t.Fatal(out.Focused)
366 }
367 // Back left (no terminal kept here, so a last column may move): it
368 // lands on the right side of DP-1.
369 set = r.key(t, "Left", ports.ModAlt|ports.ModShift)
370 if got := shown(set)["DP-1"]; len(got) != 2 {
371 t.Fatal(got)
372 }
373 for _, s := range set {
374 for _, w := range s.Windows {
375 if w.ID == 2 && (s.Output != "DP-1" || !w.Focused || w.Rect.X == 0) {
376 t.Fatalf("%s %+v", s.Output, w)
377 }
378 }
379 }
380}
381
382func TestPointerFocusesOutput(t *testing.T) {
383 r := startMulti(t, nil, left, right)
384 r.input <- ports.PointerMotion{X: 250, Y: 50}
385 receive(t, r.scenes)
386 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
387 t.Fatal(out.Focused)
388 }
389 // New windows open where the pointer is.
390 if got := shown(r.mapWindow(t, 1)); len(got["DP-2"]) != 1 {
391 t.Fatal(got)
392 }
393}
394
395func TestEmptyWorkspaceGetsTerminal(t *testing.T) {
396 r := startRig(t, true, nil, left, right)
397 // One terminal per output, each tagged for its workspace.
398 var reqs []ports.SpawnRequest
399 for len(reqs) < 2 {
400 reqs = append(reqs, receive(t, r.spawn))
401 }
402 if len(r.spawn) != 0 || reqs[0].Argv[0] != "foot" || reqs[0].Env[0] == reqs[1].Env[0] {
403 t.Fatal(reqs, len(r.spawn))
404 }
405 token := func(req ports.SpawnRequest) string { return req.Env[0][len(ports.SlotEnv)+1:] }
406 // The focused screen is DP-2 now; DP-1's terminal still lands on DP-1.
407 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
408 r.client <- ports.WindowMapped{ID: 1, Slot: token(reqs[0])}
409 r.client <- ports.WindowMapped{ID: 2, Slot: token(reqs[1])}
410 receive(t, r.scenes)
411 set := receive(t, r.scenes)
412 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 1 || got["DP-1"][0] == got["DP-2"][0] {
413 t.Fatal(got)
414 }
415 // A terminal that exits right after mapping is not respawned at once
416 // (no loop for a broken terminal command).
417 r.client <- ports.WindowUnmapped{ID: 2}
418 receive(t, r.scenes)
419 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
420 if len(r.spawn) != 0 {
421 t.Fatal("respawned at once")
422 }
423}
424
425func TestLastColumnStaysOnItsOutput(t *testing.T) {
426 r := startRig(t, true, nil, left, right)
427 r.mapWindow(t, 1)
428 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
429 if got := shown(set); len(got["DP-1"]) != 1 || len(got["DP-2"]) != 0 {
430 t.Fatal(got)
431 }
432}
433
434func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) {
435 r := startMulti(t, nil, left, right)
436 r.mapWindow(t, 1)
437 r.input <- ports.PointerMotion{X: 10, Y: 10}
438 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
439 // Jitter on DP-1 keeps DP-2 focused: the next window opens there.
440 r.input <- ports.PointerMotion{X: 11, Y: 10}
441 if got := shown(r.mapWindow(t, 2)); len(got["DP-2"]) != 1 {
442 t.Fatal(got)
443 }
444 // Entering DP-1 again focuses it.
445 r.input <- ports.PointerMotion{X: 250, Y: 10}
446 r.input <- ports.PointerMotion{X: 10, Y: 10}
447 receive(t, r.scenes)
448 if got := shown(r.mapWindow(t, 3)); len(got["DP-1"]) != 2 {
449 t.Fatal(got)
450 }
451}
452
453func TestStateSnapshot(t *testing.T) {
454 r := startMulti(t, nil, left, right)
455 r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100}
456 receive(t, r.scenes)
457 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
458 <-r.state // the channel holds the latest snapshot only
459 r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200}
460 receive(t, r.scenes)
461 // Snapshots from before window 2 may still be queued: wait for it.
462 st := receive(t, r.state)
463 for len(st.Windows) < 2 {
464 st = receive(t, r.state)
465 }
466 want := ports.State{
467 Output: "DP-2",
468 Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 1, Count: 1}},
469 Windows: []ports.WindowState{
470 {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, Visible: true},
471 {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, Visible: true},
472 },
473 }
474 want.Window = &want.Windows[1]
475 if !reflect.DeepEqual(st, want) {
476 t.Fatalf("got %+v\nwant %+v", st, want)
477 }
478 // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen.
479 r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true}
480 receive(t, r.scenes)
481 st = receive(t, r.state)
482 if st.Outputs[1] != (ports.OutputState{Name: "DP-2", Active: 2, Count: 2}) || st.Windows[1].Visible || st.Window != nil {
483 t.Fatalf("%+v", st)
484 }
485}
486
487func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) {
488 r := startMulti(t, func(c *ports.Config) {
489 c.Workspaces = []ports.WorkspaceConfig{{Name: "notes", Hidden: true}}
490 c.Binds["Alt+n"] = "workspace notes"
491 }, left)
492 r.client <- ports.WindowMapped{ID: 1, PID: 100}
493 receive(t, r.scenes)
494 <-r.state
495 // An app ID set after map reaches the state.
496 r.client <- ports.WindowAppID{ID: 1, AppID: "foot"}
497 receive(t, r.scenes)
498 // Snapshots from before the app ID may still be queued.
499 for st := receive(t, r.state); st.Windows[0].AppID != "foot"; st = receive(t, r.state) {
500 }
501 // A hidden workspace on screen: no active number, its name, window 1 off screen.
502 r.key(t, "n", ports.ModAlt)
503 st := receive(t, r.state)
504 if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible {
505 t.Fatalf("%+v", st)
506 }
507}
508
509func TestMoveColumnOntoFullscreenStaysVisible(t *testing.T) {
510 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
511 r.mapWindow(t, 1)
512 r.mapWindow(t, 2)
513 // Window 2 goes to DP-2, alone, and goes fullscreen in place.
514 r.key(t, "Right", ports.ModAlt|ports.ModShift)
515 r.key(t, "f", ports.ModAlt|ports.ModShift)
516 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
517 // Window 1 joins from DP-1: the fullscreen ends so it is visible and focused.
518 set := r.key(t, "Right", ports.ModAlt|ports.ModShift)
519 for _, s := range set {
520 for _, w := range s.Windows {
521 if w.ID == 1 && (s.Output != "DP-2" || w.Hidden || !w.Focused) {
522 t.Fatalf("%s %+v", s.Output, w)
523 }
524 }
525 }
526}
527
528// windowsOf lists the tiled windows of an output in scene order, with the
529// focused one.
530func windowsOf(set []ports.Scene, out string) (ids []ports.WindowID, focused ports.WindowID) {
531 for _, s := range set {
532 if s.Output != out {
533 continue
534 }
535 for _, w := range s.Windows {
536 if !w.Hidden {
537 ids = append(ids, w.ID)
538 }
539 if w.Focused {
540 focused = w.ID
541 }
542 }
543 }
544 return ids, focused
545}
546
547func TestExpelCrossesToFullNeighbor(t *testing.T) {
548 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
549 // DP-1 (max 2): [1] [2] [3] in the spiral; consume 3 left gives
550 // [1] [2 3], focus on 3 at the bottom of the edge column.
551 r.mapWindow(t, 1)
552 r.mapWindow(t, 2)
553 r.mapWindow(t, 3)
554 r.key(t, "bracketleft", ports.ModAlt)
555 // DP-2: [4] [5], full too.
556 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
557 r.mapWindow(t, 4)
558 r.mapWindow(t, 5)
559 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
560 // Expel right at the edge: 3 stacks under 4, the first column of DP-2.
561 set := r.key(t, "bracketright", ports.ModAlt)
562 if got, _ := windowsOf(set, "DP-1"); !reflect.DeepEqual(got, []ports.WindowID{1, 2}) {
563 t.Fatal(got)
564 }
565 got, focused := windowsOf(set, "DP-2")
566 if !reflect.DeepEqual(got, []ports.WindowID{4, 3, 5}) || focused != 3 {
567 t.Fatal(got, focused)
568 }
569 if out := lastOutputs(t, r.commands); out.Focused != "DP-2" {
570 t.Fatal(out.Focused)
571 }
572 // 3 is not in the edge column of DP-2: it stacks into [5].
573 set = r.key(t, "bracketright", ports.ModAlt)
574 if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
575 t.Fatal(got, focused)
576 }
577 // Now at the edge with no right monitor: nothing moves.
578 set = r.key(t, "bracketright", ports.ModAlt)
579 if got, focused := windowsOf(set, "DP-2"); !reflect.DeepEqual(got, []ports.WindowID{4, 5, 3}) || focused != 3 {
580 t.Fatal(got, focused)
581 }
582}
583
584func TestExpelOntoFixedFullscreenFloats(t *testing.T) {
585 r := startMulti(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }, left, right)
586 // DP-1: [1] [2 3], focus on 3.
587 r.mapWindow(t, 1)
588 r.mapWindow(t, 2)
589 r.mapWindow(t, 3)
590 r.key(t, "bracketleft", ports.ModAlt)
591 // DP-2: [4] [5]; 5 goes fullscreen on its own workspace.
592 r.key(t, "Right", ports.ModAlt|ports.ModCtrl)
593 r.mapWindow(t, 4)
594 r.mapWindow(t, 5)
595 r.key(t, "f", ports.ModAlt|ports.ModShift)
596 r.key(t, "Left", ports.ModAlt|ports.ModCtrl)
597 // 3 floats above the fullscreen window, visible and focused.
598 set := r.key(t, "bracketright", ports.ModAlt)
599 got, focused := windowsOf(set, "DP-2")
600 if !slices.Contains(got, 3) || focused != 3 {
601 t.Fatal(got, focused)
602 }
603}