v0.4.0 b6be3a8

capindicator_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
9	"github.com/bnema/neferwl/internal/ports"
10	"github.com/stretchr/testify/mock"
11)
12
13// indicatorClock is a clock the test moves; its timers never fire by
14// themselves: the test calls captureFlashTick, and the durations asked for
15// are recorded: timers holds the NewTimer durations, resets the Reset ones.
16type indicatorClock struct {
17	now    time.Time
18	timers []time.Duration
19	resets []time.Duration
20}
21
22func indicatorCore(t *testing.T) (*Core, *indicatorClock) {
23	t.Helper()
24	ic := &indicatorClock{now: time.Unix(1000, 0)}
25	clock := portsmocks.NewMockClock(t)
26	clock.EXPECT().Now().RunAndReturn(func() time.Time { return ic.now }).Maybe()
27	clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
28		ic.timers = append(ic.timers, d)
29		timer := portsmocks.NewMockTimer(t)
30		timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
31		timer.EXPECT().Stop().Return(true).Maybe()
32		timer.EXPECT().Reset(mock.Anything).RunAndReturn(func(d time.Duration) bool {
33			ic.resets = append(ic.resets, d)
34			return false
35		}).Maybe()
36		return timer
37	}).Maybe()
38	var cfg ports.Config
39	cfg.Keyboard.CmdKey = "super"
40	cfg.Layout.MaxColumns = 2
41	cfg.Border.Width = 0
42	c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64), Clock: clock})
43	if err != nil {
44		t.Fatal(err)
45	}
46	c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
47	return c, ic
48}
49
50func indicatorScene(t *testing.T, c *Core) ports.Scene {
51	t.Helper()
52	if err := c.publish(context.Background()); err != nil {
53		t.Fatal(err)
54	}
55	return (<-c.ch.Scenes)[0]
56}
57
58func TestCaptureIndicatorFlashLastsOneSecond(t *testing.T) {
59	c, ic := indicatorCore(t)
60	if s := indicatorScene(t, c); s.CaptureIndicators != nil {
61		t.Fatalf("indicator without capture: %+v", s.CaptureIndicators)
62	}
63	// A one-shot screenshot of the output, by any protocol.
64	if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
65		t.Fatal("first frame shows nothing new")
66	}
67	want := []ports.CaptureIndicator{{Rect: Rect{W: 300, H: 200}}}
68	if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0] != want[0] {
69		t.Fatalf("indicators %+v", s.CaptureIndicators)
70	}
71	if len(ic.timers) != 1 || ic.timers[0] != ports.CaptureFlash {
72		t.Fatalf("timers %v, want one of %v", ic.timers, ports.CaptureFlash)
73	}
74	// A frame later extends the flash without a new scene.
75	ic.now = ic.now.Add(600 * time.Millisecond)
76	if c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
77		t.Fatal("extending a flash asks for a scene")
78	}
79	// The first timer fires at its due time: the flash was extended, it stays.
80	ic.now = ic.now.Add(400 * time.Millisecond)
81	if c.captureFlashTick() {
82		t.Fatal("flash ended early")
83	}
84	if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {
85		t.Fatalf("indicators %+v", s.CaptureIndicators)
86	}
87	// Just before the end it stays; at the end it goes.
88	ic.now = ic.now.Add(599 * time.Millisecond)
89	if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {
90		t.Fatal("flash shorter than one second after the last frame")
91	}
92	ic.now = ic.now.Add(time.Millisecond)
93	if !c.captureFlashTick() {
94		t.Fatal("flash did not end")
95	}
96	if s := indicatorScene(t, c); s.CaptureIndicators != nil {
97		t.Fatalf("indicators %+v after the flash", s.CaptureIndicators)
98	}
99	if c.capt.timerStop != nil || len(c.capt.flashes) != 0 {
100		t.Fatal("timer or flash left")
101	}
102}
103
104// Engaging protection clears a live flash and its timer, so no indicator is
105// shown for a capture that can no longer happen, and none comes back after the
106// unlock; capture flashes then work again.
107func TestCaptureIndicatorFlashClearedByProtectionAndWorksAfterUnlock(t *testing.T) {
108	c, ic := indicatorCore(t)
109	state := ports.SecurityState{Generation: 1}
110	gate := portsmocks.NewMockSessionSecurity(t)
111	gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state }).Maybe()
112	c.ch.Security = gate
113	if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
114		t.Fatal("first frame shows nothing new")
115	}
116	if len(c.capt.flashes) != 1 || c.capt.timerStop == nil {
117		t.Fatalf("flash %d, timer armed %v", len(c.capt.flashes), c.capt.timerStop != nil)
118	}
119	state = ports.SecurityState{Generation: 2, Protected: true}
120	if !c.syncSecurity() {
121		t.Fatal("protection not engaged")
122	}
123	if len(c.capt.flashes) != 0 || c.capt.timerStop != nil || c.capt.timerC != nil {
124		t.Fatalf("flash %d, timer %v left under protection", len(c.capt.flashes), c.capt.timerStop != nil)
125	}
126	if s := indicatorScene(t, c); s.CaptureIndicators != nil {
127		t.Fatalf("indicators %+v while protected", s.CaptureIndicators)
128	}
129	// The clock passes the old deadline: nothing comes back after the unlock.
130	ic.now = ic.now.Add(2 * time.Second)
131	state = ports.SecurityState{Generation: 3}
132	if !c.syncSecurity() {
133		t.Fatal("protection not released")
134	}
135	if s := indicatorScene(t, c); s.CaptureIndicators != nil {
136		t.Fatalf("stale indicators %+v after the unlock", s.CaptureIndicators)
137	}
138	timers := len(ic.timers)
139	if !c.captureFrame(ports.CaptureFrameTaken{Output: "A"}) {
140		t.Fatal("first frame after the unlock shows nothing new")
141	}
142	if s := indicatorScene(t, c); len(s.CaptureIndicators) != 1 {
143		t.Fatalf("indicators %+v after the unlock", s.CaptureIndicators)
144	}
145	if len(ic.timers) != timers+1 || c.capt.timerStop == nil {
146		t.Fatalf("timer not re-armed after the unlock: %v", ic.timers)
147	}
148	ic.now = ic.now.Add(ports.CaptureFlash)
149	if !c.captureFlashTick() {
150		t.Fatal("flash did not end after the unlock")
151	}
152}
153
154func TestCaptureIndicatorRegionAndUnion(t *testing.T) {
155	c, _ := indicatorCore(t)
156	c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 250, Y: 150, W: 100, H: 100}})
157	c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 250, Y: 150, W: 100, H: 100}})
158	c.captureFrame(ports.CaptureFrameTaken{Output: "A"})
159	s := indicatorScene(t, c)
160	if len(s.CaptureIndicators) != 2 || s.CaptureIndicators[0].Rect != (Rect{X: 250, Y: 150, W: 50, H: 50}) || s.CaptureIndicators[1].Rect != (Rect{W: 300, H: 200}) {
161		t.Fatalf("indicators %+v", s.CaptureIndicators)
162	}
163	// A target with nothing on screen marks nothing and stores nothing.
164	c.captureFrame(ports.CaptureFrameTaken{Output: "gone"})
165	c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: Rect{X: 900, Y: 900, W: 5, H: 5}})
166	if len(c.capt.flashes) != 2 {
167		t.Fatalf("flashes %+v", c.capt.flashes)
168	}
169}
170
171func TestCaptureIndicatorRecordingLivesWithItsSession(t *testing.T) {
172	c, ic := indicatorCore(t)
173	c.captureOpen(ports.CaptureSessionOpen{ID: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}})
174	if s := indicatorScene(t, c); s.CaptureIndicators != nil {
175		t.Fatalf("indicator of a session that captured nothing: %+v", s.CaptureIndicators)
176	}
177	if !c.captureFrame(ports.CaptureFrameTaken{Session: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}}) {
178		t.Fatal("first frame of a session shows nothing new")
179	}
180	// Long after the flash, the session still shows.
181	ic.now = ic.now.Add(time.Hour)
182	c.captureFlashTick()
183	s := indicatorScene(t, c)
184	if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != (Rect{X: 10, Y: 10, W: 50, H: 40}) {
185		t.Fatalf("indicators %+v", s.CaptureIndicators)
186	}
187	c.captureClose(1)
188	if s := indicatorScene(t, c); s.CaptureIndicators != nil {
189		t.Fatalf("indicators %+v after the session", s.CaptureIndicators)
190	}
191}
192
193func TestCaptureIndicatorHiddenWorkspacePill(t *testing.T) {
194	c, ws, _ := hiddenCaptureCommands(t)
195	ic := &indicatorClock{now: time.Unix(5, 0)}
196	clock := portsmocks.NewMockClock(t)
197	clock.EXPECT().Now().RunAndReturn(func() time.Time { return ic.now }).Maybe()
198	timer := portsmocks.NewMockTimer(t)
199	timer.EXPECT().C().Return(make(chan time.Time)).Maybe()
200	timer.EXPECT().Stop().Return(true).Maybe()
201	timer.EXPECT().Reset(mock.Anything).Return(false).Maybe()
202	clock.EXPECT().NewTimer(mock.Anything).Return(timer).Maybe()
203	c.ch.Clock = clock
204	c.configures.cw.reset()
205	c.captureOpen(ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID})
206	c.captureFrame(ports.CaptureFrameTaken{Session: 1, Workspace: ws.ID})
207	s := indicatorScene(t, c)
208	pill := ports.CaptureIndicator{Pill: true, Rect: Rect{X: 300 - ports.CapturePillInset - ports.CapturePillSize, Y: ports.CapturePillInset, W: ports.CapturePillSize, H: ports.CapturePillSize}}
209	if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0] != pill {
210		t.Fatalf("indicators %+v, want %+v", s.CaptureIndicators, pill)
211	}
212	if s.CaptureScene == nil || s.CaptureScene.CaptureIndicators != nil || s.CaptureScene.Capture != nil {
213		t.Fatalf("the off-screen workspace scene carries an indicator: %+v", s.CaptureScene)
214	}
215	// On screen, the same session shows a border on its frame instead.
216	c.screens[0].mon.show(ws)
217	if c.screens[0].mon.Current() != ws {
218		t.Fatal("workspace not on screen")
219	}
220	s = indicatorScene(t, c)
221	if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Pill || s.CaptureIndicators[0].Rect != ws.Output {
222		t.Fatalf("indicators %+v", s.CaptureIndicators)
223	}
224}
225
226// With nothing captured the indicator costs nothing in core.
227func TestCaptureIndicatorNoAllocationWhenIdle(t *testing.T) {
228	c, _ := indicatorCore(t)
229	sc := c.screens[0]
230	if n := testing.AllocsPerRun(100, func() {
231		c.captureExpire()
232		c.armCaptureTimer()
233		if c.captureIndicators(sc) != nil {
234			t.Fatal("indicators")
235		}
236	}); n != 0 {
237		t.Fatalf("%v allocations with no capture", n)
238	}
239	// A live flash, steady state, allocates nothing either (measured on the
240	// system clock: the generated clock mock allocates on every call).
241	c.ch.Clock = nil
242	c.captureFrame(ports.CaptureFrameTaken{Output: "A"})
243	if n := testing.AllocsPerRun(100, func() { c.captureExpire(); c.armCaptureTimer() }); n != 0 {
244		t.Fatalf("%v allocations keeping a flash", n)
245	}
246}
247
248// A target core cannot locate (the workspace or output is gone) has no mark
249// to show: core marks nothing, and the output owner fails such a capture
250// after its bounded wait rather than serving it without an indicator.
251func TestCaptureIndicatorMissingForAnUnresolvableTarget(t *testing.T) {
252	c, _ := indicatorCore(t)
253	for _, v := range []ports.CaptureFrameTaken{{Output: "GONE"}, {Workspace: 999}, {Output: "A", Region: Rect{X: 900, Y: 900, W: 10, H: 10}}} {
254		if c.captureFrame(v) {
255			t.Fatalf("%+v marked", v)
256		}
257	}
258	if s := indicatorScene(t, c); s.CaptureIndicators != nil {
259		t.Fatalf("indicators %+v for a target that is not there", s.CaptureIndicators)
260	}
261}
262
263// A target thinner than the border would have nothing to draw: core lists a
264// mark at least ports.MinCaptureMark wide and tall around it, inside the
265// output; an output smaller than that is marked whole.
266func TestCaptureIndicatorInflatesThinTargets(t *testing.T) {
267	c, _ := indicatorCore(t)
268	for _, tc := range []struct {
269		region, want Rect
270	}{
271		{Rect{X: 100, Y: 50, W: 1, H: 100}, Rect{X: 98, Y: 50, W: 5, H: 100}},
272		{Rect{X: 100, Y: 50, W: 100, H: 1}, Rect{X: 100, Y: 48, W: 100, H: 5}},
273		{Rect{X: 100, Y: 50, W: 1, H: 1}, Rect{X: 98, Y: 48, W: 5, H: 5}},
274		{Rect{X: 0, Y: 0, W: 1, H: 1}, Rect{X: 0, Y: 0, W: 5, H: 5}},
275		{Rect{X: 299, Y: 199, W: 1, H: 1}, Rect{X: 295, Y: 195, W: 5, H: 5}},
276		{Rect{X: 100, Y: 50, W: 4, H: 4}, Rect{X: 100, Y: 50, W: 5, H: 5}},
277		{Rect{X: 100, Y: 50, W: 5, H: 5}, Rect{X: 100, Y: 50, W: 5, H: 5}},
278	} {
279		c.capt.flashes = nil
280		c.captureFrame(ports.CaptureFrameTaken{Output: "A", Region: tc.region})
281		s := indicatorScene(t, c)
282		if len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != tc.want {
283			t.Fatalf("region %+v: marks %+v, want %+v", tc.region, s.CaptureIndicators, tc.want)
284		}
285		m := s.CaptureIndicators[0]
286		if !m.Drawable() {
287			t.Fatalf("region %+v: mark %+v draws nothing", tc.region, m)
288		}
289		if m.Rect.X > tc.region.X || m.Rect.Y > tc.region.Y || m.Rect.X+m.Rect.W < tc.region.X+tc.region.W || m.Rect.Y+m.Rect.H < tc.region.Y+tc.region.H {
290			t.Fatalf("region %+v not inside its mark %+v", tc.region, m.Rect)
291		}
292	}
293	// An output smaller than the minimum is marked whole.
294	if got := thickenMark(Rect{W: 1, H: 1}, Rect{W: 3, H: 4}); got != (Rect{W: 3, H: 4}) {
295		t.Fatalf("tiny output mark %+v", got)
296	}
297}
298
299// A recording session that keeps being published allocates nothing for its
300// capture state: the marks, the scene capture and the session states are
301// scratch or shared while unchanged.
302func TestCaptureRecordingPublishAllocations(t *testing.T) {
303	c, _ := indicatorCore(t)
304	c.ch.Clock = nil
305	cmds := make(chan ports.ClientCommand, 64)
306	c.ch.Commands = cmds
307	c.captureOpen(ports.CaptureSessionOpen{ID: 1, Output: "A", Region: Rect{X: 10, Y: 10, W: 50, H: 40}})
308	c.captureOpen(ports.CaptureSessionOpen{ID: 2, Output: "A"})
309	for id := uint64(1); id <= 2; id++ {
310		c.captureFrame(ports.CaptureFrameTaken{Session: id, Output: "A"})
311	}
312	ctx := context.Background()
313	sc := c.screens[0]
314	step := func() {
315		v, err := c.captureEvaluate(ctx)
316		if err != nil {
317			t.Fatal(err)
318		}
319		if c.captureSceneFor(sc, v) == nil || len(c.captureIndicators(sc)) == 0 {
320			t.Fatal("no capture state")
321		}
322	}
323	step()
324	for len(cmds) > 0 {
325		<-cmds
326	}
327	first := sc.capMarks
328	n := testing.AllocsPerRun(100, step)
329	t.Logf("capture state allocations per recording publish: %v", n)
330	if n != 0 {
331		t.Fatalf("%v allocations per recording publish", n)
332	}
333	if len(cmds) != 0 {
334		t.Fatal("steady publish sent a command")
335	}
336	if &first[0] != &sc.capMarks[0] {
337		t.Fatal("unchanged marks were cloned")
338	}
339}
340
341// The pill of a workspace rendered for capture only lies whole inside its
342// output; an output too small for it (<= 8 logical px tall, < 12 wide) is
343// filled whole instead, never cut to nothing.
344func TestCaptureIndicatorPillFitsTheOutput(t *testing.T) {
345	for _, tc := range []struct {
346		name string
347		out  Rect
348		want Rect
349	}{
350		{"regular", Rect{W: 300, H: 200}, Rect{X: 280, Y: 8, W: 12, H: 12}},
351		{"short", Rect{W: 300, H: 8}, Rect{W: 300, H: 8}},
352		{"narrow", Rect{W: 11, H: 200}, Rect{W: 11, H: 200}},
353		{"tiny", Rect{W: 5, H: 6}, Rect{W: 5, H: 6}},
354		{"just fits", Rect{W: 20, H: 20}, Rect{X: 0, Y: 8, W: 12, H: 12}},
355	} {
356		got := pillRect(tc.out)
357		if got != tc.want {
358			t.Fatalf("%s: pill %+v, want %+v", tc.name, got, tc.want)
359		}
360		m := ports.CaptureIndicator{Pill: true, Rect: got}
361		if !m.DrawableIn(tc.out.W, tc.out.H) {
362			t.Fatalf("%s: pill %+v not drawable in %+v", tc.name, got, tc.out)
363		}
364	}
365	// A hidden workspace on a short output lists the clamped pill.
366	c, ws, _ := hiddenCaptureCommands(t)
367	c.screens[0].mon.SetOutput(300, 8)
368	c.configures.cw.reset()
369	c.captureOpen(ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID})
370	c.captureFrame(ports.CaptureFrameTaken{Session: 1, Workspace: ws.ID})
371	s := indicatorScene(t, c)
372	if len(s.CaptureIndicators) != 1 || !s.CaptureIndicators[0].Pill || s.CaptureIndicators[0].Rect != (Rect{W: 300, H: 8}) {
373		t.Fatalf("indicators %+v", s.CaptureIndicators)
374	}
375}

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