| @@ -0,0 +1,344 @@ |
| 1 | + | package core |
| 2 | + | |
| 3 | + | import ( |
| 4 | + | "context" |
| 5 | + | "testing" |
| 6 | + | "testing/synctest" |
| 7 | + | "time" |
| 8 | + | |
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" |
| 10 | + | "github.com/bnema/neferwl/internal/ports" |
| 11 | + | "github.com/stretchr/testify/mock" |
| 12 | + | "github.com/stretchr/testify/require" |
| 13 | + | ) |
| 14 | + | |
| 15 | + | func TestPositionHintChangesAndTiming(t *testing.T) { |
| 16 | + | c, clock := indicatorCore(t) |
| 17 | + | c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true |
| 18 | + | a := c.cur() |
| 19 | + | a.mon.Current().Overflow = OverflowCascade |
| 20 | + | a.mon.Current().MaxColumns = 1 |
| 21 | + | a.mon.AddWindow(1) |
| 22 | + | a.mon.AddWindow(2) |
| 23 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) |
| 24 | + | b := c.screens[1] |
| 25 | + | a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1 |
| 26 | + | a.mon.Current().Focus = 1 |
| 27 | + | c.updatePositionHints(nil) |
| 28 | + | require.False(t, a.positionHint.live) |
| 29 | + | a.mon.Current().Focus = 0 |
| 30 | + | c.updatePositionHints(nil) |
| 31 | + | require.True(t, a.positionHint.sample.Shown()) |
| 32 | + | require.False(t, b.positionHint.live) |
| 33 | + | start := a.positionHint.start |
| 34 | + | clock.now = clock.now.Add(100 * time.Millisecond) |
| 35 | + | c.updatePositionHints(nil) |
| 36 | + | require.Equal(t, start, a.positionHint.start, "no-op restarted timer") |
| 37 | + | a.mon.Current().Focus = 1 |
| 38 | + | c.updatePositionHints(nil) |
| 39 | + | require.Equal(t, clock.now, a.positionHint.start) |
| 40 | + | clock.now = clock.now.Add(positionHintHold) |
| 41 | + | c.updatePositionHints(nil) |
| 42 | + | require.True(t, c.positionHintFading(a), "hold boundary must start frame scheduling") |
| 43 | + | clock.now = clock.now.Add(positionHintFade / 2) |
| 44 | + | c.updatePositionHints(nil) |
| 45 | + | require.Equal(t, float32(0.5), a.positionHint.sample.Opacity) |
| 46 | + | saved := a.positionHint.sample |
| 47 | + | c.focusScreen = 1 |
| 48 | + | c.updatePositionHints(b) |
| 49 | + | require.Equal(t, saved, a.positionHint.sample) |
| 50 | + | require.True(t, b.positionHint.live) |
| 51 | + | clock.now = clock.now.Add(positionHintFade / 2) |
| 52 | + | c.updatePositionHints(a) |
| 53 | + | require.False(t, a.positionHint.live) |
| 54 | + | require.True(t, b.positionHint.live) |
| 55 | + | } |
| 56 | + | |
| 57 | + | func TestPositionHintWorkspaceAndSuppression(t *testing.T) { |
| 58 | + | for _, mode := range []string{"overview", "fullscreen", "off", "no-animation"} { |
| 59 | + | t.Run(mode, func(t *testing.T) { |
| 60 | + | c, clock := indicatorCore(t) |
| 61 | + | c.cfg.Focus.PositionHint = true |
| 62 | + | sc := c.cur() |
| 63 | + | sc.mon.AddWindow(1) |
| 64 | + | c.updatePositionHints(nil) |
| 65 | + | sc.mon.show(sc.mon.Workspaces[1]) |
| 66 | + | c.updatePositionHints(nil) |
| 67 | + | require.True(t, sc.positionHint.live) |
| 68 | + | switch mode { |
| 69 | + | case "overview": |
| 70 | + | sc.mon.ov.open = true |
| 71 | + | case "fullscreen": |
| 72 | + | sc.mon.Current().Overflow = OverflowFixed |
| 73 | + | sc.mon.Current().fullscreen = 1 |
| 74 | + | case "off": |
| 75 | + | c.cfg.Focus.PositionHint = false |
| 76 | + | default: |
| 77 | + | clock.now = clock.now.Add(positionHintHold) |
| 78 | + | } |
| 79 | + | c.updatePositionHints(nil) |
| 80 | + | require.False(t, sc.positionHint.sample.Shown()) |
| 81 | + | require.Nil(t, c.positionHintTimer.timerC) |
| 82 | + | }) |
| 83 | + | } |
| 84 | + | } |
| 85 | + | |
| 86 | + | func TestPositionHintGrid(t *testing.T) { |
| 87 | + | c, _ := indicatorCore(t) |
| 88 | + | m := c.cur().mon |
| 89 | + | w := m.Current() |
| 90 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 |
| 91 | + | m.AddWindow(1) |
| 92 | + | m.AddWindow(2) |
| 93 | + | h := positionHintGrid(m) |
| 94 | + | require.False(t, h.Vertical) |
| 95 | + | require.Equal(t, uint8(2), h.Counts[0]) |
| 96 | + | require.Equal(t, uint8(1), h.Counts[1], "empty noncascade workspace") |
| 97 | + | require.Equal(t, uint8(1), h.ActiveRow) |
| 98 | + | m.show(m.Workspaces[1]) |
| 99 | + | h = positionHintGrid(m) |
| 100 | + | require.True(t, h.Vertical, "current workspace decides axis") |
| 101 | + | require.Equal(t, uint8(1), h.Columns) |
| 102 | + | require.Equal(t, uint8(2), h.Counts[0]) |
| 103 | + | require.Equal(t, uint8(1), h.ActiveRow) |
| 104 | + | } |
| 105 | + | |
| 106 | + | func TestPositionHintAllocations(t *testing.T) { |
| 107 | + | // Real clock avoids measuring generated mock bookkeeping, as publishRig does. |
| 108 | + | c := publishRig(t, 1) |
| 109 | + | c.cfg.Focus.PositionHint = true |
| 110 | + | sc := c.cur() |
| 111 | + | w := sc.mon.Current() |
| 112 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 |
| 113 | + | c.updatePositionHints(nil) |
| 114 | + | defer c.stopPositionHintTimer() |
| 115 | + | for name, run := range map[string]func(){ |
| 116 | + | "trigger": func() { w.Focus = (w.Focus + 1) % 2; c.updatePositionHints(nil) }, |
| 117 | + | "fade": func() { |
| 118 | + | sc.positionHint.live = true |
| 119 | + | sc.positionHint.start = c.now().Add(-positionHintHold - 50*time.Millisecond) |
| 120 | + | c.updatePositionHints(nil) |
| 121 | + | }, |
| 122 | + | "inactive": func() { sc.positionHint.live = false; c.updatePositionHints(nil) }, |
| 123 | + | } { |
| 124 | + | t.Run(name, func(t *testing.T) { run(); require.Zero(t, testing.AllocsPerRun(100, run)) }) |
| 125 | + | } |
| 126 | + | } |
| 127 | + | |
| 128 | + | func TestPositionHintMoveWorkspaceDestination(t *testing.T) { |
| 129 | + | c, _ := indicatorCore(t) |
| 130 | + | c.cfg.Focus.PositionHint = true |
| 131 | + | c.cur().mon.AddWindow(1) |
| 132 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) |
| 133 | + | c.updatePositionHints(nil) |
| 134 | + | source := c.cur() |
| 135 | + | moved := source.mon.Current() |
| 136 | + | c.moveWorkspace(dirRight) |
| 137 | + | c.updatePositionHints(nil) |
| 138 | + | require.Same(t, moved, c.cur().mon.Current()) |
| 139 | + | require.False(t, source.positionHint.live) |
| 140 | + | require.True(t, c.cur().positionHint.sample.Shown()) |
| 141 | + | } |
| 142 | + | |
| 143 | + | func TestPositionHintCapsAndProtection(t *testing.T) { |
| 144 | + | c, _ := indicatorCore(t) |
| 145 | + | c.cfg.Focus.PositionHint = true |
| 146 | + | m := c.cur().mon |
| 147 | + | for range 20 { |
| 148 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) |
| 149 | + | } |
| 150 | + | m.Active = 20 |
| 151 | + | h := positionHintGrid(m) |
| 152 | + | require.Equal(t, uint8(16), h.Counts[0]) |
| 153 | + | require.Equal(t, uint8(15), h.ActiveRow) |
| 154 | + | m.Current().Overflow = OverflowCascade |
| 155 | + | h = positionHintGrid(m) |
| 156 | + | require.Equal(t, uint8(16), h.Columns) |
| 157 | + | require.Equal(t, uint8(15), h.ActiveCol) |
| 158 | + | c.updatePositionHints(nil) |
| 159 | + | c.cur().positionHint.live = true |
| 160 | + | c.security.Protected = true |
| 161 | + | c.updatePositionHints(nil) |
| 162 | + | require.False(t, c.cur().positionHint.live) |
| 163 | + | } |
| 164 | + | |
| 165 | + | func TestPositionHintIncludesNamedEmptyAndMixedOverflow(t *testing.T) { |
| 166 | + | c, _ := indicatorCore(t) |
| 167 | + | m := c.cur().mon |
| 168 | + | w := m.Current() |
| 169 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 |
| 170 | + | m.AddWindow(1) |
| 171 | + | m.AddWindow(2) |
| 172 | + | named := m.newWorkspace() |
| 173 | + | named.Name, named.Overflow = "hidden", OverflowScroll |
| 174 | + | named.Columns = []Column{{Windows: []WindowID{3}}, {Windows: []WindowID{4}, BandStart: true}} |
| 175 | + | m.hidden = append(m.hidden, named) |
| 176 | + | empty := m.newWorkspace() |
| 177 | + | empty.Name = "empty" |
| 178 | + | m.hidden = append(m.hidden, empty) |
| 179 | + | h := positionHintGrid(m) |
| 180 | + | require.Equal(t, uint8(4), h.Columns) |
| 181 | + | require.Equal(t, [16]uint8{2, 1, 1, 1}, h.Counts) |
| 182 | + | require.Equal(t, uint8(0), h.ActiveCol) |
| 183 | + | } |
| 184 | + | |
| 185 | + | func TestPositionHintOverviewCommittedBaseline(t *testing.T) { |
| 186 | + | for _, cancel := range []bool{false, true} { |
| 187 | + | t.Run(map[bool]string{false: "accept", true: "cancel"}[cancel], func(t *testing.T) { |
| 188 | + | c, _ := indicatorCore(t) |
| 189 | + | c.cfg.Focus.PositionHint = true |
| 190 | + | m := c.cur().mon |
| 191 | + | m.AddWindow(1) |
| 192 | + | origin := m.Current() |
| 193 | + | other := m.Workspaces[1] |
| 194 | + | other.AddWindow(2) |
| 195 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) |
| 196 | + | m.ToggleOverview() |
| 197 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) |
| 198 | + | m.showOverview(other) |
| 199 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) |
| 200 | + | require.Same(t, origin, c.cur().positionHint.workspace) |
| 201 | + | m.showOverview(origin) |
| 202 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) |
| 203 | + | m.showOverview(other) |
| 204 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) |
| 205 | + | if cancel { |
| 206 | + | m.CancelOverview() |
| 207 | + | } else { |
| 208 | + | m.ToggleOverview() |
| 209 | + | } |
| 210 | + | require.Equal(t, !cancel, indicatorScene(t, c).PositionHint.Shown()) |
| 211 | + | }) |
| 212 | + | } |
| 213 | + | } |
| 214 | + | |
| 215 | + | func TestPositionHintScrolledAwayFullscreen(t *testing.T) { |
| 216 | + | c, _ := indicatorCore(t) |
| 217 | + | c.cfg.Focus.PositionHint = true |
| 218 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) |
| 219 | + | a := c.screens[0] |
| 220 | + | w := a.mon.Current() |
| 221 | + | w.Overflow = OverflowScroll |
| 222 | + | a.mon.AddWindow(1) |
| 223 | + | a.mon.AddWindow(2) |
| 224 | + | w.fullscreen = 1 |
| 225 | + | w.View = w.columnX(1) |
| 226 | + | require.NotZero(t, w.fullscreen) |
| 227 | + | require.Zero(t, w.cover()) |
| 228 | + | c.focusScreen = 1 |
| 229 | + | c.updatePositionHints(nil) |
| 230 | + | c.focusScreen = 0 |
| 231 | + | c.updatePositionHints(nil) |
| 232 | + | require.True(t, a.positionHint.sample.Shown()) |
| 233 | + | } |
| 234 | + | |
| 235 | + | func TestPositionHintStartupHotplugReload(t *testing.T) { |
| 236 | + | c, _ := indicatorCore(t) |
| 237 | + | c.cfg.Focus.PositionHint = true |
| 238 | + | require.False(t, indicatorScene(t, c).PositionHint.Shown()) |
| 239 | + | c.addScreen(ports.OutputInfo{Name: "B", Width: 300, Height: 200}) |
| 240 | + | for _, s := range stepScenes(t, c, nil) { |
| 241 | + | require.False(t, s.PositionHint.Shown()) |
| 242 | + | } |
| 243 | + | require.NoError(t, c.apply(c.cfg)) |
| 244 | + | for _, s := range stepScenes(t, c, nil) { |
| 245 | + | require.False(t, s.PositionHint.Shown()) |
| 246 | + | } |
| 247 | + | c.focusScreen = 1 |
| 248 | + | require.True(t, stepScenes(t, c, nil)["B"].PositionHint.Shown()) |
| 249 | + | } |
| 250 | + | |
| 251 | + | func TestPositionHintMaxGridTransitionAllocations(t *testing.T) { |
| 252 | + | c := publishRig(t, 1) |
| 253 | + | c.cfg.Focus.PositionHint = true |
| 254 | + | m := c.cur().mon |
| 255 | + | for len(m.Workspaces) < 16 { |
| 256 | + | m.Workspaces = append(m.Workspaces, m.newWorkspace()) |
| 257 | + | } |
| 258 | + | for _, w := range m.Workspaces { |
| 259 | + | w.Overflow, w.MaxColumns = OverflowCascade, 1 |
| 260 | + | w.Columns = make([]Column, 16) |
| 261 | + | for i := range w.Columns { |
| 262 | + | w.Columns[i] = Column{Windows: []WindowID{WindowID(i + 100)}, BandStart: true} |
| 263 | + | } |
| 264 | + | } |
| 265 | + | c.updatePositionHints(nil) |
| 266 | + | defer c.stopPositionHintTimer() |
| 267 | + | run := func() { m.Active = (m.Active + 1) % 16; c.updatePositionHints(nil) } |
| 268 | + | run() |
| 269 | + | require.Equal(t, [16]uint8{16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16}, c.cur().positionHint.sample.Counts) |
| 270 | + | require.Zero(t, testing.AllocsPerRun(100, run)) |
| 271 | + | } |
| 272 | + | |
| 273 | + | func TestPositionHintRunTimerLifecycle(t *testing.T) { |
| 274 | + | synctest.Test(t, func(t *testing.T) { |
| 275 | + | c := publishRig(t, 2) |
| 276 | + | c.cfg.Focus.PositionHint, c.cfg.Animations.On = true, true |
| 277 | + | c.ch.Commands = nil |
| 278 | + | now := time.Unix(1000, 0) |
| 279 | + | clock := portsmocks.NewMockClock(t) |
| 280 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe() |
| 281 | + | timer := portsmocks.NewMockTimer(t) |
| 282 | + | tick := make(chan time.Time, 1) |
| 283 | + | timer.EXPECT().C().Return((<-chan time.Time)(tick)).Maybe() |
| 284 | + | timer.EXPECT().Reset(500 * time.Millisecond).Return(true).Once() |
| 285 | + | timer.EXPECT().Reset(100 * time.Millisecond).Return(true).Once() |
| 286 | + | timer.EXPECT().Stop().Return(true).Once() |
| 287 | + | timer.EXPECT().Reset(positionHintHold).Return(true).Once() |
| 288 | + | clock.EXPECT().NewTimer(positionHintHold).Return(timer).Once() |
| 289 | + | // The second timer is the existing animation fallback, not another hint timer. |
| 290 | + | frame := portsmocks.NewMockTimer(t) |
| 291 | + | frame.EXPECT().C().Return(make(chan time.Time)).Maybe() |
| 292 | + | frame.EXPECT().Stop().Return(true).Maybe() |
| 293 | + | frame.EXPECT().Reset(mock.Anything).Return(true).Maybe() |
| 294 | + | clock.EXPECT().NewTimer(mock.Anything).Return(frame).Maybe() |
| 295 | + | c.opts.Clock = clock |
| 296 | + | a, b := c.screens[0], c.screens[1] |
| 297 | + | a.mon.Current().Overflow, a.mon.Current().MaxColumns = OverflowCascade, 1 |
| 298 | + | a.mon.Current().Focus = 1 |
| 299 | + | c.updatePositionHints(nil) |
| 300 | + | a.mon.Current().Focus = 0 |
| 301 | + | c.updatePositionHints(nil) |
| 302 | + | now = now.Add(100 * time.Millisecond) |
| 303 | + | c.focusScreen = 1 |
| 304 | + | c.updatePositionHints(nil) |
| 305 | + | require.Same(t, timer, c.positionHintTimer.timer) |
| 306 | + | frames := make(chan ports.OutputFrame) |
| 307 | + | c.ch.Frames = frames |
| 308 | + | ctx, cancel := context.WithCancel(context.Background()) |
| 309 | + | done := make(chan error, 1) |
| 310 | + | go func() { done <- c.Run(ctx) }() |
| 311 | + | synctest.Wait() |
| 312 | + | now = now.Add(500 * time.Millisecond) |
| 313 | + | tick <- now |
| 314 | + | synctest.Wait() |
| 315 | + | require.True(t, a.positionHint.fading) |
| 316 | + | require.False(t, b.positionHint.fading) |
| 317 | + | // B still holds for 100ms; the same timer selects its deadline. |
| 318 | + | now = now.Add(100 * time.Millisecond) |
| 319 | + | tick <- now |
| 320 | + | synctest.Wait() |
| 321 | + | require.True(t, b.positionHint.fading) |
| 322 | + | require.Nil(t, c.positionHintTimer.timerC) |
| 323 | + | now = now.Add(positionHintFade) |
| 324 | + | frames <- ports.OutputFrame{Output: "A"} |
| 325 | + | synctest.Wait() |
| 326 | + | frames <- ports.OutputFrame{Output: "B"} |
| 327 | + | synctest.Wait() |
| 328 | + | require.False(t, a.positionHint.sample.Shown()) |
| 329 | + | require.False(t, b.positionHint.sample.Shown()) |
| 330 | + | require.False(t, c.animating()) |
| 331 | + | require.Nil(t, c.positionHintTimer.timerC) |
| 332 | + | // Owner is durably blocked: disabling a newly restarted hint stops |
| 333 | + | // the retained timer instead of leaving an idle wakeup armed. |
| 334 | + | c.focusScreen = 0 |
| 335 | + | c.updatePositionHints(nil) |
| 336 | + | require.NotNil(t, c.positionHintTimer.timerC) |
| 337 | + | c.cfg.Focus.PositionHint = false |
| 338 | + | c.updatePositionHints(nil) |
| 339 | + | require.Nil(t, c.positionHintTimer.timerC) |
| 340 | + | cancel() |
| 341 | + | synctest.Wait() |
| 342 | + | require.NoError(t, <-done) |
| 343 | + | }) |
| 344 | + | } |