1package core_test
2
3import (
4 "context"
5 "slices"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/bnema/neferwl/internal/core"
11 "github.com/bnema/neferwl/internal/ports"
12)
13
14type slotRig struct {
15 client chan ports.ClientEvent
16 input chan ports.InputEvent
17 reload chan ports.ConfigChanged
18 spawn chan ports.SpawnRequest
19 scenes chan []ports.Scene
20 workspaces chan ports.Workspaces
21 errs chan error
22 cfg ports.Config
23 // With animations on and a clock the test moves (startSlotsMode),
24 // land flips the output past every spring.
25 clk *stepClock
26 frames chan ports.OutputFrame
27 lands int
28}
29
30// startSlots runs core with a hidden "dev" workspace of two slots, toggled
31// by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
32// fixed overflow.
33func startSlots(t *testing.T, numbered ...bool) *slotRig {
34 t.Helper()
35 return startSlotsMode(t, nil, numbered...)
36}
37
38// startSlotsMode is startSlots with animations forced off (*false) or on
39// with a clock the test moves (*true), whose springs land through land;
40// nil keeps the default config on the system clock.
41func startSlotsMode(t *testing.T, animated *bool, numbered ...bool) *slotRig {
42 t.Helper()
43 cfg := altCmdDefaults()
44 if animated != nil {
45 cfg.Animations.On = *animated
46 }
47 cfg.Border.Width = 0
48 // A cell is a tenth of the width: the slots take 7 and 3 cells.
49 cfg.Layout.MaxColumns = 10
50 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Slots: []ports.SlotConfig{
51 {Index: 1, Cells: 7, Argv: []string{"code"}},
52 {Index: 2, Cells: 3, Argv: []string{"foot"}},
53 }}}
54 cfg.Binds["Alt+d"] = "workspace dev"
55 if len(numbered) > 1 {
56 cfg.Layout.Overflow = "fixed"
57 }
58 r := &slotRig{
59 client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16),
60 reload: make(chan ports.ConfigChanged, 4), spawn: make(chan ports.SpawnRequest, 16),
61 scenes: make(chan []ports.Scene, 1), workspaces: make(chan ports.Workspaces, 1), errs: make(chan error, 4), cfg: cfg,
62 }
63 output := make(chan ports.OutputEvent, 1)
64 commands := make(chan ports.ClientCommand, 1024)
65 ch := core.Channels{Client: r.client, Input: r.input, Output: output, Config: r.reload, Commands: commands, Spawn: r.spawn, Scenes: r.scenes, Workspaces: r.workspaces, ConfigErrors: r.errs}
66 opts := core.Options{}
67 if animated != nil && *animated {
68 r.clk, r.frames = newStepClock(t), make(chan ports.OutputFrame)
69 opts.Clock, ch.Frames = r.clk.clock, r.frames
70 t.Cleanup(func() {
71 if r.lands == 0 {
72 t.Error("no spring ever ran: the animations-on variant checked nothing")
73 }
74 })
75 }
76 c, err := core.New(cfg, ch, opts)
77 if err != nil {
78 t.Fatal(err)
79 }
80 ctx, cancel := context.WithCancel(context.Background())
81 done := make(chan error, 1)
82 go func() { done <- c.Run(ctx) }()
83 t.Cleanup(func() { cancel(); <-done })
84 go func() { // commands are not under test
85 for range commands {
86 }
87 }()
88 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
89 scene(t, r.scenes)
90 return r
91}
92
93// land is s once every spring has landed: the scene the flips publish, or s
94// itself when nothing animates (always with animations off or on the
95// system clock).
96func (r *slotRig) land(t *testing.T, s ports.Scene) ports.Scene {
97 t.Helper()
98 if r.clk == nil {
99 return s
100 }
101 set, ok := r.clk.settle(t, r.frames, r.scenes, "OUT-1")
102 if !ok {
103 return s
104 }
105 r.lands++
106 return set[0]
107}
108
109// token returns the SlotEnv value of a spawn request.
110func token(t *testing.T, req ports.SpawnRequest) string {
111 t.Helper()
112 for _, e := range req.Env {
113 if v, ok := strings.CutPrefix(e, ports.SlotEnv+"="); ok {
114 return v
115 }
116 }
117 t.Fatalf("no %s in %v", ports.SlotEnv, req.Env)
118 return ""
119}
120
121// noSpawn checks that nothing was spawned. Core sends spawns before it
122// publishes the scene of the same event, so callers wait for that scene first.
123func noSpawn(t *testing.T, ch <-chan ports.SpawnRequest) {
124 t.Helper()
125 select {
126 case v := <-ch:
127 t.Fatalf("unexpected spawn %v", v.Argv)
128 default:
129 }
130}
131
132// press sends Alt+key and waits for the scene it publishes.
133func (r *slotRig) press(t *testing.T, key string) ports.Scene {
134 t.Helper()
135 r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt, Pressed: true}
136 r.input <- ports.KeyEvent{Keysym: key, Mods: ports.ModAlt}
137 return scene(t, r.scenes)
138}
139
140// sync waits for core to handle everything sent so far.
141func (r *slotRig) sync(t *testing.T) ports.Scene {
142 t.Helper()
143 return r.press(t, "Left") // focus-column-left publishes a scene
144}
145
146// fill maps the slot windows 2 (code) and 3 (foot) and waits until core
147// has placed them: client and input events are separate channels.
148func (r *slotRig) fill(t *testing.T) (code, foot ports.SpawnRequest) {
149 t.Helper()
150 code, foot = receive(t, r.spawn), receive(t, r.spawn)
151 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
152 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
153 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
154 return code, foot
155}
156
157// sceneMatch waits for a first-output scene matching ok.
158func sceneMatch(t *testing.T, ch <-chan []ports.Scene, ok func(ports.Scene) bool) ports.Scene {
159 t.Helper()
160 deadline := time.After(time.Second)
161 for {
162 select {
163 case s := <-ch:
164 if ok(s[0]) {
165 return s[0]
166 }
167 case <-deadline:
168 t.Fatal("no matching scene")
169 }
170 }
171}
172
173func visible(s ports.Scene) map[ports.WindowID]ports.Rect {
174 m := map[ports.WindowID]ports.Rect{}
175 for _, w := range s.Windows {
176 if !w.Hidden {
177 m[w.ID] = w.Rect
178 }
179 }
180 return m
181}
182
183// Activating a workspace through the protocol must count as showing it for
184// pending-slot retry, just like switching to it with a key.
185func TestWorkspaceActivateRetriesPendingSlot(t *testing.T) {
186 r := startSlots(t)
187 first, second := receive(t, r.spawn), receive(t, r.spawn)
188 // The channel keeps the latest snapshot: one from before the output
189 // may still be there.
190 initial := receive(t, r.workspaces)
191 for len(initial.Outputs) == 0 {
192 initial = receive(t, r.workspaces)
193 }
194 var dev uint64
195 for _, w := range initial.Outputs[0].Workspaces {
196 if w.Configured == "dev" {
197 dev = w.ID
198 }
199 }
200 if dev == 0 {
201 t.Fatal(initial)
202 }
203 r.client <- ports.WorkspaceActivate{IDs: []uint64{dev}}
204 scene(t, r.scenes)
205 noSpawn(t, r.spawn) // first show marks pending slots stale
206 r.press(t, "d") // leave dev
207 r.client <- ports.WorkspaceActivate{IDs: []uint64{dev}}
208 scene(t, r.scenes)
209 again := receive(t, r.spawn)
210 if token(t, again) == token(t, first) || token(t, again) == token(t, second) {
211 t.Fatal("activation did not refresh the pending slot token")
212 }
213}
214
215func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
216 r := startSlots(t)
217 code, foot := receive(t, r.spawn), receive(t, r.spawn)
218 if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) {
219 t.Fatal(code, foot)
220 }
221 // A normal window on workspace 1 keeps focus while slot windows map.
222 r.client <- ports.WindowMapped{ID: 1}
223 // foot maps first; code's window still goes left of it.
224 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
225 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
226 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
227 for _, w := range s.Windows {
228 if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {
229 t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
230 }
231 }
232 s = r.press(t, "d")
233 got := visible(s)
234 if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
235 t.Fatal(got)
236 }
237 noSpawn(t, r.spawn) // full slots are not respawned
238}
239
240func TestSlotRefilledOnlyWhenShown(t *testing.T) {
241 both(t, func(t *testing.T, animated bool) { slotRefilledOnlyWhenShown(t, startSlotsMode(t, &animated)) })
242}
243
244func slotRefilledOnlyWhenShown(t *testing.T, r *slotRig) {
245 // Wait for both windows: a one-window scene from before the unmap
246 // would let the key below race it.
247 _, foot := r.fill(t)
248 r.client <- ports.WindowUnmapped{ID: 3} // foot exits
249 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
250 noSpawn(t, r.spawn) // nothing relaunches on its own
251 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
252 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
253 again := receive(t, r.spawn)
254 if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
255 t.Fatal(again)
256 }
257 r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
258 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
259 if got := visible(r.land(t, s)); got[4].X != 70 {
260 t.Fatal(got)
261 }
262 // Leaving and showing dev again with full slots spawns nothing.
263 r.press(t, "d")
264 r.press(t, "d")
265 noSpawn(t, r.spawn)
266}
267
268func TestSlotExtraWindowsAreNormal(t *testing.T) {
269 r := startSlots(t)
270 code := receive(t, r.spawn)
271 receive(t, r.spawn)
272 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
273 // A second window of the same process: the slot is taken, normal rules.
274 r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}
275 r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}
276 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
277 if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {
278 t.Fatalf("extra windows not on the workspace on screen: %v", got)
279 }
280}
281
282func TestSlotReload(t *testing.T) {
283 r := startSlots(t)
284 code, foot := receive(t, r.spawn), receive(t, r.spawn)
285 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
286 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
287 cfg := r.cfg
288 ws := cfg.Workspaces[0]
289 ws.Slots = []ports.SlotConfig{
290 {Index: 1, Cells: 5, Argv: []string{"code"}}, // width change only
291 {Index: 2, Cells: 3, Argv: []string{"kitty"}}, // empty slot, new command
292 {Index: 3, Cells: 2, Argv: []string{"btop"}}, // new slot
293 }
294 cfg.Workspaces = []ports.WorkspaceConfig{ws}
295 r.reload <- ports.ConfigChanged{Config: cfg}
296 var argv []string
297 for range 2 {
298 argv = append(argv, receive(t, r.spawn).Argv[0])
299 }
300 slices.Sort(argv)
301 if !slices.Equal(argv, []string{"btop", "kitty"}) {
302 t.Fatal(argv)
303 }
304 scene(t, r.scenes)
305 noSpawn(t, r.spawn)
306 // The old foot spawn no longer fills slot 2 (its command changed): it
307 // opens as a normal window on workspace 1, on screen.
308 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
309 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
310 if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {
311 t.Fatal(got)
312 }
313 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
314 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
315 s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
316 if got := visible(s); got[2].W != 50 {
317 t.Fatal(got)
318 }
319 noSpawn(t, r.spawn) // kitty and btop are still starting
320 // Dropping the workspace's slots spawns nothing and keeps the window.
321 ws.Slots = nil
322 cfg.Workspaces = []ports.WorkspaceConfig{ws}
323 r.reload <- ports.ConfigChanged{Config: cfg}
324 scene(t, r.scenes)
325 noSpawn(t, r.spawn)
326}
327
328func TestInvalidSlotWidthRejected(t *testing.T) {
329 r := startSlots(t)
330 receive(t, r.spawn)
331 receive(t, r.spawn)
332 cfg := r.cfg
333 ws := cfg.Workspaces[0]
334 ws.Slots = []ports.SlotConfig{{Index: 1, Cells: 17, Argv: []string{"x"}}}
335 cfg.Workspaces = []ports.WorkspaceConfig{ws}
336 r.reload <- ports.ConfigChanged{Config: cfg}
337 if err := receive(t, r.errs); err == nil {
338 t.Fatal("no error")
339 }
340 r.sync(t)
341 noSpawn(t, r.spawn)
342}
343
344// Reloading while the slot workspace is on screen spawns a new slot once.
345func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
346 r := startSlots(t)
347 r.fill(t)
348 r.press(t, "d")
349 cfg := r.cfg
350 ws := cfg.Workspaces[0]
351 ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Cells: 2, Argv: []string{"btop"}})
352 cfg.Workspaces = []ports.WorkspaceConfig{ws}
353 r.reload <- ports.ConfigChanged{Config: cfg}
354 if v := receive(t, r.spawn); v.Argv[0] != "btop" {
355 t.Fatal(v)
356 }
357 scene(t, r.scenes)
358 noSpawn(t, r.spawn)
359}
360
361// Binds on the workspace on screen do not refill its empty slots; only
362// showing it again does.
363func TestSlotNotRefilledByOtherBinds(t *testing.T) {
364 r := startSlots(t)
365 r.fill(t)
366 r.press(t, "d")
367 r.client <- ports.WindowUnmapped{ID: 3}
368 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
369 r.sync(t)
370 noSpawn(t, r.spawn)
371 r.press(t, "d")
372 r.press(t, "d")
373 if v := receive(t, r.spawn); v.Argv[0] != "foot" {
374 t.Fatal(v)
375 }
376}
377
378// After a slot window closes, another window with the old token (a child
379// of the slot app) opens as a normal window, not in the empty slot.
380func TestSlotOldTokenIsNormal(t *testing.T) {
381 r := startSlots(t)
382 _, foot := r.fill(t)
383 r.client <- ports.WindowUnmapped{ID: 3}
384 r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}
385 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
386 if got := visible(s); got[9] == (ports.Rect{}) {
387 t.Fatalf("old-token window hidden in the slot: %v", got)
388 }
389}
390
391// A slot whose command never maps a window gets one show to do it; on the
392// next show it is respawned and its old token no longer fills it.
393func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
394 both(t, func(t *testing.T, animated bool) { pendingSlotRespawnedOnSecondShow(t, startSlotsMode(t, &animated)) })
395}
396
397func pendingSlotRespawnedOnSecondShow(t *testing.T, r *slotRig) {
398 code, foot := receive(t, r.spawn), receive(t, r.spawn)
399 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
400 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
401 r.press(t, "d") // foot still starting: not respawned
402 noSpawn(t, r.spawn)
403 r.press(t, "d")
404 r.press(t, "d")
405 again := receive(t, r.spawn)
406 if again.Argv[0] != "foot" || token(t, again) == token(t, foot) {
407 t.Fatal(again)
408 }
409 r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}
410 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
411 if got := visible(s); got[7] == (ports.Rect{}) {
412 t.Fatalf("stale-token window not placed as a normal one: %v", got)
413 }
414 r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
415 s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
416 if got := visible(r.land(t, s)); got[8].W != 30 {
417 t.Fatalf("respawned window not in its slot: %v", got)
418 }
419}
420
421// Moving a slot window to another workspace releases the slot; showing the
422// workspace again refills it.
423// Startup commands are spawned once, before the slots.
424func TestStartupCommands(t *testing.T) {
425 cfg := scrollDefaults()
426 cfg.Startup = [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"waybar"}}
427 spawn := make(chan ports.SpawnRequest, 4)
428 c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: make(chan ports.InputEvent), Output: make(chan ports.OutputEvent), Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: make(chan []ports.Scene, 1), ConfigErrors: make(chan error, 1)}, core.Options{})
429 if err != nil {
430 t.Fatal(err)
431 }
432 ctx, cancel := context.WithCancel(context.Background())
433 done := make(chan error, 1)
434 go func() { done <- c.Run(ctx) }()
435 defer func() { cancel(); <-done }()
436 for _, want := range cfg.Startup {
437 if got := receive(t, spawn); !slices.Equal(got.Argv, want) {
438 t.Fatalf("spawned %q, want %q", got.Argv, want)
439 }
440 }
441}
442
443// A spawn bind with several commands sends each one, in order.
444func TestSpawnBindSeveralCommands(t *testing.T) {
445 cfg := altCmdDefaults()
446 cfg.Terminal.AutoOpen = "off"
447 cfg.Binds["Alt+p"] = "spawn grim -g x; notify-send done"
448 input := make(chan ports.InputEvent, 4)
449 output := make(chan ports.OutputEvent, 1)
450 scenes := make(chan []ports.Scene, 1)
451 spawn := make(chan ports.SpawnRequest, 4)
452 c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: input, Output: output, Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: scenes, ConfigErrors: make(chan error, 1)}, core.Options{})
453 if err != nil {
454 t.Fatal(err)
455 }
456 ctx, cancel := context.WithCancel(context.Background())
457 done := make(chan error, 1)
458 go func() { done <- c.Run(ctx) }()
459 defer func() { cancel(); <-done }()
460 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
461 scene(t, scenes)
462 input <- ports.KeyEvent{Keysym: "p", Mods: ports.ModAlt, Pressed: true}
463 for _, want := range [][]string{{"grim", "-g", "x"}, {"notify-send", "done"}} {
464 if got := receive(t, spawn); !slices.Equal(got.Argv, want) {
465 t.Fatalf("spawned %q, want %q", got.Argv, want)
466 }
467 }
468}
469
470// A fullscreen slot window (fixed overflow) keeps its slot: coming back to
471// dev spawns no duplicate.
472func TestSlotKeptWhileFullscreen(t *testing.T) {
473 r := startSlots(t, true, true)
474 r.fill(t)
475 r.press(t, "d") // on dev, focus on code (slot 1)
476 r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
477 r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift}
478 scene(t, r.scenes)
479 r.press(t, "d") // leave and show dev while code is away
480 r.press(t, "d")
481 noSpawn(t, r.spawn)
482 // code opens a second window while fullscreen: the slot stays.
483 r.client <- ports.WindowMapped{ID: 9}
484 scene(t, r.scenes)
485 r.press(t, "d")
486 r.press(t, "d")
487 noSpawn(t, r.spawn)
488}
489
490// Expelling a slot window from a stacked column keeps one column per slot:
491// the slot and its width follow the window, and nothing is respawned.
492func TestSlotFollowsExpelledWindow(t *testing.T) {
493 r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
494 r.fill(t)
495 r.press(t, "d") // dev: [code] [foot], focus on code
496 r.client <- ports.WindowMapped{ID: 9}
497 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
498 r.press(t, "bracketleft") // 9 joins code: [code 9] [foot]
499 r.press(t, "Up") // focus code, the slot window
500 r.press(t, "bracketright") // code leaves: [9] [code] [foot]
501 r.press(t, "d")
502 s := r.press(t, "d") // shown again: no slot is empty
503 noSpawn(t, r.spawn)
504 if got := visible(s); got[2].W != 70 {
505 t.Fatalf("slots: %v", got)
506 }
507}
508
509// A floating child keeps the pending claim for the first ordinary window.
510func TestFloatingSlotTokenDoesNotClaim(t *testing.T) {
511 r := startSlots(t)
512 code, _ := receive(t, r.spawn), receive(t, r.spawn)
513 r.client <- ports.WindowMapped{ID: 10, Slot: token(t, code), Floating: true, Width: 20, Height: 20}
514 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
515 r.client <- ports.WindowMapped{ID: 11, Slot: token(t, code)}
516 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
517 s := r.press(t, "d")
518 if got := visible(s); got[11].W != 70 {
519 t.Fatalf("floating child consumed slot claim: %v", got)
520 }
521 r.client <- ports.WindowMapped{ID: 12, Slot: token(t, code)}
522 s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
523 if got := visible(s); got[12].W == 70 {
524 t.Fatalf("duplicate token claimed slot: %v", got)
525 }
526}