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}