fix(config): make runtime provider read current config
- commit
- 035c4f0528c2afa067654d01b74a5d529e94c4c2
- author
- brice · · 2026-06-23 14:25 UTC
- committer
- brice · · 2026-06-23 20:01 UTC
- parent
- 75ca6b4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +106 -19
M internal/bootstrap/runtime_config.go +14 -15
| @@ -1,35 +1,37 @@ | |||
| 1 | 1 | package bootstrap | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "sync" | |
| 5 | - | ||
| 6 | 4 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 5 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 6 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 7 | ) | |
| 10 | 8 | ||
| 11 | 9 | type configChangeManager interface { | |
| 10 | + | Get() *config.Config | |
| 12 | 11 | Watch() error | |
| 13 | 12 | OnConfigChange(func(*config.Config)) | |
| 14 | 13 | } | |
| 15 | 14 | ||
| 16 | 15 | type runtimeConfigProvider struct { | |
| 17 | - | mu sync.RWMutex | |
| 18 | - | current port.RuntimeConfigSnapshot | |
| 19 | - | manager configChangeManager | |
| 16 | + | fallback port.RuntimeConfigSnapshot | |
| 17 | + | manager configChangeManager | |
| 20 | 18 | } | |
| 21 | 19 | ||
| 22 | 20 | func NewRuntimeConfigProvider(cfg *config.Config, manager configChangeManager) port.RuntimeConfigProvider { | |
| 23 | 21 | return &runtimeConfigProvider{ | |
| 24 | - | current: RuntimeConfigSnapshotFromConfig(cfg), | |
| 25 | - | manager: manager, | |
| 22 | + | fallback: RuntimeConfigSnapshotFromConfig(cfg), | |
| 23 | + | manager: manager, | |
| 26 | 24 | } | |
| 27 | 25 | } | |
| 28 | 26 | ||
| 29 | 27 | func (p *runtimeConfigProvider) Current() port.RuntimeConfigSnapshot { | |
| 30 | - | p.mu.RLock() | |
| 31 | - | defer p.mu.RUnlock() | |
| 32 | - | return cloneRuntimeConfigSnapshot(p.current) | |
| 28 | + | if p == nil { | |
| 29 | + | return port.RuntimeConfigSnapshot{} | |
| 30 | + | } | |
| 31 | + | if p.manager == nil { | |
| 32 | + | return cloneRuntimeConfigSnapshot(p.fallback) | |
| 33 | + | } | |
| 34 | + | return cloneRuntimeConfigSnapshot(RuntimeConfigSnapshotFromConfig(p.manager.Get())) | |
| 33 | 35 | } | |
| 34 | 36 | ||
| 35 | 37 | func (p *runtimeConfigProvider) Watch() error { | |
| @@ -45,9 +47,6 @@ func (p *runtimeConfigProvider) OnChange(callback func(port.RuntimeConfigSnapsho | |||
| 45 | 47 | } | |
| 46 | 48 | p.manager.OnConfigChange(func(cfg *config.Config) { | |
| 47 | 49 | snapshot := RuntimeConfigSnapshotFromConfig(cfg) | |
| 48 | - | p.mu.Lock() | |
| 49 | - | p.current = snapshot | |
| 50 | - | p.mu.Unlock() | |
| 51 | 50 | if callback != nil { | |
| 52 | 51 | callback(cloneRuntimeConfigSnapshot(snapshot)) | |
| 53 | 52 | } | |
| @@ -83,8 +82,8 @@ func RuntimeConfigSnapshotFromConfig(cfg *config.Config) port.RuntimeConfigSnaps | |||
| 83 | 82 | DefaultUIScale: cfg.DefaultUIScale, | |
| 84 | 83 | SidebarWidth: cfg.SidebarWidth, | |
| 85 | 84 | Appearance: cfg.Appearance, | |
| 86 | - | Workspace: cfg.Workspace, | |
| 87 | - | Session: cfg.Session, | |
| 85 | + | Workspace: cloneWorkspaceConfig(cfg.Workspace), | |
| 86 | + | Session: cloneSessionConfig(cfg.Session), | |
| 88 | 87 | Clipboard: port.RuntimeClipboardConfig{AutoCopyOnSelection: cfg.Clipboard.AutoCopyOnSelection}, | |
| 89 | 88 | SearchShortcuts: runtimeSearchShortcutsFromConfig(cfg.SearchShortcuts), | |
| 90 | 89 | DefaultSearchEngine: cfg.DefaultSearchEngine, | |
| @@ -1,35 +1,37 @@ | |||||
| 1 | package bootstrap | 1 | package bootstrap | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "sync" | |||
| 5 | - | ||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 4 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/domain/entity" | 5 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 6 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 9 | ) | 7 | ) | ||
| 10 | 8 | ||||
| 11 | type configChangeManager interface { | 9 | type configChangeManager interface { | ||
| 10 | + | Get() *config.Config | |||
| 12 | Watch() error | 11 | Watch() error | ||
| 13 | OnConfigChange(func(*config.Config)) | 12 | OnConfigChange(func(*config.Config)) | ||
| 14 | } | 13 | } | ||
| 15 | 14 | ||||
| 16 | type runtimeConfigProvider struct { | 15 | type runtimeConfigProvider struct { | ||
| 17 | - | mu sync.RWMutex | 16 | + | fallback port.RuntimeConfigSnapshot |
| 18 | - | current port.RuntimeConfigSnapshot | 17 | + | manager configChangeManager |
| 19 | - | manager configChangeManager | |||
| 20 | } | 18 | } | ||
| 21 | 19 | ||||
| 22 | func NewRuntimeConfigProvider(cfg *config.Config, manager configChangeManager) port.RuntimeConfigProvider { | 20 | func NewRuntimeConfigProvider(cfg *config.Config, manager configChangeManager) port.RuntimeConfigProvider { | ||
| 23 | return &runtimeConfigProvider{ | 21 | return &runtimeConfigProvider{ | ||
| 24 | - | current: RuntimeConfigSnapshotFromConfig(cfg), | 22 | + | fallback: RuntimeConfigSnapshotFromConfig(cfg), |
| 25 | - | manager: manager, | 23 | + | manager: manager, |
| 26 | } | 24 | } | ||
| 27 | } | 25 | } | ||
| 28 | 26 | ||||
| 29 | func (p *runtimeConfigProvider) Current() port.RuntimeConfigSnapshot { | 27 | func (p *runtimeConfigProvider) Current() port.RuntimeConfigSnapshot { | ||
| 30 | - | p.mu.RLock() | 28 | + | if p == nil { |
| 31 | - | defer p.mu.RUnlock() | 29 | + | return port.RuntimeConfigSnapshot{} |
| 32 | - | return cloneRuntimeConfigSnapshot(p.current) | 30 | + | } |
| 31 | + | if p.manager == nil { | |||
| 32 | + | return cloneRuntimeConfigSnapshot(p.fallback) | |||
| 33 | + | } | |||
| 34 | + | return cloneRuntimeConfigSnapshot(RuntimeConfigSnapshotFromConfig(p.manager.Get())) | |||
| 33 | } | 35 | } | ||
| 34 | 36 | ||||
| 35 | func (p *runtimeConfigProvider) Watch() error { | 37 | func (p *runtimeConfigProvider) Watch() error { | ||
| @@ -45,9 +47,6 @@ func (p *runtimeConfigProvider) OnChange(callback func(port.RuntimeConfigSnapsho | |||||
| 45 | } | 47 | } | ||
| 46 | p.manager.OnConfigChange(func(cfg *config.Config) { | 48 | p.manager.OnConfigChange(func(cfg *config.Config) { | ||
| 47 | snapshot := RuntimeConfigSnapshotFromConfig(cfg) | 49 | snapshot := RuntimeConfigSnapshotFromConfig(cfg) | ||
| 48 | - | p.mu.Lock() | |||
| 49 | - | p.current = snapshot | |||
| 50 | - | p.mu.Unlock() | |||
| 51 | if callback != nil { | 50 | if callback != nil { | ||
| 52 | callback(cloneRuntimeConfigSnapshot(snapshot)) | 51 | callback(cloneRuntimeConfigSnapshot(snapshot)) | ||
| 53 | } | 52 | } | ||
| @@ -83,8 +82,8 @@ func RuntimeConfigSnapshotFromConfig(cfg *config.Config) port.RuntimeConfigSnaps | |||||
| 83 | DefaultUIScale: cfg.DefaultUIScale, | 82 | DefaultUIScale: cfg.DefaultUIScale, | ||
| 84 | SidebarWidth: cfg.SidebarWidth, | 83 | SidebarWidth: cfg.SidebarWidth, | ||
| 85 | Appearance: cfg.Appearance, | 84 | Appearance: cfg.Appearance, | ||
| 86 | - | Workspace: cfg.Workspace, | 85 | + | Workspace: cloneWorkspaceConfig(cfg.Workspace), |
| 87 | - | Session: cfg.Session, | 86 | + | Session: cloneSessionConfig(cfg.Session), |
| 88 | Clipboard: port.RuntimeClipboardConfig{AutoCopyOnSelection: cfg.Clipboard.AutoCopyOnSelection}, | 87 | Clipboard: port.RuntimeClipboardConfig{AutoCopyOnSelection: cfg.Clipboard.AutoCopyOnSelection}, | ||
| 89 | SearchShortcuts: runtimeSearchShortcutsFromConfig(cfg.SearchShortcuts), | 88 | SearchShortcuts: runtimeSearchShortcutsFromConfig(cfg.SearchShortcuts), | ||
| 90 | DefaultSearchEngine: cfg.DefaultSearchEngine, | 89 | DefaultSearchEngine: cfg.DefaultSearchEngine, | ||
M internal/bootstrap/runtime_config_test.go +92 -4
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | type fakeConfigManager struct { | |
| 13 | + | current *config.Config | |
| 13 | 14 | watchCalls int | |
| 14 | 15 | callbacks []func(*config.Config) | |
| 15 | 16 | } | |
| @@ -23,6 +24,17 @@ func (f *fakeConfigManager) OnConfigChange(callback func(*config.Config)) { | |||
| 23 | 24 | f.callbacks = append(f.callbacks, callback) | |
| 24 | 25 | } | |
| 25 | 26 | ||
| 27 | + | func (f *fakeConfigManager) Get() *config.Config { | |
| 28 | + | return f.current | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (f *fakeConfigManager) emit(next *config.Config) { | |
| 32 | + | f.current = next | |
| 33 | + | for _, callback := range f.callbacks { | |
| 34 | + | callback(next) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 26 | 38 | func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) { | |
| 27 | 39 | cfg := config.DefaultConfig() | |
| 28 | 40 | cfg.DefaultUIScale = 1.25 | |
| @@ -69,7 +81,7 @@ func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) { | |||
| 69 | 81 | ||
| 70 | 82 | next := config.DefaultConfig() | |
| 71 | 83 | next.DefaultUIScale = 1.8 | |
| 72 | - | manager.callbacks[0](next) | |
| 84 | + | manager.emit(next) | |
| 73 | 85 | ||
| 74 | 86 | if got := provider.Current().UI.DefaultUIScale; got != 1.8 { | |
| 75 | 87 | t.Fatalf("Current DefaultUIScale=%v, want 1.8", got) | |
| @@ -79,6 +91,46 @@ func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) { | |||
| 79 | 91 | } | |
| 80 | 92 | } | |
| 81 | 93 | ||
| 94 | + | func TestRuntimeConfigProviderCurrentReflectsManagerGetWithoutSubscription(t *testing.T) { | |
| 95 | + | initial := config.DefaultConfig() | |
| 96 | + | initial.DefaultUIScale = 1 | |
| 97 | + | manager := &fakeConfigManager{current: initial} | |
| 98 | + | provider := NewRuntimeConfigProvider(initial, manager) | |
| 99 | + | ||
| 100 | + | next := config.DefaultConfig() | |
| 101 | + | next.DefaultUIScale = 1.8 | |
| 102 | + | manager.current = next | |
| 103 | + | ||
| 104 | + | if got := provider.Current().UI.DefaultUIScale; got != 1.8 { | |
| 105 | + | t.Fatalf("Current DefaultUIScale=%v, want 1.8", got) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestRuntimeConfigProviderOnChangeCallbackCanCallCurrent(t *testing.T) { | |
| 110 | + | initial := config.DefaultConfig() | |
| 111 | + | initial.DefaultUIScale = 1 | |
| 112 | + | manager := &fakeConfigManager{current: initial} | |
| 113 | + | provider := NewRuntimeConfigProvider(initial, manager) | |
| 114 | + | ||
| 115 | + | completed := false | |
| 116 | + | var seen port.RuntimeConfigSnapshot | |
| 117 | + | provider.OnChange(func(port.RuntimeConfigSnapshot) { | |
| 118 | + | seen = provider.Current() | |
| 119 | + | completed = true | |
| 120 | + | }) | |
| 121 | + | ||
| 122 | + | next := config.DefaultConfig() | |
| 123 | + | next.DefaultUIScale = 1.8 | |
| 124 | + | manager.emit(next) | |
| 125 | + | ||
| 126 | + | if !completed { | |
| 127 | + | t.Fatal("callback did not complete") | |
| 128 | + | } | |
| 129 | + | if seen.UI.DefaultUIScale != 1.8 { | |
| 130 | + | t.Fatalf("callback Current DefaultUIScale=%v, want 1.8", seen.UI.DefaultUIScale) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 82 | 134 | func TestRuntimeConfigProviderWatchDelegatesToManager(t *testing.T) { | |
| 83 | 135 | manager := &fakeConfigManager{} | |
| 84 | 136 | provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager) | |
| @@ -96,7 +148,7 @@ func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) { | |||
| 96 | 148 | cfg.SearchShortcuts = map[string]config.SearchShortcut{ | |
| 97 | 149 | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | |
| 98 | 150 | } | |
| 99 | - | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{}) | |
| 151 | + | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{current: cfg}) | |
| 100 | 152 | ||
| 101 | 153 | first := provider.Current() | |
| 102 | 154 | first.UI.SearchShortcuts["gh"] = port.RuntimeSearchShortcut{URL: "mutated"} | |
| @@ -109,7 +161,7 @@ func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) { | |||
| 109 | 161 | ||
| 110 | 162 | func TestRuntimeConfigProviderCurrentReturnsNestedConfigClone(t *testing.T) { | |
| 111 | 163 | cfg := runtimeConfigWithNestedMutableFields() | |
| 112 | - | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{}) | |
| 164 | + | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{current: cfg}) | |
| 113 | 165 | ||
| 114 | 166 | first := provider.Current() | |
| 115 | 167 | mutateNestedRuntimeConfigSnapshot(first) | |
| @@ -126,7 +178,7 @@ func TestRuntimeConfigProviderOnChangePassesNestedConfigClone(t *testing.T) { | |||
| 126 | 178 | mutateNestedRuntimeConfigSnapshot(snapshot) | |
| 127 | 179 | }) | |
| 128 | 180 | ||
| 129 | - | manager.callbacks[0](runtimeConfigWithNestedMutableFields()) | |
| 181 | + | manager.emit(runtimeConfigWithNestedMutableFields()) | |
| 130 | 182 | ||
| 131 | 183 | assertNestedRuntimeConfigSnapshotUnchanged(t, provider.Current()) | |
| 132 | 184 | } | |
| @@ -236,6 +288,33 @@ func assertMapEntryAbsent(t *testing.T, actions map[string]entity.ActionBinding, | |||
| 236 | 288 | } | |
| 237 | 289 | } | |
| 238 | 290 | ||
| 291 | + | func assertNestedSourceConfigUnchanged(t *testing.T, cfg *config.Config) { | |
| 292 | + | t.Helper() | |
| 293 | + | ||
| 294 | + | assertActionBinding(t, cfg.Workspace.PaneMode.Actions, "split-right", "r") | |
| 295 | + | assertMapEntryAbsent(t, cfg.Workspace.PaneMode.Actions, "added-pane") | |
| 296 | + | ||
| 297 | + | assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l") | |
| 298 | + | assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab") | |
| 299 | + | ||
| 300 | + | assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right") | |
| 301 | + | assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize") | |
| 302 | + | ||
| 303 | + | assertActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", "ctrl+h") | |
| 304 | + | assertMapEntryAbsent(t, cfg.Workspace.Shortcuts.Actions, "added-shortcut") | |
| 305 | + | ||
| 306 | + | profile := cfg.Workspace.FloatingPane.Profiles["docs"] | |
| 307 | + | if got := profile.Keys[0]; got != "alt+d" { | |
| 308 | + | t.Fatalf("floating profile key=%q, want %q", got, "alt+d") | |
| 309 | + | } | |
| 310 | + | if _, ok := cfg.Workspace.FloatingPane.Profiles["added-floating"]; ok { | |
| 311 | + | t.Fatal("floating pane profiles must not include entries added through a returned snapshot") | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | assertActionBinding(t, cfg.Session.SessionMode.Actions, "session-manager", "s") | |
| 315 | + | assertMapEntryAbsent(t, cfg.Session.SessionMode.Actions, "added-session") | |
| 316 | + | } | |
| 317 | + | ||
| 239 | 318 | func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) { | |
| 240 | 319 | cfg := config.DefaultConfig() | |
| 241 | 320 | cfg.DefaultUIScale = 1.4 | |
| @@ -276,6 +355,15 @@ func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) { | |||
| 276 | 355 | } | |
| 277 | 356 | } | |
| 278 | 357 | ||
| 358 | + | func TestRuntimeConfigSnapshotFromConfigDetachesNestedMutableFields(t *testing.T) { | |
| 359 | + | cfg := runtimeConfigWithNestedMutableFields() | |
| 360 | + | snapshot := RuntimeConfigSnapshotFromConfig(cfg) | |
| 361 | + | ||
| 362 | + | mutateNestedRuntimeConfigSnapshot(snapshot) | |
| 363 | + | ||
| 364 | + | assertNestedSourceConfigUnchanged(t, cfg) | |
| 365 | + | } | |
| 366 | + | ||
| 279 | 367 | func TestRuntimeConfigSnapshotFromNilConfigReturnsZeroSnapshot(t *testing.T) { | |
| 280 | 368 | got := RuntimeConfigSnapshotFromConfig(nil) | |
| 281 | 369 | if !reflect.DeepEqual(got, port.RuntimeConfigSnapshot{}) { | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | type fakeConfigManager struct { | 12 | type fakeConfigManager struct { | ||
| 13 | + | current *config.Config | |||
| 13 | watchCalls int | 14 | watchCalls int | ||
| 14 | callbacks []func(*config.Config) | 15 | callbacks []func(*config.Config) | ||
| 15 | } | 16 | } | ||
| @@ -23,6 +24,17 @@ func (f *fakeConfigManager) OnConfigChange(callback func(*config.Config)) { | |||||
| 23 | f.callbacks = append(f.callbacks, callback) | 24 | f.callbacks = append(f.callbacks, callback) | ||
| 24 | } | 25 | } | ||
| 25 | 26 | ||||
| 27 | + | func (f *fakeConfigManager) Get() *config.Config { | |||
| 28 | + | return f.current | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (f *fakeConfigManager) emit(next *config.Config) { | |||
| 32 | + | f.current = next | |||
| 33 | + | for _, callback := range f.callbacks { | |||
| 34 | + | callback(next) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 26 | func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) { | 38 | func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) { | ||
| 27 | cfg := config.DefaultConfig() | 39 | cfg := config.DefaultConfig() | ||
| 28 | cfg.DefaultUIScale = 1.25 | 40 | cfg.DefaultUIScale = 1.25 | ||
| @@ -69,7 +81,7 @@ func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) { | |||||
| 69 | 81 | ||||
| 70 | next := config.DefaultConfig() | 82 | next := config.DefaultConfig() | ||
| 71 | next.DefaultUIScale = 1.8 | 83 | next.DefaultUIScale = 1.8 | ||
| 72 | - | manager.callbacks[0](next) | 84 | + | manager.emit(next) |
| 73 | 85 | ||||
| 74 | if got := provider.Current().UI.DefaultUIScale; got != 1.8 { | 86 | if got := provider.Current().UI.DefaultUIScale; got != 1.8 { | ||
| 75 | t.Fatalf("Current DefaultUIScale=%v, want 1.8", got) | 87 | t.Fatalf("Current DefaultUIScale=%v, want 1.8", got) | ||
| @@ -79,6 +91,46 @@ func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) { | |||||
| 79 | } | 91 | } | ||
| 80 | } | 92 | } | ||
| 81 | 93 | ||||
| 94 | + | func TestRuntimeConfigProviderCurrentReflectsManagerGetWithoutSubscription(t *testing.T) { | |||
| 95 | + | initial := config.DefaultConfig() | |||
| 96 | + | initial.DefaultUIScale = 1 | |||
| 97 | + | manager := &fakeConfigManager{current: initial} | |||
| 98 | + | provider := NewRuntimeConfigProvider(initial, manager) | |||
| 99 | + | ||||
| 100 | + | next := config.DefaultConfig() | |||
| 101 | + | next.DefaultUIScale = 1.8 | |||
| 102 | + | manager.current = next | |||
| 103 | + | ||||
| 104 | + | if got := provider.Current().UI.DefaultUIScale; got != 1.8 { | |||
| 105 | + | t.Fatalf("Current DefaultUIScale=%v, want 1.8", got) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestRuntimeConfigProviderOnChangeCallbackCanCallCurrent(t *testing.T) { | |||
| 110 | + | initial := config.DefaultConfig() | |||
| 111 | + | initial.DefaultUIScale = 1 | |||
| 112 | + | manager := &fakeConfigManager{current: initial} | |||
| 113 | + | provider := NewRuntimeConfigProvider(initial, manager) | |||
| 114 | + | ||||
| 115 | + | completed := false | |||
| 116 | + | var seen port.RuntimeConfigSnapshot | |||
| 117 | + | provider.OnChange(func(port.RuntimeConfigSnapshot) { | |||
| 118 | + | seen = provider.Current() | |||
| 119 | + | completed = true | |||
| 120 | + | }) | |||
| 121 | + | ||||
| 122 | + | next := config.DefaultConfig() | |||
| 123 | + | next.DefaultUIScale = 1.8 | |||
| 124 | + | manager.emit(next) | |||
| 125 | + | ||||
| 126 | + | if !completed { | |||
| 127 | + | t.Fatal("callback did not complete") | |||
| 128 | + | } | |||
| 129 | + | if seen.UI.DefaultUIScale != 1.8 { | |||
| 130 | + | t.Fatalf("callback Current DefaultUIScale=%v, want 1.8", seen.UI.DefaultUIScale) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 82 | func TestRuntimeConfigProviderWatchDelegatesToManager(t *testing.T) { | 134 | func TestRuntimeConfigProviderWatchDelegatesToManager(t *testing.T) { | ||
| 83 | manager := &fakeConfigManager{} | 135 | manager := &fakeConfigManager{} | ||
| 84 | provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager) | 136 | provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager) | ||
| @@ -96,7 +148,7 @@ func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) { | |||||
| 96 | cfg.SearchShortcuts = map[string]config.SearchShortcut{ | 148 | cfg.SearchShortcuts = map[string]config.SearchShortcut{ | ||
| 97 | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | 149 | "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"}, | ||
| 98 | } | 150 | } | ||
| 99 | - | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{}) | 151 | + | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{current: cfg}) |
| 100 | 152 | ||||
| 101 | first := provider.Current() | 153 | first := provider.Current() | ||
| 102 | first.UI.SearchShortcuts["gh"] = port.RuntimeSearchShortcut{URL: "mutated"} | 154 | first.UI.SearchShortcuts["gh"] = port.RuntimeSearchShortcut{URL: "mutated"} | ||
| @@ -109,7 +161,7 @@ func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) { | |||||
| 109 | 161 | ||||
| 110 | func TestRuntimeConfigProviderCurrentReturnsNestedConfigClone(t *testing.T) { | 162 | func TestRuntimeConfigProviderCurrentReturnsNestedConfigClone(t *testing.T) { | ||
| 111 | cfg := runtimeConfigWithNestedMutableFields() | 163 | cfg := runtimeConfigWithNestedMutableFields() | ||
| 112 | - | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{}) | 164 | + | provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{current: cfg}) |
| 113 | 165 | ||||
| 114 | first := provider.Current() | 166 | first := provider.Current() | ||
| 115 | mutateNestedRuntimeConfigSnapshot(first) | 167 | mutateNestedRuntimeConfigSnapshot(first) | ||
| @@ -126,7 +178,7 @@ func TestRuntimeConfigProviderOnChangePassesNestedConfigClone(t *testing.T) { | |||||
| 126 | mutateNestedRuntimeConfigSnapshot(snapshot) | 178 | mutateNestedRuntimeConfigSnapshot(snapshot) | ||
| 127 | }) | 179 | }) | ||
| 128 | 180 | ||||
| 129 | - | manager.callbacks[0](runtimeConfigWithNestedMutableFields()) | 181 | + | manager.emit(runtimeConfigWithNestedMutableFields()) |
| 130 | 182 | ||||
| 131 | assertNestedRuntimeConfigSnapshotUnchanged(t, provider.Current()) | 183 | assertNestedRuntimeConfigSnapshotUnchanged(t, provider.Current()) | ||
| 132 | } | 184 | } | ||
| @@ -236,6 +288,33 @@ func assertMapEntryAbsent(t *testing.T, actions map[string]entity.ActionBinding, | |||||
| 236 | } | 288 | } | ||
| 237 | } | 289 | } | ||
| 238 | 290 | ||||
| 291 | + | func assertNestedSourceConfigUnchanged(t *testing.T, cfg *config.Config) { | |||
| 292 | + | t.Helper() | |||
| 293 | + | ||||
| 294 | + | assertActionBinding(t, cfg.Workspace.PaneMode.Actions, "split-right", "r") | |||
| 295 | + | assertMapEntryAbsent(t, cfg.Workspace.PaneMode.Actions, "added-pane") | |||
| 296 | + | ||||
| 297 | + | assertActionBinding(t, cfg.Workspace.TabMode.Actions, "next-tab", "l") | |||
| 298 | + | assertMapEntryAbsent(t, cfg.Workspace.TabMode.Actions, "added-tab") | |||
| 299 | + | ||||
| 300 | + | assertActionBinding(t, cfg.Workspace.ResizeMode.Actions, "grow-right", "right") | |||
| 301 | + | assertMapEntryAbsent(t, cfg.Workspace.ResizeMode.Actions, "added-resize") | |||
| 302 | + | ||||
| 303 | + | assertActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", "ctrl+h") | |||
| 304 | + | assertMapEntryAbsent(t, cfg.Workspace.Shortcuts.Actions, "added-shortcut") | |||
| 305 | + | ||||
| 306 | + | profile := cfg.Workspace.FloatingPane.Profiles["docs"] | |||
| 307 | + | if got := profile.Keys[0]; got != "alt+d" { | |||
| 308 | + | t.Fatalf("floating profile key=%q, want %q", got, "alt+d") | |||
| 309 | + | } | |||
| 310 | + | if _, ok := cfg.Workspace.FloatingPane.Profiles["added-floating"]; ok { | |||
| 311 | + | t.Fatal("floating pane profiles must not include entries added through a returned snapshot") | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | assertActionBinding(t, cfg.Session.SessionMode.Actions, "session-manager", "s") | |||
| 315 | + | assertMapEntryAbsent(t, cfg.Session.SessionMode.Actions, "added-session") | |||
| 316 | + | } | |||
| 317 | + | ||||
| 239 | func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) { | 318 | func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) { | ||
| 240 | cfg := config.DefaultConfig() | 319 | cfg := config.DefaultConfig() | ||
| 241 | cfg.DefaultUIScale = 1.4 | 320 | cfg.DefaultUIScale = 1.4 | ||
| @@ -276,6 +355,15 @@ func TestRuntimeConfigSnapshotFromConfigMapsUIRuntimeFields(t *testing.T) { | |||||
| 276 | } | 355 | } | ||
| 277 | } | 356 | } | ||
| 278 | 357 | ||||
| 358 | + | func TestRuntimeConfigSnapshotFromConfigDetachesNestedMutableFields(t *testing.T) { | |||
| 359 | + | cfg := runtimeConfigWithNestedMutableFields() | |||
| 360 | + | snapshot := RuntimeConfigSnapshotFromConfig(cfg) | |||
| 361 | + | ||||
| 362 | + | mutateNestedRuntimeConfigSnapshot(snapshot) | |||
| 363 | + | ||||
| 364 | + | assertNestedSourceConfigUnchanged(t, cfg) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 279 | func TestRuntimeConfigSnapshotFromNilConfigReturnsZeroSnapshot(t *testing.T) { | 367 | func TestRuntimeConfigSnapshotFromNilConfigReturnsZeroSnapshot(t *testing.T) { | ||
| 280 | got := RuntimeConfigSnapshotFromConfig(nil) | 368 | got := RuntimeConfigSnapshotFromConfig(nil) | ||
| 281 | if !reflect.DeepEqual(got, port.RuntimeConfigSnapshot{}) { | 369 | if !reflect.DeepEqual(got, port.RuntimeConfigSnapshot{}) { | ||