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}