1package theme
2
3import (
4 "testing"
5
6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"
8 "github.com/stretchr/testify/require"
9)
10
11func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
12 teal := renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
13 explicit := domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5}
14 tests := []struct {
15 name string
16 theme Theme
17 policy domain.ThemeAccent
18 want Accent
19 }{
20 {
21 name: "explicit unknown slot decorates only with its exact index",
22 theme: paletteTheme(map[int]renderer.RGB{2: teal, 10: teal}),
23 policy: explicit,
24 want: Accent{Slot: 5, IndexedOnly: true},
25 },
26 {
27 name: "explicit known slot without truecolor remains semantic",
28 theme: func() Theme {
29 t := paletteTheme(map[int]renderer.RGB{5: teal})
30 return t
31 }(),
32 policy: explicit,
33 want: Accent{RGB: teal, Slot: 5, Known: true},
34 },
35 {
36 name: "automatic absent osc palette keeps dark scheme indexed blue fallback",
37 theme: Theme{
38 Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10},
39 HasFG: true, HasBG: true, Known: true, SchemeKnown: true, UsePalette: true,
40 },
41 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
42 want: Accent{Slot: 12, IndexedOnly: true},
43 },
44 {
45 name: "automatic absent defaults keeps light scheme indexed blue fallback",
46 theme: Theme{
47 Known: true, SchemeKnown: true, Light: true, UsePalette: true,
48 },
49 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
50 want: Accent{Slot: 4, IndexedOnly: true},
51 },
52 {
53 name: "automatic absent palette and unknown scheme stays neutral",
54 theme: Theme{Known: true, UsePalette: true},
55 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
56 want: Accent{},
57 },
58 {
59 name: "palette off suppresses explicit indexed decoration",
60 theme: func() Theme {
61 t := paletteTheme(map[int]renderer.RGB{5: teal})
62 t.UsePalette = false
63 return t
64 }(),
65 policy: explicit,
66 want: Accent{},
67 },
68 }
69
70 for _, tt := range tests {
71 t.Run(tt.name, func(t *testing.T) {
72 require.Equal(t, tt.want, ResolveAccent(tt.theme, tt.policy))
73 })
74 }
75}
76
77func TestResolveIndexedAccentNeverFillsAnIndexedBackground(t *testing.T) {
78 theme := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}})
79 resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 5})
80 require.Equal(t, Accent{Slot: 5, IndexedOnly: true}, resolved.Accent)
81
82 for name, style := range legacyAndSemanticStyles(resolved.Styles) {
83 require.NotEqualf(t, 5, style.Background, "%s must not use an indexed accent background", name)
84 }
85 require.Equal(t, 5, resolved.Styles.BorderMuted.Foreground)
86 require.Equal(t, 5, resolved.Styles.PickerSeparator.Foreground)
87}
88
89func TestPickerSeparatorUsesSecondaryTextContrast(t *testing.T) {
90 theme := rampTheme(false)
91 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
92 theme.Palette[10] = theme.Palette[2]
93 theme.PaletteKnown = 1<<2 | 1<<10
94 resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
95
96 want, ok := secondaryText(theme.Foreground, theme.Background)
97 require.True(t, ok)
98 require.Equal(t, want, resolved.Styles.PickerSeparator.ForegroundRGB)
99 require.False(t, resolved.Styles.PickerSeparator.HasBackgroundRGB)
100 require.Equal(t, want, resolved.Styles.PickerDescription.ForegroundRGB)
101 require.False(t, resolved.Styles.PickerDescription.HasBackgroundRGB)
102 require.GreaterOrEqual(t, ContrastRatio(resolved.Styles.PickerSeparator.ForegroundRGB, theme.Background), normalTextContrast)
103}
104
105func TestLegacyAliasesPaletteOffAndForcedThemesRemainExact(t *testing.T) {
106 paletteOff := paletteTheme(map[int]renderer.RGB{2: {R: 0x7d, G: 0xb5, B: 0xb5}})
107 paletteOff.UsePalette = false
108 tests := []struct {
109 name string
110 theme Theme
111 }{
112 {name: "palette off", theme: paletteOff},
113 {name: "forced dark", theme: BuiltinDark},
114 {name: "forced light", theme: BuiltinLight},
115 }
116 for _, tt := range tests {
117 t.Run(tt.name, func(t *testing.T) {
118 got := Resolve(tt.theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}).Styles
119 want := prePhase3LegacyAliases(tt.theme)
120 // PickerSeparator is now a semantic role with the SurfaceBar
121 // background; it was never a compatibility alias.
122 delete(want, "PickerSeparator")
123 // Active neutral sessions and tab names share the history anchor.
124 want["Accent"] = rgbSurface(tt.theme.Background, tt.theme.Foreground)
125 want["TabNameActive"] = EmphasisStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
126 want["TabTitleActive"] = MutedVariantStyle(rgbSurface(tt.theme.Background, tt.theme.Foreground), tt.theme)
127 for name, style := range want {
128 require.Equalf(t, style, legacyAndSemanticStyles(got)[name], "%s", name)
129 }
130 })
131 }
132}
133
134func prePhase3LegacyAliases(t Theme) map[string]renderer.Style {
135 status := renderer.DefaultStyle()
136 accent := inverseStyle()
137 border := renderer.DefaultStyle()
138 muted := renderer.DefaultStyle()
139 if usable(t) {
140 status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12))
141 accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend))
142 border = foregroundStyle(Blend(t.Foreground, t.Background, 0.40))
143 muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45))
144 }
145 selection := accent
146 return map[string]renderer.Style{
147 "StatusBar": status,
148 "Accent": accent,
149 "Border": border,
150 "Selection": selection,
151 "CopyStatus": selection,
152 "PaletteDesc": muted,
153 "TabName": EmphasisStyle(status, t),
154 "TabNameActive": EmphasisStyle(accent, t),
155 "TabTitle": MutedVariantStyle(status, t),
156 "TabTitleActive": MutedVariantStyle(accent, t),
157 "PickerName": EmphasisStyle(renderer.DefaultStyle(), t),
158 "PickerSelectionName": EmphasisStyle(selection, t),
159 "PickerSelectionMuted": MutedVariantStyle(selection, t),
160 "PickerSeparator": muted,
161 }
162}
163
164func legacyAndSemanticStyles(styles Styles) map[string]renderer.Style {
165 return map[string]renderer.Style{
166 "SurfaceBar": styles.SurfaceBar, "SurfaceInactive": styles.SurfaceInactive,
167 "SurfaceRecent": styles.SurfaceRecent, "SurfaceActive": styles.SurfaceActive,
168 "BorderMuted": styles.BorderMuted, "BorderActive": styles.BorderActive,
169 "NeutralBorder": styles.NeutralBorder, "PickerSeparator": styles.PickerSeparator, "StatusBar": styles.StatusBar,
170 "Accent": styles.Accent, "Border": styles.Border, "Selection": styles.Selection,
171 "CopyStatus": styles.CopyStatus, "PaletteDesc": styles.PaletteDesc,
172 "TabName": styles.TabName, "TabNameActive": styles.TabNameActive,
173 "TabTitle": styles.TabTitle, "TabTitleActive": styles.TabTitleActive,
174 "PickerName": styles.PickerName, "PickerSelectionName": styles.PickerSelectionName,
175 "PickerSelectionMuted": styles.PickerSelectionMuted,
176 }
177}