detached f5267fd

test(theme): cover accent lifecycle end to end

commit
f5267fd04945c4e30803d5bc18db181e992200bc
author
brice · · 2026-07-18 10:04 UTC
parent
d3a09a8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
321321 assertTrailingGarbageFails(t, full, UnmarshalTheme)
322322 }
323323
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+
324341 func TestResizeGoldenAndRoundTrip(t *testing.T) {
325342 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
326343 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
288288 require.False(t, final.HasForeground)
289289 require.False(t, final.HasBackground)
290290 h.detach(t)
291+ require.Len(t, h.transport.snapshots(), 4, "initial and replacement generations each publish only cleared then definitive Theme snapshots")
291292 }
292293
293294 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 @@
11 package main_test
22
33 import (
4+ "io"
45 "net"
56 "sync"
67 "testing"
78
89 "github.com/bnema/vev/internal/adapters/ipc"
10+ "github.com/bnema/vev/internal/adapters/sshstdio"
911 "github.com/bnema/vev/internal/ports"
1012 "github.com/bnema/vev/internal/testutil/replaytest"
1113 "github.com/bnema/vev/pkg/renderer"
@@ -46,6 +48,80 @@ func TestTransportReplayIntegration(t *testing.T) {
4648 require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame))
4749 }
4850
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+
49125 func integrationFrameRows(frame renderer.Frame) []string {
50126 rows := make([]string, frame.Height)
51127 for y := range rows {
@@ -1,11 +1,13 @@
1 package main_test1 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close