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 c.flash.pending != nil {
393 t.Fatal("hover focus flashed")
394 }
395}
396
397func TestFlashCancelsStaleRunningTargets(t *testing.T) {
398 for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
399 for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
400 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
401 c, clock := flashCore(t)
402 a := c.cur()
403 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
404 b := c.screens[1]
405 startFlash(t, c, clock, level, 3)
406 if indicatorScene(t, c).Flash == nil {
407 t.Fatal("setup: no running flash")
408 }
409 switch change {
410 case "overview":
411 a.mon.ToggleOverview()
412 case "output gone":
413 c.screens = []*screen{b}
414 c.focusScreen = 0
415 case "fullscreen":
416 a.mon.ToggleFullscreen()
417 case "unmapped":
418 c.windows.drop(3)
419 case "hidden":
420 w := a.mon.Current()
421 w.ToggleWindowStash()
422 w.ToggleStashVisible()
423 case "other output":
424 a.mon.RemoveWindow(3)
425 b.mon.AddWindow(3)
426 case "workspace changed":
427 a.mon.Focus(a.mon.Active + 1)
428 }
429 // Cancellation must not wait for the target output's next flip.
430 scenes := stepScenes(t, c, b)
431 shouldCancel := level != flashMonitor || change == "overview" || change == "output gone"
432 if shouldCancel {
433 if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
434 t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
435 }
436 } else if scenes["A"].Flash == nil || !c.animating() {
437 t.Fatal("monitor flash cancelled for a window change")
438 }
439 })
440 }
441 }
442}
443
444func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
445 c, clock := flashCore(t)
446 startFlash(t, c, clock, flashWindow, 3)
447 first := c.flash.start
448 startFlash(t, c, clock, flashWindow, 2)
449 c.requestFlash(flashWindow, 3)
450 clock.now = clock.now.Add(flashSettle)
451 if c.flashTick() || c.flash.running != nil {
452 t.Fatal("A flashed again after B within cooldown")
453 }
454 clock.now = first.Add(flashCooldown)
455 startFlash(t, c, clock, flashWindow, 3)
456 if c.flash.running.window != 3 {
457 t.Fatal("A did not flash after cooldown")
458 }
459 // Inserting after all previous starts expire prunes old target keys.
460 clock.now = clock.now.Add(flashCooldown)
461 startFlash(t, c, clock, flashMonitor, 0)
462 if len(c.flash.lastStarts) != 1 {
463 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
464 }
465}
466
467// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
468func BenchmarkFlashSceneBuild(b *testing.B) {
469 var cfg ports.Config
470 cfg.Layout.MaxColumns = 2
471 cfg.Keyboard.CmdKey = "super"
472 cfg.Animations.On = true
473 cfg.Focus.Flash = true
474 cfg.Focus.FlashOpacity = 0.8
475 cfg.Focus.FlashDuration = time.Hour
476 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
477 if err != nil {
478 b.Fatal(err)
479 }
480 c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
481 c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
482 c.cur().mon.AddWindow(1)
483 c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
484 c.flash.start = time.Now()
485 c.flash.rect = Rect{W: 1000, H: 600}
486 c.refreshShown()
487 ctx := context.Background()
488 b.ReportAllocs()
489 b.ResetTimer()
490 for range b.N {
491 c.refreshShown()
492 c.advanceFlash(nil)
493 scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
494 if err != nil {
495 b.Fatal(err)
496 }
497 c.cur().last = scene
498 }
499}
500
501func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
502 for _, tc := range []struct {
503 name string
504 overflow Overflow
505 action Action
506 }{
507 {"vertical", OverflowScroll, ActionFocusWindowDown},
508 {"horizontal", OverflowCascade, ActionFocusColumnRight},
509 } {
510 t.Run(tc.name, func(t *testing.T) {
511 c, _ := flashCore(t)
512 m := c.cur().mon
513 m.SetOverflow(tc.overflow)
514 before := m.Current()
515 m.Focus(1)
516 m.AddWindow(4)
517 m.Focus(0)
518 c.applyAction(tc.action)
519 if m.Current() == before {
520 t.Fatal("setup: edge did not switch workspace")
521 }
522 if id, _ := m.Focused(); id != 4 {
523 t.Fatalf("did not land on occupied workspace: %d", id)
524 }
525 if c.flash.pending != nil || c.flash.running != nil {
526 t.Fatal("workspace switch flashed")
527 }
528 })
529 }
530}
531
532func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
533 c, clock := flashCore(t)
534 c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
535 if c.flash.pending != nil {
536 t.Fatal("mapping requested flash")
537 }
538 c.applyAction(ActionToggleFloating)
539 if c.flash.pending == nil || c.flash.pending.window != 4 {
540 t.Fatal("explicit action did not request flash")
541 }
542 c.stopAnimations()
543 clock.now = clock.now.Add(flashSettle)
544 if !c.flashTick() {
545 t.Fatal("freshly mapped target rejected")
546 }
547}
548
549func TestFlashPendingTimerStopped(t *testing.T) {
550 for _, reason := range []string{"replacement", "security", "run exit"} {
551 t.Run(reason, func(t *testing.T) {
552 c, clock := flashCore(t)
553 timer := portsmocks.NewMockTimer(t)
554 timer.EXPECT().C().Return(make(chan time.Time)).Once()
555 timer.EXPECT().Stop().Return(true).Times(1)
556 strictClock := portsmocks.NewMockClock(t)
557 strictClock.EXPECT().Now().Return(clock.now).Maybe()
558 strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
559 c.opts.Clock = strictClock
560 c.requestFlash(flashWindow, 3)
561 // Only the pending flash timer belongs to the strict clock.
562 c.opts.Clock = nil
563 switch reason {
564 case "replacement":
565 c.requestFlash(flashWindow, 2)
566 c.stopFlash()
567 case "security":
568 gate := portsmocks.NewMockSessionSecurity(t)
569 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
570 c.opts.Security = gate
571 c.syncSecurity()
572 case "run exit":
573 ctx, cancel := context.WithCancel(context.Background())
574 cancel()
575 if err := c.Run(ctx); err != nil {
576 t.Fatal(err)
577 }
578 }
579 })
580 }
581}
582
583func TestFlashUnchangedSampleIsReused(t *testing.T) {
584 c, clock := flashCore(t)
585 startFlash(t, c, clock, flashWindow, 3)
586 first := indicatorScene(t, c).Flash
587 if first == nil {
588 t.Fatal("missing flash")
589 }
590 if next := indicatorScene(t, c).Flash; next != first {
591 t.Fatal("unchanged immutable sample not reused")
592 }
593 clock.now = clock.now.Add(time.Millisecond)
594 if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
595 t.Fatal("changed sample mutated prior scene")
596 }
597}
598
599func TestFlashCrossScreenActionsMarkDestinationOutput(t *testing.T) {
600 for _, tc := range []struct {
601 name string
602 action Action
603 occupied bool
604 }{
605 {"column focus to empty", ActionFocusColumnRight, false},
606 {"column focus to occupied", ActionFocusColumnRight, true},
607 {"monitor focus to empty", ActionFocusMonitorRight, false},
608 {"column move to empty", ActionMoveColumnRight, false},
609 {"column move to occupied", ActionMoveColumnRight, true},
610 } {
611 t.Run(tc.name, func(t *testing.T) {
612 c, clock := flashCore(t)
613 a := c.cur()
614 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
615 b := c.screens[1]
616 if tc.occupied {
617 c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
618 b.mon.AddWindow(4)
619 }
620 c.applyAction(tc.action)
621 if c.cur() != b {
622 t.Fatal("action did not cross screens")
623 }
624 if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashMonitor, output: "B"}) {
625 t.Fatalf("destination request %+v", c.flash.pending)
626 }
627 clock.now = clock.now.Add(flashSettle)
628 if !c.flashTick() {
629 t.Fatal("destination flash did not start")
630 }
631 scenes := stepScenes(t, c, b)
632 if scenes["A"].Flash != nil {
633 t.Fatal("source output flashed")
634 }
635 f := scenes["B"].Flash
636 if f == nil || f.Rect != (Rect{W: 400, H: 300}) || !scenes["B"].Animating {
637 t.Fatalf("destination scene flash %+v", f)
638 }
639 if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
640 t.Fatal("setup: destination is not empty")
641 }
642 clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
643 scenes = stepScenes(t, c, b)
644 if scenes["B"].Flash != nil || c.flashing(a) || c.animating() {
645 t.Fatal("destination flash did not end")
646 }
647 })
648 }
649}
650
651func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
652 c, clock := flashCore(t)
653 sc := c.cur()
654 c.cur().mon.Current().FocusID(1)
655 c.applyAction(ActionFocusColumnRight)
656 if c.cur() != sc || c.flash.pending == nil || c.flash.pending.level != flashColumn {
657 t.Fatalf("same-screen request %+v", c.flash.pending)
658 }
659 clock.now = clock.now.Add(flashSettle)
660 if !c.flashTick() {
661 t.Fatal("column flash did not start")
662 }
663 s := indicatorScene(t, c)
664 for _, w := range s.Windows {
665 if w.ID == c.flash.running.window && s.Flash.Rect != w.Rect {
666 t.Fatalf("column rect %+v want %+v", s.Flash.Rect, w.Rect)
667 }
668 }
669}
670
671func TestFlashCancelsOnGeometryChange(t *testing.T) {
672 for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
673 for _, change := range []string{"resize", "new window"} {
674 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
675 c, clock := flashCore(t)
676 startFlash(t, c, clock, level, 3)
677 before := indicatorScene(t, c)
678 if before.Flash == nil {
679 t.Fatal("setup: no flash")
680 }
681 if level == flashMonitor {
682 c.cur().mon.SetOutput(400, 300)
683 } else if change == "resize" {
684 c.cur().mon.Current().Apply(ActionCycleColumnWidth)
685 } else {
686 c.cur().mon.AddWindow(4)
687 c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
688 }
689 after := indicatorScene(t, c)
690 if after.Flash != nil || c.flash.running != nil {
691 t.Fatal("stale geometry retained")
692 }
693 if before.Flash.Rect != c.flash.published.Rect {
694 t.Fatal("old flash sample mutated")
695 }
696 })
697 }
698 }
699}
700
701func TestFlashGeometryRevalidationAllocations(t *testing.T) {
702 for _, level := range []flashLevel{flashWindow, flashColumn, flashMonitor} {
703 t.Run(string(rune('0'+level)), func(t *testing.T) {
704 c, clock := flashCore(t)
705 startFlash(t, c, clock, level, 3)
706 // Measure production work, not the generated clock mock's call log.
707 c.opts.Clock = nil
708 c.flash.start = time.Now()
709 c.cfg.Focus.FlashDuration = time.Hour
710 c.advanceFlash(nil) // warm owner-owned layout scratch
711 if n := testing.AllocsPerRun(100, func() { c.advanceFlash(nil) }); n != 0 {
712 t.Fatalf("flash geometry validation allocates %v times per frame", n)
713 }
714 })
715 }
716}
717
718func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
719 for _, test := range []struct {
720 name string
721 gaps int
722 float bool
723 level flashLevel
724 radius int
725 }{
726 {"tile with gaps", 12, false, flashWindow, 11},
727 {"column with gaps", 12, false, flashColumn, 11},
728 {"tile without gaps", 0, false, flashWindow, 0},
729 {"float", 0, true, flashWindow, 11},
730 {"monitor", 12, false, flashMonitor, 0},
731 } {
732 t.Run(test.name, func(t *testing.T) {
733 c, clock := flashCore(t)
734 c.cfg.Border.Width, c.cfg.Border.Radius = 3, 14
735 w := c.cur().mon.Current()
736 w.Gaps = test.gaps
737 if test.float {
738 c.applyAction(ActionToggleFloating)
739 c.stopFlash()
740 c.stopAnimations()
741 }
742 id := WindowID(3)
743 if f, ok := w.Focused(); ok && test.float {
744 id = f
745 }
746 startFlash(t, c, clock, test.level, id)
747 want := Rect{W: 300, H: 200}
748 if test.level != flashMonitor {
749 for _, p := range c.cur().mon.Layout() {
750 if p.ID == id {
751 want = p.Rect.Inset(p.Inset, 3)
752 if test.level == flashColumn {
753 want = c.flash.rect
754 }
755 }
756 }
757 }
758 if c.flash.rect != want || c.flash.radius != test.radius {
759 t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)
760 }
761 if f := c.flashFor(c.cur()); f == nil || f.Radius != test.radius {
762 t.Fatalf("published %+v", f)
763 }
764 if test.level == flashWindow && test.gaps > 0 {
765 for _, p := range c.cur().mon.Layout() {
766 if p.ID == id && (p.Inset != ports.SideAll || c.flash.rect.W != p.Rect.W-6 || c.flash.rect.H != p.Rect.H-6) {
767 t.Fatalf("tile %+v not inset by 3 on all sides: %+v", p, c.flash.rect)
768 }
769 }
770 }
771 })
772 }
773}
774
775func TestFlashClickFocus(t *testing.T) {
776 for _, test := range []struct {
777 name string
778 target WindowID
779 enabled bool
780 want bool
781 }{
782 {"different window", 1, true, true},
783 {"already focused", 3, true, false},
784 {"disabled", 1, false, false},
785 } {
786 t.Run(test.name, func(t *testing.T) {
787 c, clock := flashCore(t)
788 c.cfg.Focus.Flash = test.enabled
789 indicatorScene(t, c)
790 if c.keyboardFocus() != 3 {
791 t.Fatal("setup: expected window 3 focused")
792 }
793 c.pointer = test.target
794 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
795 t.Fatal(err)
796 }
797 if c.keyboardFocus() != test.target {
798 t.Fatalf("focus = %d, want %d", c.keyboardFocus(), test.target)
799 }
800 if (c.flash.pending != nil) != test.want {
801 t.Fatalf("flash request = %+v, want flash %v", c.flash.pending, test.want)
802 }
803 // Let the click's viewport transition settle before the flash.
804 clock.now = clock.now.Add(time.Second)
805 stepScenes(t, c, c.cur())
806 if c.flashTick() != test.want {
807 t.Fatalf("flash start: want %v", test.want)
808 }
809 if test.want && (c.flash.running.window != test.target || c.flash.running.level != flashWindow || c.flashFor(c.cur()) == nil) {
810 t.Fatalf("wrong running flash: %+v", c.flash.running)
811 }
812 })
813 }
814}
815
816func TestFlashClickModalFocus(t *testing.T) {
817 for _, unchanged := range []bool{false, true} {
818 t.Run(map[bool]string{false: "changed", true: "unchanged"}[unchanged], func(t *testing.T) {
819 c, _ := flashCore(t)
820 w := c.cur().mon.Current()
821 c.windows.mapped(ports.WindowMapped{ID: 4}, c.now())
822 w.AddDialog(4, 1, 100, 80)
823 w.SetModal(4, true)
824 if !unchanged {
825 w.Click(2)
826 }
827 indicatorScene(t, c)
828 c.pointer = 1
829 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
830 t.Fatal(err)
831 }
832 if c.keyboardFocus() != 4 {
833 t.Fatalf("focus = %d, want modal 4", c.keyboardFocus())
834 }
835 if unchanged {
836 if c.flash.pending != nil {
837 t.Fatalf("unchanged modal flashed: %+v", c.flash.pending)
838 }
839 } else if c.flash.pending == nil || c.flash.pending.window != 4 {
840 t.Fatalf("want modal flash, got %+v", c.flash.pending)
841 }
842 })
843 }
844}
845
846func TestFlashModifierClickDrag(t *testing.T) {
847 for _, resize := range []bool{false, true} {
848 for _, mode := range []string{"changed", "unchanged", "disabled"} {
849 t.Run(map[bool]string{false: "move", true: "resize"}[resize]+"/"+mode, func(t *testing.T) {
850 c, clock := flashCore(t)
851 w := c.cur().mon.Current()
852 w.Click(1)
853 if resize {
854 w.Apply(ActionToggleFloating)
855 }
856 if mode != "unchanged" {
857 w.Click(3)
858 }
859 c.cfg.Focus.Flash = mode != "disabled"
860 indicatorScene(t, c)
861 c.pointer = 1
862 c.mods = c.cmdMod
863 button := uint32(btnLeft)
864 if resize {
865 button = btnRight
866 }
867 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: button, Pressed: true}); err != nil {
868 t.Fatal(err)
869 }
870 if c.drag == nil || c.drag.id != 1 || c.drag.resize != resize {
871 t.Fatalf("drag not started: %+v", c.drag)
872 }
873 want := mode == "changed"
874 if (c.flash.pending != nil) != want {
875 t.Fatalf("flash = %+v, want %v", c.flash.pending, want)
876 }
877 clock.now = clock.now.Add(time.Second)
878 stepScenes(t, c, c.cur())
879 if c.flashTick() != want {
880 t.Fatalf("flash start: want %v", want)
881 }
882 })
883 }
884 }
885}