v0.30.0 eb3068f

runtime_config_test.go RawHistoryFolder

1package bootstrap
2
3import (
4	"reflect"
5	"testing"
6
7	bootstrapmocks "github.com/bnema/dumber/internal/bootstrap/mocks"
8	"github.com/bnema/dumber/internal/domain/entity"
9	"github.com/bnema/dumber/internal/infrastructure/config"
10	"github.com/stretchr/testify/mock"
11)
12
13type configManagerState struct {
14	current    *config.Config
15	watchCalls int
16	callbacks  []func(*config.Config)
17}
18
19func newMockConfigManager(t *testing.T, state *configManagerState) *bootstrapmocks.MockConfigChangeManager {
20	t.Helper()
21
22	manager := bootstrapmocks.NewMockConfigChangeManager(t)
23	manager.EXPECT().Get().RunAndReturn(state.get).Maybe()
24	manager.EXPECT().Watch().RunAndReturn(state.watch).Maybe()
25	manager.EXPECT().OnConfigChange(mock.Anything).RunAndReturn(state.onConfigChange).Maybe()
26	return manager
27}
28
29func (s *configManagerState) watch() error {
30	s.watchCalls++
31	return nil
32}
33
34func (s *configManagerState) onConfigChange(callback func(*config.Config)) {
35	s.callbacks = append(s.callbacks, callback)
36}
37
38func (s *configManagerState) get() *config.Config {
39	return s.current
40}
41
42func (s *configManagerState) emit(next *config.Config) {
43	s.current = next
44	for _, callback := range s.callbacks {
45		callback(next)
46	}
47}
48
49func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) {
50	cfg := config.DefaultConfig()
51	cfg.DefaultUIScale = 1.25
52	cfg.Appearance.SansFont = "Inter"
53	cfg.Appearance.SerifFont = "Literata"
54	cfg.Appearance.MonospaceFont = "Fira Code"
55	cfg.Appearance.DefaultFontSize = 17
56	cfg.Debug.EnableDevTools = true
57	cfg.Logging.CaptureConsole = true
58	cfg.Engine.WebKit.DrawCompositingIndicators = true
59	cfg.Media.HardwareDecodingMode = config.HardwareDecodingForce
60	cfg.Clipboard.AutoCopyOnSelection = true
61
62	got := EngineSettingsPayloadFromConfig(cfg)
63
64	if got.DefaultUIScale != 1.25 {
65		t.Fatalf("DefaultUIScale=%v, want 1.25", got.DefaultUIScale)
66	}
67	if got.WebContent.SansFont != "Inter" ||
68		got.WebContent.SerifFont != "Literata" ||
69		got.WebContent.MonospaceFont != "Fira Code" ||
70		got.WebContent.DefaultFontSize != 17 {
71		t.Fatalf("font settings not mapped: %#v", got.WebContent)
72	}
73	if !got.WebContent.EnableDevTools ||
74		!got.WebContent.CaptureConsole ||
75		!got.WebContent.DrawCompositingIndicators {
76		t.Fatalf("debug settings not mapped: %#v", got.WebContent)
77	}
78	if got.WebContent.HardwareDecoding != entity.EngineHardwareDecodingForce {
79		t.Fatalf("HardwareDecoding=%q, want %q", got.WebContent.HardwareDecoding, entity.EngineHardwareDecodingForce)
80	}
81	autoCopyField := reflect.ValueOf(got.WebContent).FieldByName("AutoCopyOnSelection")
82	if !autoCopyField.IsValid() || !autoCopyField.Bool() {
83		t.Fatalf("AutoCopyOnSelection not mapped in web content payload: %#v", got.WebContent)
84	}
85}
86
87func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) {
88	initial := config.DefaultConfig()
89	initial.DefaultUIScale = 1
90	manager := &configManagerState{}
91	provider := NewRuntimeConfigProvider(initial, newMockConfigManager(t, manager))
92
93	var seen entity.RuntimeConfigSnapshot
94	provider.OnChange(func(snapshot entity.RuntimeConfigSnapshot) {
95		seen = snapshot
96	})
97
98	next := config.DefaultConfig()
99	next.DefaultUIScale = 1.8
100	manager.emit(next)
101
102	if got := provider.Current().UI.DefaultUIScale; got != 1.8 {
103		t.Fatalf("Current DefaultUIScale=%v, want 1.8", got)
104	}
105	if seen.UI.DefaultUIScale != 1.8 {
106		t.Fatalf("callback DefaultUIScale=%v, want 1.8", seen.UI.DefaultUIScale)
107	}
108}
109
110func TestRuntimeConfigProviderCurrentReflectsManagerGetWithoutSubscription(t *testing.T) {
111	initial := config.DefaultConfig()
112	initial.DefaultUIScale = 1
113	manager := &configManagerState{current: initial}
114	provider := NewRuntimeConfigProvider(initial, newMockConfigManager(t, manager))
115
116	next := config.DefaultConfig()
117	next.DefaultUIScale = 1.8
118	manager.current = next
119
120	if got := provider.Current().UI.DefaultUIScale; got != 1.8 {
121		t.Fatalf("Current DefaultUIScale=%v, want 1.8", got)
122	}
123}
124
125func TestRuntimeConfigProviderCurrentFallsBackWhenManagerGetReturnsNil(t *testing.T) {
126	initial := config.DefaultConfig()
127	initial.DefaultUIScale = 1.35
128	manager := &configManagerState{}
129	provider := NewRuntimeConfigProvider(initial, newMockConfigManager(t, manager))
130
131	got := provider.Current()
132	want := RuntimeConfigSnapshotFromConfig(initial)
133
134	if !reflect.DeepEqual(got, want) {
135		t.Fatalf("Current()=%#v, want initial fallback %#v", got, want)
136	}
137}
138
139func TestRuntimeConfigProviderOnChangeCallbackCanCallCurrent(t *testing.T) {
140	initial := config.DefaultConfig()
141	initial.DefaultUIScale = 1
142	manager := &configManagerState{current: initial}
143	provider := NewRuntimeConfigProvider(initial, newMockConfigManager(t, manager))
144
145	completed := false
146	var seen entity.RuntimeConfigSnapshot
147	provider.OnChange(func(entity.RuntimeConfigSnapshot) {
148		seen = provider.Current()
149		completed = true
150	})
151
152	next := config.DefaultConfig()
153	next.DefaultUIScale = 1.8
154	manager.emit(next)
155
156	if !completed {
157		t.Fatal("callback did not complete")
158	}
159	if seen.UI.DefaultUIScale != 1.8 {
160		t.Fatalf("callback Current DefaultUIScale=%v, want 1.8", seen.UI.DefaultUIScale)
161	}
162}
163
164func TestRuntimeConfigProviderOnChangeFallsBackWhenPayloadIsNil(t *testing.T) {
165	initial := config.DefaultConfig()
166	initial.DefaultUIScale = 1.35
167	manager := &configManagerState{current: initial}
168	provider := NewRuntimeConfigProvider(initial, newMockConfigManager(t, manager))
169
170	var seen entity.RuntimeConfigSnapshot
171	provider.OnChange(func(snapshot entity.RuntimeConfigSnapshot) {
172		seen = snapshot
173	})
174
175	manager.emit(nil)
176	want := RuntimeConfigSnapshotFromConfig(initial)
177
178	if !reflect.DeepEqual(seen, want) {
179		t.Fatalf("callback snapshot=%#v, want initial fallback %#v", seen, want)
180	}
181}
182
183func TestRuntimeConfigProviderOnChangeNilCallbackDoesNotRegister(t *testing.T) {
184	manager := &configManagerState{current: config.DefaultConfig()}
185	provider := NewRuntimeConfigProvider(config.DefaultConfig(), newMockConfigManager(t, manager))
186
187	provider.OnChange(nil)
188
189	if len(manager.callbacks) != 0 {
190		t.Fatalf("callbacks=%d, want 0", len(manager.callbacks))
191	}
192}
193
194func TestRuntimeConfigProviderWatchDelegatesToManager(t *testing.T) {
195	manager := &configManagerState{}
196	provider := NewRuntimeConfigProvider(config.DefaultConfig(), newMockConfigManager(t, manager))
197
198	if err := provider.Watch(); err != nil {
199		t.Fatalf("Watch returned error: %v", err)
200	}
201	if manager.watchCalls != 1 {
202		t.Fatalf("watchCalls=%d, want 1", manager.watchCalls)
203	}
204}
205
206func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) {
207	cfg := config.DefaultConfig()
208	cfg.SearchShortcuts = map[string]config.SearchShortcut{
209		"gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"},
210	}
211	provider := NewRuntimeConfigProvider(cfg, newMockConfigManager(t, &configManagerState{current: cfg}))
212
213	first := provider.Current()
214	first.UI.SearchShortcuts["gh"] = entity.RuntimeSearchShortcut{URL: "mutated"}
215	second := provider.Current()
216
217	if second.UI.SearchShortcuts["gh"].URL == "mutated" {
218		t.Fatal("Current must return a snapshot clone so callers cannot mutate provider state")
219	}
220}
221
222func TestRuntimeConfigProviderCurrentReturnsNestedConfigClone(t *testing.T) {
223	cfg := runtimeConfigWithNestedMutableFields()
224	provider := NewRuntimeConfigProvider(cfg, newMockConfigManager(t, &configManagerState{current: cfg}))
225
226	first := provider.Current()
227	mutateNestedRuntimeConfigSnapshot(first)
228	second := provider.Current()
229
230	assertNestedRuntimeConfigSnapshotUnchanged(t, second)
231}
232
233func TestRuntimeConfigProviderOnChangePassesNestedConfigClone(t *testing.T) {
234	manager := &configManagerState{}
235	provider := NewRuntimeConfigProvider(config.DefaultConfig(), newMockConfigManager(t, manager))
236
237	provider.OnChange(func(snapshot entity.RuntimeConfigSnapshot) {
238		mutateNestedRuntimeConfigSnapshot(snapshot)
239	})
240
241	manager.emit(runtimeConfigWithNestedMutableFields())
242
243	assertNestedRuntimeConfigSnapshotUnchanged(t, provider.Current())
244}
245
246func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {
247	got := EngineSettingsPayloadFromConfig(nil)
248	if got != (entity.EngineSettingsPayload{}) {
249		t.Fatalf("payload=%#v, want zero value", got)
250	}
251}
252
253func runtimeConfigWithNestedMutableFields() *config.Config {
254	cfg := config.DefaultConfig()
255	cfg.Workspace.PaneMode.Actions = map[string]entity.ActionBinding{
256		"split-right": {Keys: []string{"r"}, Desc: "Split right"},
257	}
258	cfg.Workspace.TabMode.Actions = map[string]entity.ActionBinding{
259		"next-tab": {Keys: []string{"l"}, Desc: "Next tab"},
260	}
261	cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{
262		"grow-right": {Keys: []string{"right"}, Desc: "Grow right"},
263	}
264	cfg.Workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
265		"toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "History"},
266	}
267	cfg.Workspace.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
268		"docs": {Keys: []string{"alt+d"}, URL: "https://docs.example", Desc: "Docs"},
269	}
270	cfg.Session.SessionMode.Actions = map[string]entity.ActionBinding{
271		"session-manager": {Keys: []string{"s"}, Desc: "Session manager"},
272	}
273	return cfg
274}
275
276func mutateNestedRuntimeConfigSnapshot(snapshot entity.RuntimeConfigSnapshot) {
277	mutateActionBinding(snapshot.UI.Workspace.PaneMode.Actions, "split-right", "mutated-pane")
278	snapshot.UI.Workspace.PaneMode.Actions["added-pane"] = entity.ActionBinding{Keys: []string{"added"}}
279
280	mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab")
281	snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}}
282
283	mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize")
284	snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}}
285
286	mutateActionBinding(snapshot.UI.Workspace.Shortcuts.Actions, "toggle-history-systemview", "mutated-shortcut")
287	snapshot.UI.Workspace.Shortcuts.Actions["added-shortcut"] = entity.ActionBinding{Keys: []string{"added"}}
288
289	profile := snapshot.UI.Workspace.FloatingPane.Profiles["docs"]
290	profile.Keys[0] = "mutated-floating"
291	snapshot.UI.Workspace.FloatingPane.Profiles["docs"] = profile
292	snapshot.UI.Workspace.FloatingPane.Profiles["added-floating"] = entity.FloatingPaneProfile{Keys: []string{"added"}}
293
294	mutateActionBinding(snapshot.UI.Session.SessionMode.Actions, "session-manager", "mutated-session")
295	snapshot.UI.Session.SessionMode.Actions["added-session"] = entity.ActionBinding{Keys: []string{"added"}}
296}
297
298func mutateActionBinding(actions map[string]entity.ActionBinding, action, key string) {
299	binding := actions[action]
300	binding.Keys[0] = key
301	actions[action] = binding
302}
303
304func assertNestedRuntimeConfigSnapshotUnchanged(t *testing.T, snapshot entity.RuntimeConfigSnapshot) {
305	t.Helper()
306
307	assertActionBinding(t, snapshot.UI.Workspace.PaneMode.Actions, "split-right", "r")
308	assertMapEntryAbsent(t, snapshot.UI.Workspace.PaneMode.Actions, "added-pane")
309
310	assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l")
311	assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab")
312
313	assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right")
314	assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize")
315
316	assertActionBinding(t, snapshot.UI.Workspace.Shortcuts.Actions, "toggle-history-systemview", "ctrl+h")
317	assertMapEntryAbsent(t, snapshot.UI.Workspace.Shortcuts.Actions, "added-shortcut")
318
319	profile := snapshot.UI.Workspace.FloatingPane.Profiles["docs"]
320	if got := profile.Keys[0]; got != "alt+d" {
321		t.Fatalf("floating profile key=%q, want %q", got, "alt+d")
322	}
323	if _, ok := snapshot.UI.Workspace.FloatingPane.Profiles["added-floating"]; ok {
324		t.Fatal("floating pane profiles must not include entries added through a returned snapshot")
325	}
326
327	assertActionBinding(t, snapshot.UI.Session.SessionMode.Actions, "session-manager", "s")
328	assertMapEntryAbsent(t, snapshot.UI.Session.SessionMode.Actions, "added-session")
329}
330
331func assertActionBinding(t *testing.T, actions map[string]entity.ActionBinding, action, wantKey string) {
332	t.Helper()
333
334	binding, ok := actions[action]
335	if !ok {
336		t.Fatalf("action %q missing", action)
337	}
338	if got := binding.Keys[0]; got != wantKey {
339		t.Fatalf("%s key=%q, want %q", action, got, wantKey)
340	}
341}
342
343func assertMapEntryAbsent(t *testing.T, actions map[string]entity.ActionBinding, action string) {
344	t.Helper()
345
346	if _, ok := actions[action]; ok {
347		t.Fatalf("actions must not include entry %q added through a returned snapshot", action)
348	}
349}
350
351func assertNestedSourceConfigUnchanged(t *testing.T, cfg *config.Config) {
352	t.Helper()
353
354	assertActionBinding(t, cfg.Workspace.PaneMode.Actions, "split-right", "r")
355	assertMapEntryAbsent(t, cfg.Workspace.PaneMode.Actions, "added-pane")
356
357	assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l")
358	assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab")
359
360	assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right")
361	assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize")
362
363	assertActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", "ctrl+h")
364	assertMapEntryAbsent(t, cfg.Workspace.Shortcuts.Actions, "added-shortcut")
365
366	profile := cfg.Workspace.FloatingPane.Profiles["docs"]
367	if got := profile.Keys[0]; got != "alt+d" {
368		t.Fatalf("floating profile key=%q, want %q", got, "alt+d")
369	}
370	if _, ok := cfg.Workspace.FloatingPane.Profiles["added-floating"]; ok {
371		t.Fatal("floating pane profiles must not include entries added through a returned snapshot")
372	}
373
374	assertActionBinding(t, cfg.Session.SessionMode.Actions, "session-manager", "s")
375	assertMapEntryAbsent(t, cfg.Session.SessionMode.Actions, "added-session")
376}
377
378func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) {
379	cfg := config.DefaultConfig()
380	cfg.DefaultUIScale = 1.4
381	cfg.SidebarWidth = 333
382	cfg.Downloads.Path = "/tmp/downloads"
383	cfg.DefaultSearchEngine = "https://search.example/?q=%s"
384	cfg.SearchShortcuts = map[string]config.SearchShortcut{
385		"gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"},
386	}
387	cfg.Workspace.NewPaneURL = "about:blank"
388	cfg.Session.SnapshotIntervalMs = 7000
389	cfg.Clipboard.AutoCopyOnSelection = true
390	cfg.Omnibox.AutoOpenOnNewPane = true
391	cfg.Update.NotifyOnNewSettings = true
392
393	got := RuntimeConfigSnapshotFromConfig(cfg)
394
395	if got.EngineSettings != EngineSettingsPayloadFromConfig(cfg) {
396		t.Fatalf("EngineSettings=%#v, want %#v", got.EngineSettings, EngineSettingsPayloadFromConfig(cfg))
397	}
398	if got.UI.DefaultUIScale != 1.4 ||
399		got.UI.SidebarWidth != 333 ||
400		got.UI.Downloads.Path != "/tmp/downloads" ||
401		got.UI.DefaultSearchEngine != "https://search.example/?q=%s" ||
402		got.UI.Workspace.NewPaneURL != "about:blank" ||
403		got.UI.Session.SnapshotIntervalMs != 7000 ||
404		!got.UI.Clipboard.AutoCopyOnSelection ||
405		!got.UI.Omnibox.AutoOpenOnNewPane ||
406		!got.UI.Update.NotifyOnNewSettings {
407		t.Fatalf("snapshot not mapped: %#v", got.UI)
408	}
409	if got.UI.SearchShortcuts["gh"].URL != "https://github.com/search?q=%s" {
410		t.Fatalf("search shortcut not mapped: %#v", got.UI.SearchShortcuts)
411	}
412	got.UI.SearchShortcuts["gh"] = entity.RuntimeSearchShortcut{URL: "mutated"}
413	if cfg.SearchShortcuts["gh"].URL == "mutated" {
414		t.Fatal("snapshot must deep-copy search shortcut map")
415	}
416}
417
418func TestRuntimeConfigSnapshotFromConfigDetachesNestedMutableFields(t *testing.T) {
419	cfg := runtimeConfigWithNestedMutableFields()
420	snapshot := RuntimeConfigSnapshotFromConfig(cfg)
421
422	mutateNestedRuntimeConfigSnapshot(snapshot)
423
424	assertNestedSourceConfigUnchanged(t, cfg)
425}
426
427func TestRuntimeConfigSnapshotFromNilConfigReturnsZeroSnapshot(t *testing.T) {
428	got := RuntimeConfigSnapshotFromConfig(nil)
429	if !reflect.DeepEqual(got, entity.RuntimeConfigSnapshot{}) {
430		t.Fatalf("snapshot=%#v, want zero value", got)
431	}
432}
433
434func TestEngineSettingsPayloadFromConfigMapsHardwareDecodingModes(t *testing.T) {
435	tests := []struct {
436		name string
437		mode config.HardwareDecodingMode
438		want entity.EngineHardwareDecodingMode
439	}{
440		{
441			name: "auto",
442			mode: config.HardwareDecodingAuto,
443			want: entity.EngineHardwareDecodingAuto,
444		},
445		{
446			name: "force",
447			mode: config.HardwareDecodingForce,
448			want: entity.EngineHardwareDecodingForce,
449		},
450		{
451			name: "disable",
452			mode: config.HardwareDecodingDisable,
453			want: entity.EngineHardwareDecodingDisable,
454		},
455		{
456			name: "unknown",
457			mode: config.HardwareDecodingMode("surprise"),
458			want: entity.EngineHardwareDecodingAuto,
459		},
460		{
461			name: "zero value",
462			mode: "",
463			want: entity.EngineHardwareDecodingAuto,
464		},
465	}
466
467	for _, tt := range tests {
468		t.Run(tt.name, func(t *testing.T) {
469			cfg := config.DefaultConfig()
470			cfg.Media.HardwareDecodingMode = tt.mode
471
472			got := EngineSettingsPayloadFromConfig(cfg)
473
474			if got.WebContent.HardwareDecoding != tt.want {
475				t.Fatalf("HardwareDecoding=%q, want %q", got.WebContent.HardwareDecoding, tt.want)
476			}
477		})
478	}
479}

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 8

Tags 42

↑↓ moveenter openesc close