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