v0.33.1 9577fee

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.VimMode.Actions = map[string]entity.ActionBinding{
262		"vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll down"},
263	}
264	cfg.Workspace.ResizeMode.Actions = map[string]entity.ActionBinding{
265		"grow-right": {Keys: []string{"right"}, Desc: "Grow right"},
266	}
267	cfg.Workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
268		"toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "History"},
269	}
270	cfg.Workspace.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
271		"docs": {Keys: []string{"alt+d"}, URL: "https://docs.example", Desc: "Docs"},
272	}
273	cfg.Session.SessionMode.Actions = map[string]entity.ActionBinding{
274		"session-manager": {Keys: []string{"s"}, Desc: "Session manager"},
275	}
276	return cfg
277}
278
279func mutateNestedRuntimeConfigSnapshot(snapshot entity.RuntimeConfigSnapshot) {
280	mutateActionBinding(snapshot.UI.Workspace.PaneMode.Actions, "split-right", "mutated-pane")
281	snapshot.UI.Workspace.PaneMode.Actions["added-pane"] = entity.ActionBinding{Keys: []string{"added"}}
282
283	mutateActionBinding(snapshot.UI.Workspace.TabMode.Actions, "next-tab", "mutated-tab")
284	snapshot.UI.Workspace.TabMode.Actions["added-tab"] = entity.ActionBinding{Keys: []string{"added"}}
285
286	mutateActionBinding(snapshot.UI.Workspace.VimMode.Actions, "vim-scroll-down", "mutated-page")
287	snapshot.UI.Workspace.VimMode.Actions["added-page"] = entity.ActionBinding{Keys: []string{"added"}}
288
289	mutateActionBinding(snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "mutated-resize")
290	snapshot.UI.Workspace.ResizeMode.Actions["added-resize"] = entity.ActionBinding{Keys: []string{"added"}}
291
292	mutateActionBinding(snapshot.UI.Workspace.Shortcuts.Actions, "toggle-history-systemview", "mutated-shortcut")
293	snapshot.UI.Workspace.Shortcuts.Actions["added-shortcut"] = entity.ActionBinding{Keys: []string{"added"}}
294
295	profile := snapshot.UI.Workspace.FloatingPane.Profiles["docs"]
296	profile.Keys[0] = "mutated-floating"
297	snapshot.UI.Workspace.FloatingPane.Profiles["docs"] = profile
298	snapshot.UI.Workspace.FloatingPane.Profiles["added-floating"] = entity.FloatingPaneProfile{Keys: []string{"added"}}
299
300	mutateActionBinding(snapshot.UI.Session.SessionMode.Actions, "session-manager", "mutated-session")
301	snapshot.UI.Session.SessionMode.Actions["added-session"] = entity.ActionBinding{Keys: []string{"added"}}
302}
303
304func mutateActionBinding(actions map[string]entity.ActionBinding, action, key string) {
305	binding := actions[action]
306	binding.Keys[0] = key
307	actions[action] = binding
308}
309
310func assertNestedRuntimeConfigSnapshotUnchanged(t *testing.T, snapshot entity.RuntimeConfigSnapshot) {
311	t.Helper()
312
313	assertActionBinding(t, snapshot.UI.Workspace.PaneMode.Actions, "split-right", "r")
314	assertMapEntryAbsent(t, snapshot.UI.Workspace.PaneMode.Actions, "added-pane")
315
316	assertActionBinding(t, snapshot.UI.Workspace.TabMode.Actions, "next-tab", "l")
317	assertMapEntryAbsent(t, snapshot.UI.Workspace.TabMode.Actions, "added-tab")
318
319	assertActionBinding(t, snapshot.UI.Workspace.VimMode.Actions, "vim-scroll-down", "j")
320	assertMapEntryAbsent(t, snapshot.UI.Workspace.VimMode.Actions, "added-page")
321
322	assertActionBinding(t, snapshot.UI.Workspace.ResizeMode.Actions, "grow-right", "right")
323	assertMapEntryAbsent(t, snapshot.UI.Workspace.ResizeMode.Actions, "added-resize")
324
325	assertActionBinding(t, snapshot.UI.Workspace.Shortcuts.Actions, "toggle-history-systemview", "ctrl+h")
326	assertMapEntryAbsent(t, snapshot.UI.Workspace.Shortcuts.Actions, "added-shortcut")
327
328	profile := snapshot.UI.Workspace.FloatingPane.Profiles["docs"]
329	if got := profile.Keys[0]; got != "alt+d" {
330		t.Fatalf("floating profile key=%q, want %q", got, "alt+d")
331	}
332	if _, ok := snapshot.UI.Workspace.FloatingPane.Profiles["added-floating"]; ok {
333		t.Fatal("floating pane profiles must not include entries added through a returned snapshot")
334	}
335
336	assertActionBinding(t, snapshot.UI.Session.SessionMode.Actions, "session-manager", "s")
337	assertMapEntryAbsent(t, snapshot.UI.Session.SessionMode.Actions, "added-session")
338}
339
340func assertActionBinding(t *testing.T, actions map[string]entity.ActionBinding, action, wantKey string) {
341	t.Helper()
342
343	binding, ok := actions[action]
344	if !ok {
345		t.Fatalf("action %q missing", action)
346	}
347	if got := binding.Keys[0]; got != wantKey {
348		t.Fatalf("%s key=%q, want %q", action, got, wantKey)
349	}
350}
351
352func assertMapEntryAbsent(t *testing.T, actions map[string]entity.ActionBinding, action string) {
353	t.Helper()
354
355	if _, ok := actions[action]; ok {
356		t.Fatalf("actions must not include entry %q added through a returned snapshot", action)
357	}
358}
359
360func assertNestedSourceConfigUnchanged(t *testing.T, cfg *config.Config) {
361	t.Helper()
362
363	assertActionBinding(t, cfg.Workspace.PaneMode.Actions, "split-right", "r")
364	assertMapEntryAbsent(t, cfg.Workspace.PaneMode.Actions, "added-pane")
365
366	assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l")
367	assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab")
368
369	assertActionBinding(t, cfg.Workspace.VimMode.Actions, "vim-scroll-down", "j")
370	assertMapEntryAbsent(t, cfg.Workspace.VimMode.Actions, "added-page")
371
372	assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right")
373	assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize")
374
375	assertActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", "ctrl+h")
376	assertMapEntryAbsent(t, cfg.Workspace.Shortcuts.Actions, "added-shortcut")
377
378	profile := cfg.Workspace.FloatingPane.Profiles["docs"]
379	if got := profile.Keys[0]; got != "alt+d" {
380		t.Fatalf("floating profile key=%q, want %q", got, "alt+d")
381	}
382	if _, ok := cfg.Workspace.FloatingPane.Profiles["added-floating"]; ok {
383		t.Fatal("floating pane profiles must not include entries added through a returned snapshot")
384	}
385
386	assertActionBinding(t, cfg.Session.SessionMode.Actions, "session-manager", "s")
387	assertMapEntryAbsent(t, cfg.Session.SessionMode.Actions, "added-session")
388}
389
390func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) {
391	cfg := config.DefaultConfig()
392	cfg.DefaultUIScale = 1.4
393	cfg.SidebarWidth = 333
394	cfg.Downloads.Path = "/tmp/downloads"
395	cfg.DefaultSearchEngine = "https://search.example/?q=%s"
396	cfg.SearchShortcuts = map[string]config.SearchShortcut{
397		"gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"},
398	}
399	cfg.Workspace.NewPaneURL = "about:blank"
400	cfg.Session.SnapshotIntervalMs = 7000
401	cfg.Clipboard.AutoCopyOnSelection = true
402	cfg.Omnibox.AutoOpenOnNewPane = true
403	cfg.Update.NotifyOnNewSettings = true
404
405	got := RuntimeConfigSnapshotFromConfig(cfg)
406
407	if got.EngineSettings != EngineSettingsPayloadFromConfig(cfg) {
408		t.Fatalf("EngineSettings=%#v, want %#v", got.EngineSettings, EngineSettingsPayloadFromConfig(cfg))
409	}
410	if got.UI.DefaultUIScale != 1.4 ||
411		got.UI.SidebarWidth != 333 ||
412		got.UI.Downloads.Path != "/tmp/downloads" ||
413		got.UI.DefaultSearchEngine != "https://search.example/?q=%s" ||
414		got.UI.Workspace.NewPaneURL != "about:blank" ||
415		got.UI.Session.SnapshotIntervalMs != 7000 ||
416		!got.UI.Clipboard.AutoCopyOnSelection ||
417		!got.UI.Omnibox.AutoOpenOnNewPane ||
418		!got.UI.Update.NotifyOnNewSettings {
419		t.Fatalf("snapshot not mapped: %#v", got.UI)
420	}
421	if got.UI.SearchShortcuts["gh"].URL != "https://github.com/search?q=%s" {
422		t.Fatalf("search shortcut not mapped: %#v", got.UI.SearchShortcuts)
423	}
424	got.UI.SearchShortcuts["gh"] = entity.RuntimeSearchShortcut{URL: "mutated"}
425	if cfg.SearchShortcuts["gh"].URL == "mutated" {
426		t.Fatal("snapshot must deep-copy search shortcut map")
427	}
428}
429
430func TestRuntimeConfigSnapshotFromConfigDetachesNestedMutableFields(t *testing.T) {
431	cfg := runtimeConfigWithNestedMutableFields()
432	snapshot := RuntimeConfigSnapshotFromConfig(cfg)
433
434	mutateNestedRuntimeConfigSnapshot(snapshot)
435
436	assertNestedSourceConfigUnchanged(t, cfg)
437}
438
439func TestRuntimeConfigSnapshotFromNilConfigReturnsZeroSnapshot(t *testing.T) {
440	got := RuntimeConfigSnapshotFromConfig(nil)
441	if !reflect.DeepEqual(got, entity.RuntimeConfigSnapshot{}) {
442		t.Fatalf("snapshot=%#v, want zero value", got)
443	}
444}
445
446func TestEngineSettingsPayloadFromConfigMapsHardwareDecodingModes(t *testing.T) {
447	tests := []struct {
448		name string
449		mode config.HardwareDecodingMode
450		want entity.EngineHardwareDecodingMode
451	}{
452		{
453			name: "auto",
454			mode: config.HardwareDecodingAuto,
455			want: entity.EngineHardwareDecodingAuto,
456		},
457		{
458			name: "force",
459			mode: config.HardwareDecodingForce,
460			want: entity.EngineHardwareDecodingForce,
461		},
462		{
463			name: "disable",
464			mode: config.HardwareDecodingDisable,
465			want: entity.EngineHardwareDecodingDisable,
466		},
467		{
468			name: "unknown",
469			mode: config.HardwareDecodingMode("surprise"),
470			want: entity.EngineHardwareDecodingAuto,
471		},
472		{
473			name: "zero value",
474			mode: "",
475			want: entity.EngineHardwareDecodingAuto,
476		},
477	}
478
479	for _, tt := range tests {
480		t.Run(tt.name, func(t *testing.T) {
481			cfg := config.DefaultConfig()
482			cfg.Media.HardwareDecodingMode = tt.mode
483
484			got := EngineSettingsPayloadFromConfig(cfg)
485
486			if got.WebContent.HardwareDecoding != tt.want {
487				t.Fatalf("HardwareDecoding=%q, want %q", got.WebContent.HardwareDecoding, tt.want)
488			}
489		})
490	}
491}

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