1package core
2
3import (
4 "context"
5 "reflect"
6 "slices"
7 "testing"
8 "time"
9
10 "github.com/bnema/neferwl/internal/ports"
11)
12
13// Allocation budgets of one animation frame (step) on two screens of six
14// windows each, with camera and rect motions (position, fade, dim, scale)
15// running, and on one screen with the overview opening (its cards' scale
16// and fade motions running). Every scene sent is freshly built (immutable
17// once sent), so the floor is not 0. Measured: 43 before presizing the scene
18// windows and separators, 36 after, 32 once the fallback timer is made once
19// and reset per frame (the fallback path, step(ctx, nil), re-arms it; the
20// flip path with another screen animating keeps it and costs the same), then
21// 6 once the layouts are built in the owner's reused buffers (layoutInto:
22// the screens' layout, the workspaces' column, row and tile rects) instead of
23// fresh slices, and 5 once the output layout is built in the scratch too
24// (copied only when it changed). What is left is the scenes themselves
25// (windows, separators, the scene set). Leaving entries (a closed window
26// still fading) add none: 3, less than a frame with six moving windows
27// (fewer configure targets). The opening overview costs 18 (the stack
28// items, the row map and the neighbor list it rebuilds every frame; its
29// layouts reuse the buffers too).
30const (
31 publishAllocBudget = 5
32 publishLeavingAllocBudget = 3
33 publishOverviewAllocBudget = 18
34)
35
36// publishRig is a Core on the real clock (a mock clock would count its own
37// bookkeeping; the fallback timer is then the system one) with screens
38// screens of six windows each. The Slowdown cap keeps every motion running
39// for seconds, so none settles during a measure even under -race or load.
40func publishRig(t testing.TB, screens int) *Core {
41 t.Helper()
42 var cfg ports.Config
43 cfg.Keyboard.CmdKey = "super"
44 cfg.Layout.MaxColumns = 2
45 cfg.Border.Width = 2
46 cfg.Animations.On = true
47 cfg.Animations.Slowdown = 10
48 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: make(chan ports.ClientCommand, 64)}, Options{})
49 if err != nil {
50 t.Fatal(err)
51 }
52 next := WindowID(1)
53 for i := range screens {
54 c.addScreen(ports.OutputInfo{Name: string(rune('A' + i)), Width: 300, Height: 200})
55 }
56 for _, sc := range c.screens {
57 if sc.name() == "" {
58 continue
59 }
60 for range 6 {
61 sc.mon.AddWindow(next)
62 next++
63 }
64 sc.arrange()
65 }
66 c.refreshShown()
67 return c
68}
69
70// publishStep is one animation frame of only (nil: the fallback timer's,
71// every screen) whose scene is taken from the channel.
72func publishStep(t *testing.T, c *Core, only *screen) func() {
73 ctx := context.Background()
74 return func() {
75 if err := c.step(ctx, only); err != nil {
76 t.Fatal(err)
77 }
78 select {
79 case <-c.ch.Scenes:
80 default:
81 t.Fatal("no scene published")
82 }
83 }
84}
85
86// startRectMotions gives every window of sc a rect motion on each
87// component: position and size, fade, dim and content scale.
88func startRectMotions(c *Core, sc *screen, t0 time.Time) {
89 sc.rects = make(map[WindowID]rectMotion, 6)
90 sp := viewSpring(100, 0)
91 for _, p := range sc.settledLayout {
92 sc.rects[p.ID] = rectMotion{
93 x: c.spring(sp, t0), y: c.spring(sp, t0),
94 w: c.spring(sp, t0), h: c.spring(sp, t0),
95 dx: 100, dy: 100, dw: 100, dh: 100,
96 fade: c.spring(levelSpring(0.5, 0), t0), df: 0.5,
97 dim: c.spring(levelSpring(0.5, 0), t0), ddim: 0.5,
98 scale: true,
99 }
100 }
101 sc.rectsWS = sc.mon.Current()
102}
103
104// checkRunning fails unless every real screen still has six rect motions
105// and its camera motion.
106func checkRunning(t *testing.T, c *Core, when string) {
107 t.Helper()
108 for _, sc := range c.screens {
109 if sc.name() == "" {
110 continue
111 }
112 if len(sc.rects) != 6 || !sc.mon.Current().view.motion.on || len(sc.shown) < 6 {
113 t.Fatalf("%s: rects %d camera %v shown %d", when, len(sc.rects), sc.mon.Current().view.motion.on, len(sc.shown))
114 }
115 }
116}
117
118// TestPublishAllocations pins the cost of publishing an animation frame, on
119// the fallback timer (every screen) and on a page flip of one screen while
120// the other still animates (the flip path keeps the timer).
121func TestPublishAllocations(t *testing.T) {
122 c := publishRig(t, 2)
123 t0 := time.Now()
124 for _, sc := range c.screens {
125 if sc.name() == "" {
126 continue
127 }
128 ws := sc.mon.Current()
129 ws.view.motion = c.spring(viewSpring(100, 0), t0)
130 ws.view.off = 100
131 startRectMotions(c, sc, t0)
132 }
133 for _, tc := range []struct {
134 name string
135 only *screen
136 }{{"fallback", nil}, {"flip", c.screens[1]}} {
137 step := publishStep(t, c, tc.only)
138 step()
139 checkRunning(t, c, "setup")
140 if tc.only != nil && !c.animatingOther(tc.only) {
141 t.Fatal("setup: the other screen does not animate")
142 }
143 n := testing.AllocsPerRun(50, step)
144 t.Logf("%s allocs per step = %v", tc.name, n)
145 checkRunning(t, c, "motions settled during the measure")
146 if n > publishAllocBudget {
147 t.Errorf("%s publish allocs per frame = %v, budget %d", tc.name, n, publishAllocBudget)
148 }
149 }
150}
151
152// TestPublishStashSlideAllocations pins the cost of publishing a frame while
153// the stash slides: windows laid out at a fractional offset, each with a
154// partial veil, with the same budget as any other animation frame.
155func TestPublishStashSlideAllocations(t *testing.T) {
156 c := publishRig(t, 1)
157 sc := c.screens[0]
158 ws := sc.mon.Current()
159 for _, id := range []WindowID{2, 3, 4, 5} {
160 ws.FocusID(id)
161 ws.ToggleWindowStash()
162 }
163 sc.arrange()
164 c.refreshShown()
165 ws.stashView.off = 0.5
166 ws.stashView.motion = c.spring(ws.stashSpring(0.5, 0), time.Now())
167 step := publishStep(t, c, nil)
168 step()
169 if !ws.stashView.motion.on || !ws.stashShown() {
170 t.Fatal("setup: no stash slide on screen")
171 }
172 var partial int
173 for _, p := range sc.shown {
174 if p.Veil > 0 && p.Veil < 1 {
175 partial++
176 }
177 }
178 if partial == 0 {
179 t.Fatal("setup: no window draws a partial veil")
180 }
181 n := testing.AllocsPerRun(50, step)
182 t.Logf("stash slide allocs per step = %v", n)
183 if !ws.stashView.motion.on {
184 t.Fatal("the slide settled during the measure")
185 }
186 if n > publishAllocBudget {
187 t.Errorf("stash slide publish allocs per frame = %v, budget %d", n, publishAllocBudget)
188 }
189}
190
191// TestPublishOverviewAllocations pins the cost of publishing while the
192// overview opens: its cards fly from the full rects (scale and fade motions
193// on every card), which guards the reuse of Core.overviewReal. The current
194// workspace is scroll or cascade, alone or between two workspaces with
195// windows (their slots and stacks are laid out every frame too).
196func TestPublishOverviewAllocations(t *testing.T) {
197 for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} {
198 for _, neighbors := range []bool{false, true} {
199 name := string(overflow) + "/alone"
200 if neighbors {
201 name = string(overflow) + "/neighbors"
202 }
203 t.Run(name, func(t *testing.T) {
204 c := publishRig(t, 1)
205 sc := c.cur()
206 m := sc.mon
207 if neighbors {
208 // Windows 1..6 are on workspace 0: move to 1 with a
209 // workspace on each side.
210 m.Focus(1)
211 for id := WindowID(10); id < 16; id++ {
212 m.AddWindow(id)
213 }
214 m.Focus(2)
215 for id := WindowID(20); id < 26; id++ {
216 m.AddWindow(id)
217 }
218 m.Focus(1)
219 }
220 m.Current().Overflow = overflow
221 sc.arrange()
222 c.refreshShown()
223 now := time.Now()
224 shots := c.snapshot(now)
225 m.ToggleOverview()
226 c.transition(shots, now)
227 step := publishStep(t, c, nil)
228 step()
229 want, slots := 6, 1
230 if neighbors {
231 want, slots = 18, 3
232 }
233 if rows, _ := m.overviewRows(); len(rows) != slots {
234 t.Fatalf("setup: %d overview slots, want %d", len(rows), slots)
235 }
236 if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() {
237 t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating())
238 }
239 n := testing.AllocsPerRun(50, step)
240 t.Logf("%s overview allocs per step = %v", name, n)
241 if len(sc.rects) == 0 {
242 t.Fatal("card motions settled during the measure")
243 }
244 if n > publishOverviewAllocBudget {
245 t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
246 }
247 })
248 }
249 }
250 // The column switcher's cards are previews too: a held switch shows
251 // them, and a publish while their motions run costs a plain frame.
252 t.Run("switcher", func(t *testing.T) {
253 c := publishRig(t, 1)
254 c.cmdMod = ports.ModSuper
255 c.mods = ports.ModSuper
256 sc := c.cur()
257 m := sc.mon
258 m.SetMaxColumns(3)
259 sc.arrange()
260 c.refreshShown()
261 ctx := context.Background()
262 if err := c.runBind(ctx, ActionSwitchColumnNext); err != nil {
263 t.Fatal(err)
264 }
265 if !c.switching() || !c.switcherTick() || !m.switcherShown() {
266 t.Fatalf("setup: switching %v, shown %v", c.switching(), m.switcherShown())
267 }
268 // The rig slows every spring: the card motions run for the whole measure.
269 step := publishStep(t, c, nil)
270 step()
271 if len(sc.rects) == 0 || !c.animating() || len(c.overviewReal) == 0 {
272 t.Fatalf("setup: %d rect motions, animating %v, real layouts %d", len(sc.rects), c.animating(), len(c.overviewReal))
273 }
274 publish := func() {
275 if err := c.publish(ctx); err != nil {
276 t.Fatal(err)
277 }
278 select {
279 case <-c.ch.Scenes:
280 default:
281 t.Fatal("no scene published")
282 }
283 }
284 n := testing.AllocsPerRun(50, publish)
285 t.Logf("switcher allocs per publish = %v", n)
286 if len(sc.rects) == 0 {
287 t.Fatal("card motions settled during the measure")
288 }
289 if n > publishAllocBudget {
290 t.Errorf("switcher publish allocs per frame = %v, budget %d", n, publishAllocBudget)
291 }
292 })
293}
294
295// TestPublishLeavingAllocations pins the cost of a frame with leaving
296// entries on every screen (two of the six windows closed, fading out, the
297// other four with rect motions): the measured 4, under a frame without
298// them (the leaving entries take no configure target).
299func TestPublishLeavingAllocations(t *testing.T) {
300 c := publishRig(t, 2)
301 t0 := time.Now()
302 for _, sc := range c.screens {
303 if sc.name() == "" {
304 continue
305 }
306 c.refreshShown()
307 layout := sc.settledLayout
308 sc.rects = make(map[WindowID]rectMotion, 6)
309 sp := viewSpring(100, 0)
310 for i, p := range layout {
311 if i < 2 {
312 sc.mon.RemoveWindow(p.ID)
313 c.leaveFrom(sc, p, nil, t0)
314 continue
315 }
316 sc.rects[p.ID] = rectMotion{
317 x: c.spring(sp, t0), y: c.spring(sp, t0),
318 w: c.spring(sp, t0), h: c.spring(sp, t0),
319 dx: 100, dy: 100, dw: 100, dh: 100,
320 }
321 }
322 sc.rectsWS = sc.mon.Current()
323 }
324 step := publishStep(t, c, nil)
325 step()
326 for _, sc := range c.screens {
327 if sc.name() == "" {
328 continue
329 }
330 leaving := 0
331 for _, p := range sc.shown {
332 if p.Leaving {
333 leaving++
334 }
335 }
336 if len(sc.rects) != 6 || leaving != 2 || len(sc.shown) != 6 {
337 t.Fatalf("setup: rects %d leaving %d shown %d", len(sc.rects), leaving, len(sc.shown))
338 }
339 }
340 n := testing.AllocsPerRun(50, step)
341 t.Logf("leaving allocs per step = %v", n)
342 for _, sc := range c.screens {
343 if sc.name() != "" && len(sc.rects) != 6 {
344 t.Fatalf("motions settled during the measure: rects %d", len(sc.rects))
345 }
346 }
347 if n > publishLeavingAllocBudget {
348 t.Errorf("publish allocs per frame with leaving entries = %v, budget %d", n, publishLeavingAllocBudget)
349 }
350}
351
352// TestPublishSubscribersAllocations pins the cost of an animation frame
353// with the state and workspace subscribers attached: their snapshots are
354// built in the owner's scratch and, unchanged during a camera and rect
355// animation, add nothing to the frame. Measured: 32 when each publish built
356// fresh snapshots (state, workspaces and output layout), 5 since. Sent
357// values never share storage with the scratch they were built in.
358func TestPublishSubscribersAllocations(t *testing.T) {
359 c := publishRig(t, 2)
360 c.ch.State = make(chan ports.State, 1)
361 c.ch.Workspaces = make(chan ports.Workspaces, 1)
362 t0 := time.Now()
363 for _, sc := range c.screens {
364 if sc.name() == "" {
365 continue
366 }
367 ws := sc.mon.Current()
368 ws.view.motion = c.spring(viewSpring(100, 0), t0)
369 ws.view.off = 100
370 startRectMotions(c, sc, t0)
371 }
372 step := publishStep(t, c, nil)
373 step()
374 checkRunning(t, c, "setup")
375 if len(c.ch.State) != 1 || len(c.ch.Workspaces) != 1 {
376 t.Fatal("setup: no snapshot sent")
377 }
378 st := <-c.ch.State
379 ws := <-c.ch.Workspaces
380 n := testing.AllocsPerRun(50, step)
381 t.Logf("subscribers allocs per step = %v", n)
382 checkRunning(t, c, "motions settled during the measure")
383 if n > publishAllocBudget {
384 t.Errorf("publish allocs per frame with subscribers = %v, budget %d", n, publishAllocBudget)
385 }
386 if len(c.ch.State) != 0 || len(c.ch.Workspaces) != 0 {
387 t.Error("an unchanged snapshot was sent again")
388 }
389 // Sent values own their storage: the next publish rewrites the scratch
390 // in place.
391 sc := &c.scratch
392 if st.Window == nil || len(st.Windows) == 0 || len(ws.Outputs) == 0 || len(c.sent.outputs.Outputs) == 0 {
393 t.Fatalf("setup: empty snapshots %+v %+v", st, ws)
394 }
395 if &st.Windows[0] == &sc.state.Windows[:1][0] || &st.Outputs[0] == &sc.state.Outputs[:1][0] || inSlice(st.Window, sc.state.Windows) || inSlice(st.Window, st.Windows) {
396 t.Error("the sent state shares the scratch")
397 }
398 for _, sent := range []ports.Workspaces{ws, c.sent.workspaces} {
399 if &sent.Outputs[0] == &sc.workspaces.Outputs[:1][0] {
400 t.Error("the sent workspaces share the scratch")
401 }
402 for k, o := range sent.Outputs {
403 if &o.Workspaces[0] == &sc.workspaces.Outputs[k].Workspaces[:1][0] {
404 t.Errorf("the sent workspaces of %s share the scratch", o.Name)
405 }
406 }
407 }
408 if &c.sent.outputs.Outputs[0] == &sc.layout[:1][0] {
409 t.Error("the sent output layout shares the scratch")
410 }
411 // And a later change leaves them as they were.
412 want := ports.State{Output: st.Output, Outputs: slices.Clone(st.Outputs), Windows: slices.Clone(st.Windows)}
413 w := *st.Window
414 want.Window = &w
415 c.windows.setIdleInhibit(st.Windows[0].ID, true)
416 c.publishState()
417 // A sent value aliasing the scratch would always equal it: nothing sent.
418 select {
419 case got := <-c.ch.State:
420 if !got.Windows[0].IdleInhibit {
421 t.Fatal("change not published")
422 }
423 default:
424 t.Fatal("change not published")
425 }
426 if !reflect.DeepEqual(st, want) {
427 t.Fatal("a sent snapshot changed after a later publish")
428 }
429}
430
431func inSlice[T any](p *T, s []T) bool {
432 s = s[:cap(s)]
433 for i := range s {
434 if p == &s[i] {
435 return true
436 }
437 }
438 return false
439}
440
441// BenchmarkPublishSubscribers is one animation frame on two screens with
442// the state and workspace subscribers attached.
443func BenchmarkPublishSubscribers(b *testing.B) {
444 c := publishRig(b, 2)
445 c.ch.State = make(chan ports.State, 1)
446 c.ch.Workspaces = make(chan ports.Workspaces, 1)
447 t0 := time.Now()
448 for _, sc := range c.screens {
449 if sc.name() == "" {
450 continue
451 }
452 ws := sc.mon.Current()
453 ws.view.motion = c.spring(viewSpring(100, 0), t0)
454 ws.view.off = 100
455 startRectMotions(c, sc, t0)
456 }
457 ctx := context.Background()
458 b.ReportAllocs()
459 for b.Loop() {
460 if err := c.step(ctx, nil); err != nil {
461 b.Fatal(err)
462 }
463 <-c.ch.Scenes
464 }
465}