slots_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close