test(theme): cover accent lifecycle end to end
- commit
- f5267fd04945c4e30803d5bc18db181e992200bc
- author
- brice · · 2026-07-18 10:04 UTC
- parent
- d3a09a8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +277 -0
M internal/ports/wire_test.go +17 -0
| @@ -321,6 +321,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) { | |||
| 321 | 321 | assertTrailingGarbageFails(t, full, UnmarshalTheme) | |
| 322 | 322 | } | |
| 323 | 323 | ||
| 324 | + | func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) { | |
| 325 | + | // A generation clear retains defaults/capabilities but has no palette bits. | |
| 326 | + | // This literal payload locks the existing 57-byte Theme layout while the | |
| 327 | + | // full definitive-palette golden above locks the final publication. | |
| 328 | + | cleared := Theme{ | |
| 329 | + | HasForeground: true, | |
| 330 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 331 | + | HasBackground: true, | |
| 332 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 333 | + | TrueColor: true, | |
| 334 | + | SchemeKnown: true, | |
| 335 | + | } | |
| 336 | + | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 337 | + | require.Equal(t, want, MarshalTheme(cleared)) | |
| 338 | + | require.Equal(t, uint16(16), ProtocolVersion) | |
| 339 | + | } | |
| 340 | + | ||
| 324 | 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| 325 | 342 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | |
| 326 | 343 | want := []byte{0x00, 0x64, 0x00, 0x28} | |
| @@ -321,6 +321,23 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) { | |||||
| 321 | assertTrailingGarbageFails(t, full, UnmarshalTheme) | 321 | assertTrailingGarbageFails(t, full, UnmarshalTheme) | ||
| 322 | } | 322 | } | ||
| 323 | 323 | ||||
| 324 | + | func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) { | |||
| 325 | + | // A generation clear retains defaults/capabilities but has no palette bits. | |||
| 326 | + | // This literal payload locks the existing 57-byte Theme layout while the | |||
| 327 | + | // full definitive-palette golden above locks the final publication. | |||
| 328 | + | cleared := Theme{ | |||
| 329 | + | HasForeground: true, | |||
| 330 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 331 | + | HasBackground: true, | |||
| 332 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 333 | + | TrueColor: true, | |||
| 334 | + | SchemeKnown: true, | |||
| 335 | + | } | |||
| 336 | + | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |||
| 337 | + | require.Equal(t, want, MarshalTheme(cleared)) | |||
| 338 | + | require.Equal(t, uint16(16), ProtocolVersion) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 324 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 341 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| 325 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | 342 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | ||
| 326 | want := []byte{0x00, 0x64, 0x00, 0x28} | 343 | want := []byte{0x00, 0x64, 0x00, 0x28} | ||
M internal/usecase/client/palette_attach_generation_test.go +1 -0
| @@ -288,6 +288,7 @@ func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T | |||
| 288 | 288 | require.False(t, final.HasForeground) | |
| 289 | 289 | require.False(t, final.HasBackground) | |
| 290 | 290 | h.detach(t) | |
| 291 | + | require.Len(t, h.transport.snapshots(), 4, "initial and replacement generations each publish only cleared then definitive Theme snapshots") | |
| 291 | 292 | } | |
| 292 | 293 | ||
| 293 | 294 | func TestAttachInitialPaletteFailureRevokesInputClaim(t *testing.T) { | |
| @@ -288,6 +288,7 @@ func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T | |||||
| 288 | require.False(t, final.HasForeground) | 288 | require.False(t, final.HasForeground) | ||
| 289 | require.False(t, final.HasBackground) | 289 | require.False(t, final.HasBackground) | ||
| 290 | h.detach(t) | 290 | h.detach(t) | ||
| 291 | + | require.Len(t, h.transport.snapshots(), 4, "initial and replacement generations each publish only cleared then definitive Theme snapshots") | |||
| 291 | } | 292 | } | ||
| 292 | 293 | ||||
| 293 | func TestAttachInitialPaletteFailureRevokesInputClaim(t *testing.T) { | 294 | func TestAttachInitialPaletteFailureRevokesInputClaim(t *testing.T) { | ||
A internal/usecase/daemon/theme_integration_test.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) ports.Theme { | |
| 15 | + | out := ports.Theme{ | |
| 16 | + | Foreground: foreground, Background: background, | |
| 17 | + | HasForeground: true, HasBackground: true, TrueColor: true, | |
| 18 | + | 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 | + | ||
| 27 | + | func 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 | + | ||
| 41 | + | func 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 | + | ||
| 142 | + | func 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;216;216;216;48;2;72;72;72mD\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;32;32;32;48;2;194;194;194mD\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, TrueColor: true, | |
| 179 | + | }).Resolved.Styles) | |
| 180 | + | require.Equal(t, []byte(tt.want), got) | |
| 181 | + | }) | |
| 182 | + | } | |
| 183 | + | } | |
| @@ -0,0 +1,183 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 11 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) ports.Theme { | |||
| 15 | + | out := ports.Theme{ | |||
| 16 | + | Foreground: foreground, Background: background, | |||
| 17 | + | HasForeground: true, HasBackground: true, TrueColor: true, | |||
| 18 | + | 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 | + | ||||
| 27 | + | func 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 | + | ||||
| 41 | + | func 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 | + | ||||
| 142 | + | func 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;216;216;216;48;2;72;72;72mD\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;32;32;32;48;2;194;194;194mD\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, TrueColor: true, | |||
| 179 | + | }).Resolved.Styles) | |||
| 180 | + | require.Equal(t, []byte(tt.want), got) | |||
| 181 | + | }) | |||
| 182 | + | } | |||
| 183 | + | } | |||
M transport_replay_integration_test.go +76 -0
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | package main_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "io" | |
| 4 | 5 | "net" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 10 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 9 | 11 | "github.com/bnema/vev/internal/ports" | |
| 10 | 12 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 11 | 13 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -46,6 +48,80 @@ func TestTransportReplayIntegration(t *testing.T) { | |||
| 46 | 48 | require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame)) | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 51 | + | // TestThemeGenerationTransportSequences proves the generation's cleared then | |
| 52 | + | // definitive wire sequence is unchanged across the local socket and remote | |
| 53 | + | // SSH-stdio transports. The transports only carry frames; neither can infer | |
| 54 | + | // or alter the palette lifecycle. | |
| 55 | + | func TestThemeGenerationTransportSequences(t *testing.T) { | |
| 56 | + | cleared := ports.Theme{ | |
| 57 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 58 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 59 | + | TrueColor: true, SchemeKnown: true, | |
| 60 | + | } | |
| 61 | + | palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}} | |
| 62 | + | definitive := cleared | |
| 63 | + | definitive.Palette = palette | |
| 64 | + | definitive.PaletteKnown = 1<<2 | 1<<10 | |
| 65 | + | sequence := []ports.Theme{cleared, definitive} | |
| 66 | + | ||
| 67 | + | tests := []struct { | |
| 68 | + | name string | |
| 69 | + | pair func(*testing.T) (ports.Transport, ports.Transport) | |
| 70 | + | }{ | |
| 71 | + | { | |
| 72 | + | name: "local ipc socket", | |
| 73 | + | pair: func(t *testing.T) (ports.Transport, ports.Transport) { | |
| 74 | + | left, right := net.Pipe() | |
| 75 | + | t.Cleanup(func() { _ = left.Close(); _ = right.Close() }) | |
| 76 | + | return ipc.NewTransport(left), ipc.NewTransport(right) | |
| 77 | + | }, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "remote ssh stdio", | |
| 81 | + | pair: func(t *testing.T) (ports.Transport, ports.Transport) { | |
| 82 | + | clientRead, serverWrite := io.Pipe() | |
| 83 | + | serverRead, clientWrite := io.Pipe() | |
| 84 | + | closeClient := func() error { | |
| 85 | + | _ = clientRead.Close() | |
| 86 | + | return clientWrite.Close() | |
| 87 | + | } | |
| 88 | + | closeServer := func() error { | |
| 89 | + | _ = serverRead.Close() | |
| 90 | + | return serverWrite.Close() | |
| 91 | + | } | |
| 92 | + | t.Cleanup(func() { _ = closeClient(); _ = closeServer() }) | |
| 93 | + | return sshstdio.NewTransport(clientRead, clientWrite, closeClient), sshstdio.NewTransport(serverRead, serverWrite, closeServer) | |
| 94 | + | }, | |
| 95 | + | }, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | for _, tt := range tests { | |
| 99 | + | t.Run(tt.name, func(t *testing.T) { | |
| 100 | + | sender, receiver := tt.pair(t) | |
| 101 | + | done := make(chan error, 1) | |
| 102 | + | go func() { | |
| 103 | + | for _, snapshot := range sequence { | |
| 104 | + | if err := sender.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)}); err != nil { | |
| 105 | + | done <- err | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | done <- nil | |
| 110 | + | }() | |
| 111 | + | ||
| 112 | + | for _, want := range sequence { | |
| 113 | + | frame, err := receiver.Recv() | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | require.Equal(t, ports.MsgTheme, frame.Type) | |
| 116 | + | got, err := ports.UnmarshalTheme(frame.Payload) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.Equal(t, want, got) | |
| 119 | + | } | |
| 120 | + | require.NoError(t, <-done) | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 49 | 125 | func integrationFrameRows(frame renderer.Frame) []string { | |
| 50 | 126 | rows := make([]string, frame.Height) | |
| 51 | 127 | for y := range rows { | |
| @@ -1,11 +1,13 @@ | |||||
| 1 | package main_test | 1 | package main_test | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "io" | |||
| 4 | "net" | 5 | "net" | ||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/adapters/ipc" | 9 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 10 | + | "github.com/bnema/vev/internal/adapters/sshstdio" | |||
| 9 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/testutil/replaytest" | 12 | "github.com/bnema/vev/internal/testutil/replaytest" | ||
| 11 | "github.com/bnema/vev/pkg/renderer" | 13 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -46,6 +48,80 @@ func TestTransportReplayIntegration(t *testing.T) { | |||||
| 46 | require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame)) | 48 | require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame)) | ||
| 47 | } | 49 | } | ||
| 48 | 50 | ||||
| 51 | + | // TestThemeGenerationTransportSequences proves the generation's cleared then | |||
| 52 | + | // definitive wire sequence is unchanged across the local socket and remote | |||
| 53 | + | // SSH-stdio transports. The transports only carry frames; neither can infer | |||
| 54 | + | // or alter the palette lifecycle. | |||
| 55 | + | func TestThemeGenerationTransportSequences(t *testing.T) { | |||
| 56 | + | cleared := ports.Theme{ | |||
| 57 | + | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 58 | + | HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 59 | + | TrueColor: true, SchemeKnown: true, | |||
| 60 | + | } | |||
| 61 | + | palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}} | |||
| 62 | + | definitive := cleared | |||
| 63 | + | definitive.Palette = palette | |||
| 64 | + | definitive.PaletteKnown = 1<<2 | 1<<10 | |||
| 65 | + | sequence := []ports.Theme{cleared, definitive} | |||
| 66 | + | ||||
| 67 | + | tests := []struct { | |||
| 68 | + | name string | |||
| 69 | + | pair func(*testing.T) (ports.Transport, ports.Transport) | |||
| 70 | + | }{ | |||
| 71 | + | { | |||
| 72 | + | name: "local ipc socket", | |||
| 73 | + | pair: func(t *testing.T) (ports.Transport, ports.Transport) { | |||
| 74 | + | left, right := net.Pipe() | |||
| 75 | + | t.Cleanup(func() { _ = left.Close(); _ = right.Close() }) | |||
| 76 | + | return ipc.NewTransport(left), ipc.NewTransport(right) | |||
| 77 | + | }, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "remote ssh stdio", | |||
| 81 | + | pair: func(t *testing.T) (ports.Transport, ports.Transport) { | |||
| 82 | + | clientRead, serverWrite := io.Pipe() | |||
| 83 | + | serverRead, clientWrite := io.Pipe() | |||
| 84 | + | closeClient := func() error { | |||
| 85 | + | _ = clientRead.Close() | |||
| 86 | + | return clientWrite.Close() | |||
| 87 | + | } | |||
| 88 | + | closeServer := func() error { | |||
| 89 | + | _ = serverRead.Close() | |||
| 90 | + | return serverWrite.Close() | |||
| 91 | + | } | |||
| 92 | + | t.Cleanup(func() { _ = closeClient(); _ = closeServer() }) | |||
| 93 | + | return sshstdio.NewTransport(clientRead, clientWrite, closeClient), sshstdio.NewTransport(serverRead, serverWrite, closeServer) | |||
| 94 | + | }, | |||
| 95 | + | }, | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | for _, tt := range tests { | |||
| 99 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 100 | + | sender, receiver := tt.pair(t) | |||
| 101 | + | done := make(chan error, 1) | |||
| 102 | + | go func() { | |||
| 103 | + | for _, snapshot := range sequence { | |||
| 104 | + | if err := sender.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)}); err != nil { | |||
| 105 | + | done <- err | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | done <- nil | |||
| 110 | + | }() | |||
| 111 | + | ||||
| 112 | + | for _, want := range sequence { | |||
| 113 | + | frame, err := receiver.Recv() | |||
| 114 | + | require.NoError(t, err) | |||
| 115 | + | require.Equal(t, ports.MsgTheme, frame.Type) | |||
| 116 | + | got, err := ports.UnmarshalTheme(frame.Payload) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | require.Equal(t, want, got) | |||
| 119 | + | } | |||
| 120 | + | require.NoError(t, <-done) | |||
| 121 | + | }) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 49 | func integrationFrameRows(frame renderer.Frame) []string { | 125 | func integrationFrameRows(frame renderer.Frame) []string { | ||
| 50 | rows := make([]string, frame.Height) | 126 | rows := make([]string, frame.Height) | ||
| 51 | for y := range rows { | 127 | for y := range rows { | ||