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}