1package daemon
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/require"
7
8 renderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/domain/terminalcap"
11 "github.com/bnema/vev/internal/protocol"
12 themeui "github.com/bnema/vev/internal/usecase/theme"
13)
14
15func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme {
16 out := protocol.Theme{
17 Foreground: foreground, Background: background,
18 HasForeground: true, HasBackground: true, SchemeKnown: true,
19 }
20 for slot, color := range palette {
21 out.Palette[slot] = color
22 out.PaletteKnown |= 1 << slot
23 }
24 return out
25}
26
27func chromeStyleSnapshot(t *testing.T, styles themeui.Styles) []byte {
28 t.Helper()
29 frame := renderer.NewFrame(6, 1)
30 for x, style := range []renderer.Style{
31 styles.SurfaceBar, styles.SurfaceInactive, styles.SurfaceRecent,
32 styles.SurfaceActive, styles.BorderMuted, styles.BorderActive,
33 } {
34 frame.Set(x, 0, renderer.Cell{Rune: rune('A' + x), Style: style})
35 }
36 out, err := renderer.New(renderer.Capabilities{}).Draw(frame, []renderer.Damage{renderer.FullRedraw()})
37 require.NoError(t, err)
38 return out
39}
40
41func TestAccentLifecycleGenerationDaemonResolutionAndReload(t *testing.T) {
42 p, release := newBlockingPTY(t)
43 defer release()
44 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
45
46 oldForeground, oldBackground := renderer.RGB{R: 230, G: 230, B: 230}, renderer.RGB{R: 8, G: 9, B: 10}
47 old := lifecycleTheme(oldForeground, oldBackground, map[uint8]renderer.RGB{
48 4: {R: 108, G: 155, B: 217}, 12: {R: 108, G: 155, B: 217},
49 })
50 d.applyTheme(sess, ac, old)
51 beforeClear := ac.getAppliedTheme()
52 require.True(t, beforeClear.Resolved.Accent.Known)
53 require.Equal(t, uint8(4), beforeClear.Resolved.Accent.Slot)
54
55 // A replacement generation's first publication retains pane defaults but
56 // clears every palette bit before daemon style resolution.
57 cleared := old
58 cleared.PaletteKnown = 0
59 cleared.Palette = [16]renderer.RGB{}
60 cleared.Light = true
61 d.applyTheme(sess, ac, cleared)
62 clearedApplied := ac.getAppliedTheme()
63 require.Equal(t, oldForeground, clearedApplied.Raw.Foreground)
64 require.Equal(t, oldBackground, clearedApplied.Raw.Background)
65 require.True(t, clearedApplied.Raw.Light)
66 require.Zero(t, clearedApplied.Raw.PaletteKnown)
67 require.False(t, clearedApplied.Resolved.Accent.Known)
68 require.Equal(t, beforeClear.Generation+1, clearedApplied.Generation)
69 assertSessionDefaultColors(t, sess, oldForeground, oldBackground)
70
71 newForeground, newBackground := renderer.RGB{R: 240, G: 240, B: 240}, renderer.RGB{R: 12, G: 13, B: 14}
72 teal := renderer.RGB{R: 125, G: 181, B: 181}
73 definitive := lifecycleTheme(newForeground, newBackground, map[uint8]renderer.RGB{
74 2: teal, 10: teal, 14: teal,
75 4: {R: 108, G: 155, B: 217}, 12: {R: 108, G: 155, B: 217},
76 })
77 d.applyTheme(sess, ac, definitive)
78 resolved := ac.getAppliedTheme()
79 require.Equal(t, clearedApplied.Generation+1, resolved.Generation)
80 require.True(t, resolved.Resolved.Accent.Known)
81 require.Equal(t, uint8(2), resolved.Resolved.Accent.Slot)
82 require.Equal(t, teal, resolved.Resolved.Accent.RGB)
83 assertSessionDefaultColors(t, sess, newForeground, newBackground)
84
85 // Policy-only reloads resolve the retained definitive snapshot: no client
86 // query or intermediate Theme publication is involved.
87 explicit := domain.Defaults()
88 explicit.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 4}
89 d.ApplyConfig(explicit)
90 reloaded := ac.getAppliedTheme()
91 require.Equal(t, resolved.Raw, reloaded.Raw)
92 require.Greater(t, reloaded.Generation, resolved.Generation)
93 require.Equal(t, uint8(4), reloaded.Resolved.Accent.Slot)
94 require.Equal(t, definitive.Palette, ac.getClientTheme().Palette)
95
96 unknown := explicit
97 unknown.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 6}
98 d.ApplyConfig(unknown)
99 require.False(t, ac.getAppliedTheme().Resolved.Accent.Known, "an explicit unknown slot must not substitute another slot")
100
101 paletteOff := explicit
102 paletteOff.ThemePalette = false
103 d.ApplyConfig(paletteOff)
104 off := ac.getAppliedTheme()
105 require.False(t, off.Raw.UsePalette)
106 require.False(t, off.Resolved.Accent.Known)
107 assertSessionDefaultColors(t, sess, newForeground, newBackground)
108
109 for _, tc := range []struct {
110 name string
111 mode domain.ThemeMode
112 want themeui.Theme
113 }{
114 {name: "forced dark", mode: domain.ThemeDark, want: themeui.BuiltinDark},
115 {name: "forced light", mode: domain.ThemeLight, want: themeui.BuiltinLight},
116 } {
117 t.Run(tc.name, func(t *testing.T) {
118 cfg := domain.Defaults()
119 cfg.Theme = tc.mode
120 d.ApplyConfig(cfg)
121 applied := ac.getAppliedTheme()
122 require.Equal(t, tc.want, applied.Raw)
123 require.False(t, applied.Resolved.Accent.Known)
124 assertSessionDefaultColors(t, sess, tc.want.Foreground, tc.want.Background)
125 })
126 }
127
128 // A timeout-finalized partial report is still one definitive daemon apply;
129 // an unavailable explicit slot remains neutral.
130 partialCfg := domain.Defaults()
131 partialCfg.ThemeAccent = domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 6}
132 d.ApplyConfig(partialCfg)
133 beforePartial := ac.getAppliedTheme()
134 partial := lifecycleTheme(newForeground, newBackground, map[uint8]renderer.RGB{2: teal})
135 d.applyTheme(sess, ac, partial)
136 partialApplied := ac.getAppliedTheme()
137 require.Equal(t, beforePartial.Generation+1, partialApplied.Generation)
138 require.Equal(t, uint16(1<<2), ac.getClientTheme().PaletteKnown)
139 require.False(t, partialApplied.Resolved.Accent.Known)
140}
141
142func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
143 raw := lifecycleTheme(
144 renderer.RGB{R: 230, G: 230, B: 230}, renderer.RGB{R: 8, G: 9, B: 10},
145 map[uint8]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}},
146 )
147 tests := []struct {
148 name string
149 config domain.Config
150 want string
151 }{
152 {
153 name: "palette off",
154 config: func() domain.Config {
155 cfg := domain.Defaults()
156 cfg.ThemePalette = false
157 return cfg
158 }(),
159 want: "\x1b[1;1HABC\x1b[0;7mD\x1b[0mEF\x1b[0m",
160 },
161 {
162 name: "forced dark",
163 config: domain.Config{Theme: domain.ThemeDark},
164 want: "\x1b[1;1H\x1b[0;38;2;216;216;216;48;2;47;47;47mABC\x1b[0;38;2;24;24;24;48;2;216;216;216mD\x1b[0;38;2;139;139;139mEF\x1b[0m",
165 },
166 {
167 name: "forced light",
168 config: domain.Config{Theme: domain.ThemeLight},
169 want: "\x1b[1;1H\x1b[0;38;2;32;32;32;48;2;222;222;222mABC\x1b[0;38;2;248;248;248;48;2;32;32;32mD\x1b[0;38;2;118;118;118mEF\x1b[0m",
170 },
171 }
172 for _, tt := range tests {
173 t.Run(tt.name, func(t *testing.T) {
174 d := newTestDaemon(t, nil, stubClock{})
175 d.ApplyConfig(tt.config)
176 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{
177 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,
178 HasFG: true, HasBG: true}, terminalcap.ColorCapabilities{}).Resolved.Styles)
179 require.Equal(t, []byte(tt.want), got)
180 })
181 }
182}