1package theme
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10)
11
12func resolvedThemeFixture(prefersDark bool) entity.ResolvedTheme {
13 light := entity.ColorPalette{
14 Background: "#ffffff",
15 Surface: "#f8f8f8",
16 SurfaceVariant: "#eeeeee",
17 Text: "#111111",
18 Muted: "#666666",
19 Accent: "#0055ff",
20 Border: "#dddddd",
21 }
22 dark := entity.ColorPalette{
23 Background: "#101010",
24 Surface: "#181818",
25 SurfaceVariant: "#242424",
26 Text: "#f5f5f5",
27 Muted: "#999999",
28 Accent: "#66aaff",
29 Border: "#333333",
30 }
31 active := light
32 if prefersDark {
33 active = dark
34 }
35 return entity.ResolvedTheme{
36 LightPalette: light,
37 DarkPalette: dark,
38 ActivePalette: active,
39 PrefersDark: prefersDark,
40 ColorSchemeSource: "test",
41 ThemeSource: entity.ThemeSourceMetadata{Kind: entity.ThemeSourceConfig},
42 Fonts: entity.ThemeFonts{
43 SansFont: "Inter",
44 SerifFont: "Georgia",
45 MonospaceFont: "JetBrains Mono",
46 GtkFont: "Adwaita Sans",
47 DefaultSize: 16,
48 },
49 UIScale: 1.5,
50 ModeColors: entity.ThemeModeColors{
51 PaneMode: "#ff0000",
52 TabMode: "#00ff00",
53 SessionMode: "#0000ff",
54 ResizeMode: "#ffff00",
55 },
56 }
57}
58
59func TestNewManager_UsesResolvedThemeFields(t *testing.T) {
60 ctx := context.Background()
61 resolved := resolvedThemeFixture(true)
62
63 manager := NewManager(ctx, resolved)
64
65 require.NotNil(t, manager)
66 assert.True(t, manager.PrefersDark())
67 assert.Equal(t, "#ffffff", manager.GetLightPalette().Background)
68 assert.Equal(t, "#101010", manager.GetDarkPalette().Background)
69 assert.Equal(t, manager.GetDarkPalette(), manager.GetCurrentPalette())
70 assert.Equal(t, "#ff0000", manager.GetModeColors().PaneMode)
71 assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, manager.fonts)
72 assert.Equal(t, "Adwaita Sans", manager.gtkFont)
73 assert.InDelta(t, 1.5, manager.uiScale, 0.000001)
74}
75
76func TestNewManager_DefaultsInvalidScaleToOne(t *testing.T) {
77 ctx := context.Background()
78 resolved := resolvedThemeFixture(true)
79 resolved.UIScale = 0
80
81 manager := NewManager(ctx, resolved)
82
83 require.NotNil(t, manager)
84 assert.InDelta(t, 1.0, manager.uiScale, 0.000001)
85}
86
87func TestManager_UpdateFromResolvedReplacesResolvedValues(t *testing.T) {
88 ctx := context.Background()
89 manager := NewManager(ctx, resolvedThemeFixture(true))
90 updated := resolvedThemeFixture(false)
91 updated.LightPalette.Background = "#abcdef"
92 updated.DarkPalette.Background = "#123456"
93 updated.Fonts.SansFont = "Recursive"
94 updated.UIScale = 2.0
95 updated.ModeColors.PaneMode = "#654321"
96
97 manager.UpdateFromResolved(ctx, updated, nil)
98
99 assert.False(t, manager.PrefersDark())
100 assert.Equal(t, "#abcdef", manager.GetLightPalette().Background)
101 assert.Equal(t, "#123456", manager.GetDarkPalette().Background)
102 assert.Equal(t, manager.GetLightPalette(), manager.GetCurrentPalette())
103 assert.Equal(t, "Recursive", manager.fonts.SansFont)
104 assert.InDelta(t, 2.0, manager.uiScale, 0.000001)
105 assert.Equal(t, "#654321", manager.GetModeColors().PaneMode)
106}
107
108func TestManager_GetWebUIThemeCSS(t *testing.T) {
109 ctx := context.Background()
110 manager := NewManager(ctx, resolvedThemeFixture(true))
111
112 css := manager.GetWebUIThemeCSS()
113
114 assert.Contains(t, css, ":root{")
115 assert.Contains(t, css, ".dark{")
116 assert.Contains(t, css, "--background: #ffffff")
117 assert.Contains(t, css, "--background: #101010")
118}
119
120func TestManager_GetBackgroundRGBAUsesCurrentPalette(t *testing.T) {
121 ctx := context.Background()
122 manager := NewManager(ctx, resolvedThemeFixture(true))
123
124 r, g, b, a := manager.GetBackgroundRGBA()
125
126 assert.InDelta(t, float32(0x10)/255, r, 0.0001)
127 assert.InDelta(t, float32(0x10)/255, g, 0.0001)
128 assert.InDelta(t, float32(0x10)/255, b, 0.0001)
129 assert.InDelta(t, float32(1), a, 0.0001)
130}
131
132func TestPaletteFromEntityAddsSemanticDefaults(t *testing.T) {
133 palette := PaletteFromEntity(entity.ColorPalette{Background: "#ffffff", Text: "#111111"}, false)
134
135 assert.Equal(t, "#ffffff", palette.Background)
136 assert.Equal(t, "#111111", palette.Text)
137 assert.Equal(t, DefaultLightPalette().Success, palette.Success)
138 assert.Equal(t, DefaultLightPalette().Warning, palette.Warning)
139 assert.Equal(t, DefaultLightPalette().Destructive, palette.Destructive)
140}
141
142func TestModeColorsFromEntity(t *testing.T) {
143 modeColors := ModeColorsFromEntity(entity.ThemeModeColors{
144 PaneMode: "#111111",
145 TabMode: "#222222",
146 SessionMode: "#333333",
147 ResizeMode: "#444444",
148 })
149
150 assert.Equal(t, "#111111", modeColors.PaneMode)
151 assert.Equal(t, "#222222", modeColors.TabMode)
152 assert.Equal(t, "#333333", modeColors.SessionMode)
153 assert.Equal(t, "#444444", modeColors.ResizeMode)
154}
155
156func TestFontConfigFromEntity(t *testing.T) {
157 fonts := FontConfigFromEntity(entity.ThemeFonts{
158 SansFont: "Inter",
159 MonospaceFont: "JetBrains Mono",
160 GtkFont: "Adwaita Sans",
161 })
162
163 assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, fonts)
164}
165
166func TestDefaultGTKFont(t *testing.T) {
167 assert.Equal(t, "Adwaita Sans", DefaultGTKFont())
168}
169
170func TestFormatGTKFontName_UsesScaledConfiguredGTKFont(t *testing.T) {
171 assert.Equal(t, "Adwaita Sans 14", formatGTKFontName("Adwaita Sans", 1.3))
172}
173
174func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) {
175 assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0))
176 assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05))
177 assert.Equal(t, "Adwaita Sans 11", formatGTKFontName("", 1.0))
178}
179
180func TestShouldApplyGTKFontName(t *testing.T) {
181 assert.True(t, shouldApplyGTKFontName("", "Fira Sans 14"))
182 assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "Fira Sans 14"))
183 assert.False(t, shouldApplyGTKFontName("Fira Sans 14", ""))
184}
185
186func TestShouldSkipThemeReapply(t *testing.T) {
187 const (
188 displayA = uintptr(0x1000)
189 displayB = uintptr(0x2000)
190 css = "window{background:#000;}"
191 otherCSS = "window{background:#fff;}"
192 font = "Adwaita Sans 14"
193 otherFont = "Adwaita Sans 17"
194 )
195
196 // Same provider, display, CSS and font: skip the reload and re-add.
197 assert.True(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, font, font),
198 "repeated window creation with an unchanged theme must skip reapply")
199 // No provider yet: full apply.
200 assert.False(t, shouldSkipThemeReapply(false, 0, displayA, "", css, "", font))
201 // Different display: the provider must be added for the new display even
202 // when the CSS is unchanged. Displays are never conflated.
203 assert.False(t, shouldSkipThemeReapply(true, displayA, displayB, css, css, font, font))
204 // Changed CSS (real theme change): reload.
205 assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, otherCSS, font, font))
206 // Changed font (scale change): reapply for the font setting.
207 assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, font, otherFont))
208 // Empty font identity never skips.
209 assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, "", ""))
210}
211
212func TestEffectiveCSSIdentity_StableForUnchangedTheme(t *testing.T) {
213 ctx := context.Background()
214 first := NewManager(ctx, resolvedThemeFixture(true))
215 second := NewManager(ctx, resolvedThemeFixture(true))
216
217 cssOf := func(m *Manager) string {
218 return GenerateCSSFullWithTiming(m.GetCurrentPalette(), m.uiScale, m.fonts, m.modeColors, m.transitionDurationMs)
219 }
220
221 // The dedup relies on CSS identity: identical resolved themes must
222 // produce identical CSS so repeated window creation skips the reload.
223 assert.Equal(t, cssOf(first), cssOf(second))
224 assert.NotEmpty(t, cssOf(first))
225
226 // A real theme change must change the identity so it is never skipped.
227 changed := resolvedThemeFixture(true)
228 changed.DarkPalette.Background = "#000001"
229 other := NewManager(ctx, changed)
230 assert.NotEqual(t, cssOf(first), cssOf(other))
231}
232
233func TestManager_AppliedFontTrackedPerDisplay(t *testing.T) {
234 ctx := context.Background()
235 m := NewManager(ctx, resolvedThemeFixture(true))
236
237 const (
238 displayA = uintptr(0x1000)
239 displayB = uintptr(0x2000)
240 font = "Adwaita Sans 14"
241 )
242
243 // No font recorded anywhere initially, including on a nil manager.
244 assert.Empty(t, m.lastAppliedFontFor(displayA))
245 assert.Empty(t, m.lastAppliedFontFor(displayB))
246 assert.Empty(t, (*Manager)(nil).lastAppliedFontFor(displayA))
247
248 // Recording for one display leaves the other untouched so its
249 // GtkSettings still get configured on first apply.
250 m.recordAppliedFontFor(displayA, font)
251 assert.Equal(t, font, m.lastAppliedFontFor(displayA))
252 assert.Empty(t, m.lastAppliedFontFor(displayB))
253
254 m.recordAppliedFontFor(displayB, font)
255 assert.Equal(t, font, m.lastAppliedFontFor(displayB))
256
257 // Updating one display does not disturb the other.
258 m.recordAppliedFontFor(displayA, "Adwaita Sans 17")
259 assert.Equal(t, "Adwaita Sans 17", m.lastAppliedFontFor(displayA))
260 assert.Equal(t, font, m.lastAppliedFontFor(displayB))
261
262 // Recording on a nil manager is a safe no-op.
263 assert.NotPanics(t, func() { (*Manager)(nil).recordAppliedFontFor(displayA, font) })
264}