1package vulkan
2
3import (
4 "bytes"
5 "image"
6 "image/color"
7 "reflect"
8 "slices"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/neferwl/internal/ports"
15)
16
17// TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
18// lists the fields that force a full redraw.
19func TestSceneFieldCount(t *testing.T) {
20 const fields = 21
21 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
22 "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
23}
24
25// TestSceneWindowFieldCount fails when SceneWindow gains or loses a field:
26// sceneDelta compares windows by struct equality, so a new field is covered
27// as long as it only changes where or how that window draws (inside Rect).
28func TestSceneWindowFieldCount(t *testing.T) {
29 const fields = 15
30 assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
31 "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
32}
33
34func deltaScene(seq uint64, ws ...ports.SceneWindow) ports.Scene {
35 return ports.Scene{Seq: seq, OutputWidth: 1000, OutputHeight: 500, Scale: 1, Background: "#000000", Windows: ws}
36}
37
38func win(id ports.WindowID, x, y, w, h int) ports.SceneWindow {
39 return ports.SceneWindow{ID: id, Rect: ports.Rect{X: x, Y: y, W: w, H: h}}
40}
41
42// held is a target that holds s after a full frame of its windows.
43func held(s ports.Scene) *target {
44 tg := &target{}
45 tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
46 return tg
47}
48
49func TestDamageOneWindowResizeIsUnionOfRects(t *testing.T) {
50 bounds := image.Rect(0, 0, 1000, 500)
51 old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
52 cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 400))
53 d := newDamage(held(old), cur, bounds)
54 require.False(t, d.all())
55 assert.Equal(t, image.Rect(600, 0, 1000, 500), d.area)
56
57 // Window moves: both places.
58 cur = deltaScene(2, win(1, 0, 0, 300, 500), win(2, 310, 0, 300, 500), win(3, 600, 0, 400, 500))
59 d = newDamage(held(old), cur, bounds)
60 require.False(t, d.all())
61 assert.Equal(t, image.Rect(300, 0, 610, 500), d.area)
62}
63
64func TestDamageDeltaAtScale(t *testing.T) {
65 old := deltaScene(1, win(1, 0, 0, 10, 10), win(2, 100, 0, 10, 10))
66 cur := deltaScene(2, win(1, 0, 0, 10, 10), win(2, 100, 0, 20, 10))
67 old.Scale, cur.Scale = 1.5, 1.5
68 d := newDamage(held(old), cur, image.Rect(0, 0, 1500, 750))
69 require.False(t, d.all())
70 assert.Equal(t, image.Rect(150, 0, 180, 15), d.area)
71}
72
73func TestDamageDeltaWindowFieldChange(t *testing.T) {
74 old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
75 cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
76 cur.Windows[0].FocusEffect = 0.3
77 d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
78 require.False(t, d.all())
79 assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
80}
81
82func TestDamageDeltaFullRedraws(t *testing.T) {
83 base := func() ports.Scene {
84 return deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
85 }
86 bounds := image.Rect(0, 0, 1000, 500)
87 for name, mod := range map[string]func(*ports.Scene){
88 "camera shift over half": func(s *ports.Scene) {
89 s.Windows = []ports.SceneWindow{win(1, 0, 0, 600, 500), win(2, 600, 0, 400, 500)}
90 s.Windows[0].Rect.X = 50
91 },
92 "layer": func(s *ports.Scene) { s.Layers = []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} },
93 "output": func(s *ports.Scene) { s.Output = "DP-2" },
94 "size": func(s *ports.Scene) { s.OutputWidth = 999 },
95 "scale": func(s *ports.Scene) { s.Scale = 2 },
96 "transform": func(s *ports.Scene) { s.Transform = 1 },
97 "off": func(s *ports.Scene) { s.Off = true },
98 "background": func(s *ports.Scene) { s.Background = "#111111" },
99 "border": func(s *ports.Scene) { s.Border.Width = 2 },
100 "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} },
101 "tile inset": func(s *ports.Scene) { s.TileInset = ports.Insets{Bottom: 10} },
102 "dim": func(s *ports.Scene) { s.Dim = 0.5 },
103 "dim behind": func(s *ports.Scene) { s.DimBehind = true },
104 "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },
105 "indicators": func(s *ports.Scene) { s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} },
106 "capture": func(s *ports.Scene) { s.Capture = &ports.SceneCapture{} },
107 "capture scene": func(s *ports.Scene) { s.CaptureScene = &ports.Scene{} },
108 "security": func(s *ports.Scene) { s.Security.Protected = true },
109 "window count": func(s *ports.Scene) { s.Windows = s.Windows[:1] },
110 "window order": func(s *ports.Scene) { s.Windows[0], s.Windows[1] = s.Windows[1], s.Windows[0] },
111 "window id": func(s *ports.Scene) { s.Windows[1].ID = 7 },
112 } {
113 t.Run(name, func(t *testing.T) {
114 cur := base()
115 mod(&cur)
116 cur.Seq = 2
117 assert.True(t, newDamage(held(base()), cur, bounds).all())
118 })
119 }
120 t.Run("seq 0 and cold target", func(t *testing.T) {
121 cur := base()
122 cur.Seq = 0
123 assert.True(t, newDamage(held(base()), cur, bounds).all())
124 cur.Seq = 2
125 assert.True(t, newDamage(&target{}, cur, bounds).all())
126 })
127}
128
129func TestDamageDeltaSeparators(t *testing.T) {
130 sep := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 500}} }
131 a, b := sep(100), sep(200)
132 old := deltaScene(1, win(1, 0, 0, 500, 500))
133 old.Separators = []ports.Separator{a, b}
134 cur := deltaScene(2, win(1, 0, 0, 500, 500))
135 cur.Separators = []ports.Separator{a, sep(250)}
136 d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
137 require.False(t, d.all())
138 assert.Equal(t, image.Rect(200, 0, 252, 500), d.area)
139
140 // Same lines, another order: nothing differs by value.
141 cur.Separators = []ports.Separator{b, a}
142 d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
143 require.False(t, d.all())
144 assert.True(t, d.area.Empty())
145
146 // A line that turns active is a different value.
147 cur.Separators = []ports.Separator{a, {Rect: b.Rect, Active: true}}
148 d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
149 require.False(t, d.all())
150 assert.Equal(t, image.Rect(200, 0, 202, 500), d.area)
151}
152
153func TestSceneDeltaAllocations(t *testing.T) {
154 // Many separators (twenty tiles' worth): the same-length lists are
155 // compared index by index, and a list that lost a line still allocates
156 // nothing.
157 const seps = 80
158 old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
159 cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 500))
160 for i := range seps {
161 s := ports.Separator{Rect: ports.Rect{X: i * 10, W: 2, H: 500}}
162 old.Separators = append(old.Separators, s)
163 cur.Separators = append(cur.Separators, s)
164 }
165 cur.Separators[5].Active = true
166 phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
167 var area image.Rectangle
168 var ok bool
169 allocs := testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, cur, phys, 1<<30) })
170 require.True(t, ok)
171 assert.False(t, area.Empty())
172 assert.True(t, image.Rect(50, 0, 52, 500).In(area), "the changed line is damaged: %v", area)
173 assert.Zero(t, allocs, "sceneDelta must not allocate")
174
175 short := cur
176 short.Separators = cur.Separators[:seps-1]
177 allocs = testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, short, phys, 1<<30) })
178 require.True(t, ok)
179 assert.True(t, image.Rect((seps-1)*10, 0, (seps-1)*10+2, 500).In(area), "the dropped line is damaged: %v", area)
180 assert.Zero(t, allocs, "sceneDelta must not allocate with lists of different lengths")
181}
182
183// Separators are damaged where a line is in one list more often than in
184// the other, whatever the order: equal lists add nothing, a changed pair
185// adds both rects, a rotated list adds nothing, a duplicated line that
186// replaces another adds both, a line added or dropped adds that line.
187func TestSceneDeltaSeparators(t *testing.T) {
188 phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
189 line := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 10}} }
190 a, b, c := line(0), line(100), line(200)
191 assert.True(t, separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, b}, phys).Empty())
192 assert.Equal(t, image.Rect(100, 0, 202, 10), separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, c}, phys))
193 assert.True(t, separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{b, c, a}, phys).Empty())
194 assert.Equal(t, image.Rect(0, 0, 102, 10), separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{a, a, c}, phys))
195 assert.Equal(t, image.Rect(200, 0, 202, 10), separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, b, c}, phys))
196 assert.Equal(t, image.Rect(0, 0, 2, 10), separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{b, c}, phys))
197}
198
199// The paint order splits at the first window that opens the floats: when
200// that window changes, the whole output is redrawn.
201func TestSceneDeltaFloatsStartMoves(t *testing.T) {
202 phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
203 tile := func(id ports.WindowID, x int) ports.SceneWindow { return win(id, x, 0, 100, 100) }
204 float := func(id ports.WindowID, x int) ports.SceneWindow {
205 w := win(id, x, 0, 100, 100)
206 w.Floating = true
207 return w
208 }
209 delta := func(a, b []ports.SceneWindow) bool {
210 _, ok := sceneDelta(deltaScene(1, a...), deltaScene(2, b...), phys, 1<<30)
211 return ok
212 }
213 // A tile becomes a float: the tile lines and the veil move under it.
214 assert.False(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
215 // The first float moves in the list.
216 assert.False(t, delta([]ports.SceneWindow{float(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
217 // A hidden or popup float opens nothing; showing it does.
218 hidden := float(2, 100)
219 hidden.Hidden = true
220 assert.False(t, delta([]ports.SceneWindow{tile(1, 0), hidden}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
221 popup := float(2, 100)
222 popup.Popup = true
223 assert.False(t, delta([]ports.SceneWindow{tile(1, 0), popup}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
224 // A below float or an overview preview does not open the floats.
225 below := float(2, 100)
226 below.Below = true
227 assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), below}))
228 preview := float(2, 100)
229 preview.Preview = 0.5
230 assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), preview}))
231 // The same first float with another rect stays a delta.
232 assert.True(t, delta([]ports.SceneWindow{tile(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 130)}))
233}
234
235// The target keeps its own copy of the compared slices: a caller reusing
236// its slices in place (DRM overlay frames, capture) must not change what the
237// target holds.
238func TestTargetHoldCopiesSlices(t *testing.T) {
239 ws := []ports.SceneWindow{win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)}
240 seps := []ports.Separator{{Rect: ports.Rect{X: 100, W: 2, H: 100}}}
241 layers := []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}}
242 hints := []ports.Rect{{W: 5, H: 5}}
243 marks := []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}}
244 s := deltaScene(1)
245 s.Windows, s.Separators, s.Layers, s.DropHints, s.CaptureIndicators = ws, seps, layers, hints, marks
246 tg := &target{}
247 d := &damageRegion{drawn: map[ports.WindowID]heldWindow{}}
248 tg.hold(s, d)
249 ws[1].Rect.X, seps[0].Rect.X, layers[0].ID, hints[0].W, marks[0].Rect.W = 150, 150, 8, 6, 6
250 phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
251 // Compared with the mutated slices as the new scene, the held copy
252 // differs in each; with a held reference nothing would differ.
253 cur := s
254 cur.Seq = 2
255 _, ok := sceneDelta(tg.scene, cur, phys, 1<<30)
256 assert.False(t, ok, "layers, hints and indicators changed: full redraw")
257 cur.Layers, cur.DropHints, cur.CaptureIndicators = tg.heldLayers, tg.heldHints, tg.heldIndicators
258 area, ok := sceneDelta(tg.scene, cur, phys, 1<<30)
259 require.True(t, ok)
260 assert.Equal(t, image.Rect(100, 0, 250, 100), area, "window 2, old and new")
261 // Zero allocations once the buffers are warm.
262 allocs := testing.AllocsPerRun(50, func() { tg.hold(s, d) })
263 assert.Zero(t, allocs, "hold must not allocate once warm")
264}
265
266// deltaCase is a frame sequence of two windows whose second one resizes.
267type deltaCase struct {
268 name string
269 scale float64
270 out ports.BufferTransform
271 sceneDim float64
272 floating bool // window 2 is a float: the veil and tile lines go under it
273 windowDim bool // window 1 is dimmed on some frames
274}
275
276func (c deltaCase) scene(seq uint64, i, w2 int, windows []ports.SceneWindow, seps []ports.Separator) ports.Scene {
277 windows[0], windows[1] = win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48)
278 windows[1].Floating = c.floating
279 if c.windowDim && i%3 == 0 {
280 windows[0].Dim = 0.5
281 }
282 seps[0] = ports.Separator{Rect: ports.Rect{X: 20, W: 1, H: 48}}
283 return ports.Scene{Seq: seq, Transform: c.out, OutputWidth: 64, OutputHeight: 48, Scale: c.scale, Background: "#101010",
284 Dim: c.sceneDim, Windows: windows, Separators: seps}
285}
286
287func deltaCases() []deltaCase {
288 return []deltaCase{
289 {name: "scale 1", scale: 1},
290 {name: "scale 1.5", scale: 1.5},
291 {name: "transform 90", scale: 1, out: 1},
292 {name: "scene dim over tiles", scale: 1, sceneDim: 0.4, floating: true},
293 {name: "window dim", scale: 1, windowDim: true},
294 {name: "scale 1.5 rotated, all", scale: 1.5, out: 1, sceneDim: 0.3, floating: true, windowDim: true},
295 }
296}
297
298// A partial redraw after a window resize is pixel-identical to a full
299// redraw, on both targets of a double-buffered output. With reuse the scene
300// slices are rewritten in place for every frame, like an output adapter
301// that reuses them: the target must not have kept a reference.
302func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) {
303 for _, reuse := range []bool{false, true} {
304 for _, tc := range deltaCases() {
305 name := tc.name
306 if reuse {
307 name += " reused slices"
308 }
309 t.Run(name, func(t *testing.T) { sceneDeltaMatchesFullRedraw(t, tc, reuse) })
310 }
311 }
312}
313
314func sceneDeltaMatchesFullRedraw(t *testing.T, tc deltaCase, reuse bool) {
315 tw, th := tc.out.Size(int(64*tc.scale+0.5), int(48*tc.scale+0.5))
316 newR := func() *Renderer {
317 r, err := New(tw, th)
318 if err != nil {
319 t.Skipf("Vulkan unavailable: %v", err)
320 }
321 t.Cleanup(r.Close)
322 return r
323 }
324 damaged, full := newR(), newR()
325 bufs, err := damaged.ExportTargets(2, nil, false)
326 if err != nil {
327 t.Skipf("no exportable targets: %v", err)
328 }
329 for _, b := range bufs {
330 for _, p := range b.Planes {
331 p.File.Close()
332 }
333 }
334 red := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
335 red.ID, red.Surface, red.Seq, red.Version = 1, 1, 1, 1
336 green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255})
337 green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1
338 contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green}
339 shared := make([]ports.SceneWindow, 2)
340 sharedSeps := make([]ports.Separator, 1)
341 var partial int
342 for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20, 22} {
343 windows, seps := shared, sharedSeps
344 if !reuse {
345 windows, seps = make([]ports.SceneWindow, 2), make([]ports.Separator, 1)
346 }
347 if reuse {
348 clear(windows)
349 }
350 s := tc.scene(uint64(10+i), i, w2, windows, seps)
351 damaged.UseTarget(i % 2)
352 before := damaged.redrawn
353 require.NoError(t, render(damaged, s, contents))
354 if damaged.redrawn-before < tw*th {
355 partial++
356 }
357 plain := s
358 plain.Seq = 0
359 plain.Windows = slices.Clone(s.Windows)
360 plain.Separators = slices.Clone(s.Separators)
361 require.NoError(t, render(full, plain, contents))
362 a, b := readPixels(t, damaged), readPixels(t, full)
363 if !bytes.Equal(a.Pix, b.Pix) {
364 t.Fatalf("frame %d (w2=%d) differs from a full redraw", i, w2)
365 }
366 }
367 assert.Positive(t, partial, "no frame was redrawn partially")
368}
369
370// A window's Fade changes only how it draws inside its rect: the delta is
371// that rect. A leaving entry (core keeps a closed or hidden window in the
372// scene while it fades) changes the window list when it appears and when
373// it goes: both redraw everything, like any list change; while it fades,
374// only its rect is redrawn.
375func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) {
376 bounds := image.Rect(0, 0, 1000, 500)
377 settled := deltaScene(1, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
378
379 faded := deltaScene(2, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
380 faded.Windows[1].Fade = 0.5
381 d := newDamage(held(settled), faded, bounds)
382 require.False(t, d.all())
383 assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
384
385 // Window 2 was unmapped: the layout re-flows and a leaving entry is
386 // appended. The list changed: full redraw.
387 leaving := deltaScene(3, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
388 leaving.Windows[1].Fade = 0.1
389 assert.True(t, newDamage(held(settled), leaving, bounds).all())
390
391 // The fade advances: its rect only, the neighbours untouched.
392 next := deltaScene(4, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
393 next.Windows[1].Fade = 0.6
394 d = newDamage(held(leaving), next, bounds)
395 require.False(t, d.all())
396 assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
397
398 // The entry settled and is dropped: full redraw (its pixels are stale).
399 gone := deltaScene(5, win(1, 0, 0, 1000, 500))
400 assert.True(t, newDamage(held(next), gone, bounds).all())
401
402 // A leaving float opens the floats where it is appended: the tile
403 // lines and the veil move under it, so floatsStart differs.
404 float := deltaScene(6, win(1, 0, 0, 1000, 500), win(2, 100, 100, 200, 200))
405 float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1
406 assert.True(t, newDamage(held(gone), float, bounds).all())
407}