v0.4.1 03674e0

kittygraphics_test.go RawHistoryFolder

1package kittygraphics
2
3import (
4	"bytes"
5	"compress/zlib"
6	"encoding/base64"
7	"errors"
8	"image"
9	"image/color"
10	"image/png"
11	"math/rand"
12	"os"
13	"strconv"
14	"testing"
15
16	"github.com/bnema/vev-vt/graphics"
17)
18
19func apc(header, payload string) []byte {
20	return append([]byte("\x1b_G"+header+";"+payload+"\x1b\\"), []byte{}...)
21}
22
23func pngBytes(t *testing.T, width, height int) []byte {
24	t.Helper()
25	img := image.NewRGBA(image.Rect(0, 0, width, height))
26	img.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
27	var out bytes.Buffer
28	if err := png.Encode(&out, img); err != nil {
29		t.Fatal(err)
30	}
31	return out.Bytes()
32}
33
34func TestParserRecognizesSplitPrefixAndPreservesText(t *testing.T) {
35	p := NewParser()
36	var events []Event
37	for _, b := range []byte("before\x1b_Ga=q,i=7;\x1b\\after") {
38		events = append(events, p.Feed([]byte{b})...)
39	}
40	var textBefore, textAfter []byte
41	var command Event
42	for _, event := range events {
43		if event.Kind == EventText && command.Kind == 0 {
44			textBefore = append(textBefore, event.Text...)
45		}
46		if event.Kind == EventCommand {
47			command = event
48		}
49		if event.Kind == EventText && command.Kind != 0 {
50			textAfter = append(textAfter, event.Text...)
51		}
52	}
53	if string(textBefore) != "before" || command.Kind != EventCommand || string(textAfter) != "after" {
54		t.Fatalf("events = %#v", events)
55	}
56	if command.Command.Controls.Action != ActionQuery || command.Command.Controls.ImageID != 7 {
57		t.Fatalf("command = %#v", command.Command)
58	}
59}
60
61func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
62	scene := graphics.NewScene(graphics.Limits{})
63	session := NewSession(scene)
64
65	result, err := session.Feed(apc("a=q,i=31,t=d,f=24,s=1,v=1", "AAAA"))
66	if err != nil {
67		t.Fatal(err)
68	}
69	if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" {
70		t.Fatalf("response = %q", got)
71	}
72	if got := scene.Usage(); got != (graphics.Usage{}) {
73		t.Fatalf("query persisted graphics state: %#v", got)
74	}
75}
76
77func TestSessionAcceptsKittenZlibCompressedRGB(t *testing.T) {
78	raw := []byte{
79		255, 0, 0, 0, 255, 0,
80		0, 0, 255, 255, 255, 255,
81	}
82	var compressed bytes.Buffer
83	zw := zlib.NewWriter(&compressed)
84	if _, err := zw.Write(raw); err != nil {
85		t.Fatal(err)
86	}
87	if err := zw.Close(); err != nil {
88		t.Fatal(err)
89	}
90
91	scene := graphics.NewScene(graphics.Limits{})
92	session := NewSession(scene)
93	payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes())
94	split := len(payload) / 2
95	split -= split % 4
96	first, err := session.Feed(apc("a=T,q=2,f=24,o=z,m=1,s=2,v=2", payload[:split]))
97	if err != nil {
98		t.Fatal(err)
99	}
100	if len(first.Mutations) != 0 {
101		t.Fatalf("first chunk mutations = %#v", first.Mutations)
102	}
103	result, err := session.Feed(apc("m=0", payload[split:]))
104	if err != nil {
105		t.Fatal(err)
106	}
107	if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
108		t.Fatalf("mutations = %#v", result.Mutations)
109	}
110	assets := scene.Snapshot().Assets()
111	if len(assets) != 1 || assets[0].Format() != graphics.AssetFormatRGB || !bytes.Equal(assets[0].Encoded(), raw) {
112		t.Fatalf("assets = %#v", assets)
113	}
114}
115
116func TestSessionAcceptsZlibCompressedCapabilityQuery(t *testing.T) {
117	raw := []byte{1, 2, 3}
118	var compressed bytes.Buffer
119	zw := zlib.NewWriter(&compressed)
120	_, _ = zw.Write(raw)
121	_ = zw.Close()
122
123	session := NewSession(graphics.NewScene(graphics.Limits{}))
124	payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes())
125	result, err := session.Feed(apc("a=q,i=31,f=24,o=z,s=1,v=1", payload))
126	if err != nil {
127		t.Fatal(err)
128	}
129	if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" {
130		t.Fatalf("response = %q", got)
131	}
132}
133
134func TestSessionBoundsZlibDecompression(t *testing.T) {
135	raw := bytes.Repeat([]byte{1}, 64)
136	var compressed bytes.Buffer
137	zw := zlib.NewWriter(&compressed)
138	_, _ = zw.Write(raw)
139	_ = zw.Close()
140
141	session := NewSession(graphics.NewScene(graphics.Limits{}), Limits{MaxDecodedBytes: 32})
142	payload := base64.RawStdEncoding.EncodeToString(compressed.Bytes())
143	_, err := session.Feed(apc("a=T,f=24,o=z,s=1,v=1", payload))
144	if !errors.Is(err, ErrPayloadTooLarge) {
145		t.Fatalf("error = %v, want ErrPayloadTooLarge", err)
146	}
147}
148
149func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
150	data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
151	if err != nil {
152		t.Fatal(err)
153	}
154	scene := graphics.NewScene(graphics.Limits{})
155	session := NewSession(scene)
156	// Feed split boundaries deliberately unrelated to APC boundaries. This
157	// models the short writes observed from the current kitten/icat fixture.
158	rng := rand.New(rand.NewSource(7))
159	var result Result
160	for len(data) != 0 {
161		n := 1 + rng.Intn(4096)
162		if n > len(data) {
163			n = len(data)
164		}
165		part, err := session.Feed(data[:n])
166		if err != nil {
167			t.Fatal(err)
168		}
169		result.Events = append(result.Events, part.Events...)
170		result.Responses = append(result.Responses, part.Responses...)
171		result.Mutations = append(result.Mutations, part.Mutations...)
172		data = data[n:]
173	}
174	if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
175		t.Fatalf("mutations = %#v", result.Mutations)
176	}
177	if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.DecodedPixels != 40000 {
178		t.Fatalf("usage = %#v", got)
179	}
180	if len(result.Responses) != 0 {
181		t.Fatalf("quiet fixture response = %q", result.Bytes())
182	}
183	if _, ok := session.Image(0); ok {
184		t.Fatal("unidentified fixture image was retained as a reusable mapping")
185	}
186}
187
188func TestDecodeBase64AcceptsStandardAndRaw(t *testing.T) {
189	want := []byte{0, 1, 2, 253, 254, 255}
190	standard := base64.StdEncoding.EncodeToString(want)
191	raw := base64.RawStdEncoding.EncodeToString(want)
192	for _, encoded := range []string{standard, raw} {
193		got, err := DecodeBase64([]byte(encoded))
194		if err != nil || !bytes.Equal(got, want) {
195			t.Fatalf("DecodeBase64(%q) = %x, %v", encoded, got, err)
196		}
197	}
198	if _, err := DecodeBase64([]byte("not+base64?")); !errors.Is(err, ErrInvalidBase64) {
199		t.Fatalf("invalid base64 error = %v", err)
200	}
201}
202
203func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
204	scene := graphics.NewScene(graphics.Limits{})
205	session := NewSession(scene)
206	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
207	result, err := session.Feed(apc("a=t,i=9,f=32,s=1,v=1,q=2", payload))
208	if err != nil {
209		t.Fatal(err)
210	}
211	if len(result.Responses) != 0 || len(result.Mutations) != 1 {
212		t.Fatalf("result = %#v", result)
213	}
214	asset, ok := session.Image(9)
215	if !ok {
216		t.Fatal("image mapping missing")
217	}
218	if _, err := session.Feed(apc("a=p,i=9,p=4,X=5,Y=6,c=1,r=1", "")); err != nil {
219		t.Fatal(err)
220	}
221	placement, ok := session.Placement(9, 4)
222	if !ok {
223		t.Fatal("placement mapping missing")
224	}
225	view, ok := scene.Snapshot().Placement(placement)
226	if !ok || view.AssetID() != asset || view.Destination() != (graphics.PixelRect{X: 5, Y: 6, Width: 1, Height: 1}) {
227		t.Fatalf("placement = %#v, ok=%v", view, ok)
228	}
229}
230
231func TestPlacementIDsAreScopedToTheirImage(t *testing.T) {
232	scene := graphics.NewScene(graphics.Limits{})
233	session := NewSession(scene)
234	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
235	for _, imageID := range []uint64{1, 2} {
236		if _, err := session.Feed(apc("a=t,i="+strconv.FormatUint(imageID, 10)+",f=32,s=1,v=1,q=2", payload)); err != nil {
237			t.Fatal(err)
238		}
239		if _, err := session.Feed(apc("a=p,i="+strconv.FormatUint(imageID, 10)+",p=7,q=2", "")); err != nil {
240			t.Fatal(err)
241		}
242	}
243	first, ok := session.Placement(1, 7)
244	if !ok {
245		t.Fatal("first placement missing")
246	}
247	second, ok := session.Placement(2, 7)
248	if !ok || first == second {
249		t.Fatalf("placement mapping collision: first=%v second=%v ok=%v", first, second, ok)
250	}
251	if got := scene.Usage().Placements; got != 2 {
252		t.Fatalf("placements = %d, want 2", got)
253	}
254}
255
256func TestParseAPCAcceptsPayloadFreeCommands(t *testing.T) {
257	for _, raw := range []string{"\x1b_Ga=d,d=a\x1b\\", "\x1b_Ga=p,i=1\x1b\\"} {
258		command, err := ParseAPC([]byte(raw))
259		if err != nil {
260			t.Fatalf("ParseAPC(%q) error = %v", raw, err)
261		}
262		if len(command.Payload) != 0 {
263			t.Fatalf("ParseAPC(%q) payload = %q", raw, command.Payload)
264		}
265	}
266}
267
268func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
269	if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {
270		t.Fatalf("duplicate control error = %v", err)
271	}
272	if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {
273		t.Fatalf("integer overflow error = %v", err)
274	}
275	if _, err := ParseCommand([]byte("a=q,q=256;")); !errors.Is(err, ErrInvalidCommand) {
276		t.Fatalf("out-of-range quiet error = %v", err)
277	}
278	if _, err := ParseCommand([]byte("a=q,i=1,I=2;")); !errors.Is(err, ErrInvalidCommand) {
279		t.Fatalf("mixed image selector error = %v", err)
280	}
281
282	p := NewParser(Limits{MaxAPCBytes: 8})
283	events := p.Feed(apc("a=q", "0123456789"))
284	if len(events) != 1 || !errors.Is(events[0].Err, ErrAPCTooLarge) {
285		t.Fatalf("oversize events = %#v", events)
286	}
287	p = NewParser()
288	if got := p.Feed([]byte("\x1b_Ga=q;payload")); len(got) != 0 {
289		t.Fatalf("truncated feed events = %#v", got)
290	}
291	got := p.Finish()
292	if len(got) != 1 || !errors.Is(got[0].Err, ErrAPCTruncated) {
293		t.Fatalf("truncated finish events = %#v", got)
294	}
295
296	session := NewSession(graphics.NewScene(graphics.Limits{}))
297	first := apc("a=t,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
298	if _, err := session.Feed(first); err != nil {
299		t.Fatal(err)
300	}
301	if _, err := session.Feed(apc("a=t,i=2,m=0,q=2", "ID")); !errors.Is(err, ErrInterleavedUpload) {
302		t.Fatalf("interleaved error = %v", err)
303	}
304	if _, err := session.Feed(apc("a=t,m=0,q=2", "IDBA")); err != nil {
305		t.Fatal(err)
306	}
307}
308
309func TestSessionTruncationAndDeleteAbortPendingUpload(t *testing.T) {
310	for _, action := range []string{"finish", "delete"} {
311		t.Run(action, func(t *testing.T) {
312			scene := graphics.NewScene(graphics.Limits{})
313			session := NewSession(scene)
314			if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")); err != nil {
315				t.Fatal(err)
316			}
317			if action == "finish" {
318				if _, err := session.Finish(); err != nil {
319					t.Fatalf("Finish error = %v", err)
320				}
321			} else if _, err := session.Feed(apc("a=d,q=2", "")); err != nil {
322				t.Fatal(err)
323			}
324			if _, err := session.Feed(apc("m=0,q=2", "IDBA")); err == nil {
325				t.Fatal("stale continuation unexpectedly succeeded")
326			}
327			if got := scene.Usage(); got != (graphics.Usage{}) {
328				t.Fatalf("stale continuation mutated scene: %#v", got)
329			}
330		})
331	}
332}
333
334func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
335	good := pngBytes(t, 1, 1)
336	scene := graphics.NewScene(graphics.Limits{})
337	session := NewSession(scene)
338	if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", base64.RawStdEncoding.EncodeToString(good[:len(good)-2]))); !errors.Is(err, ErrInvalidPNG) {
339		t.Fatalf("malformed PNG error = %v", err)
340	}
341	if got := scene.Usage(); got.Assets != 0 || got.Placements != 0 {
342		t.Fatalf("malformed PNG mutated scene: %#v", got)
343	}
344}
345
346func TestImageGeometryRejectsOverflowBeforeByteArithmetic(t *testing.T) {
347	controls := Controls{Width: ^uint64(0), HasWidth: true, Height: ^uint64(0), HasHeight: true}
348	limits := Limits{MaxDimension: ^uint64(0), MaxDecodedPixels: ^uint64(0)}
349	if _, _, err := imageGeometry(nil, FormatRGBA, controls, limits); !errors.Is(err, ErrPayloadTooLarge) {
350		t.Fatalf("overflowing geometry error = %v", err)
351	}
352}
353
354func TestChunkedTransmitAndDisplayRetainsDisplayAction(t *testing.T) {
355	scene := graphics.NewScene(graphics.Limits{})
356	session := NewSession(scene)
357	first := apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
358	if _, err := session.Feed(first); err != nil {
359		t.Fatal(err)
360	}
361	last := apc("m=0,q=2", "IDBA")
362	result, err := session.Feed(last)
363	if err != nil {
364		t.Fatal(err)
365	}
366	if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
367		t.Fatalf("mutations = %#v", result.Mutations)
368	}
369	if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
370		t.Fatalf("usage = %#v", got)
371	}
372}
373
374func TestControlsIgnoreExtensionsAndBoundIntegerFields(t *testing.T) {
375	controls, err := ParseControls([]byte("a=p,i=1,N=1,S=2,O=3"))
376	if err != nil || controls.ImageID != 1 {
377		t.Fatalf("extension controls = %#v, %v", controls, err)
378	}
379	if _, err := ParseControls([]byte("a=p,s=4294967296")); !errors.Is(err, ErrIntegerOverflow) {
380		t.Fatalf("out-of-range uint32 control error = %v", err)
381	}
382	if _, err := ParseControls([]byte("a=p,z=2147483648")); !errors.Is(err, ErrIntegerOverflow) {
383		t.Fatalf("out-of-range int32 control error = %v", err)
384	}
385}
386
387func TestImplicitPlacementIDsStopAtKittyLimit(t *testing.T) {
388	session := NewSession(nil)
389	session.nextPlacement = MaxKittyID + 1
390	if _, ok := session.takePlacementID(1); ok {
391		t.Fatal("implicit placement ID exceeded Kitty's uint32 namespace")
392	}
393}
394
395func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) {
396	session := NewSession(graphics.NewScene(graphics.Limits{}))
397	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
398	if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", payload)); err != nil {
399		t.Fatal(err)
400	}
401	for _, header := range []string{"a=p,i=1,P=2", "a=p,i=1,H=1", "a=d,d=p,x=1,y=1", "a=d,d=P,x=1,y=1"} {
402		if _, err := session.Feed(apc(header, "")); !errors.Is(err, ErrUnsupported) {
403			t.Fatalf("%q error = %v, want ErrUnsupported", header, err)
404		}
405	}
406}
407
408func TestImageGeometryRejectsRawDecodedByteLimit(t *testing.T) {
409	controls := Controls{Width: 2, HasWidth: true, Height: 2, HasHeight: true}
410	limits := normalizeLimits(Limits{MaxDecodedBytes: 11})
411	if _, _, err := imageGeometry(make([]byte, 12), FormatRGB, controls, limits); !errors.Is(err, ErrPayloadTooLarge) {
412		t.Fatalf("error = %v, want ErrPayloadTooLarge", err)
413	}
414}
415
416func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) {
417	data := pngBytes(t, 2, 2)
418	if _, _, err := imageGeometry(data, FormatPNG, Controls{}, normalizeLimits(Limits{MaxDecodedBytes: 15})); !errors.Is(err, ErrPayloadTooLarge) {
419		t.Fatalf("PNG byte-budget error = %v", err)
420	}
421	scene := graphics.NewScene(graphics.Limits{})
422	session := NewSession(scene)
423	payload := base64.RawStdEncoding.EncodeToString(data)
424	if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", payload)); err != nil {
425		t.Fatal(err)
426	}
427	assetID, ok := session.Image(1)
428	if !ok {
429		t.Fatal("image mapping missing")
430	}
431	asset, ok := scene.Snapshot().Asset(assetID)
432	if !ok || asset.Format() != graphics.AssetFormatPNG || asset.Blob().Format != graphics.AssetFormatPNG {
433		t.Fatalf("asset format = %#v, ok=%v", asset, ok)
434	}
435}
436
437func TestPlacementFailureEmitsStableProtocolError(t *testing.T) {
438	scene := graphics.NewScene(graphics.Limits{})
439	session := NewSession(scene)
440	if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
441		t.Fatal(err)
442	}
443	result, err := session.Feed(apc("a=p,i=1,p=7,w=2,q=0", ""))
444	if !errors.Is(err, graphics.ErrInvalidPlacement) {
445		t.Fatalf("placement error = %v", err)
446	}
447	if got := result.Bytes(); string(got) != "\x1b_Gi=1;EINVAL\x1b\\" {
448		t.Fatalf("placement response = %q", got)
449	}
450	if len(result.Mutations) != 0 || scene.Usage().Placements != 0 {
451		t.Fatalf("failed placement mutated state: result=%#v usage=%#v", result, scene.Usage())
452	}
453}
454
455func TestFailedImageReplacementPreservesAssetAndPlacements(t *testing.T) {
456	scene := graphics.NewScene(graphics.Limits{MaxEncodedBytes: 4, MaxDecodedPixels: 4})
457	session := NewSession(scene)
458	if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
459		t.Fatal(err)
460	}
461	if _, err := session.Feed(apc("a=p,i=1,p=7,q=2", "")); err != nil {
462		t.Fatal(err)
463	}
464	assetID, ok := session.Image(1)
465	if !ok {
466		t.Fatal("original image mapping missing")
467	}
468	placementID, ok := session.Placement(1, 7)
469	if !ok {
470		t.Fatal("original placement mapping missing")
471	}
472	before := scene.Snapshot()
473	result, err := session.Feed(apc("a=t,i=1,f=32,s=2,v=1,q=0", "AQIDBAUGBwg"))
474	if !errors.Is(err, graphics.ErrEncodedBudget) {
475		t.Fatalf("replacement error = %v", err)
476	}
477	if got := result.Bytes(); string(got) != "\x1b_Gi=1;E2BIG\x1b\\" {
478		t.Fatalf("replacement response = %q", got)
479	}
480	if got, ok := session.Image(1); !ok || got != assetID {
481		t.Fatalf("replacement changed image mapping: id=%v ok=%v want=%v", got, ok, assetID)
482	}
483	if got, ok := session.Placement(1, 7); !ok || got != placementID {
484		t.Fatalf("replacement changed placement mapping: id=%v ok=%v want=%v", got, ok, placementID)
485	}
486	if scene.Snapshot().Generation() != before.Generation() || scene.Usage() != before.Usage() {
487		t.Fatalf("failed replacement changed scene: before=%#v after=%#v", before.Usage(), scene.Usage())
488	}
489	if _, ok := scene.Snapshot().Placement(placementID); !ok {
490		t.Fatal("failed replacement removed original placement")
491	}
492}
493
494func TestFailedDisplayedImageReplacementPreservesOldPlacement(t *testing.T) {
495	scene := graphics.NewScene(graphics.Limits{})
496	session := NewSession(scene)
497	if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
498		t.Fatal(err)
499	}
500	assetID, ok := session.Image(1)
501	if !ok {
502		t.Fatal("original image mapping missing")
503	}
504	placementID, ok := session.Placement(1, 1)
505	if !ok {
506		t.Fatal("original placement mapping missing")
507	}
508
509	result, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,w=2,q=0", "AQIDBA"))
510	if !errors.Is(err, graphics.ErrInvalidPlacement) {
511		t.Fatalf("replacement placement error = %v", err)
512	}
513	if got := result.Bytes(); string(got) != "\x1b_Gi=1;EINVAL\x1b\\" {
514		t.Fatalf("replacement response = %q", got)
515	}
516	if got, ok := session.Image(1); !ok || got != assetID {
517		t.Fatalf("failed displayed replacement changed image: id=%v ok=%v want=%v", got, ok, assetID)
518	}
519	if got, ok := session.Placement(1, 1); !ok || got != placementID {
520		t.Fatalf("failed displayed replacement changed placement: id=%v ok=%v want=%v", got, ok, placementID)
521	}
522	if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
523		t.Fatalf("failed displayed replacement usage = %#v", got)
524	}
525}
526
527func TestDeleteSelectedImagePlacementsAndImageNumberNamespace(t *testing.T) {
528	scene := graphics.NewScene(graphics.Limits{})
529	session := NewSession(scene)
530	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
531	for range 2 {
532		if _, err := session.Feed(apc("a=t,I=7,f=32,s=1,v=1,q=2", payload)); err != nil {
533			t.Fatal(err)
534		}
535	}
536	if got := scene.Usage(); got.Assets != 2 || got.Placements != 0 {
537		t.Fatalf("numbered uploads collapsed namespaces: %#v", got)
538	}
539	if _, err := session.Feed(apc("a=p,I=7,p=2,q=2", "")); err != nil {
540		t.Fatal(err)
541	}
542	if _, err := session.Feed(apc("a=p,i=1,p=1,q=2", "")); err != nil {
543		t.Fatal(err)
544	}
545	if got := scene.Usage(); got.Placements != 2 {
546		t.Fatalf("placements = %#v", got)
547	}
548	if _, err := session.Feed(apc("a=d,d=i,i=1,q=2", "")); err != nil {
549		t.Fatal(err)
550	}
551	if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {
552		t.Fatalf("selected delete affected unrelated scene state: %#v", got)
553	}
554	if _, err := session.Feed(apc("a=d,d=I,I=7,q=2", "")); err != nil {
555		t.Fatal(err)
556	}
557	if got := scene.Usage(); got.Assets != 1 || got.Placements != 0 {
558		t.Fatalf("image-number delete = %#v", got)
559	}
560	if _, ok := session.Image(1); !ok {
561		t.Fatal("image ID namespace was removed by image-number delete")
562	}
563}
564
565func FuzzParserNeverPanics(f *testing.F) {
566	f.Add([]byte("text\x1b_Ga=q;\x1b\\"))
567	f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))
568	f.Fuzz(func(t *testing.T, data []byte) {
569		p := NewParser(Limits{MaxAPCBytes: 1024, MaxPayloadBytes: 512, MaxUploadBytes: 512})
570		_ = p.Feed(data)
571		_ = p.Finish()
572	})
573}
574
575func FuzzSessionNeverPanics(f *testing.F) {
576	f.Add([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;AQIDBA==\x1b\\"))
577	f.Add([]byte("\x1b_Ga=T,f=100,m=1;AAAA\x1b\\\x1b_Gm=0;AAAA\x1b\\"))
578	f.Fuzz(func(t *testing.T, data []byte) {
579		s := NewSession(graphics.NewScene(graphics.Limits{}), Limits{
580			MaxAPCBytes: 4096, MaxPayloadBytes: 2048, MaxUploadBytes: 2048,
581			MaxDimension: 128, MaxDecodedPixels: 4096,
582		})
583		_, _ = s.Feed(data)
584		_, _ = s.Finish()
585	})
586}

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 2

Tags 13

↑↓ moveenter openesc close