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}