palette_generation_test.go RawHistoryFolder

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 }

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