1package client
2
3import (
4 "bytes"
5 "context"
6 "io"
7 "log/slog"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/require"
13
14 renderer "github.com/bnema/vev-vt"
15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"
17)
18
19type paletteAttachClock struct{}
20
21func (paletteAttachClock) Now() time.Time { return time.Time{} }
22func (paletteAttachClock) NewTimer(time.Duration) ports.Timer {
23 return paletteAttachTimer{ch: make(chan time.Time)}
24}
25
26type paletteAttachTimer struct{ ch chan time.Time }
27
28func (t paletteAttachTimer) C() <-chan time.Time { return t.ch }
29func (paletteAttachTimer) Reset(time.Duration) bool { return false }
30func (paletteAttachTimer) Stop() bool { return true }
31
32type paletteAttachReader struct {
33 mu sync.Mutex
34 data []byte
35 done chan struct{}
36 once sync.Once
37}
38
39func newPaletteAttachReader(data []byte) *paletteAttachReader {
40 return &paletteAttachReader{data: append([]byte(nil), data...), done: make(chan struct{})}
41}
42
43// Read retains unread bytes after a short read and blocks only until close.
44// This keeps the test terminal reader faithful to io.Reader and makes a
45// failed test teardown deterministic instead of leaving a goroutine parked.
46func (r *paletteAttachReader) Read(p []byte) (int, error) {
47 r.mu.Lock()
48 if len(r.data) != 0 {
49 n := copy(p, r.data)
50 r.data = r.data[n:]
51 r.mu.Unlock()
52 return n, nil
53 }
54 r.mu.Unlock()
55 <-r.done
56 return 0, io.EOF
57}
58
59func (r *paletteAttachReader) close() { r.once.Do(func() { close(r.done) }) }
60
61type paletteAttachTerminal struct {
62 in io.Reader
63 out bytes.Buffer
64 resize chan domain.Size
65}
66
67func (t *paletteAttachTerminal) EnterRaw() (func() error, error) {
68 return func() error { return nil }, nil
69}
70func (t *paletteAttachTerminal) Size() (domain.Size, error) {
71 return domain.Size{Cols: 80, Rows: 24}, nil
72}
73func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Size { return t.resize }
74func (t *paletteAttachTerminal) In() io.Reader { return t.in }
75func (t *paletteAttachTerminal) Out() io.Writer { return &t.out }
76func (t *paletteAttachTerminal) Flush() error { return nil }
77
78type paletteAttachTransport struct {
79 mu sync.Mutex
80 sent []ports.Frame
81 themes []ports.Theme
82 finals int
83 finalSet chan struct{}
84}
85
86func (t *paletteAttachTransport) Send(frame ports.Frame) error {
87 t.mu.Lock()
88 defer t.mu.Unlock()
89 t.sent = append(t.sent, frame)
90 if frame.Type == ports.MsgTheme {
91 theme, err := ports.UnmarshalTheme(frame.Payload)
92 if err != nil {
93 return err
94 }
95 t.themes = append(t.themes, theme)
96 if theme.HasForeground && theme.HasBackground {
97 t.finals++
98 if t.finals == 1 {
99 close(t.finalSet)
100 }
101 }
102 }
103 return nil
104}
105
106func (t *paletteAttachTransport) Recv() (ports.Frame, error) {
107 t.mu.Lock()
108 first := len(t.sent) == 1
109 t.mu.Unlock()
110 if first {
111 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
112 }
113 <-t.finalSet
114 return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}, nil
115}
116func (*paletteAttachTransport) Close() error { return nil }
117
118func TestPaletteAttachReaderPreservesShortReadRemainderAndCloses(t *testing.T) {
119 reader := newPaletteAttachReader([]byte("abcdef"))
120 first := make([]byte, 2)
121 n, err := reader.Read(first)
122 require.NoError(t, err)
123 require.Equal(t, "ab", string(first[:n]))
124
125 rest := make([]byte, 8)
126 n, err = reader.Read(rest)
127 require.NoError(t, err)
128 require.Equal(t, "cdef", string(rest[:n]))
129
130 reader.close()
131 n, err = reader.Read(rest)
132 require.Zero(t, n)
133 require.ErrorIs(t, err, io.EOF)
134}
135
136func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
137 input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y"))
138 t.Cleanup(input.close)
139 term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}
140 transport := &paletteAttachTransport{finalSet: make(chan struct{})}
141 ms := &milestones{}
142 runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)}
143 attempt := &attachAttempt{
144 runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
145 milestones: ms, themeState: &terminalThemeState{},
146 enterRaw: func() error { ms.rawEntered = true; return nil },
147 reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
148 }
149
150 result := attempt.run(context.Background())
151 require.NoError(t, result.err)
152 require.Equal(t, paletteColorBatch, term.out.String())
153
154 transport.mu.Lock()
155 defer transport.mu.Unlock()
156 require.Len(t, transport.sent, 3)
157 require.Equal(t, []ports.MsgType{ports.MsgHello, ports.MsgTheme, ports.MsgTheme}, []ports.MsgType{
158 transport.sent[0].Type, transport.sent[1].Type, transport.sent[2].Type,
159 }, "the protocol requires Hello before the two palette publications")
160 require.Len(t, transport.themes, 2)
161 cleared, definitive := transport.themes[0], transport.themes[1]
162 require.Zero(t, cleared.PaletteKnown)
163 require.False(t, cleared.HasForeground)
164 require.True(t, definitive.HasForeground)
165 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, definitive.Foreground)
166 require.True(t, definitive.HasBackground)
167 require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, definitive.Background)
168 require.Equal(t, uint16(1<<2), definitive.PaletteKnown)
169}