1package client
2
3import (
4 "testing"
5 "time"
6
7 "github.com/stretchr/testify/require"
8
9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/ports"
11)
12
13// manualPaletteClock applies only declarative coordinator timer actions. Its
14// callbacks re-enter the coordinator through generation-tagged events; they
15// never perform terminal I/O or protocol sends.
16type manualPaletteClock struct {
17 drain, completion map[paletteGenerationID]bool
18}
19
20func newManualPaletteClock() *manualPaletteClock {
21 return &manualPaletteClock{drain: map[paletteGenerationID]bool{}, completion: map[paletteGenerationID]bool{}}
22}
23
24func (c *manualPaletteClock) apply(actions []paletteGenerationAction) {
25 for _, action := range actions {
26 switch action.kind {
27 case paletteActionArmDrainDeadline:
28 c.drain[action.id] = true
29 case paletteActionArmCompletionDeadline:
30 c.completion[action.id] = true
31 case paletteActionCancelDrainDeadline:
32 delete(c.drain, action.id)
33 case paletteActionCancelCompletionDeadline:
34 delete(c.completion, action.id)
35 }
36 }
37}
38
39func (c *manualPaletteClock) fireDrain(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction {
40 if !c.drain[id] {
41 return nil
42 }
43 delete(c.drain, id)
44 return g.handle(paletteGenerationEvent{id: id, kind: paletteEventDrainDeadline})
45}
46
47func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id paletteGenerationID) []paletteGenerationAction {
48 if !c.completion[id] {
49 return nil
50 }
51 delete(c.completion, id)
52 return g.handle(paletteGenerationEvent{id: id, kind: paletteEventCompletionDeadline})
53}
54
55func generationTheme() ports.Theme {
56 return ports.Theme{
57 TrueColor: true,
58 HasForeground: true,
59 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
60 HasBackground: true,
61 Background: renderer.RGB{R: 4, G: 5, B: 6},
62 SchemeKnown: true,
63 Light: true,
64 PaletteKnown: 1 << 2,
65 Palette: [16]renderer.RGB{2: {R: 7, G: 8, B: 9}},
66 }
67}
68
69func actionKinds(actions []paletteGenerationAction) []paletteGenerationActionKind {
70 out := make([]paletteGenerationActionKind, len(actions))
71 for i := range actions {
72 out[i] = actions[i].kind
73 }
74 return out
75}
76
77func findAction(t *testing.T, actions []paletteGenerationAction, kind paletteGenerationActionKind) paletteGenerationAction {
78 t.Helper()
79 for _, action := range actions {
80 if action.kind == kind {
81 return action
82 }
83 }
84 t.Fatalf("missing action %v in %#v", kind, actions)
85 return paletteGenerationAction{}
86}
87
88func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) {
89 g := newPaletteGenerationCoordinator()
90 clock := newManualPaletteClock()
91 start := g.start(generationTheme(), false)
92 clock.apply(start)
93
94 require.Equal(t, []paletteGenerationActionKind{
95 paletteActionPublishCleared,
96 paletteActionWriteBatch,
97 paletteActionArmCompletionDeadline,
98 }, actionKinds(start))
99 cleared := findAction(t, start, paletteActionPublishCleared).theme
100 require.True(t, cleared.HasForeground)
101 require.Equal(t, generationTheme().Foreground, cleared.Foreground)
102 require.Zero(t, cleared.PaletteKnown)
103 require.Zero(t, cleared.Palette)
104
105 batch := findAction(t, start, paletteActionWriteBatch)
106 require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07"+
107 "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"+
108 "\x1b[?2031$p", batch.bytes)
109 id := batch.id
110 g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12})
111 g.background(id, renderer.RGB{R: 13, G: 14, B: 15})
112 g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18})
113 final := g.marker(id)
114 clock.apply(final)
115
116 require.Equal(t, []paletteGenerationActionKind{
117 paletteActionCancelDrainDeadline,
118 paletteActionCancelCompletionDeadline,
119 paletteActionPublishFinal,
120 }, actionKinds(final))
121 got := findAction(t, final, paletteActionPublishFinal).theme
122 require.True(t, got.TrueColor)
123 require.True(t, got.HasForeground)
124 require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground)
125 require.True(t, got.HasBackground)
126 require.Equal(t, uint16(1<<3), got.PaletteKnown)
127}
128
129func TestPaletteGenerationReplacementWaitsForDrain(t *testing.T) {
130 g := newPaletteGenerationCoordinator()
131 clock := newManualPaletteClock()
132 start := g.start(generationTheme(), true)
133 clock.apply(start)
134
135 require.Equal(t, []paletteGenerationActionKind{
136 paletteActionPublishCleared,
137 paletteActionWriteDrain,
138 paletteActionArmDrainDeadline,
139 }, actionKinds(start))
140 drain := findAction(t, start, paletteActionWriteDrain)
141 require.Equal(t, "\x1b[?2031$p", drain.bytes)
142 id := drain.id
143 batch := g.marker(id)
144 clock.apply(batch)
145 require.Equal(t, []paletteGenerationActionKind{
146 paletteActionCancelDrainDeadline,
147 paletteActionWriteBatch,
148 paletteActionArmCompletionDeadline,
149 }, actionKinds(batch))
150}
151
152func TestPaletteGenerationLateDrainBoundaryExcludesStaleOSC(t *testing.T) {
153 g := newPaletteGenerationCoordinator()
154 clock := newManualPaletteClock()
155 start := g.start(generationTheme(), true)
156 clock.apply(start)
157 id := findAction(t, start, paletteActionWriteDrain).id
158
159 timedOut := clock.fireDrain(g, id)
160 clock.apply(timedOut)
161 require.Equal(t, []paletteGenerationActionKind{
162 paletteActionWriteBatch,
163 paletteActionArmCompletionDeadline,
164 }, actionKinds(timedOut))
165
166 // The drain reply may be late, so reports received before its boundary are
167 // conservatively excluded from this generation's accumulator.
168 g.foreground(id, renderer.RGB{R: 1, G: 2, B: 3})
169 g.background(id, renderer.RGB{R: 4, G: 5, B: 6})
170 g.palette(id, 2, renderer.RGB{R: 7, G: 8, B: 9})
171
172 lateDrain := g.marker(id)
173 require.Empty(t, lateDrain, "the late drain response must not finalize the batch")
174 g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12})
175 g.background(id, renderer.RGB{R: 13, G: 14, B: 15})
176 g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18})
177 complete := g.marker(id)
178 clock.apply(complete)
179 final := findAction(t, complete, paletteActionPublishFinal).theme
180 require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, final.Foreground)
181 require.Equal(t, renderer.RGB{R: 13, G: 14, B: 15}, final.Background)
182 require.Equal(t, uint16(1<<3), final.PaletteKnown)
183 require.Equal(t, renderer.RGB{R: 16, G: 17, B: 18}, final.Palette[3])
184}
185
186func TestPaletteGenerationCompletionDeadlineFinalizesAfterMissingLateDrain(t *testing.T) {
187 g := newPaletteGenerationCoordinator()
188 clock := newManualPaletteClock()
189 start := g.start(generationTheme(), true)
190 clock.apply(start)
191 id := findAction(t, start, paletteActionWriteDrain).id
192 clock.apply(clock.fireDrain(g, id))
193
194 // One marker may be the completion marker while the timed-out drain reply
195 // never arrives, so it cannot finalize by marker accounting alone.
196 require.Empty(t, g.marker(id))
197 final := clock.fireCompletion(g, id)
198 clock.apply(final)
199 require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind)
200}
201
202func TestPaletteGenerationStaleTimerAndMarkerAreNoOps(t *testing.T) {
203 g := newPaletteGenerationCoordinator()
204 clock := newManualPaletteClock()
205 first := g.start(generationTheme(), true)
206 clock.apply(first)
207 oldID := findAction(t, first, paletteActionWriteDrain).id
208 second := g.start(generationTheme(), false)
209 clock.apply(second)
210 newID := findAction(t, second, paletteActionWriteBatch).id
211
212 require.Empty(t, g.marker(oldID))
213 require.Empty(t, clock.fireDrain(g, oldID))
214 require.Empty(t, clock.fireCompletion(g, oldID))
215 final := g.marker(newID)
216 require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind)
217}
218
219func TestPaletteMarkerScannerConsumesSplitMarkerAndPreservesInput(t *testing.T) {
220 scanner := paletteMarkerScanner{}
221 var got []byte
222 markers := 0
223 emit := func(data []byte) { got = append(got, data...) }
224 marker := func() { markers++ }
225
226 scanner.scan([]byte("a\x1b[?2031;"), emit, marker)
227 scanner.scan([]byte("2$yb"), emit, marker)
228 scanner.flush(emit)
229
230 require.Equal(t, []byte("ab"), got)
231 require.Equal(t, 1, markers)
232}
233
234func TestPaletteGenerationLateOSCDoesNotMutateFinalizedAccumulator(t *testing.T) {
235 g := newPaletteGenerationCoordinator()
236 start := g.start(generationTheme(), false)
237 id := findAction(t, start, paletteActionWriteBatch).id
238 g.palette(id, 2, renderer.RGB{R: 1, G: 2, B: 3})
239 final := g.marker(id)
240 want := findAction(t, final, paletteActionPublishFinal).theme
241
242 g.palette(id, 2, renderer.RGB{R: 9, G: 9, B: 9})
243 require.Equal(t, want, g.finalizedTheme())
244}
245
246func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) {
247 require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline)
248}