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