1package core
2
3import (
4 "context"
5 "slices"
6 "testing"
7
8 "github.com/bnema/neferwl/internal/ports"
9)
10
11func namedOverviewMonitor() *Monitor {
12 m := overviewMonitor()
13 m.SetNamed([]NamedWorkspace{{Name: "dev"}, {Name: "empty"}, {Name: "game"}})
14 m.Focus(1)
15 m.ToggleNamed("dev")
16 m.AddWindow(5)
17 m.AddWindow(6)
18 m.Current().ToggleWindowStash()
19 m.ToggleNamed("game")
20 m.AddWindow(7)
21 m.Focus(1)
22 return m
23}
24
25// The overview rebuilds its row order and slots every frame: with named
26// workspaces to group and sort, from the monitor's scratch, it costs nothing.
27func TestOverviewNamedRowsAllocations(t *testing.T) {
28 m := namedOverviewMonitor()
29 m.Focus(0)
30 m.ToggleNamed("dev")
31 m.ToggleOverview()
32 if len(m.overviewWorkspaces()) < 4 {
33 t.Fatal("setup: named rows missing")
34 }
35 if n := testing.AllocsPerRun(50, func() { m.overviewWorkspaces() }); n != 0 {
36 t.Errorf("overviewWorkspaces allocs = %v, want 0", n)
37 }
38 if n := testing.AllocsPerRun(50, func() { m.overviewRows() }); n != 0 {
39 t.Errorf("overviewRows allocs = %v, want 0", n)
40 }
41}
42
43func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) {
44 m := namedOverviewMonitor()
45 numbered := slices.Clone(m.Workspaces)
46 m.Focus(0)
47 m.ToggleNamed("dev")
48 m.ToggleNamed("dev")
49 m.ToggleOverview()
50 if !slices.Equal(m.overviewWorkspaces(), []*Workspace{numbered[0], m.byName("dev"), numbered[1], m.byName("game")}) {
51 t.Fatal("named workspace not directly below its invocation workspace")
52 }
53 m.OverviewMove(0, 1)
54 if m.Current().Name != "dev" {
55 t.Fatal("down from workspace 1 did not reach invoked named workspace")
56 }
57 m.OverviewMove(1, 0) // leave the named workspace's stash pile
58 m.OverviewMove(0, 1)
59 if m.Current() != numbered[1] {
60 t.Fatal("down from named did not resume numbered sequence")
61 }
62 m.CancelOverview()
63 m.Focus(1)
64 m.ToggleNamed("dev")
65 m.ToggleNamed("dev")
66 m.ToggleOverview()
67 if !slices.Equal(m.overviewWorkspaces(), []*Workspace{numbered[0], numbered[1], m.byName("dev"), m.byName("game")}) {
68 t.Fatal("calling named workspace elsewhere did not update its placement")
69 }
70 if !slices.Equal(m.Workspaces, numbered) {
71 t.Fatal("overview attachment changed workspace numbering")
72 }
73}
74
75func TestOverviewNamedNavigationDoesNotReattach(t *testing.T) {
76 m := namedOverviewMonitor()
77 m.Focus(0)
78 m.ToggleNamed("dev")
79 m.ToggleNamed("dev")
80 m.ToggleOverview()
81 before := slices.Clone(m.overviewWorkspaces())
82 m.OverviewMove(0, 1)
83 m.OverviewMove(0, 1)
84 m.OverviewMove(0, -1)
85 if !slices.Equal(m.overviewWorkspaces(), before) || m.Current().Name != "dev" {
86 t.Fatal("browsing from below changed named attachment")
87 }
88 _, dividers := m.overviewRows()
89 if len(dividers) != 2 {
90 t.Fatalf("named group needs upper and lower dividers: %+v", dividers)
91 }
92 m.ToggleOverview()
93 m.ToggleNamed("dev")
94 if m.Current() != before[0] {
95 t.Fatal("entering named from below lost invocation return")
96 }
97}
98
99func TestOverviewNamedAnchorRemoved(t *testing.T) {
100 m := namedOverviewMonitor()
101 m.Focus(0)
102 m.take(m.Workspaces[1])
103 m.ToggleOverview()
104 rows := m.overviewWorkspaces()
105 if !slices.Equal(rows, []*Workspace{m.Workspaces[0], m.byName("dev"), m.byName("game")}) {
106 t.Fatal("removed anchor left named rows unreachable")
107 }
108 m.OverviewMove(0, 1)
109 if m.Current().Name != "dev" {
110 t.Fatal("orphan named row not reachable")
111 }
112}
113
114func TestOverviewNamedAnchorSurvivesConfigAndMonitorMove(t *testing.T) {
115 m := namedOverviewMonitor()
116 anchor, dev, game := m.Workspaces[1], m.byName("dev"), m.byName("game")
117 m.SetNamed([]NamedWorkspace{{Name: "game"}, {Name: "dev"}})
118 if !slices.Equal(m.overviewWorkspaces(), []*Workspace{m.Workspaces[0], anchor, game, dev}) {
119 t.Fatal("config reorder lost attachment or ignored named order")
120 }
121 m.Focus(0)
122 m.take(anchor)
123 m.take(dev)
124 m.take(game)
125 dst := newMonitor("", "")
126 dst.SetOutput(300, 200)
127 dst.useSpecs([]NamedWorkspace{{Name: "dev"}, {Name: "game"}})
128 dst.adopt(anchor, false, 0)
129 dst.adopt(game, true, 0)
130 dst.adopt(dev, true, 0)
131 rows := dst.overviewWorkspaces()
132 if at := slices.Index(rows, anchor); at < 0 || at+2 >= len(rows) || rows[at+1] != dev || rows[at+2] != game {
133 t.Fatal("moved named workspaces not reachable on destination monitor")
134 }
135 if dev.overviewAfter != nil && !dst.has(dev.overviewAfter) {
136 t.Fatal("named workspace retained detached monitor anchor")
137 }
138}
139
140func TestOverviewNamedAnchorSurvivesReplug(t *testing.T) {
141 cfg := ports.Config{}
142 cfg.Layout.MaxColumns = 3
143 cfg.Keyboard.CmdKey = "super"
144 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Monitor: "OUT-1"}}
145 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
146 if err != nil {
147 t.Fatal(err)
148 }
149 c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200})
150 home := c.cur()
151 home.mon.SetNamed([]NamedWorkspace{{Name: "dev"}})
152 home.mon.AddWindow(1)
153 anchor := home.mon.Current()
154 home.mon.ToggleNamed("dev")
155 home.mon.AddWindow(2)
156 dev := home.mon.Current()
157 home.mon.Focus(1)
158 home.mon.AddWindow(3) // a later row makes bottom fallback detectably wrong
159 c.addScreen(ports.OutputInfo{Name: "OUT-2", Width: 300, Height: 200})
160 c.removeScreen("OUT-1")
161 c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200})
162 c.settleGuests()
163 for _, sc := range c.screens {
164 if sc.name() != "OUT-1" {
165 continue
166 }
167 if !sc.mon.has(dev) || dev.overviewAfter != anchor {
168 t.Fatal("replug lost named invocation anchor")
169 }
170 rows := sc.mon.overviewWorkspaces()
171 at := slices.Index(rows, anchor)
172 if at < 0 || at+1 >= len(rows) || rows[at+1] != dev {
173 t.Fatal("replug did not restore named row below invocation workspace")
174 }
175 return
176 }
177 t.Fatal("missing replugged screen")
178}
179
180func TestOverviewNamedEmptyAnchor(t *testing.T) {
181 m := namedOverviewMonitor()
182 m.Focus(len(m.Workspaces) - 1)
183 anchor := m.Current()
184 m.ToggleNamed("dev")
185 m.ToggleOverview()
186 rows := m.overviewWorkspaces()
187 if at := slices.Index(rows, anchor); at < 0 || at+1 >= len(rows) || rows[at+1] != m.byName("dev") {
188 t.Fatal("empty invocation workspace missing above named row")
189 }
190}
191
192func TestOverviewNamedNavigation(t *testing.T) {
193 m := namedOverviewMonitor()
194 numbered := slices.Clone(m.Workspaces)
195 m.ToggleOverview()
196 m.OverviewMove(0, 1)
197 if m.Current().Name != "dev" {
198 t.Fatalf("down from last occupied numbered workspace: %q", m.Current().Name)
199 }
200 if id := m.card(); id != 6 {
201 t.Fatalf("named stash selection: %d", id)
202 }
203 m.OverviewMove(1, 0)
204 m.OverviewMove(0, 1)
205 if m.Current().Name != "game" || m.card() != 0 {
206 t.Fatalf("next named workspace: %q, card %d", m.Current().Name, m.card())
207 }
208 m.OverviewMove(0, 1)
209 if m.Current().Name != "game" {
210 t.Fatal("past last named workspace")
211 }
212 m.OverviewMove(0, -1)
213 m.OverviewMove(1, 0)
214 m.OverviewMove(0, -1)
215 if m.Current() != numbered[1] || !slices.Equal(m.Workspaces, numbered) {
216 t.Fatal("return to numbered workspaces changed numbering")
217 }
218 m.CancelOverview()
219 m.Focus(0)
220 m.ToggleOverview()
221 _, dividers := m.overviewRows()
222 if len(dividers) != 0 {
223 t.Fatalf("divider within numbered group: %+v", dividers)
224 }
225}
226
227func TestOverviewNamedRowsKeepWidthAndStash(t *testing.T) {
228 m := namedOverviewMonitor()
229 m.ToggleOverview()
230 _, dividers := m.overviewRows()
231 if len(dividers) != 1 || dividers[0].Active || dividers[0].Rect.W != 276 {
232 t.Fatalf("group divider: %+v", dividers)
233 }
234 below := previewOf(t, m.Layout(), 5)
235 pile := previewOf(t, m.Layout(), 6)
236 if below.Hidden || !below.Peek || pile.Hidden || !pile.Peek {
237 t.Fatalf("named neighbor and stash not visible: %+v, %+v", below, pile)
238 }
239 if pile.Preview != overviewCardZoom {
240 t.Fatalf("stash zoom %v", pile.Preview)
241 }
242 m.OverviewMove(0, 1)
243 selected := previewOf(t, m.Layout(), 5)
244 if selected.Hidden || selected.Rect.X != below.Rect.X || selected.Rect.W != below.Rect.W || selected.Preview != below.Preview {
245 t.Fatalf("named row geometry changed: %+v, %+v", below, selected)
246 }
247 above := previewOf(t, m.Layout(), 4)
248 if above.Hidden || !above.Peek || above.Rect.Y >= selected.Rect.Y {
249 t.Fatalf("numbered neighbor above named: %+v", above)
250 }
251 _, dividers = m.overviewRows()
252 if len(dividers) != 1 || dividers[0].Rect.Y <= above.Rect.Y+above.Rect.H || dividers[0].Rect.Y >= selected.Rect.Y {
253 t.Fatalf("divider not between groups: %+v", dividers)
254 }
255 game := previewOf(t, m.Layout(), 7)
256 if game.Hidden || !game.Peek || game.Rect.Y <= selected.Rect.Y {
257 t.Fatalf("next named neighbor: %+v", game)
258 }
259}
260
261func TestOverviewNamedCancelAndToggleReturn(t *testing.T) {
262 m := namedOverviewMonitor()
263 m.ToggleNamed("dev")
264 back := m.back
265 m.ToggleOverview()
266 m.OverviewMove(0, 1)
267 m.CancelOverview()
268 if m.Current().Name != "dev" || m.back != back {
269 t.Fatal("Escape did not restore named workspace and its return target")
270 }
271 m.Focus(1)
272 m.ToggleOverview()
273 m.OverviewMove(0, 1)
274 m.ToggleOverview()
275 m.ToggleNamed("dev")
276 if m.Current() != back {
277 t.Fatal("named toggle did not return to overview entry workspace")
278 }
279}
280
281func TestOverviewNamedBrowseKeepsNumberedReturn(t *testing.T) {
282 for _, click := range []bool{false, true} {
283 m := namedOverviewMonitor()
284 back := m.Current()
285 m.ToggleOverview()
286 m.OverviewMove(0, 1)
287 if click {
288 m.OverviewPick(7)
289 } else {
290 m.OverviewMove(1, 0)
291 m.OverviewMove(0, 1)
292 m.ToggleOverview()
293 }
294 if m.Current().Name != "game" || m.back != back {
295 t.Fatalf("click %v: browsing lost numbered return", click)
296 }
297 m.ToggleNamed("game")
298 if m.Current() != back {
299 t.Fatalf("click %v: toggle did not return to numbered workspace", click)
300 }
301 }
302}
303
304func TestOverviewWorkspaceBindKeepsCurrentSelection(t *testing.T) {
305 m := maximizedOverview()
306 m.ToggleOverview()
307 m.OverviewMove(0, -1)
308 m.OverviewMove(1, 0)
309 front, selected := m.stackFront(m.Current()), m.overviewTarget()
310 if front.kind != stackColumns || selected != 3 {
311 t.Fatal("expected provisional hidden column 3")
312 }
313 for _, action := range []Action{"focus-workspace 1", "workspace unknown"} {
314 m.Apply(action)
315 if m.stackFront(m.Current()) != front || m.overviewTarget() != selected {
316 t.Fatalf("%s reset provisional selection", action)
317 }
318 }
319}
320
321func TestOverviewNamedNeighborPick(t *testing.T) {
322 m := namedOverviewMonitor()
323 back := m.Current()
324 m.ToggleOverview()
325 p := previewOf(t, m.Layout(), 6)
326 if p.Hidden {
327 t.Fatal("named stash neighbor hidden")
328 }
329 id := overviewIn(m.Layout(), float64(p.Rect.X+p.Rect.W/2), float64(p.Rect.Y+p.Rect.H/2))
330 if id != 6 {
331 t.Fatalf("stash hit: %d", id)
332 }
333 m.OverviewPick(id)
334 if m.ov.open || m.Current().Name != "dev" || m.back != back || !m.Current().stashFocused() {
335 t.Fatal("named stash click did not commit selection and return target")
336 }
337}
338
339func TestOverviewNamedFocusBinds(t *testing.T) {
340 for _, action := range []Action{ActionFocusWindowDown, ActionFocusWorkspaceNext} {
341 t.Run(string(action), func(t *testing.T) {
342 m := namedOverviewMonitor()
343 m.ToggleOverview()
344 m.Apply(action)
345 if m.Current().Name != "dev" {
346 t.Fatalf("focus bind selected %q", m.Current().Name)
347 }
348 })
349 }
350 m := namedOverviewMonitor()
351 m.ToggleOverview()
352 m.Apply(Action("workspace dev"))
353 if m.card() != 6 {
354 t.Fatal("named bind did not initialize stash selection")
355 }
356 m.Apply(Action("focus-workspace 2"))
357 if m.card() != 0 || m.ov.row != nil {
358 t.Fatal("numbered bind retained previous row selection")
359 }
360}
361
362func TestOverviewNamedOutsideNavigationUnchanged(t *testing.T) {
363 m := namedOverviewMonitor()
364 m.Apply(ActionFocusWorkspaceNext)
365 if m.shown != nil || !m.Current().empty() {
366 t.Fatal("outside overview, down no longer reaches numbered spare")
367 }
368 m.ToggleNamed("dev")
369 m.Apply(ActionFocusWorkspacePrev)
370 if m.Current().Name != "dev" {
371 t.Fatal("outside overview, named workspace entered numbered list")
372 }
373}
374
375func TestOverviewNamedEmptyCurrentAndRemoval(t *testing.T) {
376 m := namedOverviewMonitor()
377 m.ToggleNamed("empty")
378 m.ToggleOverview()
379 if !m.Current().empty() {
380 t.Fatal("expected empty named current")
381 }
382 m.OverviewMove(0, 1)
383 if m.Current().Name != "game" {
384 t.Fatal("could not leave empty named row")
385 }
386 m.SetNamed([]NamedWorkspace{{Name: "game"}})
387 m.CancelOverview()
388 if !m.has(m.Current()) || m.back != nil && !m.has(m.back) {
389 t.Fatal("config removal left dangling overview return")
390 }
391}
392
393func TestOverviewNamedColumnPickClearsStash(t *testing.T) {
394 m := namedOverviewMonitor()
395 m.ToggleNamed("dev")
396 m.ToggleOverview()
397 if m.card() != 6 {
398 t.Fatal("expected stash selection")
399 }
400 m.OverviewPick(5)
401 if m.Current().stashFocused() || m.ov.open {
402 t.Fatal("column click kept stash selection")
403 }
404 id, _ := m.Focused()
405 if id != 5 {
406 t.Fatalf("column click focus %d", id)
407 }
408}
409
410func TestOverviewNamedDividerPublished(t *testing.T) {
411 scenes := make(chan []ports.Scene, 1)
412 cfg := ports.Config{}
413 cfg.Keyboard.CmdKey = "super"
414 cfg.Layout.MaxColumns = 3
415 c, err := New(cfg, Channels{Commands: make(chan ports.ClientCommand, 256), Scenes: scenes}, Options{})
416 if err != nil {
417 t.Fatal(err)
418 }
419 c.addScreen(ports.OutputInfo{Name: "OUT-1", Width: 300, Height: 200})
420 c.cur().mon = namedOverviewMonitor()
421 c.cur().mon.ToggleOverview()
422 if err := c.publish(context.Background()); err != nil {
423 t.Fatal(err)
424 }
425 scene := (<-scenes)[0]
426 _, dividers := c.cur().mon.overviewRows()
427 if len(dividers) != 1 || !slices.Contains(scene.Separators, dividers[0]) {
428 t.Fatalf("group divider not published: %+v", scene.Separators)
429 }
430}
431
432func TestOverviewNamedScrollAndSwipe(t *testing.T) {
433 m := namedOverviewMonitor()
434 m.ToggleOverview()
435 m.overviewFocus(ActionFocusWorkspaceNext)
436 if m.Current().Name != "dev" {
437 t.Fatal("swipe did not cross groups")
438 }
439 m.overviewFocus(ActionFocusColumnRight)
440 m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}})
441 if m.Current().Name != "game" {
442 t.Fatal("scroll did not reach next named workspace")
443 }
444}