feat/hints a471fa4

stash_spawn_internal_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"errors"
6	"strings"
7	"testing"
8	"testing/synctest"
9	"time"
10
11	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12	"github.com/bnema/neferwl/internal/ports"
13)
14
15// claimBase is the instant of the fixed test clock.
16var claimBase = time.Unix(1000, 0)
17
18// fixedClock is a mock clock at *now, which a test moves by assigning.
19func fixedClock(t *testing.T, now *time.Time) ports.Clock {
20	t.Helper()
21	clock := portsmocks.NewMockClock(t)
22	clock.EXPECT().Now().RunAndReturn(func() time.Time { return *now }).Maybe()
23	return clock
24}
25
26func stashSpawnCore(t *testing.T, commands chan ports.ClientCommand, spawn chan ports.SpawnRequest, clock ports.Clock) *Core {
27	t.Helper()
28	var cfg ports.Config
29	cfg.Keyboard.CmdKey = "alt"
30	cfg.Layout.MaxColumns = 2
31	c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands, Spawn: spawn}, Options{Clock: clock})
32	if err != nil {
33		t.Fatal(err)
34	}
35	c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
36	return c
37}
38
39func claimToken(req ports.SpawnRequest) string {
40	return strings.TrimPrefix(req.Env[0], ports.SlotEnv+"=")
41}
42
43// Wayland reads tokens only while slots are pending, so the flag is in the
44// command channel before the spawn can reach the launcher, also when the
45// display reads nothing: in a synctest bubble the sender is waited on until
46// it blocks on the unread channel, and the spawn channel must still be empty.
47func TestSpawnStashPublishesPendingBeforeSpawn(t *testing.T) {
48	synctest.Test(t, func(t *testing.T) {
49		now := claimBase
50		commands := make(chan ports.ClientCommand)
51		spawn := make(chan ports.SpawnRequest, 1)
52		c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
53		ctx, cancel := context.WithCancel(context.Background())
54		t.Cleanup(cancel)
55		c.out.start()
56		go c.out.run(ctx, commands)
57		done := make(chan error, 1)
58		go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
59		synctest.Wait()
60		if len(spawn) != 0 {
61			t.Fatal("spawned before the pending flag was delivered")
62		}
63		select {
64		case err := <-done:
65			t.Fatalf("spawnStash returned before the flag was read: %v", err)
66		default:
67		}
68		if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
69			t.Fatal("first command is not SlotsPending{true}")
70		}
71		synctest.Wait()
72		if err := <-done; err != nil {
73			t.Fatal(err)
74		}
75		if len(spawn) != 1 {
76			t.Fatal("no spawn after the flag")
77		}
78	})
79}
80
81// A send the launcher queue suppresses leaves no claim behind, and the
82// pending flag falls again at the next publish.
83func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
84	now := claimBase
85	commands := make(chan ports.ClientCommand, 8)
86	c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed
87	if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
88		t.Fatal(err)
89	}
90	if c.placement.anyPending() {
91		t.Fatal("suppressed claims stayed pending")
92	}
93	if v := (<-commands).(ports.SlotsPending); !v.Pending {
94		t.Fatal("pending not announced")
95	}
96	if err := c.publishPending(context.Background()); err != nil {
97		t.Fatal(err)
98	}
99	if v := (<-commands).(ports.SlotsPending); v.Pending {
100		t.Fatal("stale pending flag not republished")
101	}
102}
103
104// From the first command that is not issued, the rest are not sent and their
105// claims are cancelled; the issued ones keep theirs.
106func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
107	for _, tc := range []struct {
108		name      string
109		queue     int
110		cancelled bool
111		issued    int
112		wantErr   error
113	}{
114		{"queue of one", 1, false, 1, nil},
115		{"publication fails", 4, true, 0, context.Canceled},
116	} {
117		t.Run(tc.name, func(t *testing.T) {
118			now := claimBase
119			commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
120			spawn := make(chan ports.SpawnRequest, tc.queue)
121			c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
122			ctx, cancel := context.WithCancel(context.Background())
123			t.Cleanup(cancel)
124			if tc.cancelled {
125				cancel()
126			} else {
127				// An unread unbuffered channel would block the flag: read it.
128				go func() {
129					select {
130					case <-commands:
131					case <-ctx.Done():
132					}
133				}()
134			}
135			err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
136			if !errors.Is(err, tc.wantErr) {
137				t.Fatalf("error %v, want %v", err, tc.wantErr)
138			}
139			if len(spawn) != tc.issued {
140				t.Fatalf("%d spawns, want %d", len(spawn), tc.issued)
141			}
142			if len(c.placement.pending) != tc.issued {
143				t.Fatalf("%d claims kept, want %d", len(c.placement.pending), tc.issued)
144			}
145			if tc.issued == 1 {
146				if _, ok := c.placement.pending[claimToken(<-spawn)]; !ok {
147					t.Fatal("the issued command lost its claim")
148				}
149			}
150		})
151	}
152}
153
154// A claim is placed up to termRetry after it was made, and not a moment
155// later, even if no publish ran between its age and the window mapping.
156func TestStashClaimExpiry(t *testing.T) {
157	for _, tc := range []struct {
158		name   string
159		age    time.Duration
160		placed bool
161	}{
162		{"fresh", 0, true},
163		{"at the limit", termRetry, true},
164		{"just after", termRetry + time.Nanosecond, false},
165	} {
166		t.Run(tc.name, func(t *testing.T) {
167			now := claimBase
168			c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
169			m := c.cur().mon
170			m.AddWindow(1)
171			target := m.Current()
172			m.show(m.Workspaces[1])
173			req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: now})
174			now = claimBase.Add(tc.age)
175			c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
176			_, w := c.screenOf(5)
177			if placed := w == target && len(target.Stash) == 1; placed != tc.placed {
178				t.Fatalf("placed %t, want %t", placed, tc.placed)
179			}
180		})
181	}
182}
183
184// A workspace that left every screen no longer takes the window.
185func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
186	now := claimBase
187	c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
188	req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now})
189	c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
190	if _, w := c.screenOf(5); w != c.cur().mon.Current() {
191		t.Fatal("window did not map onto the current workspace")
192	}
193	if c.placement.anyPending() {
194		t.Fatal("claim kept")
195	}
196}
197
198// A window opened by a stash spawn is shown, selected and focused in the
199// stash whatever fullscreen state the workspace and the stash are in.
200func TestStashSpawnedAlwaysShown(t *testing.T) {
201	// base has tile 1 and stash [2 3], 3 selected, the stash shown.
202	base := func() *Workspace {
203		w := workspace()
204		w.AddWindow(1)
205		w.AddWindow(2)
206		w.AddWindow(3)
207		w.FocusID(2)
208		w.ToggleWindowStash()
209		w.FocusID(3)
210		w.ToggleWindowStash()
211		return w
212	}
213	shown := func(t *testing.T, w *Workspace) {
214		t.Helper()
215		if id, _ := w.Focused(); id != 5 {
216			t.Fatalf("focused %d, want 5", id)
217		}
218		if p := previewOf(t, w.Layout(), 5); p.Hidden || !p.Focused {
219			t.Fatalf("new window %+v", p)
220		}
221		if w.Stash[w.stashAt].ID != 5 {
222			t.Fatalf("selected %d", w.Stash[w.stashAt].ID)
223		}
224	}
225
226	t.Run("covering tile outside the stash", func(t *testing.T) {
227		w := base()
228		w.FocusID(1)
229		w.ToggleFullscreen()
230		w.stashSpawned(5)
231		shown(t, w)
232		if w.cover() != 1 || !w.stashOverCover() {
233			t.Fatalf("cover %d: the tile must stay fullscreen under the stash", w.cover())
234		}
235		if p := previewOf(t, w.Layout(), 1); !p.Fullscreen || p.Hidden {
236			t.Fatalf("tile %+v", p)
237		}
238		w.ToggleStashVisible()
239		if id, _ := w.Focused(); id != 1 || w.cover() != 1 {
240			t.Fatalf("hidden stash: focused %d cover %d, want the tile", id, w.cover())
241		}
242	})
243
244	t.Run("fullscreen stash member", func(t *testing.T) {
245		w := base()
246		w.ToggleFullscreen() // 3
247		if w.cover() != 3 {
248			t.Fatalf("setup: cover %d", w.cover())
249		}
250		w.stashSpawned(5)
251		shown(t, w)
252		if w.cover() != 0 {
253			t.Fatalf("cover %d: the stash member still covers", w.cover())
254		}
255		if p := previewOf(t, w.Layout(), 3); p.Fullscreen {
256			t.Fatalf("old member %+v", p)
257		}
258	})
259
260	t.Run("fullscreen waiting for the stash", func(t *testing.T) {
261		w := base()
262		w.ToggleStashVisible() // hidden
263		w.SetFullscreen(3, true)
264		if w.hiddenFullscreen != 3 {
265			t.Fatalf("setup: deferred %d", w.hiddenFullscreen)
266		}
267		w.stashSpawned(5)
268		shown(t, w)
269		if w.cover() != 0 || w.hiddenFullscreen != 0 {
270			t.Fatalf("cover %d, deferred %d", w.cover(), w.hiddenFullscreen)
271		}
272		w.ToggleStashVisible()
273		w.ToggleStashVisible()
274		shown(t, w)
275	})
276}

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