1package core
2
3import (
4 "github.com/bnema/neferwl/internal/ports"
5 "testing"
6)
7
8// axisCore is a cascade core whose monitor "M" has three workspaces of five
9// windows (ids 11-15, 21-25, 31-35; bands of three) and a trailing empty
10// one. Workspace 2 is active. With sides, monitors L and R flank M.
11func axisCore(t *testing.T, sides bool, overflow Overflow, maxCols int) (*Core, *Monitor) {
12 t.Helper()
13 c, _ := cascadeMovesCore(t, 0, maxCols, sides)
14 m := c.cur().mon
15 m.SetOverflow(overflow)
16 m.SetMaxColumns(maxCols)
17 for ws := range 3 {
18 m.Focus(ws)
19 for i := 1; i <= 5; i++ {
20 m.AddWindow(WindowID((ws+1)*10 + i))
21 }
22 }
23 m.Focus(1)
24 return c, m
25}
26
27func wantActive(t *testing.T, m *Monitor, active int) {
28 t.Helper()
29 if m.Active != active {
30 t.Fatalf("active workspace %d, want %d", m.Active, active)
31 }
32}
33
34// AC1: past the band edge with no monitor, left/right step the workspace.
35func TestCascadeFocusColumnStepsWorkspaceAtBandEdge(t *testing.T) {
36 c, m := axisCore(t, false, OverflowCascade, 3)
37 m.Current().FocusID(23) // last column of band 1, not the last band
38 c.applyAction(ActionFocusColumnRight)
39 wantActive(t, m, 2)
40 m.Focus(1)
41 m.Current().FocusID(24) // first column of band 2
42 c.applyAction(ActionFocusColumnLeft)
43 wantActive(t, m, 0)
44 // The first workspace stays put at the start.
45 m.Current().FocusID(11)
46 c.applyAction(ActionFocusColumnLeft)
47 wantActive(t, m, 0)
48 // Inside the band, focus moves and the workspace stays.
49 c.applyAction(ActionFocusColumnRight)
50 wantActive(t, m, 0)
51 if id, _ := m.Current().Focused(); id != 12 {
52 t.Fatalf("focused %d, want 12", id)
53 }
54}
55
56// Occupied workspaces win over monitors; monitors still precede trailing empties.
57func TestCascadeFocusColumnGoesToWorkspaceBeforeMonitor(t *testing.T) {
58 c, m := axisCore(t, true, OverflowCascade, 3)
59 m.Current().FocusID(23)
60 c.applyAction(ActionFocusColumnRight)
61 wantActive(t, m, 2)
62 if c.cur().mon != m {
63 t.Fatal("left monitor before workspace")
64 }
65 m.Focus(1)
66 m.Current().FocusID(24)
67 c.applyAction(ActionFocusColumnLeft)
68 wantActive(t, m, 0)
69 if c.cur().mon != m {
70 t.Fatal("left monitor before workspace")
71 }
72 m.Focus(2)
73 m.Current().FocusID(35)
74 c.applyAction(ActionFocusColumnRight)
75 wantFocus(t, c, "R", 0)
76 wantActive(t, m, 2)
77}
78
79// With no monitor on that side, the trailing empty workspace is the last
80// stop, and left comes back from it.
81func TestCascadeFocusColumnReachesTrailingWorkspaceWithoutMonitor(t *testing.T) {
82 c, m := axisCore(t, false, OverflowCascade, 3)
83 m.Focus(2)
84 m.Current().FocusID(35)
85 c.applyAction(ActionFocusColumnRight)
86 wantActive(t, m, 3)
87 if !m.Current().empty() {
88 t.Fatal("want the trailing empty workspace")
89 }
90 c.applyAction(ActionFocusColumnLeft)
91 wantActive(t, m, 2)
92}
93
94// One monitor on the left only. Right steps to the next workspace; left
95// from its empty band returns to the occupied workspace before that monitor.
96func TestCascadeStepRightThenLeftReturnsToWorkspace(t *testing.T) {
97 c := layoutCore(1, tile{"L", 0, 0, 900, 600}, tile{"M", 900, 0, 900, 600})
98 m := c.cur().mon
99 m.SetOverflow(OverflowCascade)
100 m.SetMaxColumns(3)
101 m.AddWindow(1)
102 m.AddWindow(5)
103 c.applyAction(ActionFocusColumnRight)
104 wantActive(t, m, 1)
105 c.applyAction(ActionFocusColumnLeft)
106 wantFocus(t, c, "M", 5)
107 wantActive(t, m, 0)
108}
109
110// AC3: up/down cross bands and stop at the last, with no workspace change.
111func TestCascadeFocusWindowDownStaysOnWorkspace(t *testing.T) {
112 c, m := axisCore(t, false, OverflowCascade, 3)
113 m.Current().FocusID(22)
114 c.applyAction(ActionFocusWindowDown)
115 wantFocus(t, c, "M", 25)
116 wantActive(t, m, 1)
117 c.applyAction(ActionFocusWindowDown)
118 wantFocus(t, c, "M", 25)
119 wantActive(t, m, 1)
120}
121
122// AC4: scroll and fixed switch workspace with up/down at the column
123// bottom; left/right at the edge do nothing without a monitor.
124func TestScrollFixedWorkspaceAxisIsVertical(t *testing.T) {
125 for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
126 t.Run(string(overflow), func(t *testing.T) {
127 c, m := axisCore(t, false, overflow, 3)
128 m.Current().FocusID(25)
129 c.applyAction(ActionFocusColumnRight)
130 wantActive(t, m, 1)
131 wantFocus(t, c, "M", 25)
132 c.applyAction(ActionFocusColumnLeft)
133 c.applyAction(ActionFocusColumnLeft)
134 c.applyAction(ActionFocusColumnLeft)
135 c.applyAction(ActionFocusColumnLeft)
136 c.applyAction(ActionFocusColumnLeft)
137 wantActive(t, m, 1)
138 c.applyAction(ActionFocusWindowDown)
139 wantActive(t, m, 2)
140 m.Focus(1)
141 m.Current().FocusID(21) // top of the first column
142 c.applyAction(ActionFocusWindowUp)
143 wantActive(t, m, 0)
144 })
145 }
146}
147
148// AC5: a shown named workspace is outside the workspace list.
149func TestCascadeNamedWorkspaceDoesNotStep(t *testing.T) {
150 c, m := axisCore(t, false, OverflowCascade, 3)
151 m.SetNamed([]NamedWorkspace{{Name: "n"}})
152 m.ToggleNamed("n")
153 shown := m.shown
154 if shown == nil {
155 t.Fatal("named workspace not shown")
156 }
157 shown.Overflow = OverflowCascade
158 shown.SetMaxColumns(3)
159 for id := WindowID(41); id <= 45; id++ {
160 m.AddWindow(id)
161 }
162 shown.FocusID(43)
163 c.applyAction(ActionFocusColumnRight)
164 if m.shown != shown || m.Active != 1 {
165 t.Fatalf("shown %p want %p, active %d", m.shown, shown, m.Active)
166 }
167 wantFocus(t, c, "M", 43)
168 shown.FocusID(41)
169 c.applyAction(ActionFocusColumnLeft)
170 if m.shown != shown || m.Active != 1 {
171 t.Fatalf("shown %p want %p, active %d", m.shown, shown, m.Active)
172 }
173 wantFocus(t, c, "M", 41)
174}
175
176// AC6: a covering fullscreen tile at the band edge steps the workspace and
177// stays fullscreen.
178func TestCascadeFullscreenBandEdgeStepsWorkspace(t *testing.T) {
179 c, m := axisCore(t, false, OverflowCascade, 3)
180 w := m.Current()
181 w.FocusID(23)
182 w.ToggleFullscreen()
183 c.applyAction(ActionFocusColumnRight)
184 wantActive(t, m, 2)
185 if w.fullscreen != 23 {
186 t.Fatalf("fullscreen %d, want 23", w.fullscreen)
187 }
188}
189
190// A covering fullscreen tile follows the same order: the monitor on that
191// side after the occupied workspace.
192func TestCascadeFullscreenGoesToWorkspaceBeforeMonitor(t *testing.T) {
193 c, m := axisCore(t, true, OverflowCascade, 3)
194 w := m.Current()
195 w.FocusID(23)
196 w.ToggleFullscreen()
197 c.applyAction(ActionFocusColumnRight)
198 wantActive(t, m, 2)
199 if c.cur().mon != m {
200 t.Fatal("left monitor before workspace")
201 }
202 if w.fullscreen != 23 {
203 t.Fatalf("fullscreen %d, want 23", w.fullscreen)
204 }
205}
206
207// A workspace holding only a float is occupied: with no monitor on that
208// side, it comes before the trailing empty one.
209func TestCascadeFloatOnlyWorkspaceIsOccupied(t *testing.T) {
210 c, m := axisCore(t, false, OverflowCascade, 3)
211 m.Focus(3)
212 m.AddFloating(9, 100, 100)
213 m.Focus(2)
214 m.Current().FocusID(35)
215 c.applyAction(ActionFocusColumnRight)
216 wantActive(t, m, 3)
217 if got := c.cur().name(); got != "M" {
218 t.Fatalf("focused screen %q, want M", got)
219 }
220}
221
222// AC7: a lone native float, or an empty cascade workspace, is at the edge on
223// both sides.
224func TestCascadeLoneFloatAndEmptyWorkspaceStepWorkspace(t *testing.T) {
225 c, m := axisCore(t, false, OverflowCascade, 3)
226 m.Focus(3) // the trailing empty workspace
227 if !m.Current().empty() {
228 t.Fatal("workspace 4 not empty")
229 }
230 c.applyAction(ActionFocusColumnLeft)
231 wantActive(t, m, 2)
232
233 // A lone native float on its own workspace, past the trailing one.
234 for _, tc := range []struct {
235 a Action
236 want int
237 }{{ActionFocusColumnLeft, 2}, {ActionFocusColumnRight, 4}} {
238 c, m = axisCore(t, false, OverflowCascade, 3)
239 m.Focus(3)
240 m.AddFloating(9, 100, 100)
241 if w := m.Current(); len(w.Columns) != 0 || !w.floatFocus {
242 t.Fatalf("columns %d floatFocus %v, want a lone focused float", len(w.Columns), w.floatFocus)
243 }
244 c.applyAction(tc.a)
245 wantActive(t, m, tc.want)
246 }
247}
248
249// Column edges visit occupied workspaces before crossing outputs, in both directions.
250func TestColumnEdgeFocusWorkspaceBeforeScreen(t *testing.T) {
251 for _, tc := range []struct {
252 name string
253 screen, start, next, neighbor int
254 action Action
255 }{
256 {"left", 1, 1, 0, 0, ActionFocusColumnLeft},
257 {"right", 0, 0, 1, 1, ActionFocusColumnRight},
258 } {
259 t.Run(tc.name, func(t *testing.T) {
260 c := layoutCore(tc.screen, tile{"laptop", 0, 0, 1920, 1080}, tile{"4K", 1920, 0, 3840, 2160})
261 m := c.cur().mon
262 m.SetOverflow(OverflowCascade)
263 for ws := range 2 {
264 m.Focus(ws)
265 m.AddWindow(WindowID(ws + 1))
266 }
267 m.Focus(tc.start)
268 c.applyAction(tc.action)
269 if c.focusScreen != tc.screen {
270 t.Fatalf("screen %d, want %d before crossing workspace", c.focusScreen, tc.screen)
271 }
272 wantActive(t, m, tc.next)
273 c.applyAction(tc.action)
274 if c.focusScreen != tc.neighbor {
275 t.Fatalf("screen %d, want neighbor %d", c.focusScreen, tc.neighbor)
276 }
277 wantActive(t, m, tc.next)
278 })
279 }
280}
281
282func TestVerticalEdgeFocusWorkspaceBeforeScreen(t *testing.T) {
283 for _, overflow := range []Overflow{OverflowScroll, OverflowFixed} {
284 for _, tc := range []struct {
285 name string
286 screen, start, next, neighbor int
287 action Action
288 }{
289 {"up", 1, 1, 0, 0, ActionFocusWindowUp},
290 {"down", 0, 0, 1, 1, ActionFocusWindowDown},
291 } {
292 t.Run(string(overflow)+"/"+tc.name, func(t *testing.T) {
293 c := layoutCore(tc.screen, tile{"top", 0, 0, 900, 600}, tile{"bottom", 0, 600, 900, 600})
294 m := c.cur().mon
295 m.SetOverflow(overflow)
296 for ws := range 2 {
297 m.Focus(ws)
298 m.AddWindow(WindowID(ws + 1))
299 }
300 m.Focus(tc.start)
301 c.applyAction(tc.action)
302 if c.focusScreen != tc.screen {
303 t.Fatal("crossed screen before occupied workspace")
304 }
305 wantActive(t, m, tc.next)
306 c.applyAction(tc.action)
307 if c.focusScreen != tc.neighbor {
308 t.Fatalf("screen %d, want %d", c.focusScreen, tc.neighbor)
309 }
310 wantActive(t, m, tc.next)
311 })
312 }
313 }
314}
315
316func TestCascadeVerticalEdgeDoesNotCrossScreen(t *testing.T) {
317 c := layoutCore(1, tile{"top", 0, 0, 900, 600}, tile{"middle", 0, 600, 900, 600}, tile{"bottom", 0, 1200, 900, 600})
318 m := c.cur().mon
319 m.SetOverflow(OverflowCascade)
320 m.AddWindow(1)
321 for _, a := range []Action{ActionFocusWindowUp, ActionFocusWindowDown} {
322 c.applyAction(a)
323 wantFocus(t, c, "middle", 1)
324 wantActive(t, m, 0)
325 }
326}
327
328func TestVerticalEdgeFocusFlashesDestination(t *testing.T) {
329 for _, tc := range []struct {
330 a Action
331 y int
332 }{{ActionFocusWindowUp, -300}, {ActionFocusWindowDown, 300}} {
333 t.Run(string(tc.a), func(t *testing.T) {
334 c, _ := flashCore(t)
335 c.cur().mon.SetOverflow(OverflowScroll)
336 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
337 c.screens[1].x, c.screens[1].y = 0, tc.y
338 c.applyAction(tc.a)
339 if c.focusScreen != 1 || c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
340 t.Fatalf("screen %d, flash %+v", c.focusScreen, c.flash.pending)
341 }
342 })
343 }
344}