1package core
2
3import (
4 "math"
5 "reflect"
6 "testing"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11func workspace() *Workspace {
12 w := &Workspace{}
13 w.SetOutput(100, 81)
14 w.SetGaps(5)
15 w.SetMaxColumns(2)
16 return w
17}
18func TestWidths(t *testing.T) {
19 for _, tc := range []struct {
20 s string
21 ok bool
22 pixels int
23 }{{"1", true, 0}, {"1/2", true, 0}, {"25px", true, 25}, {"2/1", false, 0}, {"0px", false, 0}, {"1/0", false, 0}, {"abc", false, 0}} {
24 v, e := ParseWidth(tc.s)
25 if (e == nil) != tc.ok || (e == nil && v.Pixels != tc.pixels) {
26 t.Errorf("%q: %v %v", tc.s, v, e)
27 }
28 }
29 if n := (Width{Num: 1, Den: 2}).Resolve(100, 5); n != 43 {
30 t.Fatal(n)
31 }
32 if n := (Width{Pixels: 200}).Resolve(100, 5); n != 90 {
33 t.Fatal(n)
34 }
35}
36func TestOperations(t *testing.T) {
37 w := workspace()
38 w.FocusColumn(1)
39 w.FocusWindow(-1)
40 w.MoveColumn(1)
41 w.CycleWidth()
42 w.ToggleFullscreen()
43 w.RemoveWindow(1)
44 if _, ok := w.Focused(); ok {
45 t.Fatal("empty focus")
46 }
47 w.AddWindow(1)
48 w.AddWindow(2)
49 w.AddWindow(3)
50 // Three auto columns, two visible: each (100-3*5)/2 = 42 wide.
51 if w.Focus != 2 || w.ViewX != 46 || w.columnWidth(0) != 42 {
52 t.Fatalf("scroll: %+v", w)
53 }
54 w.FocusColumn(-1)
55 if w.ViewX != 46 {
56 t.Fatal(w.ViewX)
57 }
58 w.FocusColumn(-1)
59 if w.ViewX != 0 {
60 t.Fatal(w.ViewX)
61 }
62 w.MoveColumn(1)
63 if w.Focus != 1 || w.Columns[1].Windows[0] != 1 {
64 t.Fatal(w.Columns)
65 }
66 w.RemoveWindow(1)
67 if id, _ := w.Focused(); id != 2 {
68 t.Fatal(id)
69 }
70 w.RemoveWindow(3)
71 w.RemoveWindow(2)
72 if len(w.Columns) != 0 {
73 t.Fatal(w.Columns)
74 }
75}
76func TestStackAndLayout(t *testing.T) {
77 w := workspace()
78 w.Columns = []Column{{Windows: []WindowID{1, 2, 3}, Focus: 1}}
79 w.RemoveWindow(2)
80 if id, _ := w.Focused(); id != 1 {
81 t.Fatal(id)
82 }
83 w.FocusWindow(1)
84 if id, _ := w.Focused(); id != 3 {
85 t.Fatal(id)
86 }
87 w.FocusWindow(-1)
88 p := w.Layout()
89 if p[0].Rect != (Rect{X: 5, Y: 5, W: 90, H: 33}) || p[1].Rect != (Rect{X: 5, Y: 43, W: 90, H: 33}) || p[0].Neighbors != ports.SideBottom || p[1].Inset != ports.SideTop {
90 t.Fatal(p)
91 }
92 w.ToggleFullscreen()
93 if p = w.Layout(); !p[0].Fullscreen || p[0].Rect != (Rect{X: 0, Y: 0, W: 100, H: 81}) || !p[1].Hidden || p[1].Rect != (Rect{}) {
94 t.Fatal(p)
95 }
96 w.ToggleFullscreen()
97 if w.Layout()[0].Fullscreen {
98 t.Fatal("toggle")
99 }
100 w.SetUsable(Rect{X: 10, Y: 10, W: 60, H: 50})
101 w.SetGaps(2)
102 if w.Layout()[0].Rect.X != 12 {
103 t.Fatal(w.Layout())
104 }
105 w.SetOutput(120, 90)
106 if w.Usable.W != 60 {
107 t.Fatal(w.Usable)
108 }
109}
110func TestPresetsAndActions(t *testing.T) {
111 w := workspace()
112 w.AddWindow(1)
113 p := []Width{{Pixels: 30}, {Pixels: 40}}
114 w.SetPresets(p)
115 p[0].Pixels = 99
116 w.AddWindow(2)
117 w.Apply(ActionCycleColumnWidth)
118 if w.Columns[1].Width.Pixels != 30 {
119 t.Fatal(w.Columns)
120 }
121 w.CycleWidth()
122 if w.Columns[1].Width.Pixels != 40 {
123 t.Fatal(w.Columns)
124 }
125 // After the last preset, back to the automatic share.
126 w.CycleWidth()
127 if w.Columns[1].Width != (Width{}) {
128 t.Fatal(w.Columns)
129 }
130 w.RemoveWindow(2)
131 for _, tc := range []struct {
132 a Action
133 want Effect
134 }{{ActionSpawnTerminal, Effect{Spawn: true}}, {ActionCloseWindow, Effect{Close: 1}}, {ActionQuit, Effect{Quit: true}}, {ActionFocusColumnLeft, Effect{}}, {ActionFocusColumnRight, Effect{}}, {ActionFocusWindowUp, Effect{}}, {ActionFocusWindowDown, Effect{}}, {ActionMoveColumnLeft, Effect{}}, {ActionMoveColumnRight, Effect{}}, {ActionToggleFullscreen, Effect{}}, {ActionCycleColumnWidth, Effect{}}, {"spawn fuzzel --prompt x", Effect{Spawn: true, Argv: []string{"fuzzel", "--prompt", "x"}}}, {"spawn ", Effect{}}} {
135 if got := w.Apply(tc.a); !reflect.DeepEqual(got, tc.want) {
136 t.Errorf("%s: %+v", tc.a, got)
137 }
138 }
139 w.SetPresets(nil)
140 old := w.Columns[0].Width
141 w.CycleWidth()
142 if w.Columns[0].Width != old {
143 t.Fatal("empty presets")
144 }
145}
146func FuzzWorkspaceOps(f *testing.F) {
147 f.Add([]byte{0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
148 f.Fuzz(func(t *testing.T, ops []byte) {
149 w := workspace()
150 w.SetPresets([]Width{{Pixels: 25}, {Pixels: 50}})
151 next := WindowID(1)
152 for _, op := range ops {
153 switch op % 18 {
154 case 0:
155 w.AddWindow(next)
156 next++
157 case 1:
158 if id, ok := w.Focused(); ok {
159 w.RemoveWindow(id)
160 }
161 case 2:
162 w.FocusColumn(-1)
163 case 3:
164 w.FocusColumn(1)
165 case 4:
166 w.FocusWindow(-1)
167 case 5:
168 w.FocusWindow(1)
169 case 6:
170 w.MoveColumn(-1)
171 case 7:
172 w.MoveColumn(1)
173 case 8:
174 w.CycleWidth()
175 case 9:
176 w.ToggleFullscreen()
177 case 10:
178 w.SetGaps(int(op) % 10)
179 case 11:
180 w.SetOutput(int(op)-50, int(op)-80)
181 case 12:
182 w.SetUsable(Rect{X: int(op) % 20, Y: int(op) % 20, W: int(op) - 50, H: int(op) - 80})
183 case 13:
184 w.SetGaps(int(op) * 2)
185 case 14:
186 if len(w.Columns) > 1 {
187 stackFocused(w)
188 }
189 case 15:
190 if len(w.Columns) > 0 {
191 w.Columns[w.Focus].Width = Width{Num: math.MaxInt, Den: math.MaxInt}
192 w.scroll()
193 }
194 case 16:
195 w.SetOutput(0, 0)
196 case 17:
197 w.SetUsable(Rect{W: math.MaxInt, H: math.MaxInt})
198 }
199 seen := map[WindowID]bool{}
200 for _, c := range w.Columns {
201 if len(c.Windows) == 0 || c.Focus < 0 || c.Focus >= len(c.Windows) {
202 t.Fatal("invalid column")
203 }
204 for _, id := range c.Windows {
205 if seen[id] {
206 t.Fatal("duplicate")
207 }
208 seen[id] = true
209 }
210 }
211 if len(w.Columns) > 0 {
212 if w.Focus < 0 || w.Focus >= len(w.Columns) {
213 t.Fatal("focus")
214 }
215 left := w.columnX(w.Focus) - w.ViewX
216 width := w.columnWidth(w.Focus)
217 minX, maxX := w.Usable.X+w.gap(), w.Usable.X+w.Usable.W-w.gap()
218 if w.fullscreenColumn(w.Focus) {
219 minX, maxX = 0, w.Output.W
220 }
221 if width <= maxX-minX && (left < minX || left+width > maxX) {
222 t.Fatal("scroll")
223 }
224 }
225 placements := w.Layout()
226 if len(placements) != len(seen) {
227 t.Fatal("count")
228 }
229 fullCount := 0
230 for _, p := range placements {
231 if p.Fullscreen {
232 fullCount++
233 }
234 r := p.Rect
235 if r.W < 0 || r.H < 0 || r.Y < 0 || r.Y > w.Output.H || r.H > w.Output.H-r.Y {
236 t.Fatalf("bounds: %+v output %+v", p, w.Output)
237 }
238 if p.Hidden && r != (Rect{}) {
239 t.Fatal("hidden rect")
240 }
241 if !p.Hidden && !p.Fullscreen && (r.Y < w.Usable.Y || r.Y > w.Usable.Y+w.Usable.H || r.H > w.Usable.Y+w.Usable.H-r.Y) {
242 t.Fatalf("usable vertical bounds: %+v usable %+v", p, w.Usable)
243 }
244 if !seen[p.ID] {
245 t.Fatal("missing")
246 }
247 delete(seen, p.ID)
248 }
249 if fullCount > 1 {
250 t.Fatal("multiple fullscreen")
251 }
252 }
253 })
254}
255
256// stackFocused moves the next column's window into the focused column.
257func stackFocused(w *Workspace) {
258 i := w.Focus
259 j := (i + 1) % len(w.Columns)
260 id := w.Columns[j].Windows[0]
261 w.Columns[i].Windows = append(w.Columns[i].Windows, id)
262 w.Columns[j].Windows = w.Columns[j].Windows[1:]
263 if len(w.Columns[j].Windows) == 0 {
264 w.Columns = append(w.Columns[:j], w.Columns[j+1:]...)
265 if j < i {
266 w.Focus--
267 }
268 } else if w.Columns[j].Focus >= len(w.Columns[j].Windows) {
269 w.Columns[j].Focus = len(w.Columns[j].Windows) - 1
270 }
271 w.scroll()
272}
273
274func TestLargeWidthAndBounds(t *testing.T) {
275 for _, tc := range []struct {
276 width Width
277 usable, gaps, want int
278 }{
279 {Width{Num: 1, Den: 1}, math.MaxInt, 0, math.MaxInt},
280 {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 0, math.MaxInt},
281 {Width{Num: math.MaxInt, Den: math.MaxInt}, math.MaxInt, 5, math.MaxInt - 10},
282 {Width{Num: 1, Den: 2}, math.MaxInt, 0, (math.MaxInt + 1) / 2},
283 {Width{Num: math.MaxInt, Den: math.MaxInt}, 100, 5, 90},
284 } {
285 if got := tc.width.Resolve(tc.usable, tc.gaps); got != tc.want {
286 t.Errorf("Resolve(%+v, %d, %d) = %d; want %d", tc.width, tc.usable, tc.gaps, got, tc.want)
287 }
288 }
289 w := workspace()
290 w.AddWindow(1)
291 w.SetUsable(Rect{X: -1, Y: -2, W: -10, H: math.MaxInt})
292 w.SetGaps(math.MaxInt)
293 for _, p := range w.Layout() {
294 if p.Rect.W != 0 || p.Rect.H < 0 {
295 t.Fatal(p)
296 }
297 }
298 w.SetOutput(-1, -1)
299 if w.Output.W != 0 || w.Output.H != 0 {
300 t.Fatal(w.Output)
301 }
302}
303func TestUnchangedUsablePreservesViewX(t *testing.T) {
304 w := workspace()
305 w.AddWindow(1)
306 w.AddWindow(2)
307 w.AddWindow(3)
308 before := w.ViewX
309 if before == 0 {
310 t.Fatal("expected a scrolled workspace")
311 }
312 w.SetUsable(w.Usable)
313 if w.ViewX != before {
314 t.Fatalf("ViewX changed from %d to %d", before, w.ViewX)
315 }
316}
317
318func TestFullscreenPersistsAndScrolls(t *testing.T) {
319 w := workspace()
320 w.AddWindow(1)
321 w.AddWindow(2)
322 w.FocusColumn(-1)
323 w.ToggleFullscreen()
324 if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X != 0 || p.Rect.W != 100 {
325 t.Fatal(p)
326 }
327 w.FocusColumn(1)
328 if p := w.Layout()[0]; !p.Fullscreen || p.Rect.X >= 0 {
329 t.Fatal(p)
330 }
331 w.ToggleFullscreen()
332 if w.Layout()[0].Fullscreen || !w.Layout()[1].Fullscreen {
333 t.Fatal(w.Layout())
334 }
335 w.RemoveWindow(2)
336 if w.Layout()[0].Fullscreen {
337 t.Fatal(w.Layout())
338 }
339}
340
341func TestSetFullscreenStackDeactivation(t *testing.T) {
342 w := workspace()
343 w.Columns = []Column{{Windows: []WindowID{1, 2}, Focus: 0}}
344 // A client request does not take focus; window 1 keeps it but is covered.
345 w.SetFullscreen(2, true)
346 p := w.Layout()
347 if !p[0].Hidden || !p[1].Fullscreen || p[1].Focused || !p[0].Focused {
348 t.Fatal(p)
349 }
350 w.SetFullscreen(2, false)
351 if w.Layout()[0].Hidden {
352 t.Fatal(w.Layout())
353 }
354}
355
356// Fixed overflow keeps every window on screen: past max-columns, new columns
357// split the newest one, alternating top/bottom and left/right.
358func TestFixedOverflowSpiral(t *testing.T) {
359 w := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
360 w.SetOutput(100, 80)
361 for id := WindowID(1); id <= 5; id++ {
362 w.AddWindow(id)
363 }
364 got := map[WindowID]Rect{}
365 for _, p := range w.Layout() {
366 got[p.ID] = p.Rect
367 }
368 want := map[WindowID]Rect{
369 1: {X: 0, Y: 0, W: 50, H: 80},
370 2: {X: 50, Y: 0, W: 50, H: 40}, // top half of slot 2
371 3: {X: 50, Y: 40, W: 25, H: 40}, // left of the bottom half
372 4: {X: 75, Y: 40, W: 25, H: 20},
373 5: {X: 75, Y: 60, W: 25, H: 20},
374 }
375 if !reflect.DeepEqual(got, want) {
376 t.Fatalf("got %v\nwant %v", got, want)
377 }
378 if w.ViewX != 0 {
379 t.Fatal("fixed overflow scrolled", w.ViewX)
380 }
381 // Up to max-columns it is the plain equal split.
382 w2 := &Workspace{MaxColumns: 2, Overflow: OverflowFixed}
383 w2.SetOutput(100, 80)
384 w2.AddWindow(1)
385 w2.AddWindow(2)
386 if p := w2.Layout(); p[0].Rect.W != 50 || p[1].Rect != (Rect{X: 50, Y: 0, W: 50, H: 80}) {
387 t.Fatal(p)
388 }
389}
390
391func fixedWorkspace(maxCols, gaps, n int) *Workspace {
392 w := &Workspace{MaxColumns: maxCols, Overflow: OverflowFixed, Gaps: gaps}
393 w.SetOutput(100, 80)
394 for id := 1; id <= n; id++ {
395 w.AddWindow(WindowID(id))
396 }
397 return w
398}
399
400func placements(w *Workspace) map[WindowID]Placement {
401 got := map[WindowID]Placement{}
402 for _, p := range w.Layout() {
403 got[p.ID] = p
404 }
405 return got
406}
407
408func TestFixedOverflowEdgeCases(t *testing.T) {
409 t.Run("new windows go last whatever the focus", func(t *testing.T) {
410 w := fixedWorkspace(2, 0, 3)
411 w.FocusID(1)
412 w.AddWindow(4)
413 if got := w.Columns[len(w.Columns)-1].Windows[0]; got != 4 {
414 t.Fatal(got)
415 }
416 })
417 for _, col := range []WindowID{1, 4} { // a plain column and a spiral slot
418 t.Run("fullscreen covers the output", func(t *testing.T) {
419 w := fixedWorkspace(2, 4, 4)
420 w.FocusID(col)
421 w.ToggleFullscreen()
422 for id, p := range placements(w) {
423 if id == col && (p.Rect != Rect{W: 100, H: 80} || !p.Fullscreen) {
424 t.Fatal(p)
425 }
426 if id != col && !p.Hidden {
427 t.Fatalf("window %d visible over fullscreen: %+v", id, p)
428 }
429 }
430 })
431 }
432 t.Run("presets ignored", func(t *testing.T) {
433 w := fixedWorkspace(2, 0, 2)
434 w.SetPresets([]Width{{Num: 2, Den: 3}})
435 w.CycleWidth()
436 w.FocusColumn(-1)
437 w.CycleWidth()
438 for id, p := range placements(w) {
439 if p.Rect.X+p.Rect.W > 100 || p.Rect.W != 50 {
440 t.Fatal(id, p)
441 }
442 }
443 })
444 t.Run("gaps between spiral halves", func(t *testing.T) {
445 got := placements(fixedWorkspace(1, 4, 2))
446 if got[1].Rect != (Rect{X: 4, Y: 4, W: 92, H: 34}) || got[2].Rect != (Rect{X: 4, Y: 42, W: 92, H: 34}) {
447 t.Fatal(got[1].Rect, got[2].Rect)
448 }
449 if got[1].Neighbors == 0 || got[2].Neighbors == 0 {
450 t.Fatal("spiral windows must keep borders to show focus")
451 }
452 })
453}
454
455// Activating a window hidden by another's fullscreen shows it; a floating
456// target stays above the fullscreen window, which keeps it.
457func TestActivateLeavesFullscreen(t *testing.T) {
458 for _, tc := range []struct {
459 name string
460 setup func(w *Workspace)
461 keep bool
462 }{
463 {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false},
464 {"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false},
465 {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, true},
466 } {
467 t.Run(tc.name, func(t *testing.T) {
468 w := &Workspace{}
469 w.SetOutput(100, 80)
470 w.AddWindow(2)
471 tc.setup(w)
472 w.FocusID(2)
473 w.SetFullscreen(2, true)
474 w.Activate(3)
475 for _, p := range w.Layout() {
476 if p.ID == 3 && (p.Hidden || !p.Focused) {
477 t.Fatalf("3 not shown and focused: %+v", p)
478 }
479 if p.ID == 2 && p.Fullscreen != tc.keep {
480 t.Fatalf("fullscreen of 2 = %v", p.Fullscreen)
481 }
482 }
483 })
484 }
485}