1package core
2
3import (
4 "reflect"
5 "testing"
6
7 "github.com/bnema/neferwl/internal/ports"
8)
9
10func monitor() *Monitor {
11 m := NewMonitor()
12 m.SetOutput(100, 80)
13 m.SetMaxColumns(2)
14 return m
15}
16
17// windows lists each workspace's windows, in column order.
18func windows(m *Monitor) [][]WindowID {
19 var out [][]WindowID
20 for _, w := range m.Workspaces {
21 list := []WindowID{}
22 for _, c := range w.Columns {
23 list = append(list, c.Windows...)
24 }
25 out = append(out, list)
26 }
27 return out
28}
29
30func TestMonitorKeepsOneEmptyWorkspaceBelow(t *testing.T) {
31 m := monitor()
32 if len(m.Workspaces) != 1 || m.Active != 0 {
33 t.Fatal(windows(m))
34 }
35 m.AddWindow(1)
36 if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {}}) {
37 t.Fatal(windows(m))
38 }
39 // Cmd+9 past the end lands on the trailing empty workspace.
40 m.FocusNumber(9)
41 if m.Active != 1 {
42 t.Fatal(m.Active)
43 }
44 m.AddWindow(2)
45 if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) {
46 t.Fatal(windows(m))
47 }
48}
49
50func TestMonitorRemovesEmptyWorkspaceOnceLeft(t *testing.T) {
51 m := monitor()
52 m.AddWindow(1)
53 m.FocusNumber(2)
54 m.AddWindow(2)
55 m.FocusNumber(3)
56 m.AddWindow(3)
57 // [1:{1}] [2:{2}] [3:{3}] [4:{}]
58 m.FocusNumber(2)
59 m.RemoveWindow(2)
60 // Still on the now-empty workspace 2: it stays while active.
61 if len(m.Workspaces) != 4 || m.Active != 1 {
62 t.Fatal(windows(m), m.Active)
63 }
64 m.FocusNumber(1)
65 // Once left it is removed: workspace 3 becomes 2.
66 if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 0 {
67 t.Fatal(windows(m), m.Active)
68 }
69 m.FocusNumber(2)
70 if id, _ := m.Focused(); id != 3 {
71 t.Fatal(id)
72 }
73}
74
75func TestMonitorEmptyFirstWorkspaceRemovedOnceLeft(t *testing.T) {
76 m := monitor()
77 m.AddWindow(1)
78 m.RemoveWindow(1)
79 m.FocusNumber(2)
80 // The only workspace stays.
81 if len(m.Workspaces) != 1 || m.Active != 0 {
82 t.Fatal(windows(m), m.Active)
83 }
84 m.AddWindow(1)
85 m.FocusNumber(2)
86 m.AddWindow(2)
87 m.FocusNumber(1)
88 m.RemoveWindow(1)
89 // Still on the now-empty workspace 1: it stays while active.
90 if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {}}) || m.Active != 0 {
91 t.Fatal(windows(m), m.Active)
92 }
93 // Once left it is removed: workspace 2 becomes 1.
94 m.FocusNumber(2)
95 if !reflect.DeepEqual(windows(m), [][]WindowID{{2}, {}}) || m.Active != 0 {
96 t.Fatal(windows(m), m.Active)
97 }
98}
99
100// A window closing elsewhere empties workspace 1: the first non-empty
101// workspace is promoted at once and the view stays on its windows.
102func TestMonitorEmptyFirstWorkspaceRemovedFromElsewhere(t *testing.T) {
103 m := monitor()
104 m.AddWindow(1)
105 m.FocusNumber(2)
106 m.AddWindow(2)
107 m.RemoveWindow(1)
108 if !reflect.DeepEqual(windows(m), [][]WindowID{{2}, {}}) || m.Active != 0 {
109 t.Fatal(windows(m), m.Active)
110 }
111}
112
113func TestMonitorRemoveElsewhereKeepsActive(t *testing.T) {
114 m := monitor()
115 m.AddWindow(1)
116 m.FocusNumber(2)
117 m.AddWindow(2)
118 m.FocusNumber(3)
119 m.AddWindow(3)
120 m.FocusNumber(3)
121 // A window on another workspace closes: the user stays on workspace 3's windows.
122 m.RemoveWindow(2)
123 if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {3}, {}}) || m.Active != 1 {
124 t.Fatal(windows(m), m.Active)
125 }
126 if id, _ := m.Focused(); id != 3 {
127 t.Fatal(id)
128 }
129}
130
131func TestMonitorLayoutHidesOtherWorkspaces(t *testing.T) {
132 m := monitor()
133 m.AddWindow(1)
134 m.FocusNumber(2)
135 m.AddWindow(2)
136 for _, p := range m.Layout() {
137 if hidden := p.ID == 1; p.Hidden != hidden {
138 t.Fatal(m.Layout())
139 }
140 }
141}
142
143func TestMonitorActions(t *testing.T) {
144 m := monitor()
145 m.AddWindow(1)
146 m.AddWindow(2)
147 // Down past the only window of the column goes to the workspace below.
148 m.Apply(ActionFocusWindowDown)
149 if m.Active != 1 {
150 t.Fatal(m.Active)
151 }
152 m.Apply(ActionFocusWindowUp)
153 if m.Active != 0 {
154 t.Fatal(m.Active)
155 }
156 // Up at the top workspace stays.
157 m.Apply(ActionFocusWorkspaceUp)
158 if m.Active != 0 {
159 t.Fatal(m.Active)
160 }
161 // Move the focused window (2) down: focus stays on workspace 1.
162 m.Apply(ActionMoveWindowToWorkspaceDown)
163 if !reflect.DeepEqual(windows(m), [][]WindowID{{1}, {2}, {}}) || m.Active != 0 {
164 t.Fatal(windows(m), m.Active)
165 }
166 m.Apply("move-window-to-workspace 3")
167 if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {1}, {}}) || m.Active != 0 {
168 t.Fatal(windows(m), m.Active)
169 }
170 m.Apply("focus-workspace 3")
171 if id, _ := m.Focused(); id != 1 {
172 t.Fatal(id)
173 }
174 // The empty workspace 1 is removed once left.
175 if !reflect.DeepEqual(windows(m), [][]WindowID{{2}, {1}, {}}) || m.Active != 1 {
176 t.Fatal(windows(m), m.Active)
177 }
178 if e := m.Apply("spawn foot"); !e.Spawn {
179 t.Fatal(e)
180 }
181}
182
183func TestEqualSharesAndBorderless(t *testing.T) {
184 m := monitor()
185 m.SetMaxColumns(3)
186 m.AddWindow(1)
187 p := m.Layout()
188 if p[0].Rect.W != 100 || p[0].Neighbors != 0 {
189 t.Fatal(p)
190 }
191 m.AddWindow(2)
192 m.AddWindow(3)
193 p = m.Layout()
194 if p[0].Rect.W != 33 || p[1].Rect.W != 33 || p[2].Rect.W != 33 || p[0].Neighbors != ports.SideRight || p[0].Inset != ports.SideRight {
195 t.Fatal(p)
196 }
197 // A fourth column scrolls: still 1/3 each.
198 m.AddWindow(4)
199 if w := m.Current(); w.columnWidth(3) != 33 || w.ViewX == 0 {
200 t.Fatal(w.ViewX)
201 }
202 // A preset on one column is kept; auto columns keep their share.
203 m.SetPresets([]Width{{Num: 1, Den: 2}})
204 m.Apply(ActionCycleColumnWidth)
205 if w := m.Current(); w.columnWidth(w.Focus) != 50 || w.columnWidth(0) != 33 {
206 t.Fatal(w.columnWidth(w.Focus), w.columnWidth(0))
207 }
208}
209
210// stack returns a monitor with columns [1] [2 3], focus on window 3.
211func stack() *Monitor {
212 m := monitor()
213 m.AddWindow(1)
214 m.AddWindow(2)
215 m.AddWindow(3)
216 w := m.Current()
217 w.Columns = []Column{{Windows: []WindowID{1}}, {Windows: []WindowID{2, 3}, Focus: 1}}
218 w.Focus = 1
219 return m
220}
221
222func TestMoveColumnToWorkspace(t *testing.T) {
223 t.Run("whole column, focus stays", func(t *testing.T) {
224 m := stack()
225 m.Apply("move-column-to-workspace 2")
226 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2, 3}, {}}) || m.Active != 0 {
227 t.Fatal(got, m.Active)
228 }
229 if w := m.Workspaces[1]; len(w.Columns) != 1 || w.Columns[0].Focus != 1 {
230 t.Fatal(w.Columns)
231 }
232 if id, _ := m.Focused(); id != 1 {
233 t.Fatal(id)
234 }
235 })
236 t.Run("window only", func(t *testing.T) {
237 m := stack()
238 m.Apply(ActionMoveWindowToWorkspaceDown)
239 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {3}, {}}) {
240 t.Fatal(got)
241 }
242 })
243 t.Run("follow shows the target", func(t *testing.T) {
244 m := stack()
245 m.SetFollowMove(true)
246 m.Apply(ActionMoveColumnToWorkspaceDown)
247 if id, _ := m.Focused(); m.Active != 1 || id != 3 {
248 t.Fatal(m.Active, id)
249 }
250 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1}, {2, 3}, {}}) {
251 t.Fatal(got)
252 }
253 })
254 t.Run("the slot window alone releases its slot", func(t *testing.T) {
255 m := stack()
256 c := &m.Current().Columns[1]
257 c.Slot, c.Focus = 2, 0
258 m.Apply(ActionMoveWindowToWorkspaceDown)
259 if c := m.Current().Columns[1]; c.Slot != 0 || c.Windows[0] != 3 {
260 t.Fatal(c)
261 }
262 })
263 t.Run("follow a floating window", func(t *testing.T) {
264 m := stack()
265 m.SetFollowMove(true)
266 m.AddFloating(9, 10, 10)
267 m.Current().FocusID(9)
268 m.Apply(ActionMoveWindowToWorkspaceDown)
269 if id, _ := m.Focused(); m.Active != 1 || id != 9 {
270 t.Fatal(m.Active, id)
271 }
272 })
273 t.Run("slot column loses its slot", func(t *testing.T) {
274 m := stack()
275 m.Current().Columns[1].Slot = 2
276 m.Apply(ActionMoveColumnToWorkspaceDown)
277 if c := m.Workspaces[1].Columns[0]; c.Slot != 0 {
278 t.Fatal(c)
279 }
280 })
281}
282
283func TestWorkspaceArg(t *testing.T) {
284 for _, tc := range []struct {
285 a Action
286 n int
287 op WorkspaceOp
288 ok bool
289 }{{"focus-workspace 3", 3, FocusWorkspace, true}, {"move-column-to-workspace 12", 12, MoveColumnToWorkspace, true}, {"move-window-to-workspace 2", 2, MoveWindowToWorkspace, true}, {"move-to-workspace 2", 0, 0, false}, {"focus-workspace 0", 0, 0, false}, {"focus-workspace x", 0, 0, false}, {"quit", 0, 0, false}} {
290 n, op, ok := WorkspaceArg(tc.a)
291 if n != tc.n || op != tc.op || ok != tc.ok {
292 t.Errorf("%s: %d %v %v", tc.a, n, op, ok)
293 }
294 }
295}
296
297func TestLonePresetColumnKeepsWidth(t *testing.T) {
298 m := monitor()
299 m.SetPresets([]Width{{Num: 1, Den: 2}})
300 m.AddWindow(1)
301 m.Apply(ActionCycleColumnWidth)
302 if p := m.Layout()[0]; p.Rect.W != 50 || p.Neighbors != 0 {
303 t.Fatal(p)
304 }
305}
306
307func named(m *Monitor, specs ...NamedWorkspace) *Monitor {
308 m.SetNamed(specs)
309 return m
310}
311
312// An empty workspace 1 stays while a named workspace is shown over it, so
313// the toggle can return to it; leaving it for another number drops it.
314func TestEmptyFirstWorkspaceUnderNamed(t *testing.T) {
315 m := named(monitor(), NamedWorkspace{Name: "dev"})
316 m.AddWindow(1)
317 m.FocusNumber(2)
318 m.AddWindow(2)
319 m.FocusNumber(1)
320 m.RemoveWindow(1)
321 m.Apply("workspace dev")
322 if !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {}}) || m.Current().Name != "dev" {
323 t.Fatal(windows(m), m.Current().Name)
324 }
325 m.Apply("workspace dev")
326 if m.Current() != m.Workspaces[0] || !reflect.DeepEqual(windows(m), [][]WindowID{{}, {2}, {}}) {
327 t.Fatal(windows(m), m.Active)
328 }
329 m.FocusNumber(2)
330 if !reflect.DeepEqual(windows(m), [][]WindowID{{2}, {}}) || m.Active != 0 {
331 t.Fatal(windows(m), m.Active)
332 }
333}
334
335func TestHiddenWorkspaceToggle(t *testing.T) {
336 m := named(monitor(), NamedWorkspace{Name: "dev"})
337 m.AddWindow(1)
338 if len(m.Workspaces) != 2 {
339 t.Fatal("hidden workspace is numbered", windows(m))
340 }
341 m.Apply("workspace dev")
342 if m.Current().Name != "dev" {
343 t.Fatal(m.Current().Name)
344 }
345 m.AddWindow(2)
346 // Up/down do not leave a hidden workspace.
347 m.Apply(ActionFocusWorkspaceDown)
348 m.Apply(ActionFocusWindowDown)
349 if m.Current().Name != "dev" {
350 t.Fatal("left the hidden workspace")
351 }
352 for _, p := range m.Layout() {
353 if hidden := p.ID == 1; p.Hidden != hidden {
354 t.Fatal(m.Layout())
355 }
356 }
357 // The same bind returns to the previous workspace.
358 m.Apply("workspace dev")
359 if id, _ := m.Focused(); id != 1 || m.Current().Name != "" {
360 t.Fatal(id, m.Current().Name)
361 }
362 // Cmd+N from the hidden workspace goes to that number.
363 m.Apply("workspace dev")
364 m.FocusNumber(1)
365 if id, _ := m.Focused(); id != 1 {
366 t.Fatal(id)
367 }
368 // Moving a window up/down from a hidden workspace does nothing.
369 m.Apply("workspace dev")
370 m.Apply(ActionMoveWindowToWorkspaceDown)
371 m.Apply(ActionMoveWindowToWorkspaceUp)
372 if id, _ := m.Focused(); id != 2 || m.Current().Name != "dev" {
373 t.Fatal(id, windows(m))
374 }
375}
376
377func TestNamedWorkspaceBindOnly(t *testing.T) {
378 m := named(monitor(), NamedWorkspace{Name: "web"})
379 if len(m.Workspaces) != 1 {
380 t.Fatal(windows(m))
381 }
382 m.Apply("workspace web")
383 if m.Current().Name != "web" {
384 t.Fatal(m.Current().Name)
385 }
386 m.Apply("workspace web")
387 if m.Current().Name != "" {
388 t.Fatal(m.Current().Name)
389 }
390}
391
392func TestNamedWorkspaceReload(t *testing.T) {
393 m := named(monitor(), NamedWorkspace{Name: "dev"}, NamedWorkspace{Name: "web"})
394 m.Apply("workspace dev")
395 m.AddWindow(7)
396 // Reload keeps windows and the workspace on screen; web becomes hidden.
397 named(m, NamedWorkspace{Name: "dev"}, NamedWorkspace{Name: "web"})
398 if m.Current().Name != "dev" || len(m.Workspaces) != 1 {
399 t.Fatal(m.Current().Name, windows(m))
400 }
401 // Dropping dev from config hands its windows to the numbered workspace.
402 named(m)
403 if m.Current().Name != "" || !reflect.DeepEqual(windows(m), [][]WindowID{{7}, {}}) {
404 t.Fatal(m.Current().Name, windows(m))
405 }
406}
407
408func TestNamedWorkspaceSettings(t *testing.T) {
409 m := named(monitor(), NamedWorkspace{Name: "dev", MaxColumns: 3, Overflow: OverflowFixed})
410 m.SetMaxColumns(1)
411 if m.Current().MaxColumns != 1 || m.Current().Overflow != "" {
412 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
413 }
414 m.Apply("workspace dev")
415 if m.Current().MaxColumns != 3 || m.Current().Overflow != OverflowFixed {
416 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
417 }
418 // New dynamic workspaces follow the defaults.
419 m.SetOverflow(OverflowScroll)
420 m.Apply("workspace dev")
421 m.FocusNumber(9)
422 if m.Current().MaxColumns != 1 || m.Current().Overflow != OverflowScroll {
423 t.Fatal(m.Current().MaxColumns, m.Current().Overflow)
424 }
425}
426
427// Cmd+F on a window that made itself fullscreen returns it to its column
428// (Wine opens monitor-sized apps fullscreen); the next press maximizes.
429func TestMaximizeLeavesClientFullscreen(t *testing.T) {
430 for _, o := range []Overflow{OverflowScroll, OverflowFixed} {
431 t.Run(string(o), func(t *testing.T) {
432 m := monitor()
433 m.SetOverflow(o)
434 m.AddWindow(1)
435 m.AddWindow(2)
436 m.SetFullscreen(2, true)
437 m.Apply(ActionMaximizeColumn)
438 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2}, {}}) || m.Active != 0 {
439 t.Fatal(got, m.Active)
440 }
441 w := m.Current()
442 if w.fullscreen != 0 || w.Columns[1].FullWidth {
443 t.Fatal(w.fullscreen, w.Columns)
444 }
445 if id, _ := m.Focused(); id != 2 {
446 t.Fatal("focus", id)
447 }
448 m.Apply(ActionMaximizeColumn)
449 if !w.Columns[1].FullWidth {
450 t.Fatal(w.Columns)
451 }
452 })
453 }
454}
455
456// Ranging over every workspace allocates nothing: normalize and the
457// per-frame paths use it.
458func TestMonitorAllAllocations(t *testing.T) {
459 m := monitor()
460 m.AddWindow(1)
461 m.SetNamed([]NamedWorkspace{{Name: "s"}})
462 n := 0
463 if a := testing.AllocsPerRun(100, func() {
464 for range m.all() {
465 n++
466 }
467 m.byName("s")
468 }); a != 0 {
469 t.Fatalf("%v allocs", a)
470 }
471}
472
473// Fixed overflow keeps a fullscreen window in place: no workspace is
474// added, a window opening meanwhile waits hidden, and the bind or a client
475// exit restores the tiles with the user's focus where it was.
476func TestFixedFullscreenStaysInPlace(t *testing.T) {
477 m := monitor()
478 m.SetOverflow(OverflowFixed)
479 for id := WindowID(1); id <= 3; id++ {
480 m.AddWindow(id)
481 }
482 w := m.Current()
483 w.FocusID(2)
484 m.Apply(ActionCycleColumnWidth) // expanded: kept through fullscreen
485 m.ToggleFullscreen()
486 m.AddWindow(4)
487 if got := windows(m); !reflect.DeepEqual(got, [][]WindowID{{1, 2, 3, 4}, {}}) || m.Current() != w || w.cover() != 2 {
488 t.Fatal(got, w.cover())
489 }
490 if id, _ := m.Focused(); id != 2 {
491 t.Fatal("focus", id)
492 }
493 m.ToggleFullscreen()
494 if id, _ := m.Focused(); id != 2 || w.fullscreen != 0 || !w.Columns[1].Expanded {
495 t.Fatal("after bind", id, w.Columns)
496 }
497}
498
499// A client fullscreen request never moves the view or the focus (ADR
500// 011). In fixed overflow, where it would cover the screen and take the
501// keyboard, only the focused window's request applies.
502func TestFullscreenRequestStaysPut(t *testing.T) {
503 for _, tc := range []struct {
504 name string
505 overflow Overflow
506 id WindowID // requests fullscreen; 1 is focused
507 applied bool
508 }{
509 {"scroll focused", OverflowScroll, 1, true},
510 {"scroll unfocused", OverflowScroll, 2, true},
511 {"scroll other workspace", OverflowScroll, 3, true},
512 {"fixed focused", OverflowFixed, 1, true},
513 {"fixed unfocused", OverflowFixed, 2, false},
514 {"fixed other workspace", OverflowFixed, 3, true},
515 } {
516 t.Run(tc.name, func(t *testing.T) {
517 m := monitor()
518 m.SetOverflow(tc.overflow)
519 m.AddWindow(3) // alone, so focused on workspace 1
520 m.FocusNumber(2)
521 m.AddWindow(2)
522 m.AddWindow(1)
523 w, _ := m.find(tc.id)
524 m.SetFullscreen(tc.id, true)
525 if id, _ := m.Focused(); m.Active != 1 || id != 1 || (w.fullscreen == tc.id) != tc.applied {
526 t.Fatal("active", m.Active, "focused", id, "fullscreen", w.fullscreen)
527 }
528 m.SetFullscreen(tc.id, false)
529 if id, _ := m.Focused(); id != 1 || w.fullscreen != 0 || len(m.Workspaces) != 3 {
530 t.Fatal("round trip", id, windows(m))
531 }
532 })
533 }
534}