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