feat/data-dnd e05cb15

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 sent
44// before the spawn can reach the launcher. In a synctest bubble the worker
45// is waited on until it blocks sending the flag on the unread command
46// channel: the spawn channel must still be empty then.
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		done := make(chan error, 1)
56		go func() { done <- c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"firefox"}}, false) }()
57		synctest.Wait()
58		if len(spawn) != 0 {
59			t.Fatal("spawned before the pending flag was delivered")
60		}
61		select {
62		case err := <-done:
63			t.Fatalf("spawnStash returned before the flag was read: %v", err)
64		default:
65		}
66		if p, ok := (<-commands).(ports.SlotsPending); !ok || !p.Pending {
67			t.Fatal("first command is not SlotsPending{true}")
68		}
69		synctest.Wait()
70		if err := <-done; err != nil {
71			t.Fatal(err)
72		}
73		if len(spawn) != 1 {
74			t.Fatal("no spawn after the flag")
75		}
76	})
77}
78
79// A send the launcher queue suppresses leaves no claim behind, and the
80// pending flag falls again at the next publish.
81func TestSpawnStashSuppressedLeavesNoClaim(t *testing.T) {
82	now := claimBase
83	commands := make(chan ports.ClientCommand, 8)
84	c := stashSpawnCore(t, commands, nil, fixedClock(t, &now)) // no launcher queue: every send is suppressed
85	if err := c.spawnStash(context.Background(), c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false); err != nil {
86		t.Fatal(err)
87	}
88	if c.placement.anyPending() {
89		t.Fatal("suppressed claims stayed pending")
90	}
91	if v := (<-commands).(ports.SlotsPending); !v.Pending {
92		t.Fatal("pending not announced")
93	}
94	if err := c.publishPending(context.Background()); err != nil {
95		t.Fatal(err)
96	}
97	if v := (<-commands).(ports.SlotsPending); v.Pending {
98		t.Fatal("stale pending flag not republished")
99	}
100}
101
102// From the first command that is not issued, the rest are not sent and their
103// claims are cancelled; the issued ones keep theirs.
104func TestSpawnStashCancelsClaimsFromFirstUnissued(t *testing.T) {
105	for _, tc := range []struct {
106		name      string
107		queue     int
108		cancelled bool
109		issued    int
110		wantErr   error
111	}{
112		{"queue of one", 1, false, 1, nil},
113		{"publication fails", 4, true, 0, context.Canceled},
114	} {
115		t.Run(tc.name, func(t *testing.T) {
116			now := claimBase
117			commands := make(chan ports.ClientCommand) // unread: a send blocks until the context ends
118			spawn := make(chan ports.SpawnRequest, tc.queue)
119			c := stashSpawnCore(t, commands, spawn, fixedClock(t, &now))
120			ctx, cancel := context.WithCancel(context.Background())
121			t.Cleanup(cancel)
122			if tc.cancelled {
123				cancel()
124			} else {
125				// An unread unbuffered channel would block the flag: read it.
126				go func() {
127					select {
128					case <-commands:
129					case <-ctx.Done():
130					}
131				}()
132			}
133			err := c.spawnStash(ctx, c.cur().mon.Current(), [][]string{{"a"}, {"b"}}, false)
134			if !errors.Is(err, tc.wantErr) {
135				t.Fatalf("error %v, want %v", err, tc.wantErr)
136			}
137			if len(spawn) != tc.issued {
138				t.Fatalf("%d spawns, want %d", len(spawn), tc.issued)
139			}
140			if len(c.placement.pending) != tc.issued {
141				t.Fatalf("%d claims kept, want %d", len(c.placement.pending), tc.issued)
142			}
143			if tc.issued == 1 {
144				if _, ok := c.placement.pending[claimToken(<-spawn)]; !ok {
145					t.Fatal("the issued command lost its claim")
146				}
147			}
148		})
149	}
150}
151
152// A claim is placed up to termRetry after it was made, and not a moment
153// later, even if no publish ran between its age and the window mapping.
154func TestStashClaimExpiry(t *testing.T) {
155	for _, tc := range []struct {
156		name   string
157		age    time.Duration
158		placed bool
159	}{
160		{"fresh", 0, true},
161		{"at the limit", termRetry, true},
162		{"just after", termRetry + time.Nanosecond, false},
163	} {
164		t.Run(tc.name, func(t *testing.T) {
165			now := claimBase
166			c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
167			m := c.cur().mon
168			m.AddWindow(1)
169			target := m.Current()
170			m.show(m.Workspaces[1])
171			req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: target, at: now})
172			now = claimBase.Add(tc.age)
173			c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
174			_, w := c.screenOf(5)
175			if placed := w == target && len(target.Stash) == 1; placed != tc.placed {
176				t.Fatalf("placed %t, want %t", placed, tc.placed)
177			}
178		})
179	}
180}
181
182// A workspace that left every screen no longer takes the window.
183func TestStashClaimWithoutWorkspaceMapsNormally(t *testing.T) {
184	now := claimBase
185	c := stashSpawnCore(t, nil, nil, fixedClock(t, &now))
186	req := c.placement.request([]string{"firefox"}, spawnTarget{kind: spawnStash, ws: &Workspace{}, at: now})
187	c.placement.place(c, ports.WindowMapped{ID: 5, Slot: claimToken(req)})
188	if _, w := c.screenOf(5); w != c.cur().mon.Current() {
189		t.Fatal("window did not map onto the current workspace")
190	}
191	if c.placement.anyPending() {
192		t.Fatal("claim kept")
193	}
194}
195
196// A window opened by a stash spawn is shown, selected and focused in the
197// stash whatever fullscreen state the workspace and the stash are in.
198func TestStashSpawnedAlwaysShown(t *testing.T) {
199	// base has tile 1 and stash [2 3], 3 selected, the stash shown.
200	base := func() *Workspace {
201		w := workspace()
202		w.AddWindow(1)
203		w.AddWindow(2)
204		w.AddWindow(3)
205		w.FocusID(2)
206		w.ToggleWindowStash()
207		w.FocusID(3)
208		w.ToggleWindowStash()
209		return w
210	}
211	shown := func(t *testing.T, w *Workspace) {
212		t.Helper()
213		if id, _ := w.Focused(); id != 5 {
214			t.Fatalf("focused %d, want 5", id)
215		}
216		if p := previewOf(t, w.Layout(), 5); p.Hidden || !p.Focused {
217			t.Fatalf("new window %+v", p)
218		}
219		if w.Stash[w.stashAt].ID != 5 {
220			t.Fatalf("selected %d", w.Stash[w.stashAt].ID)
221		}
222	}
223
224	t.Run("covering tile outside the stash", func(t *testing.T) {
225		w := base()
226		w.FocusID(1)
227		w.ToggleFullscreen()
228		w.stashSpawned(5)
229		shown(t, w)
230		if w.cover() != 1 || !w.stashOverCover() {
231			t.Fatalf("cover %d: the tile must stay fullscreen under the stash", w.cover())
232		}
233		if p := previewOf(t, w.Layout(), 1); !p.Fullscreen || p.Hidden {
234			t.Fatalf("tile %+v", p)
235		}
236		w.ToggleStashVisible()
237		if id, _ := w.Focused(); id != 1 || w.cover() != 1 {
238			t.Fatalf("hidden stash: focused %d cover %d, want the tile", id, w.cover())
239		}
240	})
241
242	t.Run("fullscreen stash member", func(t *testing.T) {
243		w := base()
244		w.ToggleFullscreen() // 3
245		if w.cover() != 3 {
246			t.Fatalf("setup: cover %d", w.cover())
247		}
248		w.stashSpawned(5)
249		shown(t, w)
250		if w.cover() != 0 {
251			t.Fatalf("cover %d: the stash member still covers", w.cover())
252		}
253		if p := previewOf(t, w.Layout(), 3); p.Fullscreen {
254			t.Fatalf("old member %+v", p)
255		}
256	})
257
258	t.Run("fullscreen waiting for the stash", func(t *testing.T) {
259		w := base()
260		w.ToggleStashVisible() // hidden
261		w.SetFullscreen(3, true)
262		if w.hiddenFullscreen != 3 {
263			t.Fatalf("setup: deferred %d", w.hiddenFullscreen)
264		}
265		w.stashSpawned(5)
266		shown(t, w)
267		if w.cover() != 0 || w.hiddenFullscreen != 0 {
268			t.Fatalf("cover %d, deferred %d", w.cover(), w.hiddenFullscreen)
269		}
270		w.ToggleStashVisible()
271		w.ToggleStashVisible()
272		shown(t, w)
273	})
274}

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