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}