v0.3.0 99dc266

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/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}

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