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