1package core
2
3import (
4 "maps"
5 "reflect"
6 "slices"
7 "testing"
8 "time"
9
10 "github.com/bnema/neferwl/internal/ports"
11)
12
13// layoutVariants builds monitors covering the layout paths that use scratch:
14// scroll and fixed overflow, a column set in cells, row shares, floats, a stash,
15// a fullscreen window, a hidden neighbor workspace, the overview and a slide
16// toward a neighbor workspace.
17func layoutVariants(t *testing.T) map[string]*Monitor {
18 t.Helper()
19 base := func(o Overflow, n int) *Monitor {
20 m := newMonitor("", "")
21 m.SetOutput(300, 200)
22 m.SetOverflow(o)
23 m.SetGaps(5)
24 m.SetMaxColumns(3)
25 for id := WindowID(1); id <= WindowID(n); id++ {
26 m.AddWindow(id)
27 }
28 return m
29 }
30 v := map[string]*Monitor{}
31 v["scroll"] = base(OverflowScroll, 5)
32 v["fixed"] = base(OverflowFixed, 5)
33 v["fixed-spiral"] = base(OverflowFixed, 7)
34
35 m := base(OverflowFixed, 3)
36 m.Current().Columns[1].Cells = 2
37 v["fixed-cells"] = m
38
39 m = base(OverflowScroll, 4)
40 w := m.Current()
41 w.Columns[0].Windows = []WindowID{1, 5, 6}
42 w.Columns[0].Shares = []int{50, 30, 20}
43 m.AddWindow(5)
44 v["shares"] = m
45
46 m = base(OverflowScroll, 4)
47 m.AddFloating(10, 100, 80)
48 m.AddFloating(11, 60, 40)
49 v["floats"] = m
50
51 m = base(OverflowScroll, 4)
52 m.Current().Apply(ActionToggleWindowStash)
53 v["stash"] = m
54
55 m = base(OverflowScroll, 4)
56 m.Current().SetFullscreen(2, true)
57 v["fullscreen"] = m
58
59 m = base(OverflowScroll, 3)
60 m.Apply(ActionFocusWorkspaceNext)
61 m.AddWindow(20)
62 m.AddWindow(21)
63 m.Apply(ActionFocusWorkspacePrev)
64 v["two-workspaces"] = m
65
66 m = base(OverflowScroll, 3)
67 m.AddFloating(10, 100, 80)
68 m.ToggleOverview()
69 v["overview"] = m
70
71 m = base(OverflowScroll, 3)
72 m.Apply(ActionFocusWorkspaceNext)
73 m.AddWindow(20)
74 m.Apply(ActionFocusWorkspacePrev)
75 m.switchView.off = 0.4
76 v["slide"] = m
77
78 // The variants must hold what they are named for, on the workspace on
79 // screen (the one whose scratch the tests exercise).
80 for name, has := range map[string]func(*Workspace) bool{
81 "stash": func(w *Workspace) bool { return len(w.Stash) > 0 && len(w.Columns) > 0 },
82 "floats": func(w *Workspace) bool { return len(w.Floats) == 2 && len(w.Columns) > 0 },
83 "shares": func(w *Workspace) bool { return validShares(w.Columns[0]) },
84 "fixed-cells": func(w *Workspace) bool { return w.Columns[1].Cells == 2 },
85 "fullscreen": func(w *Workspace) bool { return w.fullscreen == 2 },
86 "scroll": func(w *Workspace) bool { return len(w.Columns) == 5 },
87 } {
88 if w := v[name].Current(); len(w.Columns) == 0 || !has(w) {
89 t.Fatalf("variant %s: the workspace on screen lacks its feature: %+v", name, w.Columns)
90 }
91 }
92 return v
93}
94
95// TestLayoutIntoMatchesLayout: the buffer-reusing API gives the same
96// placements as the fresh one, whatever the buffer held before (a larger
97// stale layout, another monitor's), and repeatedly.
98func TestLayoutIntoMatchesLayout(t *testing.T) {
99 variants := layoutVariants(t)
100 var buf []Placement
101 for _, name := range slices.Sorted(maps.Keys(variants)) {
102 m := variants[name]
103 want := m.Layout()
104 if len(want) == 0 {
105 t.Fatalf("%s: empty layout", name)
106 }
107 for range 3 {
108 // Poison the reused buffer: stale entries must not leak.
109 for i := range buf[:cap(buf)] {
110 buf[:cap(buf)][i] = Placement{ID: 999, Rect: Rect{X: 1, Y: 1, W: 1, H: 1}, Focused: true, Preview: 3}
111 }
112 buf = m.layoutInto(buf)
113 if !slices.Equal(buf, want) {
114 t.Fatalf("%s: layoutInto differs\n got %+v\nwant %+v", name, buf, want)
115 }
116 if again := m.Layout(); !slices.Equal(again, want) {
117 t.Fatalf("%s: Layout changed after layoutInto\n got %+v\nwant %+v", name, again, want)
118 }
119 }
120 for w := range m.all() {
121 ws := w.Layout()
122 if got := w.layoutInto(buf); !slices.Equal(got, ws) {
123 t.Fatalf("%s: workspace layoutInto differs", name)
124 }
125 }
126 }
127}
128
129// TestLayoutResultsDoNotAlias: Layout() returns a slice the caller owns. A
130// retained result stays unchanged after further layouts of the same monitor
131// or workspace (nested or later, reused-buffer calls included), and two
132// results never share storage.
133func TestLayoutResultsDoNotAlias(t *testing.T) {
134 for name, m := range layoutVariants(t) {
135 first := m.Layout()
136 kept := slices.Clone(first)
137 wfirst := m.Current().Layout()
138 wkept := slices.Clone(wfirst)
139
140 second := m.Layout()
141 if &first[0] == &second[0] {
142 t.Fatalf("%s: two Layout() calls share storage", name)
143 }
144 var buf []Placement
145 for range 2 {
146 buf = m.layoutInto(buf)
147 buf = m.Current().layoutInto(buf)
148 m.Current().previewTiles()
149 m.Current().overviewZoom(m.overviewAxis())
150 }
151 // layoutInto builds in the caller's buffer, never in a scratch of
152 // the workspace: with the capacity there it returns that storage.
153 w := m.Current()
154 big := make([]Placement, 0, 2*len(buf)+8)
155 res := w.layoutInto(big)
156 if len(res) == 0 || &res[0] != &big[:1][0] {
157 t.Fatalf("%s: Workspace.layoutInto did not build in the caller's buffer", name)
158 }
159 if res = m.layoutInto(big); len(res) == 0 || &res[0] != &big[:1][0] {
160 t.Fatalf("%s: Monitor.layoutInto did not build in the caller's buffer", name)
161 }
162 // A result kept across the calls that use the workspace scratch
163 // (zoom tiles, preview tiles, columns, rows, a second layout) is
164 // unchanged.
165 mine := w.layoutInto(make([]Placement, 0, len(res)))
166 mineKept := slices.Clone(mine)
167 w.overviewZoom(m.overviewAxis())
168 w.peekStep(m.overviewAxis())
169 w.previewTiles()
170 w.columnRects()
171 _ = w.Layout()
172 _ = w.layoutInto(nil)
173 _ = m.Layout()
174 if !slices.Equal(mine, mineKept) {
175 t.Fatalf("%s: a kept layoutInto result changed across scratch-using calls\n got %+v\nwant %+v", name, mine, mineKept)
176 }
177 // Change the layout itself, then lay out again.
178 if m.ov.open {
179 m.OverviewMove(1, 0)
180 } else {
181 m.Current().Apply(ActionCycleColumnWidth)
182 }
183 _ = m.Layout()
184 _ = m.Current().Layout()
185 m.switchView.off = 0.3
186 _ = m.Layout()
187 m.switchView.off = 0
188
189 if !slices.Equal(first, kept) {
190 t.Fatalf("%s: a retained Monitor.Layout() changed\n got %+v\nwant %+v", name, first, kept)
191 }
192 if !slices.Equal(wfirst, wkept) {
193 t.Fatalf("%s: a retained Workspace.Layout() changed", name)
194 }
195 }
196}
197
198// TestRectHelpersKeepAppendSemantics: the Into helpers append after dst's
199// length and give the same rows as the fresh ones.
200func TestRectHelpersKeepAppendSemantics(t *testing.T) {
201 r := Rect{X: 3, Y: 4, W: 50, H: 103}
202 pre := []Rect{{W: 1}, {W: 2}}
203 got := stackRectsInto(slices.Clone(pre), r, 3, 5)
204 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], stackRects(r, 3, 5)) {
205 t.Fatalf("stackRectsInto: %+v", got)
206 }
207 c := Column{Windows: []WindowID{1, 2, 3}, Shares: []int{50, 30, 20}}
208 got = rowRectsInto(slices.Clone(pre), r, c, 5)
209 if !slices.Equal(got[:2], pre) || !slices.Equal(got[2:], rowRects(r, c, 5)) {
210 t.Fatalf("rowRectsInto: %+v", got)
211 }
212 // A reused destination overwrites in place.
213 buf := make([]Rect, 0, 8)
214 a := stackRectsInto(buf, r, 3, 5)
215 b := stackRectsInto(buf, r, 2, 5)
216 if &a[0] != &b[0] || len(b) != 2 {
217 t.Fatalf("not reused: %d %d", len(a), len(b))
218 }
219}
220
221// cloneScene copies every slice a scene carries.
222func cloneScene(s ports.Scene) ports.Scene {
223 s.Windows = slices.Clone(s.Windows)
224 s.Separators = slices.Clone(s.Separators)
225 s.DropHints = slices.Clone(s.DropHints)
226 s.Layers = slices.Clone(s.Layers)
227 s.CaptureIndicators = slices.Clone(s.CaptureIndicators)
228 return s
229}
230
231// TestPublishedScenesAreNeverMutated: scenes cross to another goroutine and
232// are immutable, so none may alias the layout scratch that the next publish
233// rebuilds. Frames run with camera and rect motions, then with the overview
234// opening; every earlier scene is compared with its copy at the end. Under
235// -race it also catches a later publish writing what a sent scene reads.
236func TestPublishedScenesAreNeverMutated(t *testing.T) {
237 type kept struct{ sent, copy ports.Scene }
238 var all []kept
239 // fresh is every screen's layout as a fresh Layout() gives it, taken
240 // before a step: the scene it publishes lists the same windows in the
241 // same order, and, where nothing moves (no rect motion, no camera), the
242 // same rects. A layout scratch shared with the real layouts the overview
243 // reads would show here.
244 type expect struct {
245 out string
246 layout []Placement
247 still bool
248 }
249 fresh := func(c *Core) []expect {
250 var e []expect
251 for _, sc := range c.screens {
252 if sc.name() != "" {
253 e = append(e, expect{sc.name(), sc.mon.Layout(), len(sc.rects) == 0 && !sc.mon.Current().view.motion.on})
254 }
255 }
256 return e
257 }
258 check := func(set []ports.Scene, want []expect, frame int) {
259 t.Helper()
260 if len(set) != len(want) {
261 t.Fatalf("frame %d: %d scenes, %d screens", frame, len(set), len(want))
262 }
263 for i, s := range set {
264 e := want[i]
265 if s.Output != e.out || len(s.Windows) != len(e.layout) {
266 t.Fatalf("frame %d: scene %s has %d windows, layout %d", frame, s.Output, len(s.Windows), len(e.layout))
267 }
268 for k, p := range e.layout {
269 w := s.Windows[k]
270 if w.ID != p.ID || w.Hidden != p.Hidden || (e.still && w.Rect != p.Rect) {
271 t.Fatalf("frame %d: scene %s window %d is %+v, the layout has %+v", frame, s.Output, k, w, p)
272 }
273 }
274 }
275 }
276 frame := 0
277 step := func(c *Core) {
278 t.Helper()
279 want := fresh(c)
280 if err := c.step(t.Context(), nil); err != nil {
281 t.Fatal(err)
282 }
283 select {
284 case set := <-c.ch.Scenes:
285 check(set, want, frame)
286 frame++
287 for _, s := range set {
288 all = append(all, kept{s, cloneScene(s)})
289 }
290 default:
291 t.Fatal("no scene published")
292 }
293 }
294
295 c := publishRig(t, 2)
296 t0 := time.Now()
297 for _, sc := range c.screens {
298 if sc.name() == "" {
299 continue
300 }
301 ws := sc.mon.Current()
302 ws.view.motion = c.spring(viewSpring(100, 0), t0)
303 ws.view.off = 100
304 startRectMotions(c, sc, t0)
305 }
306 for range 12 {
307 step(c)
308 }
309
310 // The overview opening: card motions, the real layouts, separators.
311 o := publishRig(t, 1)
312 now := time.Now()
313 shots := o.snapshot(now)
314 o.cur().mon.ToggleOverview()
315 o.transition(shots, now)
316 for range 12 {
317 step(o)
318 }
319 // The overview settled (no card motion): its scenes list the previews
320 // exactly as the monitor lays them out, frame after frame.
321 o.cur().stopRects()
322 still := 0
323 for range 3 {
324 if w := fresh(o); !w[0].still {
325 t.Fatal("setup: the overview still moves")
326 }
327 step(o)
328 still++
329 }
330 if !o.cur().mon.ov.open || still == 0 {
331 t.Fatal("setup: overview closed")
332 }
333
334 if len(all) < 39 {
335 t.Fatalf("only %d scenes", len(all))
336 }
337 changed := 0
338 for i := 1; i < len(all); i++ {
339 if !reflect.DeepEqual(all[i].sent, all[i-1].sent) {
340 changed++
341 }
342 }
343 if changed == 0 {
344 t.Fatal("frames never differ: the test would catch nothing")
345 }
346 for i, k := range all {
347 if !reflect.DeepEqual(k.sent, k.copy) {
348 t.Fatalf("scene %d (%s) was mutated after it was sent", i, k.sent.Output)
349 }
350 }
351}
352
353// TestSettledLayoutEmptyFallsBackToLive: an empty layout is nil (as a fresh
354// Layout() was), so a screen with nothing published yet measures the live
355// layout: a window added after a publish of an empty monitor is listed by
356// shownLayout without another publish.
357func TestSettledLayoutEmptyFallsBackToLive(t *testing.T) {
358 c := publishRig(t, 1)
359 sc := c.cur()
360 for _, p := range slices.Clone(sc.settledLayout) {
361 sc.mon.RemoveWindow(p.ID)
362 }
363 if err := c.publish(t.Context()); err != nil {
364 t.Fatal(err)
365 }
366 if sc.settledLayout != nil || sc.shown != nil {
367 t.Fatalf("empty layout kept: settled %v shown %v (cap %d)", sc.settledLayout, sc.shown, cap(sc.layoutBuf))
368 }
369 if got := sc.shownLayout(); len(got) != 0 {
370 t.Fatalf("empty monitor lists %+v", got)
371 }
372 sc.mon.AddWindow(77)
373 got := sc.shownLayout()
374 if len(got) != 1 || got[0].ID != 77 || got[0].Hidden {
375 t.Fatalf("the window added after the publish is not listed: %+v", got)
376 }
377}