outputmanagement_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"image"
7	"testing"
8
9	"github.com/bnema/neferwl/internal/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}

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 23

Tags 10

↑↓ moveenter openesc close