main f2d166b

stash_spawn_internal_test.go RawHistoryFolder

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

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