main f2d166b

outputmanagement_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close