v0.4.0 b6be3a8

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

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