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} {
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 for _, p := range c.cur().mon.Layout() {
119 if p.ID == 3 {
120 want = p.Rect
121 }
122 }
123
124 if c.flash.rect != want {
125 t.Fatalf("rect %+v want %+v", c.flash.rect, want)
126 }
127 })
128 }
129}
130
131func TestFlashSkipsIneligibleTargets(t *testing.T) {
132 for _, test := range []struct {
133 name string
134 change func(*Core, *indicatorClock)
135 }{
136 {"disabled", func(c *Core, _ *indicatorClock) { c.cfg.Focus.Flash = false }},
137 {"animations off", func(c *Core, _ *indicatorClock) { c.cfg.Animations.On = false }},
138 {"protected", func(c *Core, _ *indicatorClock) { c.security.Protected = true }},
139 {"overview", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleOverview() }},
140 {"fullscreen", func(c *Core, _ *indicatorClock) { c.cur().mon.ToggleFullscreen() }},
141 {"gone", func(c *Core, _ *indicatorClock) { c.cur().mon.RemoveWindow(3) }},
142 } {
143 t.Run(test.name, func(t *testing.T) {
144 c, clock := flashCore(t)
145 test.change(c, clock)
146 c.requestFlash(flashWindow, 3)
147 clock.now = clock.now.Add(flashSettle)
148 if c.flashTick() || c.flash.running != nil {
149 t.Fatal("ineligible flash")
150 }
151 })
152 }
153}
154
155func TestFlashStopAndColor(t *testing.T) {
156 c, clock := flashCore(t)
157 c.cfg.Focus.FlashColor = "#123456"
158 c.cfg.Animations.Slowdown = 10
159 startFlash(t, c, clock, flashWindow, 3)
160 if s := indicatorScene(t, c); s.Flash.Color != "#123456" {
161 t.Fatal("configured color ignored")
162 }
163 clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
164 if indicatorScene(t, c).Flash != nil {
165 t.Fatal("duration scaled by slowdown")
166 }
167 c.requestFlash(flashWindow, 2)
168 c.stopFlash()
169 if c.flash.timerC != nil || c.flash.pending != nil || c.flash.running != nil {
170 t.Fatal("stop retained state")
171 }
172}
173
174func TestFlashActionTriggers(t *testing.T) {
175 for _, test := range []struct {
176 action Action
177 level flashLevel
178 setup func(*Core)
179 }{
180 {ActionFocusColumnLeft, flashColumn, nil},
181 {ActionFocusColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
182 {ActionMoveColumnLeft, flashColumn, nil},
183 {ActionMoveColumnRight, flashColumn, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
184 {ActionConsumeOrExpelLeft, flashWindow, nil},
185 {ActionConsumeOrExpelRight, flashWindow, func(c *Core) { c.cur().mon.Current().FocusID(1) }},
186 {ActionToggleFloating, flashWindow, nil},
187 {ActionFocusWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
188 {ActionFocusWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
189 {ActionMoveWindowUp, flashWindow, func(c *Core) { c.cur().mon.Current().ConsumeOrExpel(-1) }},
190 {ActionMoveWindowDown, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ConsumeOrExpel(-1); w.FocusWindow(-1) }},
191 {ActionToggleWindowStash, flashWindow, func(c *Core) { c.cur().mon.Current().ToggleWindowStash() }},
192 {ActionToggleStashVisible, flashWindow, func(c *Core) { w := c.cur().mon.Current(); w.ToggleWindowStash(); w.ToggleStashVisible() }},
193 {ActionToggleOverview, flashWindow, func(c *Core) { c.cur().mon.ToggleOverview() }},
194 } {
195 t.Run(string(test.action), func(t *testing.T) {
196 c, clock := flashCore(t)
197 if test.setup != nil {
198 test.setup(c)
199 }
200 c.applyAction(test.action)
201 if c.flash.pending == nil || c.flash.pending.level != test.level {
202 t.Fatalf("pending %+v want level %v", c.flash.pending, test.level)
203 }
204 target := *c.flash.pending
205 clock.now = clock.now.Add(flashSettle)
206 if !c.flashTick() {
207 t.Fatal("action flash did not start")
208 }
209 var want Rect
210 for _, p := range c.cur().mon.Layout() {
211 if p.ID == target.window {
212 want = p.Rect
213 }
214 }
215 if c.flash.rect != want {
216 t.Fatalf("action rect %+v want %+v", c.flash.rect, want)
217 }
218 })
219 }
220}
221
222func TestFlashExcludedActions(t *testing.T) {
223 for _, action := range []Action{ActionFocusWorkspacePrev, ActionFocusWorkspaceNext, ActionMoveWindowToWorkspaceNext, ActionMoveColumnToWorkspaceNext, ActionToggleFullscreen, ActionMaximizeColumn, ActionCycleColumnWidth, ActionFocusColumnRight, ActionMoveColumnRight, ActionMoveWindowUp} {
224 t.Run(string(action), func(t *testing.T) {
225 c, _ := flashCore(t)
226 c.applyAction(action)
227 if c.flash.pending != nil {
228 t.Fatalf("unexpected flash %+v", c.flash.pending)
229 }
230 })
231 }
232}
233
234func TestFlashCrossOutputActionTriggers(t *testing.T) {
235 for _, action := range []Action{ActionFocusMonitorLeft, ActionFocusMonitorRight, ActionFocusMonitorUp, ActionFocusMonitorDown, ActionMoveWorkspaceLeft, ActionMoveWorkspaceRight, ActionMoveWorkspaceToMonitorUp, ActionMoveWorkspaceToMonitorDown} {
236 t.Run(string(action), func(t *testing.T) {
237 c, clock := flashCore(t)
238 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
239 a, b := c.screens[0], c.screens[1]
240 a.x, a.y = 0, 0
241 b.x, b.y = 300, 0
242 switch monitorDirection(action) {
243 case dirLeft:
244 b.x = -400
245 case dirUp:
246 b.x, b.y = 0, -300
247 case dirDown:
248 b.x, b.y = 0, 200
249 }
250 c.applyAction(action)
251 id, _ := b.mon.Focused()
252 if id == 0 {
253 if c.flash.pending != nil {
254 t.Fatalf("empty destination request %+v", c.flash.pending)
255 }
256 return
257 }
258 if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: id, output: "B"}) {
259 t.Fatalf("destination request %+v", c.flash.pending)
260 }
261 clock.now = clock.now.Add(flashSettle)
262 if !c.flashTick() {
263 t.Fatal("destination window flash did not start")
264 }
265 want, _, ok := c.flashRect(*c.flash.running, b)
266 if !ok || c.flash.rect != want {
267 t.Fatalf("window rect %+v want %+v", c.flash.rect, want)
268 }
269 })
270 }
271}
272
273func TestFlashColumnUnion(t *testing.T) {
274 c, clock := flashCore(t)
275 w := c.cur().mon.Current()
276 w.ConsumeOrExpel(-1)
277 startFlash(t, c, clock, flashColumn, 3)
278 col := w.Columns[w.columnOf(3)]
279 var area int
280 for _, p := range c.cur().mon.Layout() {
281 for _, id := range col.Windows {
282 if id == p.ID {
283 area += p.Rect.W * p.Rect.H
284 }
285 }
286 }
287 if c.flash.rect.W*c.flash.rect.H != area {
288 t.Fatalf("union %+v does not cover column area %d", c.flash.rect, area)
289 }
290}
291
292func TestFlashGestureLanding(t *testing.T) {
293 c, _ := flashCore(t)
294 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
295 c.swipeUpdate(ports.SwipeUpdate{DX: -800, Time: 50 * time.Millisecond})
296 c.swipeEnd(ports.SwipeEnd{Time: 60 * time.Millisecond})
297 if c.flash.pending == nil || c.flash.pending.level != flashColumn {
298 t.Fatalf("landing request %+v", c.flash.pending)
299 }
300 c.stopFlash()
301 c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: 100 * time.Millisecond})
302 c.swipeUpdate(ports.SwipeUpdate{DX: 800, Time: 150 * time.Millisecond})
303 c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: 160 * time.Millisecond})
304 if c.flash.pending != nil {
305 t.Fatal("cancelled gesture flashed")
306 }
307}
308
309func TestFlashLockClearsPendingAndRunning(t *testing.T) {
310 for _, running := range []bool{false, true} {
311 c, clock := flashCore(t)
312 state := ports.SecurityState{}
313 gate := portsmocks.NewMockSessionSecurity(t)
314 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState { return state })
315 c.opts.Security = gate
316 if running {
317 startFlash(t, c, clock, flashWindow, 3)
318 } else {
319 c.requestFlash(flashWindow, 3)
320 }
321 state = ports.SecurityState{Generation: 1, Protected: true}
322 if !c.syncSecurity() {
323 t.Fatal("security did not change")
324 }
325 if c.flash.pending != nil || c.flash.running != nil || c.flash.timerC != nil {
326 t.Fatal("lock retained flash")
327 }
328 state = ports.SecurityState{Generation: 2}
329 c.syncSecurity()
330 if indicatorScene(t, c).Flash != nil || c.flash.pending != nil {
331 t.Fatal("unlock requested flash")
332 }
333 }
334}
335
336func TestFlashOverviewClickAndKeyboard(t *testing.T) {
337 for _, click := range []bool{true, false} {
338 c, _ := flashCore(t)
339 c.cur().mon.ToggleOverview()
340 scene := indicatorScene(t, c)
341 if click {
342 var rect Rect
343 for _, p := range scene.Windows {
344 if p.ID == 2 {
345 rect = p.Rect
346 }
347 }
348 c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
349 picked, err := c.overviewClick(context.Background())
350 if err != nil || !picked {
351 t.Fatalf("click %v %v", picked, err)
352 }
353 } else {
354 if err := c.keyEvent(context.Background(), ports.KeyEvent{Keysym: "Return", Pressed: true}); err != nil {
355 t.Fatal(err)
356 }
357 }
358 if c.flash.pending == nil || c.flash.pending.level != flashWindow {
359 t.Fatalf("overview request %+v", c.flash.pending)
360 }
361 }
362}
363
364func TestFlashTileDrop(t *testing.T) {
365 c, _ := flashCore(t)
366 scene := indicatorScene(t, c)
367 var rect Rect
368 for _, p := range scene.Windows {
369 if p.ID == 2 {
370 rect = p.Rect
371 }
372 }
373 c.cursorX, c.cursorY = float64(rect.X+rect.W/2), float64(rect.Y+rect.H/2)
374 c.drag = &dragState{id: 3}
375 if err := c.endDrag(context.Background()); err != nil {
376 t.Fatal(err)
377 }
378 if c.flash.pending == nil || c.flash.pending.level != flashWindow || c.flash.pending.window != 3 {
379 t.Fatalf("drop request %+v", c.flash.pending)
380 }
381}
382
383func TestFlashNoImplicitFocusTriggers(t *testing.T) {
384 c, _ := flashCore(t)
385 ctx := context.Background()
386 indicatorScene(t, c)
387 if c.flash.pending != nil {
388 t.Fatal("startup publication flashed")
389 }
390 for _, ev := range []ports.ClientEvent{ports.WindowMapped{ID: 4}, ports.WindowActivate{ID: 1}} {
391 if _, stop := c.clientEvent(ctx, ev); stop {
392 t.Fatalf("event %T stopped", ev)
393 }
394 if c.flash.pending != nil {
395 t.Fatalf("event %T flashed", ev)
396 }
397 }
398 indicatorScene(t, c)
399 if err := c.pointerMotion(ctx, ports.PointerMotion{X: 50, Y: 50}); err != nil {
400 t.Fatal(err)
401 }
402 if c.flash.pending != nil {
403 t.Fatal("hover focus flashed")
404 }
405}
406
407func TestFlashCancelsStaleRunningTargets(t *testing.T) {
408 for _, level := range []flashLevel{flashWindow, flashColumn} {
409 for _, change := range []string{"overview", "output gone", "fullscreen", "unmapped", "hidden", "other output", "workspace changed"} {
410 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
411 c, clock := flashCore(t)
412 a := c.cur()
413 c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200})
414 b := c.screens[1]
415 startFlash(t, c, clock, level, 3)
416 if indicatorScene(t, c).Flash == nil {
417 t.Fatal("setup: no running flash")
418 }
419 switch change {
420 case "overview":
421 a.mon.ToggleOverview()
422 case "output gone":
423 c.screens = []*screen{b}
424 c.focusScreen = 0
425 case "fullscreen":
426 a.mon.ToggleFullscreen()
427 case "unmapped":
428 c.windows.drop(3)
429 case "hidden":
430 w := a.mon.Current()
431 w.ToggleWindowStash()
432 w.ToggleStashVisible()
433 case "other output":
434 a.mon.RemoveWindow(3)
435 b.mon.AddWindow(3)
436 case "workspace changed":
437 a.mon.Focus(a.mon.Active + 1)
438 }
439 // Cancellation must not wait for the target output's next flip.
440 scenes := stepScenes(t, c, b)
441 if scenes["A"].Flash != nil || c.flash.running != nil || c.animating() {
442 t.Fatalf("stale flash retained: %+v, animating=%v", scenes["A"].Flash, c.animating())
443 }
444 })
445 }
446 }
447}
448
449func TestFlashCooldownRemembersEveryTarget(t *testing.T) {
450 c, clock := flashCore(t)
451 startFlash(t, c, clock, flashWindow, 3)
452 first := c.flash.start
453 startFlash(t, c, clock, flashWindow, 2)
454 c.requestFlash(flashWindow, 3)
455 clock.now = clock.now.Add(flashSettle)
456 if c.flashTick() || c.flash.running != nil {
457 t.Fatal("A flashed again after B within cooldown")
458 }
459 clock.now = first.Add(flashCooldown)
460 startFlash(t, c, clock, flashWindow, 3)
461 if c.flash.running.window != 3 {
462 t.Fatal("A did not flash after cooldown")
463 }
464 // Inserting after all previous starts expire prunes old target keys.
465 clock.now = clock.now.Add(flashCooldown)
466 startFlash(t, c, clock, flashWindow, 3)
467 if len(c.flash.lastStarts) != 1 {
468 t.Fatalf("expired cooldown entries retained: %v", c.flash.lastStarts)
469 }
470}
471
472// BenchmarkFlashSceneBuild includes revalidation and immutable scene creation.
473func BenchmarkFlashSceneBuild(b *testing.B) {
474 var cfg ports.Config
475 cfg.Layout.MaxColumns = 2
476 cfg.Keyboard.CmdKey = "super"
477 cfg.Animations.On = true
478 cfg.Focus.Flash = true
479 cfg.Focus.FlashOpacity = 0.8
480 cfg.Focus.FlashDuration = time.Hour
481 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1)}, Options{})
482 if err != nil {
483 b.Fatal(err)
484 }
485 c.addScreen(ports.OutputInfo{Name: "A", Width: 1000, Height: 600})
486 c.windows.mapped(ports.WindowMapped{ID: 1}, time.Now())
487 c.cur().mon.AddWindow(1)
488 c.flash.running = &flashTarget{level: flashWindow, window: 1, output: "A"}
489 c.flash.start = time.Now()
490 c.flash.rect = Rect{W: 1000, H: 600}
491 c.refreshShown()
492 ctx := context.Background()
493 b.ReportAllocs()
494 b.ResetTimer()
495 for range b.N {
496 c.refreshShown()
497 c.advanceFlash(nil)
498 scene, err := c.sceneFor(ctx, 0, c.cur(), nil, nil)
499 if err != nil {
500 b.Fatal(err)
501 }
502 c.cur().last = scene
503 }
504}
505
506func TestFlashEdgeWorkspaceSwitchDoesNotFlash(t *testing.T) {
507 for _, tc := range []struct {
508 name string
509 overflow Overflow
510 action Action
511 }{
512 {"vertical", OverflowScroll, ActionFocusWindowDown},
513 {"horizontal", OverflowCascade, ActionFocusColumnRight},
514 } {
515 t.Run(tc.name, func(t *testing.T) {
516 c, _ := flashCore(t)
517 m := c.cur().mon
518 m.SetOverflow(tc.overflow)
519 before := m.Current()
520 m.Focus(1)
521 m.AddWindow(4)
522 m.Focus(0)
523 c.applyAction(tc.action)
524 if m.Current() == before {
525 t.Fatal("setup: edge did not switch workspace")
526 }
527 if id, _ := m.Focused(); id != 4 {
528 t.Fatalf("did not land on occupied workspace: %d", id)
529 }
530 if c.flash.pending != nil || c.flash.running != nil {
531 t.Fatal("workspace switch flashed")
532 }
533 })
534 }
535}
536
537func TestFlashExplicitActionOnFreshlyMappedWindow(t *testing.T) {
538 c, clock := flashCore(t)
539 c.clientEvent(context.Background(), ports.WindowMapped{ID: 4})
540 if c.flash.pending != nil {
541 t.Fatal("mapping requested flash")
542 }
543 c.applyAction(ActionToggleFloating)
544 if c.flash.pending == nil || c.flash.pending.window != 4 {
545 t.Fatal("explicit action did not request flash")
546 }
547 c.stopAnimations()
548 clock.now = clock.now.Add(flashSettle)
549 if !c.flashTick() {
550 t.Fatal("freshly mapped target rejected")
551 }
552}
553
554func TestFlashPendingTimerStopped(t *testing.T) {
555 for _, reason := range []string{"replacement", "security", "run exit"} {
556 t.Run(reason, func(t *testing.T) {
557 c, clock := flashCore(t)
558 timer := portsmocks.NewMockTimer(t)
559 timer.EXPECT().C().Return(make(chan time.Time)).Once()
560 timer.EXPECT().Stop().Return(true).Times(1)
561 strictClock := portsmocks.NewMockClock(t)
562 strictClock.EXPECT().Now().Return(clock.now).Maybe()
563 strictClock.EXPECT().NewTimer(flashSettle).Return(timer).Once()
564 c.opts.Clock = strictClock
565 c.requestFlash(flashWindow, 3)
566 // Only the pending flash timer belongs to the strict clock.
567 c.opts.Clock = nil
568 switch reason {
569 case "replacement":
570 c.requestFlash(flashWindow, 2)
571 c.stopFlash()
572 case "security":
573 gate := portsmocks.NewMockSessionSecurity(t)
574 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
575 c.opts.Security = gate
576 c.syncSecurity()
577 case "run exit":
578 ctx, cancel := context.WithCancel(context.Background())
579 cancel()
580 if err := c.Run(ctx); err != nil {
581 t.Fatal(err)
582 }
583 }
584 })
585 }
586}
587
588func TestFlashUnchangedSampleIsReused(t *testing.T) {
589 c, clock := flashCore(t)
590 startFlash(t, c, clock, flashWindow, 3)
591 first := indicatorScene(t, c).Flash
592 if first == nil {
593 t.Fatal("missing flash")
594 }
595 if next := indicatorScene(t, c).Flash; next != first {
596 t.Fatal("unchanged immutable sample not reused")
597 }
598 clock.now = clock.now.Add(time.Millisecond)
599 if next := indicatorScene(t, c).Flash; next == first || first.Alpha != c.cfg.Focus.FlashOpacity {
600 t.Fatal("changed sample mutated prior scene")
601 }
602}
603
604func TestFlashCrossScreenActionsMarkDestinationWindow(t *testing.T) {
605 for _, tc := range []struct {
606 name string
607 action Action
608 occupied bool
609 want WindowID
610 }{
611 {"column focus to empty", ActionFocusColumnRight, false, 0},
612 {"column focus to occupied", ActionFocusColumnRight, true, 4},
613 {"monitor focus to empty", ActionFocusMonitorRight, false, 0},
614 {"column move to empty", ActionMoveColumnRight, false, 3},
615 {"column move to occupied", ActionMoveColumnRight, true, 3},
616 } {
617 t.Run(tc.name, func(t *testing.T) {
618 c, clock := flashCore(t)
619 a := c.cur()
620 c.addScreen(ports.OutputInfo{Name: "B", Width: 400, Height: 300})
621 b := c.screens[1]
622 if tc.occupied {
623 c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
624 b.mon.AddWindow(4)
625 }
626 c.applyAction(tc.action)
627 if c.cur() != b {
628 t.Fatal("action did not cross screens")
629 }
630 id, _ := b.mon.Focused()
631 if id != tc.want {
632 t.Fatalf("focus %d want %d", id, tc.want)
633 }
634 if tc.want == 0 {
635 if c.flash.pending != nil {
636 t.Fatalf("empty destination request %+v", c.flash.pending)
637 }
638 clock.now = clock.now.Add(flashSettle)
639 if c.flashTick() || stepScenes(t, c, b)["B"].Flash != nil {
640 t.Fatal("empty destination flashed")
641 }
642 return
643 }
644 if c.flash.pending == nil || *c.flash.pending != (flashTarget{level: flashWindow, window: id, output: "B"}) {
645 t.Fatalf("destination request %+v", c.flash.pending)
646 }
647 clock.now = clock.now.Add(flashSettle)
648 if !c.flashTick() {
649 t.Fatal("destination flash did not start")
650 }
651 scenes := stepScenes(t, c, b)
652 if scenes["A"].Flash != nil {
653 t.Fatal("source output flashed")
654 }
655 f := scenes["B"].Flash
656 want, _, ok := c.flashRect(*c.flash.running, b)
657 if f == nil || !ok || f.Rect != want || !scenes["B"].Animating {
658 t.Fatalf("destination scene flash %+v", f)
659 }
660 if !tc.occupied && tc.action != ActionMoveColumnRight && len(scenes["B"].Windows) != 0 {
661 t.Fatal("setup: destination is not empty")
662 }
663 clock.now = clock.now.Add(c.cfg.Focus.FlashDuration)
664 scenes = stepScenes(t, c, b)
665 if scenes["B"].Flash != nil || c.flashing(a) || c.animating() {
666 t.Fatal("destination flash did not end")
667 }
668 })
669 }
670}
671
672func TestFlashSameScreenColumnFocusKeepsColumnLevel(t *testing.T) {
673 c, clock := flashCore(t)
674 sc := c.cur()
675 c.cur().mon.Current().FocusID(1)
676 c.applyAction(ActionFocusColumnRight)
677 if c.cur() != sc || c.flash.pending == nil || c.flash.pending.level != flashColumn {
678 t.Fatalf("same-screen request %+v", c.flash.pending)
679 }
680 clock.now = clock.now.Add(flashSettle)
681 if !c.flashTick() {
682 t.Fatal("column flash did not start")
683 }
684 s := indicatorScene(t, c)
685 for _, w := range s.Windows {
686 if w.ID == c.flash.running.window && s.Flash.Rect != w.Rect {
687 t.Fatalf("column rect %+v want %+v", s.Flash.Rect, w.Rect)
688 }
689 }
690}
691
692func TestFlashCancelsOnGeometryChange(t *testing.T) {
693 for _, level := range []flashLevel{flashWindow, flashColumn} {
694 for _, change := range []string{"resize", "new window"} {
695 t.Run(string(rune('0'+level))+"/"+change, func(t *testing.T) {
696 c, clock := flashCore(t)
697 startFlash(t, c, clock, level, 3)
698 before := indicatorScene(t, c)
699 if before.Flash == nil {
700 t.Fatal("setup: no flash")
701 }
702 if change == "resize" {
703 c.cur().mon.Current().Apply(ActionCycleColumnWidth)
704 } else {
705 c.cur().mon.AddWindow(4)
706 c.windows.mapped(ports.WindowMapped{ID: 4}, clock.now)
707 }
708 after := indicatorScene(t, c)
709 if after.Flash != nil || c.flash.running != nil {
710 t.Fatal("stale geometry retained")
711 }
712 if before.Flash.Rect != c.flash.published.Rect {
713 t.Fatal("old flash sample mutated")
714 }
715 })
716 }
717 }
718}
719
720func TestFlashGeometryRevalidationAllocations(t *testing.T) {
721 for _, level := range []flashLevel{flashWindow, flashColumn} {
722 t.Run(string(rune('0'+level)), func(t *testing.T) {
723 c, clock := flashCore(t)
724 startFlash(t, c, clock, level, 3)
725 // Measure production work, not the generated clock mock's call log.
726 c.opts.Clock = nil
727 c.flash.start = time.Now()
728 c.cfg.Focus.FlashDuration = time.Hour
729 c.advanceFlash(nil) // warm owner-owned layout scratch
730 if n := testing.AllocsPerRun(100, func() { c.advanceFlash(nil) }); n != 0 {
731 t.Fatalf("flash geometry validation allocates %v times per frame", n)
732 }
733 })
734 }
735}
736
737func TestFlashFollowsInnerBorderAndRadius(t *testing.T) {
738 for _, test := range []struct {
739 name string
740 gaps int
741 float bool
742 level flashLevel
743 radius int
744 }{
745 {"tile with gaps", 12, false, flashWindow, 11},
746 {"column with gaps", 12, false, flashColumn, 11},
747 {"tile without gaps", 0, false, flashWindow, 0},
748 {"float", 0, true, flashWindow, 11},
749 } {
750 t.Run(test.name, func(t *testing.T) {
751 c, clock := flashCore(t)
752 c.cfg.Border.Width, c.cfg.Border.Radius = 3, 14
753 w := c.cur().mon.Current()
754 w.Gaps = test.gaps
755 if test.float {
756 c.applyAction(ActionToggleFloating)
757 c.stopFlash()
758 c.stopAnimations()
759 }
760 id := WindowID(3)
761 if f, ok := w.Focused(); ok && test.float {
762 id = f
763 }
764 startFlash(t, c, clock, test.level, id)
765 want := Rect{W: 300, H: 200}
766 for _, p := range c.cur().mon.Layout() {
767 if p.ID == id {
768 want = p.Rect.Inset(p.Inset, 3)
769 if test.level == flashColumn {
770 want = c.flash.rect
771 }
772 }
773 }
774
775 if c.flash.rect != want || c.flash.radius != test.radius {
776 t.Fatalf("rect %+v radius %d, want %+v radius %d", c.flash.rect, c.flash.radius, want, test.radius)
777 }
778 if f := c.flashFor(c.cur()); f == nil || f.Radius != test.radius {
779 t.Fatalf("published %+v", f)
780 }
781 if test.level == flashWindow && test.gaps > 0 {
782 for _, p := range c.cur().mon.Layout() {
783 if p.ID == id && (p.Inset != ports.SideAll || c.flash.rect.W != p.Rect.W-6 || c.flash.rect.H != p.Rect.H-6) {
784 t.Fatalf("tile %+v not inset by 3 on all sides: %+v", p, c.flash.rect)
785 }
786 }
787 }
788 })
789 }
790}
791
792func TestFlashClickFocus(t *testing.T) {
793 for _, test := range []struct {
794 name string
795 target WindowID
796 enabled bool
797 want bool
798 }{
799 {"different window", 1, true, true},
800 {"already focused", 3, true, false},
801 {"disabled", 1, false, false},
802 } {
803 t.Run(test.name, func(t *testing.T) {
804 c, clock := flashCore(t)
805 c.cfg.Focus.Flash = test.enabled
806 indicatorScene(t, c)
807 if c.keyboardFocus() != 3 {
808 t.Fatal("setup: expected window 3 focused")
809 }
810 c.pointer = test.target
811 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
812 t.Fatal(err)
813 }
814 if c.keyboardFocus() != test.target {
815 t.Fatalf("focus = %d, want %d", c.keyboardFocus(), test.target)
816 }
817 if (c.flash.pending != nil) != test.want {
818 t.Fatalf("flash request = %+v, want flash %v", c.flash.pending, test.want)
819 }
820 // Let the click's viewport transition settle before the flash.
821 clock.now = clock.now.Add(time.Second)
822 stepScenes(t, c, c.cur())
823 if c.flashTick() != test.want {
824 t.Fatalf("flash start: want %v", test.want)
825 }
826 if test.want && (c.flash.running.window != test.target || c.flash.running.level != flashWindow || c.flashFor(c.cur()) == nil) {
827 t.Fatalf("wrong running flash: %+v", c.flash.running)
828 }
829 })
830 }
831}
832
833func TestFlashClickModalFocus(t *testing.T) {
834 for _, unchanged := range []bool{false, true} {
835 t.Run(map[bool]string{false: "changed", true: "unchanged"}[unchanged], func(t *testing.T) {
836 c, _ := flashCore(t)
837 w := c.cur().mon.Current()
838 c.windows.mapped(ports.WindowMapped{ID: 4}, c.now())
839 w.AddDialog(4, 1, 100, 80)
840 w.SetModal(4, true)
841 if !unchanged {
842 w.Click(2)
843 }
844 indicatorScene(t, c)
845 c.pointer = 1
846 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: btnLeft, Pressed: true}); err != nil {
847 t.Fatal(err)
848 }
849 if c.keyboardFocus() != 4 {
850 t.Fatalf("focus = %d, want modal 4", c.keyboardFocus())
851 }
852 if unchanged {
853 if c.flash.pending != nil {
854 t.Fatalf("unchanged modal flashed: %+v", c.flash.pending)
855 }
856 } else if c.flash.pending == nil || c.flash.pending.window != 4 {
857 t.Fatalf("want modal flash, got %+v", c.flash.pending)
858 }
859 })
860 }
861}
862
863func TestFlashModifierClickDrag(t *testing.T) {
864 for _, resize := range []bool{false, true} {
865 for _, mode := range []string{"changed", "unchanged", "disabled"} {
866 t.Run(map[bool]string{false: "move", true: "resize"}[resize]+"/"+mode, func(t *testing.T) {
867 c, clock := flashCore(t)
868 w := c.cur().mon.Current()
869 w.Click(1)
870 if resize {
871 w.Apply(ActionToggleFloating)
872 }
873 if mode != "unchanged" {
874 w.Click(3)
875 }
876 c.cfg.Focus.Flash = mode != "disabled"
877 indicatorScene(t, c)
878 c.pointer = 1
879 c.mods = c.cmdMod
880 button := uint32(btnLeft)
881 if resize {
882 button = btnRight
883 }
884 if err := c.pointerButton(context.Background(), ports.PointerButton{Button: button, Pressed: true}); err != nil {
885 t.Fatal(err)
886 }
887 if c.drag == nil || c.drag.id != 1 || c.drag.resize != resize {
888 t.Fatalf("drag not started: %+v", c.drag)
889 }
890 want := mode == "changed"
891 if (c.flash.pending != nil) != want {
892 t.Fatalf("flash = %+v, want %v", c.flash.pending, want)
893 }
894 clock.now = clock.now.Add(time.Second)
895 stepScenes(t, c, c.cur())
896 if c.flashTick() != want {
897 t.Fatalf("flash start: want %v", want)
898 }
899 })
900 }
901 }
902}