| @@ -0,0 +1,600 @@ |
| 1 | + | package core |
| 2 | + | |
| 3 | + | import ( |
| 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 | + | |
| 12 | + | func 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 | + | |
| 28 | + | func 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 | + | |
| 37 | + | func 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 | + | |
| 70 | + | func 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 | + | |
| 100 | + | func 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 | + | |
| 132 | + | func 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 | + | |
| 156 | + | func 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 | + | |
| 175 | + | func 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 | + | |
| 223 | + | func 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 | + | |
| 235 | + | func 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 | + | |
| 263 | + | func 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 | + | |
| 282 | + | func 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 | + | |
| 299 | + | func 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 | + | |
| 326 | + | func 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 | + | |
| 354 | + | func 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 | + | |
| 373 | + | func 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 | + | |
| 400 | + | func 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 | + | |
| 447 | + | func 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. |
| 471 | + | func 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 | + | |
| 504 | + | func 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 | + | |
| 535 | + | func 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 | + | |
| 552 | + | func 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 | + | |
| 586 | + | func 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 | + | } |