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