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 errs chan error
22 cfg ports.Config
23}
24
25// startSlots runs core with a hidden "dev" workspace of two slots, toggled
26// by Alt+d, on a 100x80 output. One flag makes dev numbered, two also set
27// fixed overflow.
28func startSlots(t *testing.T, numbered ...bool) *slotRig {
29 t.Helper()
30 cfg := config.Defaults()
31 cfg.Keyboard.CmdKey = "alt"
32 cfg.Border.Width = 0
33 cfg.Workspaces = []ports.WorkspaceConfig{{Name: "dev", Hidden: len(numbered) == 0, 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), 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, 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
139func TestSlotsSpawnAtStartAndFillInOrder(t *testing.T) {
140 r := startSlots(t)
141 code, foot := receive(t, r.spawn), receive(t, r.spawn)
142 if !slices.Equal(code.Argv, []string{"code"}) || !slices.Equal(foot.Argv, []string{"foot"}) || token(t, code) == token(t, foot) {
143 t.Fatal(code, foot)
144 }
145 // A normal window on workspace 1 keeps focus while slot windows map.
146 r.client <- ports.WindowMapped{ID: 1}
147 // foot maps first; code's window still goes left of it.
148 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
149 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
150 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
151 for _, w := range s.Windows {
152 if w.ID != 1 && !w.Hidden || w.ID == 1 && !w.Focused {
153 t.Fatalf("slot window on screen or focus moved: %+v", s.Windows)
154 }
155 }
156 s = r.press(t, "d")
157 got := visible(s)
158 if got[2].X != 0 || got[2].W != 70 || got[3].X != 70 || got[3].W != 30 {
159 t.Fatal(got)
160 }
161 noSpawn(t, r.spawn) // full slots are not respawned
162}
163
164func TestSlotRefilledOnlyWhenShown(t *testing.T) {
165 r := startSlots(t)
166 // Wait for both windows: a one-window scene from before the unmap
167 // would let the key below race it.
168 _, foot := r.fill(t)
169 r.client <- ports.WindowUnmapped{ID: 3} // foot exits
170 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
171 noSpawn(t, r.spawn) // nothing relaunches on its own
172 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
173 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
174 again := receive(t, r.spawn)
175 if !slices.Equal(again.Argv, []string{"foot"}) || token(t, again) == token(t, foot) {
176 t.Fatal(again)
177 }
178 r.client <- ports.WindowMapped{ID: 4, Slot: token(t, again)}
179 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 2 })
180 if got := visible(s); got[4].X != 70 {
181 t.Fatal(got)
182 }
183 // Leaving and showing dev again with full slots spawns nothing.
184 r.press(t, "d")
185 r.press(t, "d")
186 noSpawn(t, r.spawn)
187}
188
189func TestSlotExtraWindowsAreNormal(t *testing.T) {
190 r := startSlots(t)
191 code := receive(t, r.spawn)
192 receive(t, r.spawn)
193 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
194 // A second window of the same process: the slot is taken, normal rules.
195 r.client <- ports.WindowMapped{ID: 5, Slot: token(t, code)}
196 r.client <- ports.WindowMapped{ID: 6, Slot: "unknown"}
197 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
198 if got := visible(s); len(got) != 2 || got[5] == (ports.Rect{}) || got[6] == (ports.Rect{}) {
199 t.Fatalf("extra windows not on the workspace on screen: %v", got)
200 }
201}
202
203func TestSlotReload(t *testing.T) {
204 r := startSlots(t)
205 code, foot := receive(t, r.spawn), receive(t, r.spawn)
206 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
207 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
208 cfg := r.cfg
209 ws := cfg.Workspaces[0]
210 ws.Slots = []ports.SlotConfig{
211 {Index: 1, Width: "50%", Argv: []string{"code"}}, // width change only
212 {Index: 2, Width: "30%", Argv: []string{"kitty"}}, // empty slot, new command
213 {Index: 3, Width: "20%", Argv: []string{"btop"}}, // new slot
214 }
215 cfg.Workspaces = []ports.WorkspaceConfig{ws}
216 r.reload <- ports.ConfigChanged{Config: cfg}
217 var argv []string
218 for range 2 {
219 argv = append(argv, receive(t, r.spawn).Argv[0])
220 }
221 slices.Sort(argv)
222 if !slices.Equal(argv, []string{"btop", "kitty"}) {
223 t.Fatal(argv)
224 }
225 scene(t, r.scenes)
226 noSpawn(t, r.spawn)
227 // The old foot spawn no longer fills slot 2 (its command changed): it
228 // opens as a normal window on workspace 1, on screen.
229 r.client <- ports.WindowMapped{ID: 3, Slot: token(t, foot)}
230 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
231 if got := visible(s); len(got) != 1 || got[3] == (ports.Rect{}) {
232 t.Fatal(got)
233 }
234 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt, Pressed: true}
235 r.input <- ports.KeyEvent{Keysym: "d", Mods: ports.ModAlt}
236 s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
237 if got := visible(s); got[2].W != 50 {
238 t.Fatal(got)
239 }
240 noSpawn(t, r.spawn) // kitty and btop are still starting
241 // Dropping the workspace's slots spawns nothing and keeps the window.
242 ws.Slots = nil
243 cfg.Workspaces = []ports.WorkspaceConfig{ws}
244 r.reload <- ports.ConfigChanged{Config: cfg}
245 scene(t, r.scenes)
246 noSpawn(t, r.spawn)
247}
248
249func TestInvalidSlotWidthRejected(t *testing.T) {
250 r := startSlots(t)
251 receive(t, r.spawn)
252 receive(t, r.spawn)
253 cfg := r.cfg
254 ws := cfg.Workspaces[0]
255 ws.Slots = []ports.SlotConfig{{Index: 1, Width: "wide", Argv: []string{"x"}}}
256 cfg.Workspaces = []ports.WorkspaceConfig{ws}
257 r.reload <- ports.ConfigChanged{Config: cfg}
258 if err := receive(t, r.errs); err == nil {
259 t.Fatal("no error")
260 }
261 r.sync(t)
262 noSpawn(t, r.spawn)
263}
264
265// Reloading while the slot workspace is on screen spawns a new slot once.
266func TestSlotReloadWhileShownSpawnsOnce(t *testing.T) {
267 r := startSlots(t)
268 r.fill(t)
269 r.press(t, "d")
270 cfg := r.cfg
271 ws := cfg.Workspaces[0]
272 ws.Slots = append(ws.Slots, ports.SlotConfig{Index: 3, Width: "20%", Argv: []string{"btop"}})
273 cfg.Workspaces = []ports.WorkspaceConfig{ws}
274 r.reload <- ports.ConfigChanged{Config: cfg}
275 if v := receive(t, r.spawn); v.Argv[0] != "btop" {
276 t.Fatal(v)
277 }
278 scene(t, r.scenes)
279 noSpawn(t, r.spawn)
280}
281
282// Binds on the workspace on screen do not refill its empty slots; only
283// showing it again does.
284func TestSlotNotRefilledByOtherBinds(t *testing.T) {
285 r := startSlots(t)
286 r.fill(t)
287 r.press(t, "d")
288 r.client <- ports.WindowUnmapped{ID: 3}
289 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
290 r.sync(t)
291 noSpawn(t, r.spawn)
292 r.press(t, "d")
293 r.press(t, "d")
294 if v := receive(t, r.spawn); v.Argv[0] != "foot" {
295 t.Fatal(v)
296 }
297}
298
299// After a slot window closes, another window with the old token (a child
300// of the slot app) opens as a normal window, not in the empty slot.
301func TestSlotOldTokenIsNormal(t *testing.T) {
302 r := startSlots(t)
303 _, foot := r.fill(t)
304 r.client <- ports.WindowUnmapped{ID: 3}
305 r.client <- ports.WindowMapped{ID: 9, Slot: token(t, foot)}
306 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(visible(s)) == 1 })
307 if got := visible(s); got[9] == (ports.Rect{}) {
308 t.Fatalf("old-token window hidden in the slot: %v", got)
309 }
310}
311
312// A slot whose command never maps a window gets one show to do it; on the
313// next show it is respawned and its old token no longer fills it.
314func TestPendingSlotRespawnedOnSecondShow(t *testing.T) {
315 r := startSlots(t)
316 code, foot := receive(t, r.spawn), receive(t, r.spawn)
317 r.client <- ports.WindowMapped{ID: 2, Slot: token(t, code)}
318 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 1 })
319 r.press(t, "d") // foot still starting: not respawned
320 noSpawn(t, r.spawn)
321 r.press(t, "d")
322 r.press(t, "d")
323 again := receive(t, r.spawn)
324 if again.Argv[0] != "foot" || token(t, again) == token(t, foot) {
325 t.Fatal(again)
326 }
327 r.client <- ports.WindowMapped{ID: 7, Slot: token(t, foot)}
328 s := sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 2 })
329 if got := visible(s); got[7] == (ports.Rect{}) {
330 t.Fatalf("stale-token window not placed as a normal one: %v", got)
331 }
332 r.client <- ports.WindowMapped{ID: 8, Slot: token(t, again)}
333 s = sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
334 if got := visible(s); got[8].W != 30 {
335 t.Fatalf("respawned window not in its slot: %v", got)
336 }
337}
338
339// Moving a slot window to another workspace releases the slot; showing the
340// workspace again refills it.
341func TestSlotReleasedWhenWindowMoved(t *testing.T) {
342 r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
343 r.fill(t)
344 r.press(t, "d") // on dev, focus on code (slot 1)
345 r.input <- ports.KeyEvent{Keysym: "Prior", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
346 scene(t, r.scenes) // move-window-to-workspace-up: code goes to workspace 1
347 noSpawn(t, r.spawn)
348 r.press(t, "d") // back to workspace 1
349 r.press(t, "d") // dev again: slot 1 is empty
350 if v := receive(t, r.spawn); v.Argv[0] != "code" {
351 t.Fatal(v)
352 }
353}
354
355// Startup commands are spawned once, before the slots.
356func TestStartupCommands(t *testing.T) {
357 cfg := config.Defaults()
358 cfg.Startup = [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"waybar"}}
359 spawn := make(chan ports.SpawnRequest, 4)
360 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)})
361 if err != nil {
362 t.Fatal(err)
363 }
364 ctx, cancel := context.WithCancel(context.Background())
365 done := make(chan error, 1)
366 go func() { done <- c.Run(ctx) }()
367 defer func() { cancel(); <-done }()
368 for _, want := range cfg.Startup {
369 if got := receive(t, spawn); !slices.Equal(got.Argv, want) {
370 t.Fatalf("spawned %q, want %q", got.Argv, want)
371 }
372 }
373}
374
375// A slot window in its own fullscreen workspace (fixed overflow) keeps its
376// slot: coming back to dev spawns no duplicate.
377func TestSlotKeptWhileFullscreenAway(t *testing.T) {
378 r := startSlots(t, true, true)
379 r.fill(t)
380 r.press(t, "d") // on dev, focus on code (slot 1)
381 r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift, Pressed: true}
382 r.input <- ports.KeyEvent{Keysym: "f", Mods: ports.ModAlt | ports.ModShift}
383 scene(t, r.scenes)
384 r.press(t, "d") // leave and show dev while code is away
385 r.press(t, "d")
386 noSpawn(t, r.spawn)
387 // code opens a second window while fullscreen: the slot stays.
388 r.client <- ports.WindowMapped{ID: 9}
389 scene(t, r.scenes)
390 r.press(t, "d")
391 r.press(t, "d")
392 noSpawn(t, r.spawn)
393}
394
395// Expelling a slot window from a stacked column keeps one column per slot:
396// the slot and its width follow the window, and nothing is respawned.
397func TestSlotFollowsExpelledWindow(t *testing.T) {
398 r := startSlots(t, true) // dev is numbered: [1] [dev] [empty]
399 r.fill(t)
400 r.press(t, "d") // dev: [code] [foot], focus on code
401 r.client <- ports.WindowMapped{ID: 9}
402 sceneMatch(t, r.scenes, func(s ports.Scene) bool { return len(s.Windows) == 3 })
403 r.press(t, "bracketleft") // 9 joins code: [code 9] [foot]
404 r.press(t, "Up") // focus code, the slot window
405 r.press(t, "bracketright") // code leaves: [9] [code] [foot]
406 r.press(t, "d")
407 s := r.press(t, "d") // shown again: no slot is empty
408 noSpawn(t, r.spawn)
409 if got := visible(s); got[2].W != 70 {
410 t.Fatalf("slots: %v", got)
411 }
412}