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

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