1package core
2
3import (
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
15func 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
57func 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
86func 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
106func 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
128func 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
143func 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
165func 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
185func 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
215func 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
235func 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
251func 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
273func 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}