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 VimMode: "#abcdef",
149 })
150
151 assert.Equal(t, "#111111", modeColors.PaneMode)
152 assert.Equal(t, "#222222", modeColors.TabMode)
153 assert.Equal(t, "#333333", modeColors.SessionMode)
154 assert.Equal(t, "#444444", modeColors.ResizeMode)
155 assert.Equal(t, "#abcdef", modeColors.VimMode)
156}
157
158func TestFontConfigFromEntity(t *testing.T) {
159 fonts := FontConfigFromEntity(entity.ThemeFonts{
160 SansFont: "Inter",
161 MonospaceFont: "JetBrains Mono",
162 GtkFont: "Adwaita Sans",
163 })
164
165 assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, fonts)
166}
167
168func TestDefaultGTKFont(t *testing.T) {
169 assert.Equal(t, "Adwaita Sans", DefaultGTKFont())
170}
171
172func TestFormatGTKFontName_UsesScaledConfiguredGTKFont(t *testing.T) {
173 assert.Equal(t, "Adwaita Sans 14", formatGTKFontName("Adwaita Sans", 1.3))
174}
175
176func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) {
177 assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0))
178 assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05))
179 assert.Equal(t, "Adwaita Sans 11", formatGTKFontName("", 1.0))
180}
181
182func TestShouldApplyGTKFontName(t *testing.T) {
183 assert.True(t, shouldApplyGTKFontName("", "Fira Sans 14"))
184 assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "Fira Sans 14"))
185 assert.False(t, shouldApplyGTKFontName("Fira Sans 14", ""))
186}
187
188func TestShouldSkipThemeReapply(t *testing.T) {
189 const (
190 displayA = uintptr(0x1000)
191 displayB = uintptr(0x2000)
192 css = "window{background:#000;}"
193 otherCSS = "window{background:#fff;}"
194 font = "Adwaita Sans 14"
195 otherFont = "Adwaita Sans 17"
196 )
197
198 // Same provider, display, CSS and font: skip the reload and re-add.
199 assert.True(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, font, font),
200 "repeated window creation with an unchanged theme must skip reapply")
201 // No provider yet: full apply.
202 assert.False(t, shouldSkipThemeReapply(false, 0, displayA, "", css, "", font))
203 // Different display: the provider must be added for the new display even
204 // when the CSS is unchanged. Displays are never conflated.
205 assert.False(t, shouldSkipThemeReapply(true, displayA, displayB, css, css, font, font))
206 // Changed CSS (real theme change): reload.
207 assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, otherCSS, font, font))
208 // Changed font (scale change): reapply for the font setting.
209 assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, font, otherFont))
210 // Empty font identity never skips.
211 assert.False(t, shouldSkipThemeReapply(true, displayA, displayA, css, css, "", ""))
212}
213
214func TestEffectiveCSSIdentity_StableForUnchangedTheme(t *testing.T) {
215 ctx := context.Background()
216 first := NewManager(ctx, resolvedThemeFixture(true))
217 second := NewManager(ctx, resolvedThemeFixture(true))
218
219 cssOf := func(m *Manager) string {
220 return GenerateCSSFullWithTiming(m.GetCurrentPalette(), m.uiScale, m.fonts, m.modeColors, m.transitionDurationMs)
221 }
222
223 // The dedup relies on CSS identity: identical resolved themes must
224 // produce identical CSS so repeated window creation skips the reload.
225 assert.Equal(t, cssOf(first), cssOf(second))
226 assert.NotEmpty(t, cssOf(first))
227
228 // A real theme change must change the identity so it is never skipped.
229 changed := resolvedThemeFixture(true)
230 changed.DarkPalette.Background = "#000001"
231 other := NewManager(ctx, changed)
232 assert.NotEqual(t, cssOf(first), cssOf(other))
233}
234
235func TestManager_AppliedFontTrackedPerDisplay(t *testing.T) {
236 ctx := context.Background()
237 m := NewManager(ctx, resolvedThemeFixture(true))
238
239 const (
240 displayA = uintptr(0x1000)
241 displayB = uintptr(0x2000)
242 font = "Adwaita Sans 14"
243 )
244
245 // No font recorded anywhere initially, including on a nil manager.
246 assert.Empty(t, m.lastAppliedFontFor(displayA))
247 assert.Empty(t, m.lastAppliedFontFor(displayB))
248 assert.Empty(t, (*Manager)(nil).lastAppliedFontFor(displayA))
249
250 // Recording for one display leaves the other untouched so its
251 // GtkSettings still get configured on first apply.
252 m.recordAppliedFontFor(displayA, font)
253 assert.Equal(t, font, m.lastAppliedFontFor(displayA))
254 assert.Empty(t, m.lastAppliedFontFor(displayB))
255
256 m.recordAppliedFontFor(displayB, font)
257 assert.Equal(t, font, m.lastAppliedFontFor(displayB))
258
259 // Updating one display does not disturb the other.
260 m.recordAppliedFontFor(displayA, "Adwaita Sans 17")
261 assert.Equal(t, "Adwaita Sans 17", m.lastAppliedFontFor(displayA))
262 assert.Equal(t, font, m.lastAppliedFontFor(displayB))
263
264 // Recording on a nil manager is a safe no-op.
265 assert.NotPanics(t, func() { (*Manager)(nil).recordAppliedFontFor(displayA, font) })
266}