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