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/core"
11 "github.com/bnema/neferwl/internal/ports"
12)
13
14// applyRig drives outputApply the way a backend owner loop does.
15type applyRig struct {
16 t *testing.T
17 a *outputApply
18}
19
20func newApplyRig(t *testing.T, headless bool) applyRig {
21 mode := ports.OutputMode{Width: 1920, Height: 1080, RefreshMilli: 60000}
22 a := newOutputApply(core.NewOutputOverrides(ports.Config{}, headless), logging.For(context.Background(), "app"))
23 a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: "DP-1"}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
24 return applyRig{t, a}
25}
26
27// started returns the configuration the backend must start now.
28func (r applyRig) started() ports.Config {
29 r.t.Helper()
30 cfg, ok := r.a.next()
31 if !ok {
32 r.t.Fatal("no configuration to start")
33 }
34 if _, again := r.a.next(); again {
35 r.t.Fatal("configuration started twice")
36 }
37 return cfg
38}
39
40func (r applyRig) idle() {
41 r.t.Helper()
42 if cfg, ok := r.a.next(); ok {
43 r.t.Fatalf("unexpected configuration to start: %+v", cfg)
44 }
45}
46
47// core returns the configuration waiting for core, if any.
48func (r applyRig) core() (ports.Config, bool) {
49 ch := make(chan ports.ConfigChanged, 1)
50 out := r.a.configOut(ch)
51 if out == nil {
52 return ports.Config{}, false
53 }
54 out <- r.a.config
55 r.a.configSent()
56 return (<-ch).Config, true
57}
58
59func (r applyRig) replies() []ports.OutputApplied {
60 var got []ports.OutputApplied
61 ch := make(chan ports.OutputApplied, 1)
62 for {
63 out, next := r.a.replyOut(ch)
64 if out == nil {
65 return got
66 }
67 out <- next
68 r.a.replySent()
69 got = append(got, <-ch)
70 }
71}
72
73func moveDP1(id uint64, x int) ports.OutputApply {
74 return ports.OutputApply{ID: id, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Pos: &image.Point{X: x}}}}
75}
76
77func TestOutputApply(t *testing.T) {
78 for _, tc := range []struct {
79 name string
80 failure error
81 }{
82 {"ready", nil},
83 {"ready error rollback", errors.New("modeset failed")},
84 {"timeout rollback", errTimeout},
85 } {
86 t.Run(tc.name, func(t *testing.T) {
87 r := newApplyRig(t, false)
88 r.a.request(moveDP1(1, 100))
89 if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos.X != 100 {
90 t.Fatalf("core config: %+v", cfg)
91 }
92 if cfg := r.started(); cfg.Outputs[0].Pos.X != 100 {
93 t.Fatalf("started: %+v", cfg)
94 }
95 if got := r.replies(); len(got) != 0 {
96 t.Fatalf("replied before backend finished: %+v", got)
97 }
98 r.a.finished(tc.failure)
99 if tc.failure != nil {
100 if got := r.replies(); len(got) != 0 {
101 t.Fatalf("replied before rollback: %+v", got)
102 }
103 if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 0 {
104 t.Fatalf("rollback core config: %+v", cfg)
105 }
106 if cfg := r.started(); len(cfg.Outputs) != 0 {
107 t.Fatalf("rollback started: %+v", cfg)
108 }
109 // A failed rollback still answers with the original failure.
110 r.a.finished(errors.New("rollback failed"))
111 }
112 r.idle()
113 got := r.replies()
114 if len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, tc.failure) || (got[0].Err != nil) != (tc.failure != nil) {
115 t.Fatalf("replies: %+v, want %v", got, tc.failure)
116 }
117 if tc.failure != nil && len(r.a.state.Effective().Outputs) != 0 {
118 t.Fatal("override survived failure")
119 }
120 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.25}}})
121 if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos != nil || cfg.Outputs[0].Scale != 1.25 {
122 t.Fatalf("reload core config: %+v", cfg)
123 }
124 r.started()
125 r.a.finished(nil)
126 if got := r.replies(); len(got) != 0 {
127 t.Fatalf("reload replied: %+v", got)
128 }
129 })
130 }
131}
132
133// Consumers that do not read (core, Wayland) never block reloads or requests:
134// replies queue in order and core only gets the latest configuration.
135func TestOutputApplyOutbox(t *testing.T) {
136 r := newApplyRig(t, false)
137 r.a.request(moveDP1(1, 100))
138 r.started()
139 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}})
140 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}})
141 r.a.request(ports.OutputApply{ID: 2})
142 r.a.request(ports.OutputApply{ID: 3})
143 // The reloads wait for the running configuration: no overlap.
144 r.idle()
145 r.a.finished(nil)
146 // Inventory updates while Wayland is not reading: only the latest waits.
147 mode := ports.OutputMode{Width: 1280, Height: 720, RefreshMilli: 60000}
148 for _, name := range []string{"DP-1", "DP-2"} {
149 r.a.heads(ports.OutputHeads{Heads: []ports.OutputHead{{Info: ports.OutputInfo{Name: name}, Enabled: true, Current: &mode, Modes: []ports.OutputMode{mode}}}})
150 }
151 headsCh := make(chan ports.OutputHeads, 1)
152 out, heads := r.a.headsOut(headsCh)
153 if out == nil || len(heads.Heads) != 1 || heads.Heads[0].Info.Name != "DP-2" {
154 t.Fatalf("latest heads: %+v", heads)
155 }
156 out <- heads
157 r.a.headsSent()
158 if got := <-headsCh; got.Heads[0].Info.Name != "DP-2" {
159 t.Fatalf("delivered heads: %+v", got)
160 }
161 if out, _ := r.a.headsOut(headsCh); out != nil {
162 t.Fatal("heads sent twice")
163 }
164 for i, got := range r.replies() {
165 if got.ID != uint64(i+1) || got.Err == nil {
166 t.Fatalf("reply %d: %+v", i+1, got)
167 }
168 }
169 if cfg, ok := r.core(); !ok || len(cfg.Outputs) != 1 || cfg.Outputs[0].Scale != 2 || cfg.Outputs[0].Pos != nil {
170 t.Fatalf("latest core config: %+v", cfg)
171 }
172 if _, ok := r.core(); ok {
173 t.Fatal("core config sent twice")
174 }
175 if cfg := r.started(); cfg.Outputs[0].Scale != 2 {
176 t.Fatalf("latest backend config: %+v", cfg)
177 }
178}
179
180// A reload supersedes a request the backend is applying: the request fails at
181// once, and a late failure of its configuration cannot roll back the reload.
182func TestReloadDuringPendingApply(t *testing.T) {
183 r := newApplyRig(t, false)
184 r.a.request(moveDP1(1, 100))
185 r.core()
186 r.started()
187 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 1.5}}})
188 if cfg, ok := r.core(); !ok || cfg.Outputs[0].Pos != nil {
189 t.Fatalf("override survived reload: %+v", cfg)
190 }
191 if got := r.replies(); len(got) != 1 || got[0].ID != 1 || !errors.Is(got[0].Err, errApplySuperseded) {
192 t.Fatalf("superseded reply: %+v", got)
193 }
194 // The reload waits for the old configuration, whose failure is dropped.
195 r.idle()
196 r.a.finished(errors.New("old apply failed"))
197 if cfg := r.started(); cfg.Outputs[0].Scale != 1.5 || cfg.Outputs[0].Pos != nil {
198 t.Fatalf("reload started: %+v", cfg)
199 }
200 r.a.finished(errors.New("reload failed"))
201 r.idle()
202 if _, ok := r.core(); ok {
203 t.Fatal("failed reload rolled back")
204 }
205 if got := r.replies(); len(got) != 0 {
206 t.Fatalf("reload replied: %+v", got)
207 }
208 // A stray result with nothing running is ignored.
209 r.a.finished(errors.New("stray"))
210 r.idle()
211 // The next request is accepted.
212 r.a.request(moveDP1(2, 50))
213 r.started()
214 r.a.finished(nil)
215 if got := r.replies(); len(got) != 1 || got[0].ID != 2 || got[0].Err != nil {
216 t.Fatalf("next request: %+v", got)
217 }
218}
219
220// A reload during a rollback supersedes the failed request.
221func TestReloadDuringRollback(t *testing.T) {
222 r := newApplyRig(t, false)
223 r.a.request(moveDP1(1, 100))
224 r.started()
225 r.a.finished(errors.New("modeset failed"))
226 r.started() // rollback
227 r.a.reload(ports.Config{Outputs: []ports.OutputConfig{{Name: "DP-1", Scale: 2}}})
228 if got := r.replies(); len(got) != 1 || !errors.Is(got[0].Err, errApplySuperseded) {
229 t.Fatalf("rollback superseded: %+v", got)
230 }
231 r.idle()
232 r.a.finished(nil) // rollback done
233 if cfg := r.started(); cfg.Outputs[0].Scale != 2 {
234 t.Fatalf("reload started: %+v", cfg)
235 }
236 r.a.finished(nil)
237 if got := r.replies(); len(got) != 0 {
238 t.Fatalf("extra reply: %+v", got)
239 }
240}
241
242func TestOutputApplyAnsweredAtOnce(t *testing.T) {
243 r := newApplyRig(t, false)
244 r.a.request(ports.OutputApply{ID: 1, Test: true, Heads: moveDP1(1, 10).Heads})
245 r.a.request(ports.OutputApply{ID: 2, Heads: []ports.HeadChange{{Name: "DP-9", Enabled: true}}})
246 r.idle()
247 if _, ok := r.core(); ok {
248 t.Fatal("test or invalid request changed core config")
249 }
250 got := r.replies()
251 if len(got) != 2 || got[0].ID != 1 || got[0].Err != nil || got[1].ID != 2 || got[1].Err == nil {
252 t.Fatalf("replies: %+v", got)
253 }
254 r.a.request(moveDP1(3, 10))
255 r.a.request(moveDP1(4, 20))
256 if got := r.replies(); len(got) != 1 || got[0].ID != 4 || got[0].Err == nil {
257 t.Fatalf("second request while pending: %+v", got)
258 }
259
260 h := newApplyRig(t, true)
261 h.a.request(ports.OutputApply{ID: 1, Heads: []ports.HeadChange{{Name: "DP-1", Enabled: true, Scale: 2}}})
262 var gaps ports.Config
263 gaps.Layout.Gaps = 4
264 h.a.reload(gaps)
265 h.idle()
266 if cfg, ok := h.core(); !ok || cfg.Layout.Gaps != 4 {
267 t.Fatalf("headless core config: %+v", cfg)
268 }
269 if got := h.replies(); len(got) != 1 || got[0].ID != 1 || got[0].Err != nil {
270 t.Fatalf("headless reply: %+v", got)
271 }
272}