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