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)
11
12func flashCore(t *testing.T) (*Core, *indicatorClock) {
13 t.Helper()
14 c, clock := indicatorCore(t)
15 c.cfg.Animations.On = true
16 c.cfg.Focus.Flash = true
17 c.cfg.Focus.FlashWidth = 3
18 c.cfg.Focus.FlashOpacity = 0.8
19 c.cfg.Focus.FlashDuration = 400 * time.Millisecond
20 c.cfg.Border.Active = "#abcdef"
21 for id := WindowID(1); id <= 3; id++ {
22 c.windows.mapped(ports.WindowMapped{ID: id}, clock.now.Add(-time.Minute))
23 c.cur().mon.AddWindow(id)
24 }
25 return c, clock
26}
27
28func startFlash(t *testing.T, c *Core, clock *indicatorClock, level flashLevel, id WindowID) {
29 t.Helper()
30 c.requestFlash(level, id)
31 clock.now = clock.now.Add(flashSettle)
32 if !c.flashTick() {
33 t.Fatal("flash did not start")
34 }
35}
36
37func TestFlashFadeAndOutputFrames(t *testing.T) {
38 c, clock := flashCore(t)
39 a := c.cur()
40 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
41 b := c.screens[1]
42 startFlash(t, c, clock, flashWindow, 3)
43 first := stepScenes(t, c, a)
44 f := first["A"].Flash
45 if f == nil || f.Alpha != 0.8 || f.Color != c.cfg.Border.Active || f.Width != 3 {
46 t.Fatalf("flash %+v", f)
47 }
48 if first["B"].Flash != nil || !first["A"].Animating || !c.animating() {
49 t.Fatal("output/animation routing")
50 }
51 clock.now = clock.now.Add(200 * time.Millisecond)
52 other := stepScenes(t, c, b)
53 if other["A"].Seq != first["A"].Seq || *other["A"].Flash != *f {
54 t.Fatal("other output advanced flash")
55 }
56 half := stepScenes(t, c, a)
57 if half["A"].Flash.Alpha != 0.4 {
58 t.Fatalf("half alpha %v", half["A"].Flash.Alpha)
59 }
60 clock.now = clock.now.Add(200 * time.Millisecond)
61 end := stepScenes(t, c, a)
62 if end["A"].Flash != nil || end["A"].Animating || c.animating() {
63 t.Fatal("flash did not stop frames")
64 }
65 if end["A"].Seq == half["A"].Seq {
66 t.Fatal("end retained animating sequence")
67 }
68}
69
70func TestFlashDebounceCooldownAndReplacement(t *testing.T) {
71 c, clock := flashCore(t)
72 c.requestFlash(flashWindow, 1)
73 clock.now = clock.now.Add(100 * time.Millisecond)
74 c.requestFlash(flashWindow, 2)
75 clock.now = clock.now.Add(50 * time.Millisecond)
76 if c.flashTick() || c.flash.pending == nil {
77 t.Fatal("early stale timer started flash")
78 }
79 clock.now = clock.now.Add(100 * time.Millisecond)
80 if !c.flashTick() || c.flash.running.window != 2 {
81 t.Fatal("last request did not win")
82 }
83 c.requestFlash(flashWindow, 2)
84 if c.flash.running != nil {
85 t.Fatal("request did not replace running flash")
86 }
87 clock.now = clock.now.Add(flashSettle)
88 if c.flashTick() {
89 t.Fatal("cooldown ignored")
90 }
91 clock.now = clock.now.Add(time.Second)
92 startFlash(t, c, clock, flashWindow, 2)
93 c.requestFlash(flashColumn, 2)
94 clock.now = clock.now.Add(flashSettle)
95 if !c.flashTick() || c.flash.running.level != flashColumn {
96 t.Fatal("level not part of cooldown key")
97 }
98}
99
100func TestFlashSettlesAndResolvesFinalRect(t *testing.T) {
101 for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
102 t.Run(string(rune('0'+level)), func(t *testing.T) {
103 c, clock := flashCore(t)
104 w := c.cur().mon.Current()
105 c.requestFlash(level, 3)
106 w.view.off = 20
107 clock.now = clock.now.Add(flashSettle)
108 if c.flashTick() || c.flash.timerC == nil {
109 t.Fatal("did not wait for layout")
110 }
111 w.view.stop()
112 w.MoveColumn(-1)
113 clock.now = clock.now.Add(flashRecheck)
114 if !c.flashTick() {
115 t.Fatal("did not start after settling")
116 }
117 want := Rect{W: 300, H: 200}
118 if level != flashMonitor {
119 for _, p := range c.cur().mon.Layout() {
120 if p.ID == 3 {
121 want = p.Rect
122 }
123 }
124 }
125 if c.flash.rect != want {
126 t.Fatalf("rect %+v want %+v", c.flash.rect, want)
127 }
128 })
129 }
130}
131
132func TestFlashSkipsIneligibleTargets(t *testing.T) {
133 for _, test := range []struct {
134 name string
135 change func(*Core, *indicatorClock)
136 }{
137 {"disabled", func(c *Core, _ *indicatorClock) { c.cfg.Focus.Flash = false }},
138 {"animations off", func(c *Core, _ *indicatorClock) { c.cfg.Animations.On = false }},
139 {"protected", func(c *Core, _ *indicatorClock) { c.security.Protected = true }},
140 {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }},
141 {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }},
142 {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }},
143 } {
144 t.Run(test.name, func(t *testing.T) {
145 c, clock := flashCore(t)
146 test.change(c, clock)
147 c.requestFlash(flashWindow, 3)
148 clock.now = clock.now.Add(flashSettle)
149 if c.flashTick() || c.flash.running != nil {
150 t.Fatal("ineligible flash")
151 }
152 })
153 }
154}
155
156func TestFlashStopAndColor(t *testing.T) {
157 c, clock := flashCore(t)
158 c.cfg.Focus.FlashColor = "#123456"
159 c.cfg.Animations.Slowdown = 10
160 startFlash(t, c, clock, flashWindow, 3)
161 if s := indicatorScene(t, c); s.Flash.Color != "#123456" {
162 t.Fatal("configured color ignored")
163 }
164 clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
165 if indicatorScene(t, c).Flash != nil {
166 t.Fatal("duration scaled by slowdown")
167 }
168 c.requestFlash(flashWindow, 2)
169 c.stopFlash()
170 if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil {
171 t.Fatal("stop retained state")
172 }
173}
174
175func TestFlashActionTriggers(t *testing.T) {
176 for _, test := range []struct {
177 action Action
178 level flashLevel
179 setup func(*Core)
180 }{
181 {ActionFocusColumnLeft, flashColumn, nil},
182 {ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
183 {ActionMoveColumnLeft, flashColumn, nil},
184 {ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
185 {ActionConsumeOrExpelLeft, flashWindow, nil},
186 {ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
187 {ActionToggleFloating, flashWindow, nil},
188 {ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
189 {ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
190 {ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
191 {ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
192 {ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }},
193 {ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }},
194 {ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }},
195 } {
196 t.Run(string(test.action), func(t *testing.T) {
197 c, clock := flashCore(t)
198 if test.setup != nil {
199 test.setup(c)
200 }
201 c.applyAction(test.action)
202 if c.flash.pending == nil || c.flash.pending.level != test.level {
203 t.Fatalf("pending %+v want level %v", c.flash.pending, test.level)
204 }
205 target := *c.flash.pending
206 clock.now = clock.now.Add(flashSettle)
207 if !c.flashTick() {
208 t.Fatal("action flash did not start")
209 }
210 var want Rect
211 for _, p := range c.cur().mon.Layout() {
212 if p.ID == target.window {
213 want = p.Rect
214 }
215 }
216 if c.flash.rect != want {
217 t.Fatalf("action rect %+v want %+v", c.flash.rect, want)
218 }
219 })
220 }
221}
222
223func TestFlashExcludedActions(t *testing.T) {
224 for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} {
225 t.Run(string(action), func(t *testing.T) {
226 c, _ := flashCore(t)
227 c.applyAction(action)
228 if c.flash.pending != nil {
229 t.Fatalf("unexpected flash %+v", c.flash.pending)
230 }
231 })
232 }
233}
234
235func TestFlashMonitorTriggers(t *testing.T) {
236 for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {
237 t.Run(string(action), func(t *testing.T) {
238 c, clock := flashCore(t)
239 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
240 a, b := c.screens[0], c.screens[1]
241 a.x, a.y = 0, 0
242 b.x, b.y = 300, 0
243 switch monitorDirection(action) {
244 case dirLeft:
245 b.x = -400
246 case dirUp:
247 b.x, b.y = 0, -300
248 case dirDown:
249 b.x, b.y = 0, 200
250 }
251 c.applyAction(action)
252 if c.flash.pending == nil || c.flash.pending.level != flashMonitor || c.flash.pending.output != "B" {
253 t.Fatalf("destination request %+v", c.flash.pending)
254 }
255 clock.now = clock.now.Add(flashSettle)
256 if !c.flashTick() || c.flash.rect != (Rect{W: 400, H: 300}) {
257 t.Fatalf("monitor rect %+v", c.flash.rect)
258 }
259 })
260 }
261}
262
263func TestFlashColumnUnion(t *testing.T) {
264 c, clock := flashCore(t)
265 w := c.cur().mon.Current()
266 w.ConsumeOrExpel(-1)
267 startFlash(t, c, clock, flashColumn, 3)
268 col := w.Columns[w.columnOf(3)]
269 var area int
270 for _, p := range c.cur().mon.Layout() {
271 for _, id := range col.Windows {
272 if id == p.ID {
273 area += p.Rect.W * p.Rect.H
274 }
275 }
276 }
277 if c.flash.rect.W*c.flash.rect.H != area {
278 t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area)
279 }
280}
281
282func TestFlashGestureLanding(t *testing.T) {
283 c, _ := flashCore(t)
284 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
285 c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond})
286 c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond})
287 if c.flash.pending == nil || c.flash.pending.level != flashColumn {
288 t.Fatalf("landing request %+v", c.flash.pending)
289 }
290 c.stopFlash()
291 c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond})
292 c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond})
293 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond})
294 if c.flash.pending != nil {
295 t.Fatal("cancelled gesture flashed")
296 }
297}
298
299func TestFlashLockClearsPendingAndRunning(t *testing.T) {
300 for _, running := range []bool{false, true} {
301 c, clock := flashCore(t)
302 state := ports.SecurityState{}
303 gate := portsmocks.NewMockSessionSecurity(t)
304 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
305 c.opts.Security = gate
306 if running {
307 startFlash(t, c, clock, flashWindow, 3)
308 } else {
309 c.requestFlash(flashWindow, 3)
310 }
311 state = ports.SecurityState{Generation: 1, Protected: true}
312 if !c.syncSecurity() {
313 t.Fatal("security did not change")
314 }
315 if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil {
316 t.Fatal("lock retained flash")
317 }
318 state = ports.SecurityState{Generation: 2}
319 c.syncSecurity()
320 if indicatorScene(t, c).Flash != nil || c.flash.pending != nil {
321 t.Fatal("unlock requested flash")
322 }
323 }
324}
325
326func TestFlashOverviewClickAndKeyboard(t *testing.T) {
327 for _, click := range []bool{true, false} {
328 c, _ := flashCore(t)
329 c.cur().mon.ToggleOverview()
330 scene := indicatorScene(t, c)
331 if click {
332 var rect Rect
333 for _, p := range scene.Windows {
334 if p.ID == 2 {
335 rect = p.Rect
336 }
337 }
338 c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
339 picked, err := c.overviewClick(context.Background())
340 if err != nil || !picked {
341 t.Fatalf("click %v %v", picked, err)
342 }
343 } else {
344 if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil {
345 t.Fatal(err)
346 }
347 }
348 if c.flash.pending == nil || c.flash.pending.level != flashWindow {
349 t.Fatalf("overview request %+v", c.flash.pending)
350 }
351 }
352}
353
354func TestFlashTileDrop(t *testing.T) {
355 c, _ := flashCore(t)
356 scene := indicatorScene(t, c)
357 var rect Rect
358 for _, p := range scene.Windows {
359 if p.ID == 2 {
360 rect = p.Rect
361 }
362 }
363 c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
364 c.drag = &dragState{id: 3}
365 if err := c.endDrag(context.Background()); err != nil {
366 t.Fatal(err)
367 }
368 if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 {
369 t.Fatalf("drop request %+v", c.flash.pending)
370 }
371}
372
373func TestFlashNoImplicitFocusTriggers(t *testing.T) {
374 c, _ := flashCore(t)
375 ctx := context.Background()
376 indicatorScene(t, c)
377 if c.flash.pending != nil {
378 t.Fatal("startup publication flashed")
379 }
380 for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} {
381 if _, stop := c.clientEvent(ctx, ev); stop {
382 t.Fatalf("event %T stopped", ev)
383 }
384 if c.flash.pending != nil {
385 t.Fatalf("event %T flashed", ev)
386 }
387 }
388 indicatorScene(t, c)
389 if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil {
390 t.Fatal(err)
391 }
392 if err := c.pointerButton(ctx, ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
393 t.Fatal(err)
394 }
395 if c.flash.pending != nil {
396 t.Fatal("pointer focus flashed")
397 }
398}
399
400func TestFlashCancelsStaleRunningTargets(t *testing.T) {
401 for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
402 for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
403 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
404 c, clock := flashCore(t)
405 a := c.cur()
406 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
407 b := c.screens[1]
408 startFlash(t, c, clock, level, 3)
409 if indicatorScene(t, c).Flash == nil {
410 t.Fatal("setup: no running flash")
411 }
412 switch change {
413 case "overview":
414 a.mon.ToggleOverview()
415 case "output gone":
416 c.screens = []*screen{b}
417 c.focusScreen = 0
418 case "fullscreen":
419 a.mon.ToggleFullscreen()
420 case "unmapped":
421 c.windows.drop(3)
422 case "hidden":
423 w := a.mon.Current()
424 w.ToggleWindowStash()
425 w.ToggleStashVisible()
426 case "other output":
427 a.mon.RemoveWindow(3)
428 b.mon.AddWindow(3)
429 case "workspace changed":
430 a.mon.Focus(a.mon.Active + 1)
431 }
432 // Cancellation must not wait for the target output's next flip.
433 scenes := stepScenes(t, c, b)
434 shouldCancel := level != flashMonitor || change == "overview" || change == "output gone"
435 if shouldCancel {
436 if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
437 t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
438 }
439 } else if scenes["A"].Flash == nil || !c.animating() {
440 t.Fatal("monitor flash cancelled for a window change")
441 }
442 })
443 }
444 }
445}
446
447func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
448 c, clock := flashCore(t)
449 startFlash(t, c, clock, flashWindow, 3)
450 first := c.flash.start
451 startFlash(t, c, clock, flashWindow, 2)
452 c.requestFlash(flashWindow, 3)
453 clock.now = clock.now.Add(flashSettle)
454 if c.flashTick() || c.flash.running != nil {
455 t.Fatal("A flashed again after B within cooldown")
456 }
457 clock.now = first.Add(flashCooldown)
458 startFlash(t, c, clock, flashWindow, 3)
459 if c.flash.running.window != 3 {
460 t.Fatal("A did not flash after cooldown")
461 }
462 // Inserting after all previous starts expire prunes old target keys.
463 clock.now = clock.now.Add(flashCooldown)
464 startFlash(t, c, clock, flashMonitor, 0)
465 if len(c.flash.lastStarts) != 1 {
466 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
467 }
468}
469
470// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
471func BenchmarkFlashSceneBuild(b *testing.B) {
472 var cfg ports.Config
473 cfg.Layout.MaxColumns = 2
474 cfg.Keyboard.CmdKey = "super"
475 cfg.Animations.On = true
476 cfg.Focus.Flash = true
477 cfg.Focus.FlashOpacity = 0.8
478 cfg.Focus.FlashDuration = time.Hour
479 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
480 if err != nil {
481 b.Fatal(err)
482 }
483 c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
484 c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
485 c.cur().mon.AddWindow(1)
486 c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
487 c.flash.start = time.Now()
488 c.flash.rect = Rect{W: 1000, H: 600}
489 c.refreshShown()
490 ctx := context.Background()
491 b.ReportAllocs()
492 b.ResetTimer()
493 for range b.N {
494 c.refreshShown()
495 c.advanceFlash(nil)
496 scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
497 if err != nil {
498 b.Fatal(err)
499 }
500 c.cur().last = scene
501 }
502}
503
504func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
505 for _, tc := range []struct {
506 name string
507 overflow Overflow
508 action Action
509 }{
510 {"vertical", OverflowScroll, ActionFocusWindowDown},
511 {"horizontal", OverflowCascade, ActionFocusColumnRight},
512 } {
513 t.Run(tc.name, func(t *testing.T) {
514 c, _ := flashCore(t)
515 m := c.cur().mon
516 m.SetOverflow(tc.overflow)
517 before := m.Current()
518 m.Focus(1)
519 m.AddWindow(4)
520 m.Focus(0)
521 c.applyAction(tc.action)
522 if m.Current() == before {
523 t.Fatal("setup: edge did not switch workspace")
524 }
525 if id, _ := m.Focused(); id != 4 {
526 t.Fatalf("did not land on occupied workspace: %d", id)
527 }
528 if c.flash.pending != nil || c.flash.running != nil {
529 t.Fatal("workspace switch flashed")
530 }
531 })
532 }
533}
534
535func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
536 c, clock := flashCore(t)
537 c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
538 if c.flash.pending != nil {
539 t.Fatal("mapping requested flash")
540 }
541 c.applyAction(ActionToggleFloating)
542 if c.flash.pending == nil || c.flash.pending.window != 4 {
543 t.Fatal("explicit action did not request flash")
544 }
545 c.stopAnimations()
546 clock.now = clock.now.Add(flashSettle)
547 if !c.flashTick() {
548 t.Fatal("freshly mapped target rejected")
549 }
550}
551
552func TestFlashPendingTimerStopped(t *testing.T) {
553 for _, reason := range []string{"replacement", "security", "run exit"} {
554 t.Run(reason, func(t *testing.T) {
555 c, clock := flashCore(t)
556 timer := portsmocks.NewMockTimer(t)
557 timer.EXPECT().C().Return(make(chan time.Time)).Once()
558 timer.EXPECT().Stop().Return(true).Times(1)
559 strictClock := portsmocks.NewMockClock(t)
560 strictClock.EXPECT().Now().Return(clock.now).Maybe()
561 strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
562 c.opts.Clock = strictClock
563 c.requestFlash(flashWindow, 3)
564 // Only the pending flash timer belongs to the strict clock.
565 c.opts.Clock = nil
566 switch reason {
567 case "replacement":
568 c.requestFlash(flashWindow, 2)
569 c.stopFlash()
570 case "security":
571 gate := portsmocks.NewMockSessionSecurity(t)
572 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
573 c.opts.Security = gate
574 c.syncSecurity()
575 case "run exit":
576 ctx, cancel := context.WithCancel(context.Background())
577 cancel()
578 if err := c.Run(ctx); err != nil {
579 t.Fatal(err)
580 }
581 }
582 })
583 }
584}
585
586func TestFlashUnchangedSampleIsReused(t *testing.T) {
587 c, clock := flashCore(t)
588 startFlash(t, c, clock, flashWindow, 3)
589 first := indicatorScene(t, c).Flash
590 if first == nil {
591 t.Fatal("missing flash")
592 }
593 if next := indicatorScene(t, c).Flash; next != first {
594 t.Fatal("unchanged immutable sample not reused")
595 }
596 clock.now = clock.now.Add(time.Millisecond)
597 if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
598 t.Fatal("changed sample mutated prior scene")
599 }
600}
601
602func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
603 for _, tc := range []struct {
604 name string
605 action Action
606 occupied bool
607 }{
608 {"column focus to empty", ActionFocusColumnRight, false},
609 {"column focus to occupied", ActionFocusColumnRight, true},
610 {"monitor focus to empty", ActionFocusMonitorRight, false},
611 {"column move to empty", ActionMoveColumnRight, false},
612 {"column move to occupied", ActionMoveColumnRight, true},
613 } {
614 t.Run(tc.name, func(t *testing.T) {
615 c, clock := flashCore(t)
616 a := c.cur()
617 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
618 b := c.screens[1]
619 if tc.occupied {
620 c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
621 b.mon.AddWindow(4)
622 }
623 c.applyAction(tc.action)
624 if c.cur() != b {
625 t.Fatal("action did not cross screens")
626 }
627 if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
628 t.Fatalf("destination request %+v", c.flash.pending)
629 }
630 clock.now = clock.now.Add(flashSettle)
631 if !c.flashTick() {
632 t.Fatal("destination flash did not start")
633 }
634 scenes := stepScenes(t, c, b)
635 if scenes["A"].Flash != nil {
636 t.Fatal("source output flashed")
637 }
638 f := scenes["B"].Flash
639 if f == nil || f.Rect != (Rect{W: 400, H: 300}) || !scenes["B"].Animating {
640 t.Fatalf("destination scene flash %+v", f)
641 }
642 if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
643 t.Fatal("setup: destination is not empty")
644 }
645 clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
646 scenes = stepScenes(t, c, b)
647 if scenes["B"].Flash != nil || c.flashing(a) || c.animating() {
648 t.Fatal("destination flash did not end")
649 }
650 })
651 }
652}
653
654func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
655 c, clock := flashCore(t)
656 sc := c.cur()
657 c.cur().mon.Current().FocusID(1)
658 c.applyAction(ActionFocusColumnRight)
659 if c.cur() != sc || c.flash.pending == nil || c.flash.pending.level != flashColumn {
660 t.Fatalf("same-screen request %+v", c.flash.pending)
661 }
662 clock.now = clock.now.Add(flashSettle)
663 if !c.flashTick() {
664 t.Fatal("column flash did not start")
665 }
666 s := indicatorScene(t, c)
667 for _, w := range s.Windows {
668 if w.ID == c.flash.running.window && s.Flash.Rect != w.Rect {
669 t.Fatalf("column rect %+v want %+v", s.Flash.Rect, w.Rect)
670 }
671 }
672}