1package ui
2
3import (
4 "context"
5 "errors"
6 "testing"
7
8 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"
10
11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/bootstrap"
14 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/infrastructure/config"
16 "github.com/bnema/dumber/internal/shared/syncdispatch"
17 uitheme "github.com/bnema/dumber/internal/ui/theme"
18)
19
20type recordingExternalThemeWatcher struct {
21 starts int
22 stops int
23 cfg entity.ExternalThemeConfig
24 trigger func()
25}
26
27func (w *recordingExternalThemeWatcher) Start(_ context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {
28 w.starts++
29 w.cfg = cfg
30 w.trigger = onChange
31 return nil
32}
33
34func (w *recordingExternalThemeWatcher) Stop() error {
35 w.stops++
36 return nil
37}
38
39type mutableExternalThemeSource struct {
40 cfg entity.ExternalThemeConfig
41 theme *entity.ExternalTheme
42 err error
43}
44
45func (s *mutableExternalThemeSource) Configure(cfg entity.ExternalThemeConfig) {
46 s.cfg = cfg
47}
48
49func (s *mutableExternalThemeSource) ExternalThemeIdentity() string {
50 if !s.cfg.Enabled {
51 return ""
52 }
53 return s.cfg.Provider + "|" + s.cfg.Format + "|" + s.cfg.Path
54}
55
56func (s *mutableExternalThemeSource) IsEnabled() bool {
57 return s.cfg.Enabled
58}
59
60func (s *mutableExternalThemeSource) Get(context.Context) (*entity.ExternalTheme, error) {
61 if !s.IsEnabled() {
62 return nil, nil
63 }
64 return s.theme, s.err
65}
66
67func immediateDispatchForExternalThemeTest(label string, fn func()) syncdispatch.SyncDispatchResult {
68 fn()
69 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
70}
71
72func TestApplyAppearanceConfigSendsCompleteEngineSettingsPayload(t *testing.T) {
73 ctx := context.Background()
74 cfg := config.DefaultConfig()
75 cfg.DefaultUIScale = 1.25
76 cfg.Appearance.SansFont = "Inter"
77 cfg.Appearance.SerifFont = "Literata"
78 cfg.Appearance.MonospaceFont = "Fira Code"
79 cfg.Appearance.DefaultFontSize = 17
80 cfg.Debug.EnableDevTools = true
81 cfg.Logging.CaptureConsole = true
82 cfg.Engine.WebKit.DrawCompositingIndicators = true
83 cfg.Media.HardwareDecodingMode = config.HardwareDecodingForce
84 cfg.Clipboard.AutoCopyOnSelection = true
85 expected := entity.EngineSettingsPayload{
86 DefaultUIScale: cfg.DefaultUIScale,
87 WebContent: entity.EngineWebContentSettingsPayload{
88 SansFont: cfg.Appearance.SansFont,
89 SerifFont: cfg.Appearance.SerifFont,
90 MonospaceFont: cfg.Appearance.MonospaceFont,
91 DefaultFontSize: cfg.Appearance.DefaultFontSize,
92 EnableDevTools: cfg.Debug.EnableDevTools,
93 CaptureConsole: cfg.Logging.CaptureConsole,
94 DrawCompositingIndicators: cfg.Engine.WebKit.DrawCompositingIndicators,
95 HardwareDecoding: entity.EngineHardwareDecodingForce,
96 AutoCopyOnSelection: cfg.Clipboard.AutoCopyOnSelection,
97 },
98 }
99 engine := portmocks.NewMockEngine(t)
100 var captured entity.EngineSettingsUpdate
101 engine.EXPECT().UpdateSettings(mock.Anything, mock.Anything).
102 Run(func(_ context.Context, update entity.EngineSettingsUpdate) {
103 captured = update
104 }).
105 Return(nil).
106 Once()
107 app := &App{
108 deps: &Dependencies{},
109 runtimeConfig: runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
110 engine: engine,
111 }
112
113 app.applyAppearanceConfig(ctx)
114
115 require.Equal(t, expected, captured.Settings)
116}
117
118func TestExternalThemeWatcherCallbackAppliesThemeThroughSharedPath(t *testing.T) {
119 ctx := context.Background()
120 cfg := config.DefaultConfig()
121 cfg.Appearance.ColorScheme = "prefer-dark"
122 cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
123 Enabled: true,
124 Provider: "noctalia",
125 Format: "dumber-json",
126 Path: "/tmp/theme.json",
127 }
128 source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
129 watcher := &recordingExternalThemeWatcher{}
130 manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
131 engine := portmocks.NewMockEngine(t)
132 var backgroundUpdated bool
133 engine.EXPECT().UpdateSettings(mock.Anything, mock.Anything).Return(nil).Once()
134 engine.EXPECT().UpdateAppearance(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
135 Run(func(_ context.Context, r, g, b, alpha float64) {
136 backgroundUpdated = true
137 require.InDelta(t, 0.0, r, 0.000001)
138 require.InDelta(t, 0.0, g, 0.000001)
139 require.InDelta(t, 0.0, b, 0.000001)
140 require.InDelta(t, 1.0, alpha, 0.000001)
141 }).
142 Return(nil).
143 Once()
144 engine.EXPECT().ContentInjector().Return(nil).Once()
145 app := &App{
146 deps: &Dependencies{
147 Theme: manager,
148 ResolveThemeUC: usecase.NewResolveThemeUseCase(source),
149 ExternalThemeSource: source,
150 ExternalThemeWatcher: watcher,
151 },
152 runtimeConfig: runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
153 dispatchOnMainThread: immediateDispatchForExternalThemeTest,
154 engine: engine,
155 }
156
157 app.syncExternalThemeWatcher(ctx)
158 require.Equal(t, 1, watcher.starts)
159 require.Equal(t, cfg.Appearance.ExternalTheme, watcher.cfg)
160 require.NotNil(t, watcher.trigger)
161
162 watcher.trigger()
163
164 require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
165 require.True(t, backgroundUpdated, "theme reload should refresh engine background color")
166}
167
168func TestExternalThemeReloadKeepsLastGoodAndDisablingClearsIt(t *testing.T) {
169 ctx := context.Background()
170 cfg := config.DefaultConfig()
171 cfg.Appearance.ColorScheme = "prefer-dark"
172 cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
173 Enabled: true,
174 Provider: "noctalia",
175 Format: "dumber-json",
176 Path: "/tmp/theme.json",
177 }
178 source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
179 manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
180 app := &App{
181 deps: &Dependencies{
182 Theme: manager,
183 ResolveThemeUC: usecase.NewResolveThemeUseCase(source),
184 ExternalThemeSource: source,
185 },
186 runtimeConfig: runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
187 dispatchOnMainThread: immediateDispatchForExternalThemeTest,
188 }
189
190 app.applyAppearanceConfig(ctx)
191 require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
192
193 source.theme = &entity.ExternalTheme{
194 Name: "broken",
195 Provider: "noctalia",
196 LightPalette: &entity.ColorPalette{Accent: "not-a-hex"},
197 DarkPalette: &entity.ColorPalette{Background: "also-bad"},
198 }
199 app.applyAppearanceConfig(ctx)
200 require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
201
202 cfg.Appearance.ExternalTheme.Enabled = false
203 setRuntimeConfigSnapshotForTest(app, bootstrap.RuntimeConfigSnapshotFromConfig(cfg))
204 app.applyAppearanceConfig(ctx)
205 require.Equal(t, cfg.Appearance.DarkPalette.Background, app.deps.Theme.GetCurrentPalette().Background)
206}
207
208func TestExternalThemeReloadKeepsLastGoodOnReadError(t *testing.T) {
209 ctx := context.Background()
210 cfg := config.DefaultConfig()
211 cfg.Appearance.ColorScheme = "prefer-dark"
212 cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
213 Enabled: true,
214 Provider: "noctalia",
215 Format: "dumber-json",
216 Path: "/tmp/theme.json",
217 }
218 source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
219 manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
220 app := &App{
221 deps: &Dependencies{
222 Theme: manager,
223 ResolveThemeUC: usecase.NewResolveThemeUseCase(source),
224 ExternalThemeSource: source,
225 },
226 runtimeConfig: runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
227 dispatchOnMainThread: immediateDispatchForExternalThemeTest,
228 }
229
230 app.applyAppearanceConfig(ctx)
231 require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
232
233 source.theme = nil
234 source.err = errors.New("temporary read failure")
235 app.applyAppearanceConfig(ctx)
236 require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
237}
238
239func TestExternalThemeReloadRecoversAfterMalformedUpdate(t *testing.T) {
240 ctx := context.Background()
241 cfg := config.DefaultConfig()
242 cfg.Appearance.ColorScheme = "prefer-dark"
243 cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
244 Enabled: true,
245 Provider: "noctalia",
246 Format: "dumber-json",
247 Path: "/tmp/theme.json",
248 }
249 source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
250 manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
251 app := &App{
252 deps: &Dependencies{
253 Theme: manager,
254 ResolveThemeUC: usecase.NewResolveThemeUseCase(source),
255 ExternalThemeSource: source,
256 },
257 runtimeConfig: runtimeConfigStateFromSnapshotForTest(bootstrap.RuntimeConfigSnapshotFromConfig(cfg)),
258 dispatchOnMainThread: immediateDispatchForExternalThemeTest,
259 }
260
261 app.applyAppearanceConfig(ctx)
262 require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
263
264 source.theme = &entity.ExternalTheme{
265 Name: "broken",
266 Provider: "noctalia",
267 LightPalette: &entity.ColorPalette{Accent: "not-a-hex"},
268 DarkPalette: &entity.ColorPalette{Background: "also-bad"},
269 }
270 app.applyAppearanceConfig(ctx)
271 require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
272
273 source.theme = externalThemeWithDarkBackground("#123456")
274 app.applyAppearanceConfig(ctx)
275 require.Equal(t, "#123456", app.deps.Theme.GetCurrentPalette().Background)
276}
277
278func TestOnShutdownStopsExternalThemeWatcher(t *testing.T) {
279 ctx, cancel := context.WithCancelCause(context.Background())
280 watcher := &recordingExternalThemeWatcher{}
281 app := &App{
282 deps: &Dependencies{ExternalThemeWatcher: watcher},
283 cancel: cancel,
284 }
285
286 app.onShutdown(ctx)
287
288 require.Equal(t, 1, watcher.stops)
289}
290
291func resolvedThemeForUITest(light, dark entity.ColorPalette) entity.ResolvedTheme {
292 return entity.ResolvedTheme{
293 LightPalette: light,
294 DarkPalette: dark,
295 ActivePalette: dark,
296 PrefersDark: true,
297 ColorSchemeSource: "test",
298 ThemeSource: entity.ThemeSourceMetadata{Kind: entity.ThemeSourceConfig},
299 Fonts: entity.DefaultThemeFonts(),
300 UIScale: 1,
301 ModeColors: entity.DefaultThemeModeColors(),
302 }
303}
304
305func externalThemeWithDarkBackground(background string) *entity.ExternalTheme {
306 return &entity.ExternalTheme{
307 Name: "Noctalia",
308 Provider: "noctalia",
309 LightPalette: &entity.ColorPalette{
310 Background: "#eeeeee",
311 Accent: "#112233",
312 },
313 DarkPalette: &entity.ColorPalette{
314 Background: background,
315 Accent: "#445566",
316 },
317 }
318}