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/protocol"
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() protocol.Theme {
56 return protocol.Theme{
57 HasForeground: true,
58 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
59 HasBackground: true,
60 Background: renderer.RGB{R: 4, G: 5, B: 6},
61 SchemeKnown: true,
62 Light: true,
63 PaletteKnown: 1 << 2,
64 Palette: [16]renderer.RGB{2: {R: 7, G: 8, B: 9}},
65 }
66}
67
68func actionKinds(actions []paletteGenerationAction) []paletteGenerationActionKind {
69 out := make([]paletteGenerationActionKind, len(actions))
70 for i := range actions {
71 out[i] = actions[i].kind
72 }
73 return out
74}
75
76func findAction(t *testing.T, actions []paletteGenerationAction, kind paletteGenerationActionKind) paletteGenerationAction {
77 t.Helper()
78 for _, action := range actions {
79 if action.kind == kind {
80 return action
81 }
82 }
83 t.Fatalf("missing action %v in %#v", kind, actions)
84 return paletteGenerationAction{}
85}
86
87func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) {
88 g := newPaletteGenerationCoordinator()
89 clock := newManualPaletteClock()
90 start := g.start(generationTheme(), false)
91 clock.apply(start)
92
93 require.Equal(t, []paletteGenerationActionKind{
94 paletteActionPublishCleared,
95 paletteActionWriteBatch,
96 paletteActionArmCompletionDeadline,
97 }, actionKinds(start))
98 cleared := findAction(t, start, paletteActionPublishCleared).theme
99 require.True(t, cleared.HasForeground)
100 require.Equal(t, generationTheme().Foreground, cleared.Foreground)
101 require.Zero(t, cleared.PaletteKnown)
102 require.Zero(t, cleared.Palette)
103
104 batch := findAction(t, start, paletteActionWriteBatch)
105 require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07"+
106 "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"+
107 "\x1b[?2031$p", batch.bytes)
108 id := batch.id
109 g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12})
110 g.background(id, renderer.RGB{R: 13, G: 14, B: 15})
111 g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18})
112 final := g.marker(id)
113 clock.apply(final)
114
115 require.Equal(t, []paletteGenerationActionKind{
116 paletteActionCancelDrainDeadline,
117 paletteActionCancelCompletionDeadline,
118 paletteActionPublishFinal,
119 }, actionKinds(final))
120 got := findAction(t, final, paletteActionPublishFinal).theme
121 require.True(t, got.HasForeground)
122 require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground)
123 require.True(t, got.HasBackground)
124 require.Equal(t, uint16(1<<3), got.PaletteKnown)
125}
126
127func TestPaletteGenerationReplacementWaitsForDrain(t *testing.T) {
128 g := newPaletteGenerationCoordinator()
129 clock := newManualPaletteClock()
130 start := g.start(generationTheme(), true)
131 clock.apply(start)
132
133 require.Equal(t, []paletteGenerationActionKind{
134 paletteActionPublishCleared,
135 paletteActionWriteDrain,
136 paletteActionArmDrainDeadline,
137 }, actionKinds(start))
138 drain := findAction(t, start, paletteActionWriteDrain)
139 require.Equal(t, "\x1b[?2031$p", drain.bytes)
140 id := drain.id
141 batch := g.marker(id)
142 clock.apply(batch)
143 require.Equal(t, []paletteGenerationActionKind{
144 paletteActionCancelDrainDeadline,
145 paletteActionWriteBatch,
146 paletteActionArmCompletionDeadline,
147 }, actionKinds(batch))
148}
149
150func TestPaletteGenerationLateDrainBoundaryExcludesStaleOSC(t *testing.T) {
151 g := newPaletteGenerationCoordinator()
152 clock := newManualPaletteClock()
153 start := g.start(generationTheme(), true)
154 clock.apply(start)
155 id := findAction(t, start, paletteActionWriteDrain).id
156
157 timedOut := clock.fireDrain(g, id)
158 clock.apply(timedOut)
159 require.Equal(t, []paletteGenerationActionKind{
160 paletteActionWriteBatch,
161 paletteActionArmCompletionDeadline,
162 }, actionKinds(timedOut))
163
164 // The drain reply may be late, so reports received before its boundary are
165 // conservatively excluded from this generation's accumulator.
166 g.foreground(id, renderer.RGB{R: 1, G: 2, B: 3})
167 g.background(id, renderer.RGB{R: 4, G: 5, B: 6})
168 g.palette(id, 2, renderer.RGB{R: 7, G: 8, B: 9})
169
170 lateDrain := g.marker(id)
171 require.Empty(t, lateDrain, "the late drain response must not finalize the batch")
172 g.foreground(id, renderer.RGB{R: 10, G: 11, B: 12})
173 g.background(id, renderer.RGB{R: 13, G: 14, B: 15})
174 g.palette(id, 3, renderer.RGB{R: 16, G: 17, B: 18})
175 complete := g.marker(id)
176 clock.apply(complete)
177 final := findAction(t, complete, paletteActionPublishFinal).theme
178 require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, final.Foreground)
179 require.Equal(t, renderer.RGB{R: 13, G: 14, B: 15}, final.Background)
180 require.Equal(t, uint16(1<<3), final.PaletteKnown)
181 require.Equal(t, renderer.RGB{R: 16, G: 17, B: 18}, final.Palette[3])
182}
183
184func TestPaletteGenerationCompletionDeadlineFinalizesAfterMissingLateDrain(t *testing.T) {
185 g := newPaletteGenerationCoordinator()
186 clock := newManualPaletteClock()
187 start := g.start(generationTheme(), true)
188 clock.apply(start)
189 id := findAction(t, start, paletteActionWriteDrain).id
190 clock.apply(clock.fireDrain(g, id))
191
192 // One marker may be the completion marker while the timed-out drain reply
193 // never arrives, so it cannot finalize by marker accounting alone.
194 require.Empty(t, g.marker(id))
195 final := clock.fireCompletion(g, id)
196 clock.apply(final)
197 require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind)
198}
199
200func TestPaletteGenerationStaleTimerAndMarkerAreNoOps(t *testing.T) {
201 g := newPaletteGenerationCoordinator()
202 clock := newManualPaletteClock()
203 first := g.start(generationTheme(), true)
204 clock.apply(first)
205 oldID := findAction(t, first, paletteActionWriteDrain).id
206 second := g.start(generationTheme(), false)
207 clock.apply(second)
208 newID := findAction(t, second, paletteActionWriteBatch).id
209
210 require.Empty(t, g.marker(oldID))
211 require.Empty(t, clock.fireDrain(g, oldID))
212 require.Empty(t, clock.fireCompletion(g, oldID))
213 final := g.marker(newID)
214 require.Equal(t, paletteActionPublishFinal, final[len(final)-1].kind)
215}
216
217func TestPaletteMarkerScannerConsumesSplitMarkerAndPreservesInput(t *testing.T) {
218 scanner := paletteMarkerScanner{}
219 var got []byte
220 markers := 0
221 emit := func(data []byte) { got = append(got, data...) }
222 marker := func() { markers++ }
223
224 scanner.scan([]byte("a\x1b[?2031;"), emit, marker)
225 scanner.scan([]byte("2$yb"), emit, marker)
226 scanner.flush(emit)
227
228 require.Equal(t, []byte("ab"), got)
229 require.Equal(t, 1, markers)
230}
231
232func TestPaletteGenerationLateOSCDoesNotMutateFinalizedAccumulator(t *testing.T) {
233 g := newPaletteGenerationCoordinator()
234 start := g.start(generationTheme(), false)
235 id := findAction(t, start, paletteActionWriteBatch).id
236 g.palette(id, 2, renderer.RGB{R: 1, G: 2, B: 3})
237 final := g.marker(id)
238 want := findAction(t, final, paletteActionPublishFinal).theme
239
240 g.palette(id, 2, renderer.RGB{R: 9, G: 9, B: 9})
241 require.Equal(t, want, g.finalizedTheme())
242}
243
244func TestPaletteGenerationDeadlineIsFixedCapabilityFallback(t *testing.T) {
245 require.Equal(t, 200*time.Millisecond, paletteGenerationDeadline)
246}
247
248// Test-only shorthands for feeding one generation's events.
249func (c *paletteGenerationCoordinator) marker(id paletteGenerationID) []paletteGenerationAction {
250 return c.handle(paletteGenerationEvent{id: id, kind: paletteEventMarker})
251}
252
253func (c *paletteGenerationCoordinator) foreground(id paletteGenerationID, rgb renderer.RGB) {
254 c.handle(paletteGenerationEvent{id: id, kind: paletteEventForeground, rgb: rgb})
255}
256
257func (c *paletteGenerationCoordinator) background(id paletteGenerationID, rgb renderer.RGB) {
258 c.handle(paletteGenerationEvent{id: id, kind: paletteEventBackground, rgb: rgb})
259}
260
261func (c *paletteGenerationCoordinator) palette(id paletteGenerationID, slot uint8, rgb renderer.RGB) {
262 c.handle(paletteGenerationEvent{id: id, kind: paletteEventPalette, slot: slot, rgb: rgb})
263}
264
265func (c *paletteGenerationCoordinator) finalizedTheme() protocol.Theme { return c.finalized }