1package app
2
3import (
4 "context"
5 "errors"
6 "image"
7 "testing"
8
9 "github.com/bnema/neferwl/internal/adapters/logging"
10 "github.com/bnema/neferwl/internal/ports"
11)
12
13// applyRig drives outputApply the way a backend owner loop does.
14type applyRig struct {
15 t *testing.T
16 a *outputApply
17}
18
19func newApplyRig(t *testing.T, headless bool) applyRig {
20 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
21 a := newOutputApply(newOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
22 a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
23 return applyRig{t, a}
24}
25
26// started returns the configuration the backend must start now.
27func (r applyRig) started() ports.Config {
28 r.t.Helper()
29 cfg, ok := r.a.next()
30 if !ok {
31 r.t.Fatal("no configuration to start")
32 }
33 if _, again := r.a.next(); again {
34 r.t.Fatal("configuration started twice")
35 }
36 return cfg
37}
38
39func (r applyRig) idle() {
40 r.t.Helper()
41 if cfg, ok := r.a.next(); ok {
42 r.t.Fatalf("unexpected configuration to start: %+v", cfg)
43 }
44}
45
46// core returns the configuration waiting for core, if any.
47func (r applyRig) core() (ports.Config, bool) {
48 ch := make(chan ports.ConfigChanged, 1)
49 out := r.a.configOut(ch)
50 if out == nil {
51 return ports.Config{}, false
52 }
53 out <- r.a.config
54 r.a.configSent()
55 return (<-ch).Config, true
56}
57
58func (r applyRig) replies() []ports.OutputApplied {
59 var got []ports.OutputApplied
60 ch := make(chan ports.OutputApplied, 1)
61 for {
62 out, next := r.a.replyOut(ch)
63 if out == nil {
64 return got
65 }
66 out <- next
67 r.a.replySent()
68 got = append(got, <-ch)
69 }
70}
71
72func moveDP1(id uint64, x int) ports.OutputApply {
73 return ports.OutputApply{ID: id, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: x}}}}
74}
75
76func TestOutputApply(t *testing.T) {
77 for _, tc := range []struct {
78 name string
79 failure error
80 }{
81 {"ready", nil},
82 {"ready error rollback", errors.New("modeset failed")},
83 {"timeout rollback", errTimeout},
84 } {
85 t.Run(tc.name, func(t *testing.T) {
86 r := newApplyRig(t, false)
87 r.a.request(moveDP1(1, 100))
88 if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos.X != 100 {
89 t.Fatalf("core config: %+v", cfg)
90 }
91 if cfg := r.started(); cfg.Outputs[0].Pos.X != 100 {
92 t.Fatalf("started: %+v", cfg)
93 }
94 if got := r.replies(); len(got) != 0 {
95 t.Fatalf("replied before backend finished: %+v", got)
96 }
97 r.a.finished(tc.failure)
98 if tc.failure != nil {
99 if got := r.replies(); len(got) != 0 {
100 t.Fatalf("replied before rollback: %+v", got)
101 }
102 if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 0 {
103 t.Fatalf("rollback core config: %+v", cfg)
104 }
105 if cfg := r.started(); len(cfg.Outputs) != 0 {
106 t.Fatalf("rollback started: %+v", cfg)
107 }
108 // A failed rollback still answers with the original failure.
109 r.a.finished(errors.New("rollback failed"))
110 }
111 r.idle()
112 got := r.replies()
113 if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) {
114 t.Fatalf("replies: %+v, want %v", got, tc.failure)
115 }
116 if tc.failure != nil && len(r.a.state.effective().Outputs) != 0 {
117 t.Fatal("override survived failure")
118 }
119 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}})
120 if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos != nil || cfg.Outputs[0].Scale != 1.25 {
121 t.Fatalf("reload core config: %+v", cfg)
122 }
123 r.started()
124 r.a.finished(nil)
125 if got := r.replies(); len(got) != 0 {
126 t.Fatalf("reload replied: %+v", got)
127 }
128 })
129 }
130}
131
132// Consumers that do not read (core, Wayland) never block reloads or requests:
133// replies queue in order and core only gets the latest configuration.
134func TestOutputApplyOutbox(t *testing.T) {
135 r := newApplyRig(t, false)
136 r.a.request(moveDP1(1, 100))
137 r.started()
138 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}})
139 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}})
140 r.a.request(ports.OutputApply{ID: 2})
141 r.a.request(ports.OutputApply{ID: 3})
142 // The reloads wait for the running configuration: no overlap.
143 r.idle()
144 r.a.finished(nil)
145 // Inventory updates while Wayland is not reading: only the latest waits.
146 mode := ports.OutputMode{Width: 1280, Height: 720, RefreshMilli: 60000}
147 for _, name := range []string{"DP-1", "DP-2"} {
148 r.a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
149 }
150 headsCh := make(chan ports.OutputHeads, 1)
151 out, heads := r.a.headsOut(headsCh)
152 if out == nil || len(heads.Heads) != 1 || heads.Heads[0].Info.Name != "DP-2" {
153 t.Fatalf("latest heads: %+v", heads)
154 }
155 out <- heads
156 r.a.headsSent()
157 if got := <-headsCh; got.Heads[0].Info.Name != "DP-2" {
158 t.Fatalf("delivered heads: %+v", got)
159 }
160 if out, _ := r.a.headsOut(headsCh); out != nil {
161 t.Fatal("heads sent twice")
162 }
163 for i, got := range r.replies() {
164 if got.ID != uint64(i+1) || got.Err == nil {
165 t.Fatalf("reply %d: %+v", i+1, got)
166 }
167 }
168 if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil {
169 t.Fatalf("latest core config: %+v", cfg)
170 }
171 if _, ok := r.core(); ok {
172 t.Fatal("core config sent twice")
173 }
174 if cfg := r.started(); cfg.Outputs[0].Scale != 2 {
175 t.Fatalf("latest backend config: %+v", cfg)
176 }
177}
178
179func TestOutputSettingsDisableAndHeadlessMode(t *testing.T) {
180 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
181 state := newOutputOverrides(ports.Config{}, true)
182 state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "HEADLESS-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
183 if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}}); err == nil {
184 t.Fatal("headless disable accepted")
185 }
186 state.headless = false
187 cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "HEADLESS-1", Enabled: false}}})
188 if err != nil || !cfg.Outputs[0].Off {
189 t.Fatalf("disable: %+v, %v", cfg, err)
190 }
191}
192
193// A reload supersedes a request the backend is applying: the request fails at
194// once, and a late failure of its configuration cannot roll back the reload.
195func TestReloadDuringPendingApply(t *testing.T) {
196 r := newApplyRig(t, false)
197 r.a.request(moveDP1(1, 100))
198 r.core()
199 r.started()
200 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}})
201 if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos != nil {
202 t.Fatalf("override survived reload: %+v", cfg)
203 }
204 if got := r.replies(); len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, errApplySuperseded) {
205 t.Fatalf("superseded reply: %+v", got)
206 }
207 // The reload waits for the old configuration, whose failure is dropped.
208 r.idle()
209 r.a.finished(errors.New("old apply failed"))
210 if cfg := r.started(); cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[0].Pos != nil {
211 t.Fatalf("reload started: %+v", cfg)
212 }
213 r.a.finished(errors.New("reload failed"))
214 r.idle()
215 if _, ok := r.core(); ok {
216 t.Fatal("failed reload rolled back")
217 }
218 if got := r.replies(); len(got) != 0 {
219 t.Fatalf("reload replied: %+v", got)
220 }
221 // A stray result with nothing running is ignored.
222 r.a.finished(errors.New("stray"))
223 r.idle()
224 // The next request is accepted.
225 r.a.request(moveDP1(2, 50))
226 r.started()
227 r.a.finished(nil)
228 if got := r.replies(); len(got) != 1 || got[0].ID != 2 || got[0].Err != nil {
229 t.Fatalf("next request: %+v", got)
230 }
231}
232
233// A reload during a rollback supersedes the failed request.
234func TestReloadDuringRollback(t *testing.T) {
235 r := newApplyRig(t, false)
236 r.a.request(moveDP1(1, 100))
237 r.started()
238 r.a.finished(errors.New("modeset failed"))
239 r.started() // rollback
240 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}})
241 if got := r.replies(); len(got) != 1 || !errors.Is(got[0].Err, errApplySuperseded) {
242 t.Fatalf("rollback superseded: %+v", got)
243 }
244 r.idle()
245 r.a.finished(nil) // rollback done
246 if cfg := r.started(); cfg.Outputs[0].Scale != 2 {
247 t.Fatalf("reload started: %+v", cfg)
248 }
249 r.a.finished(nil)
250 if got := r.replies(); len(got) != 0 {
251 t.Fatalf("extra reply: %+v", got)
252 }
253}
254
255func TestOutputApplyAnsweredAtOnce(t *testing.T) {
256 r := newApplyRig(t, false)
257 r.a.request(ports.OutputApply{ID: 1, Test: true, Heads: moveDP1(1, 10).Heads})
258 r.a.request(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{{Name: "DP-9", Enabled: true}}})
259 r.idle()
260 if _, ok := r.core(); ok {
261 t.Fatal("test or invalid request changed core config")
262 }
263 got := r.replies()
264 if len(got) != 2 || got[0].ID != 1 || got[0].Err != nil || got[1].ID != 2 || got[1].Err == nil {
265 t.Fatalf("replies: %+v", got)
266 }
267 r.a.request(moveDP1(3, 10))
268 r.a.request(moveDP1(4, 20))
269 if got := r.replies(); len(got) != 1 || got[0].ID != 4 || got[0].Err == nil {
270 t.Fatalf("second request while pending: %+v", got)
271 }
272
273 h := newApplyRig(t, true)
274 h.a.request(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Scale: 2}}})
275 var gaps ports.Config
276 gaps.Layout.Gaps = 4
277 h.a.reload(gaps)
278 h.idle()
279 if cfg, ok := h.core(); !ok || cfg.Layout.Gaps != 4 {
280 t.Fatalf("headless core config: %+v", cfg)
281 }
282 if got := h.replies(); len(got) != 1 || got[0].ID != 1 || got[0].Err != nil {
283 t.Fatalf("headless reply: %+v", got)
284 }
285}
286
287// A reload that only changes output scales (a saved scale bind) keeps the
288// runtime overrides; any other change drops them.
289func TestReloadScaleOnlyKeepsOverrides(t *testing.T) {
290 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
291 file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
292 state := newOutputOverrides(file, false)
293 state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}, {Info: ports.OutputInfo{Name: "DP-2"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
294 if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: 100}}, {Name: "DP-2", Enabled: true}}}); err != nil {
295 t.Fatal(err)
296 }
297 scaled := file
298 scaled.Outputs = []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}, {Name: "DP-2", Scale: 1.25, ScaleOnly: true, SDRBrightness: ports.DefaultSDRBrightness}}
299 cfg := state.reload(scaled)
300 if len(cfg.Outputs) != 2 || cfg.Outputs[0].Pos == nil || cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[1].Scale != 1.25 {
301 t.Fatalf("scale-only reload: %+v", cfg.Outputs)
302 }
303 other := scaled
304 other.Layout.Gaps = 4
305 if cfg := state.reload(other); cfg.Outputs[0].Pos != nil {
306 t.Fatalf("other reload kept overrides: %+v", cfg.Outputs)
307 }
308}
309
310// A transform change takes the full reload path; a scale change alone does not.
311func TestScaleChangesTransform(t *testing.T) {
312 old := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
313 cur := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2, Transform: 1}}}
314 if _, only := scaleChanges(old, cur); only {
315 t.Fatal("a transform change must take the full reload path")
316 }
317 cur.Outputs[0].Transform = 0
318 cur.Outputs[0].Scale = 1.5
319 if _, only := scaleChanges(old, cur); !only {
320 t.Fatal("a scale-only change stays a scale change")
321 }
322}
323
324// A runtime transform lands in the effective configuration and survives a
325// reload that only changes file scales.
326func TestOutputApplyTransform(t *testing.T) {
327 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
328 file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}}
329 state := newOutputOverrides(file, false)
330 state.heads = ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}}
331 tr := ports.BufferTransform(3)
332 cfg, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &tr}}})
333 if err != nil {
334 t.Fatal(err)
335 }
336 if len(cfg.Outputs) != 1 || cfg.Outputs[0].Transform != 3 {
337 t.Fatalf("applied transform: %+v", cfg.Outputs)
338 }
339 scaled := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}}
340 cfg = state.reload(scaled)
341 if cfg.Outputs[0].Transform != 3 || cfg.Outputs[0].Scale != 1.5 {
342 t.Fatalf("scale reload lost the runtime transform: %+v", cfg.Outputs)
343 }
344 // Without a transform in the change, the current one stays.
345 cfg, err = state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true}}})
346 if err != nil || cfg.Outputs[0].Transform != 3 {
347 t.Fatalf("unset transform changed: %+v, %v", cfg.Outputs, err)
348 }
349 bad := ports.BufferTransform(8)
350 if _, err := state.apply(ports.OutputApply{Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Transform: &bad}}}); err == nil {
351 t.Fatal("transform 8 accepted")
352 }
353}
354
355// A runtime position never replaces a file relation: it only takes
356// precedence over it in core, and the relation is followed again once the
357// override is dropped.
358func TestOutputApplyKeepsFileAnchor(t *testing.T) {
359 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
360 anchor := ports.OutputAnchor{Relation: ports.RelationRightOf, To: "DP-1", Offset: 40}
361 file := ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-2", Anchor: anchor, ScaleOnly: true}}}
362 o := newOutputOverrides(file, false)
363 head := func(name string) ports.OutputHead {
364 return ports.OutputHead{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}
365 }
366 o.heads = ports.OutputHeads{Heads: []ports.OutputHead{head("DP-1"), head("DP-2")}}
367 change := func(name string, x int) ports.HeadChange {
368 return ports.HeadChange{Name: name, Enabled: true, Pos: &image.Point{X: x}}
369 }
370 byName := func(cfg ports.Config, name string) ports.OutputConfig {
371 t.Helper()
372 var found []ports.OutputConfig
373 for _, v := range cfg.Outputs {
374 if v.Name == name {
375 found = append(found, v)
376 }
377 }
378 if len(found) != 1 {
379 t.Fatalf("%s entries: %+v", name, cfg.Outputs)
380 }
381 return found[0]
382 }
383
384 cfg, err := o.apply(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{change("DP-1", 100), {Name: "DP-2", Enabled: true}}})
385 if err != nil {
386 t.Fatal(err)
387 }
388 dp1, dp2 := byName(cfg, "DP-1"), byName(cfg, "DP-2")
389 if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos != nil || dp2.Anchor != anchor {
390 t.Fatalf("position on the reference only: DP-1 %+v, DP-2 %+v", dp1, dp2)
391 }
392
393 cfg, err = o.apply(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{change("DP-1", 100), change("DP-2", 900)}})
394 if err != nil {
395 t.Fatal(err)
396 }
397 dp1, dp2 = byName(cfg, "DP-1"), byName(cfg, "DP-2")
398 if dp1.Pos == nil || dp1.Pos.X != 100 || dp2.Pos == nil || dp2.Pos.X != 900 || dp2.Anchor != anchor {
399 t.Fatalf("position on both: DP-1 %+v, DP-2 %+v", dp1, dp2)
400 }
401
402 // A non scale-only reload drops the overrides: the file relation is back alone.
403 file.Keyboard.RepeatRate = 33
404 cfg = o.reload(file)
405 if dp2 = byName(cfg, "DP-2"); dp2.Pos != nil || dp2.Anchor != anchor || len(cfg.Outputs) != 1 {
406 t.Fatalf("after reload: %+v", cfg.Outputs)
407 }
408}