v0.3.0 cf370a0

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}
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}

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 25

Tags 10

↑↓ moveenter openesc close