feat/data-dnd e05cb15

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

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