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