v0.8.1 1f54f61

graphics_output_test.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"io"
6	"os"
7	"strconv"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12
13	vt "github.com/bnema/vev-vt"
14	"github.com/bnema/vev-vt/graphics"
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/domain/layout"
17	"github.com/bnema/vev/internal/domain/terminalcap"
18	"github.com/bnema/vev/internal/protocol"
19	"github.com/bnema/vev/internal/protocol/wire"
20	"github.com/stretchr/testify/require"
21)
22
23func attachmentOutputWithGraphics(state *graphicsOutputState) *attachmentOutput {
24	output := newOutputStateStream()
25	output.graphicsOutput = state
26	return output
27}
28
29func kittenGraphicsSnapshot(t *testing.T) *graphics.Snapshot {
30	t.Helper()
31	data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
32	require.NoError(t, err)
33	screen := vt.NewScreen(80, 24)
34	for len(data) > 0 {
35		n := 791
36		if n > len(data) {
37			n = len(data)
38		}
39		screen.Write(data[:n])
40		data = data[n:]
41	}
42	snapshot := screen.GraphicsSnapshot()
43	require.NotNil(t, snapshot)
44	require.Equal(t, uint64(1), snapshot.Usage().Assets)
45	require.Equal(t, uint64(1), snapshot.Usage().Placements)
46	return snapshot
47}
48
49func recordIndex(data []byte, record string) int {
50	return bytes.Index(data, []byte(record))
51}
52
53type lateGraphicsCleanupTransport struct {
54	started     chan struct{}
55	release     chan struct{}
56	finished    chan struct{}
57	sends       chan wire.Envelope
58	startedOnce sync.Once
59	finishOnce  sync.Once
60}
61
62func newLateGraphicsCleanupTransport() *lateGraphicsCleanupTransport {
63	return &lateGraphicsCleanupTransport{
64		started:  make(chan struct{}),
65		release:  make(chan struct{}),
66		finished: make(chan struct{}),
67		sends:    make(chan wire.Envelope, 1),
68	}
69}
70
71func (t *lateGraphicsCleanupTransport) Send(frame wire.Envelope) error {
72	t.startedOnce.Do(func() { close(t.started) })
73	<-t.release
74	t.sends <- frame
75	t.finishOnce.Do(func() { close(t.finished) })
76	return nil
77}
78
79func (*lateGraphicsCleanupTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
80func (*lateGraphicsCleanupTransport) Close() error                 { return nil }
81
82func TestGraphicsOutputReplaysStaticKittenIcatFixture(t *testing.T) {
83	state := newGraphicsOutputState()
84	snapshot := kittenGraphicsSnapshot(t)
85
86	first, err := state.prepare(snapshot, true)
87	require.NoError(t, err)
88	require.NotNil(t, first)
89	require.Contains(t, string(first.data), "\x1b_Ga=t,i=")
90	require.Contains(t, string(first.data), ",f=100")
91	require.Contains(t, string(first.data), "\x1b_Ga=p,i=")
92	require.Contains(t, string(first.data), ",q=2")
93	require.Less(t, recordIndex(first.data, "\x1b_Ga=t,"), recordIndex(first.data, "\x1b_Ga=p,"), "upload must precede placement")
94	outer := vt.NewScreen(80, 24)
95	outer.Write(first.data)
96	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
97	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
98	first.commit()
99
100	unchanged, err := state.prepare(snapshot, false)
101	require.NoError(t, err)
102	require.Empty(t, unchanged.data, "unchanged graphics must not enter ordinary text output")
103	unchanged.commit()
104
105	replay, err := state.prepare(snapshot, true)
106	require.NoError(t, err)
107	text := string(replay.data)
108	require.Less(t, strings.Index(text, "a=d,d=p,p="), strings.Index(text, "a=d,d=i,i="), "placement cleanup must precede image cleanup")
109	require.Less(t, strings.Index(text, "a=d,d=i,i="), strings.Index(text, "a=t,i="), "delete must precede replay upload")
110	require.Less(t, strings.Index(text, "a=t,i="), strings.Index(text, "a=p,i="), "upload must precede replay placement")
111}
112
113func TestKittyAttachmentPaintCarriesGraphicsInTheOutputStateRecord(t *testing.T) {
114	p, releasePTY := newBlockingPTY(t)
115	defer releasePTY()
116	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
117	ac.terminalCapabilities.KittyGraphics = true
118	ac.output.graphicsOutput = newGraphicsOutputState()
119	fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
120	require.NoError(t, err)
121	pane := sess.tabs[0].focusedPane()
122	// This fixture contains pixel-space Kitty output without cell metadata;
123	// provide the terminal pixel geometry explicitly so attachment clipping can
124	// map its placement safely.
125	geometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 23}, PixelWidth: 800, PixelHeight: 460}
126	pane.geometry = geometry
127	setScreenGeometry(pane.screen, geometry)
128	pane.screen.Write(fixture)
129
130	d.paint(sess, ac, true, nil)
131	frame := awaitFrame(t, sends, "Output")
132	output := unmarshalTestOutput(t, frame.Payload)
133	require.Contains(t, string(output.Data), "\x1b_Ga=t,i=")
134	require.Contains(t, string(output.Data), ",f=100")
135	require.Contains(t, string(output.Data), "\x1b_Ga=p,i=")
136	require.Contains(t, string(output.Data), ",q=2")
137	require.Positive(t, output.New, "graphics must ride the ordinary state-bearing output")
138
139	outer := vt.NewScreen(output.Size.Cols, output.Size.Rows)
140	outer.Write(output.Data)
141	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
142	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
143}
144
145func TestFreshCapabilityDowngradePreservesForeignTerminalGraphics(t *testing.T) {
146	p, releasePTY := newBlockingPTY(t)
147	defer releasePTY()
148	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
149	ac.terminalCapabilities.KittyGraphics = true
150	ac.output.graphicsOutput = newGraphicsOutputState()
151	ac.output.graphicsUnsupportedWarned.Store(true)
152	d.mu.Lock()
153	d.reconfigureAttachmentOutput(sess, ac, protocol.Hello{KittyDirectGraphics: false})
154	d.mu.Unlock()
155	require.False(t, ac.terminalCapabilities.SupportsKittyGraphics())
156	require.Nil(t, ac.output.graphicsOutput)
157	require.False(t, ac.output.graphicsUnsupportedWarned.Load(), "replacement terminal must receive its own unsupported-graphics warning")
158
159	d.paint(sess, ac, true, nil)
160	frame := awaitFrame(t, sends, "Output")
161	output := unmarshalTestOutput(t, frame.Payload)
162	require.Positive(t, output.New)
163	require.NotContains(t, string(output.Data), "\x1b_Ga=d,d=A", "vev must never globally delete terminal images")
164
165	outer := vt.NewScreen(80, 24)
166	foreign := newGraphicsOutputState()
167	foreignOutput, err := foreign.prepare(kittenGraphicsSnapshot(t), true)
168	require.NoError(t, err)
169	outer.Write(foreignOutput.data)
170	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
171	outer.Write(output.Data)
172	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Assets)
173	require.Equal(t, uint64(1), outer.GraphicsSnapshot().Usage().Placements)
174}
175
176func TestReplacementKittyTerminalGetsFreshGraphicsState(t *testing.T) {
177	d := newTestDaemon(t, nil, stubClock{})
178	sess := &session{sessionCore: sessionCore{id: "work"}}
179	ac := &attachedClient{clientID: [16]byte{1}, output: newOutputStateStream()}
180
181	d.mu.Lock()
182	oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, ac.clientID))
183	oldState := newGraphicsOutputStateWithLease(oldBase, oldFence)
184	oldState.mayHaveEmitted = true
185	ac.output.graphicsOutput = oldState
186	ac.output.graphicsUnsupportedWarned.Store(true)
187	d.reconfigureAttachmentOutput(sess, ac, protocol.Hello{KittyDirectGraphics: true})
188	d.mu.Unlock()
189
190	require.True(t, ac.terminalCapabilities.SupportsKittyGraphics())
191	require.NotNil(t, ac.output.graphicsOutput)
192	require.NotSame(t, oldState, ac.output.graphicsOutput)
193	require.NotEqual(t, oldBase, ac.output.graphicsOutput.namespaceBase, "replacement terminal must not reuse terminal-global Kitty IDs")
194	require.False(t, ac.output.graphicsUnsupportedWarned.Load())
195}
196
197func TestPaneGraphicsCoordinateGeometrySurvivesAttachmentResize(t *testing.T) {
198	fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
199	require.NoError(t, err)
200	p := newPane("pane", nil, domain.Size{Cols: 80, Rows: 24})
201	oldGeometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480}
202	newGeometry := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 1600, PixelHeight: 960}
203
204	p.mu.Lock()
205	setScreenGeometry(p.screen, oldGeometry)
206	writePaneScreenLocked(p, fixture)
207	first := capturePaneRenderStateLockedInto(p, domain.Rect{Width: 80, Height: 24}, capturedPaneRenderState{})
208	setScreenGeometry(p.screen, newGeometry)
209	second := capturePaneRenderStateLockedInto(p, domain.Rect{Width: 80, Height: 24}, capturedPaneRenderState{})
210	p.mu.Unlock()
211
212	require.Equal(t, oldGeometry, first.graphicsGeometry)
213	require.Equal(t, oldGeometry, second.graphicsGeometry, "retained scene pixels must not be reinterpreted as the resized cell units")
214}
215
216func TestGraphicsOutputChunksLargeRGBUpload(t *testing.T) {
217	const width, height = 300, 300
218	raw := make([]byte, width*height*3)
219	state := uint32(1)
220	for i := range raw {
221		state = state*1664525 + 1013904223
222		raw[i] = byte(state >> 24)
223	}
224	scene := graphics.NewScene(graphics.Limits{})
225	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
226	require.NoError(t, err)
227	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
228	require.NoError(t, err)
229
230	prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
231	require.NoError(t, err)
232	var uploadHeaders []string
233	for data := prepared.data; ; {
234		start := bytes.Index(data, []byte("\x1b_G"))
235		if start < 0 {
236			break
237		}
238		end := bytes.Index(data[start+3:], []byte("\x1b\\"))
239		require.NotEqual(t, -1, end)
240		body := data[start+3 : start+3+end]
241		header, payload, _ := bytes.Cut(body, []byte(";"))
242		if bytes.Contains(header, []byte("a=t")) {
243			uploadHeaders = append(uploadHeaders, string(header))
244			require.LessOrEqual(t, len(payload), graphicsUploadChunkBytes)
245		}
246		data = data[start+3+end+2:]
247	}
248	require.Greater(t, len(uploadHeaders), 1, "large RGB data must use Kitty continuation chunks")
249	require.Contains(t, uploadHeaders[0], "f=24")
250	require.Contains(t, uploadHeaders[0], "m=1")
251	require.Contains(t, uploadHeaders[len(uploadHeaders)-1], "m=0")
252}
253
254func TestGraphicsOutputCompressesLargeRGBUpload(t *testing.T) {
255	const width, height = 100, 100
256	raw := bytes.Repeat([]byte{0x7f}, width*height*3)
257	scene := graphics.NewScene(graphics.Limits{})
258	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
259	require.NoError(t, err)
260	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
261	require.NoError(t, err)
262
263	prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
264	require.NoError(t, err)
265	require.Contains(t, string(prepared.data), ",f=24,s=100,v=100,o=z")
266}
267
268func TestGraphicsOutputRelaysLargeKittenIcatRGBWithinWireBudget(t *testing.T) {
269	const width, height = 2048, 1200
270	raw := make([]byte, width*height*3)
271	state := uint32(1)
272	for i := range raw {
273		state = state*1664525 + 1013904223
274		raw[i] = byte(state >> 24)
275	}
276	scene := graphics.NewScene(graphics.Limits{})
277	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: raw, Format: graphics.AssetFormatRGB, Width: width, Height: height})
278	require.NoError(t, err)
279	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: width, Height: height})
280	require.NoError(t, err)
281
282	prepared, err := newGraphicsOutputState().prepare(scene.Snapshot(), true)
283	require.NoError(t, err)
284	require.Greater(t, len(prepared.data), 8<<20, "valid icat RGB replay must not be suppressed by the old graphics-only cap")
285	context := publicationTestContext()
286	context.Publication = 1
287	_, err = marshalOutputState(prepared.data, 1, 0, 1, 0, 0, domain.Size{Cols: 80, Rows: 24}, true, &context)
288	require.NoError(t, err, "the relayed graphics stream must fit the real Output wire budget")
289}
290
291func TestGraphicsOutputDefersCleanupThatDoesNotFitFrameBudget(t *testing.T) {
292	graphicsState := newGraphicsOutputState()
293	seed, err := graphicsState.prepare(kittenGraphicsSnapshot(t), true)
294	require.NoError(t, err)
295	seed.commit()
296	ac := &attachedClient{
297		output:               attachmentOutputWithGraphics(graphicsState),
298		terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
299	}
300
301	deferred, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, 0)
302	require.NoError(t, err, "graphics cleanup must not abort an ANSI frame when no graphics budget remains")
303	require.Nil(t, deferred)
304	require.NotEmpty(t, graphicsState.assets, "required image deletes must remain pending for a later frame")
305	require.NotEmpty(t, graphicsState.placements, "required placement deletes must remain pending for a later frame")
306
307	cleanup, err := graphicsOutputDataWithDaemonLimit(nil, &capturedRenderState{}, ac, false, maxGraphicsOutputBytes)
308	require.NoError(t, err)
309	require.NotNil(t, cleanup)
310	require.Contains(t, string(cleanup.data), "a=d,d=p,p=")
311	require.Contains(t, string(cleanup.data), "a=d,d=i,i=")
312	cleanup.commit()
313	require.Empty(t, graphicsState.assets)
314	require.Empty(t, graphicsState.placements)
315}
316
317func TestGraphicsOutputIDsAreIsolatedAcrossAttachments(t *testing.T) {
318	snapshot := kittenGraphicsSnapshot(t)
319	left, right := newGraphicsOutputState(), newGraphicsOutputState()
320	leftPrepared, err := left.prepare(snapshot, true)
321	require.NoError(t, err)
322	rightPrepared, err := right.prepare(snapshot, true)
323	require.NoError(t, err)
324	var leftID, rightID uint64
325	for _, asset := range leftPrepared.state.assets {
326		leftID = asset.id
327		break
328	}
329	for _, asset := range rightPrepared.state.assets {
330		rightID = asset.id
331		break
332	}
333	require.NotZero(t, leftID)
334	require.NotZero(t, rightID)
335	require.NotEqual(t, leftID, rightID, "terminal-global image IDs must not be reused by another attachment")
336}
337
338func TestUnsupportedGraphicsWarningIsBoundedForLateSceneCreation(t *testing.T) {
339	pty, releasePTY := newBlockingPTY(t)
340	defer releasePTY()
341	d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
342	ac.terminalCapabilities.KittyGraphics = false
343	fixture, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
344	require.NoError(t, err)
345	sess.tabs[0].focusedPane().screen.Write(fixture)
346
347	require.True(t, d.warnUnsupportedGraphics(ac), "late graphics creation should explain suppression")
348	require.False(t, d.warnUnsupportedGraphics(ac), "suppression warning must be bounded per attachment")
349	toasts, _ := visibleToasts(ac)
350	require.Len(t, toasts, 1)
351}
352
353func TestGraphicsNamespacesAreDeterministicAndCollisionSafe(t *testing.T) {
354	d := &Daemon{}
355	d.mu.Lock()
356	defer d.mu.Unlock()
357	first, firstFence := d.reserveGraphicsNamespaceLeaseLocked("session:work:attachment")
358	second, _ := d.reserveGraphicsNamespaceLeaseLocked("session:work:attachment")
359	require.NotZero(t, first)
360	require.NotZero(t, second)
361	require.NotEqual(t, first, second, "equal attachment/session keys still need a collision-safe namespace")
362	d.releaseGraphicsNamespaceLeaseLocked(&graphicsOutputState{namespaceBase: first, namespaceFence: firstFence})
363	reused, _ := d.reserveGraphicsNamespaceLeaseLocked("session:work:attachment")
364	require.Equal(t, first, reused, "released namespaces should deterministically return to their preferred block")
365}
366
367func TestGraphicsOutputExhaustionResetsInsideItsNamespace(t *testing.T) {
368	base := graphicsIDNamespaceSize + 1
369	state := newGraphicsOutputStateWithBase(base)
370	state.nextID = base + graphicsIDNamespaceSize - 1
371	snapshot := kittenGraphicsSnapshot(t)
372	prepared, err := state.prepare(snapshot, false)
373	require.NoError(t, err, "namespace exhaustion must recover through an attachment-local replay")
374	require.NotNil(t, prepared)
375	limit := base + graphicsIDNamespaceSize - 1
376	for _, asset := range prepared.state.assets {
377		require.GreaterOrEqual(t, asset.id, base)
378		require.LessOrEqual(t, asset.id, limit)
379	}
380	for _, placement := range prepared.state.placements {
381		require.GreaterOrEqual(t, placement.id, base)
382		require.LessOrEqual(t, placement.id, limit)
383	}
384	require.Equal(t, base+2, prepared.state.nextID, "the replay should restart at the reserved block base")
385}
386
387func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T) {
388	d := newTestDaemon(t, nil, stubClock{})
389	base := graphicsIDNamespaceSize + 1
390	block := (base - 1) / graphicsIDNamespaceSize
391
392	parkedState := newGraphicsOutputStateWithBase(base)
393	parked := &parkedAttachment{
394		sess: &session{sessionCore: sessionCore{name: "parked"}},
395		ac:   &attachedClient{output: attachmentOutputWithGraphics(parkedState)},
396		done: make(chan struct{}),
397	}
398	d.mu.Lock()
399	d.graphics.reserved[block] = struct{}{}
400	d.resume.parked[1] = parked
401	d.mu.Unlock()
402	d.expireParked(1, parked)
403	require.Nil(t, parked.ac.output.graphicsOutput)
404	d.mu.Lock()
405	_, reserved := d.graphics.reserved[block]
406	d.mu.Unlock()
407	require.False(t, reserved, "park expiry with no outer objects may release the attachment namespace")
408
409	failedState := newGraphicsOutputStateWithBase(base)
410	seed, err := failedState.prepare(kittenGraphicsSnapshot(t), true)
411	require.NoError(t, err)
412	seed.commit()
413	failed := &attachedClient{
414		tr:     failingOutputTransport{},
415		output: attachmentOutputWithGraphics(failedState),
416	}
417	d.mu.Lock()
418	d.graphics.reserved[block] = struct{}{}
419	d.mu.Unlock()
420	require.Error(t, d.cleanupAttachmentOutput(failed), "failing transport must surface the cleanup send error")
421	require.Nil(t, failed.output.graphicsOutput)
422	d.mu.Lock()
423	_, reserved = d.graphics.reserved[block]
424	d.mu.Unlock()
425	require.True(t, reserved, "failed terminal cleanup must quarantine the namespace")
426}
427
428func TestGraphicsNamespaceStaysQuarantinedAfterSocketSendSuccessWithoutTerminalFlush(t *testing.T) {
429	d := newTestDaemon(t, nil, stubClock{})
430	key := "session:unflushed:attachment"
431	d.mu.Lock()
432	oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
433	d.mu.Unlock()
434
435	state := newGraphicsOutputStateWithLease(oldBase, oldFence)
436	seed, err := state.prepare(kittenGraphicsSnapshot(t), true)
437	require.NoError(t, err)
438	seed.markSendAttempted()
439	seed.commit()
440
441	// This transport models only successful delivery to the client socket. It
442	// deliberately provides no evidence that the client flushed the side effect
443	// to its outer terminal before being lost.
444	transport := &closeTrackingTransport{}
445	ac := &attachedClient{tr: transport, output: attachmentOutputWithGraphics(state)}
446	require.NoError(t, d.cleanupAttachmentOutput(ac))
447	require.Nil(t, ac.output.graphicsOutput)
448	sends := transport.Sends()
449	require.Len(t, sends, 1)
450	cleanup := unmarshalTestOutput(t, sends[0].Payload)
451	require.Zero(t, cleanup.New, "graphics cleanup is an unacknowledged side-effect frame")
452
453	d.mu.Lock()
454	_, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
455	newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
456	d.mu.Unlock()
457	require.True(t, quarantined, "socket-send success cannot prove terminal cleanup")
458	require.NotEqual(t, oldBase, newBase, "an unflushed cleanup must not expose the old IDs to another attachment")
459}
460
461func TestGraphicsNamespaceStaysQuarantinedAfterFinalDeleteAndParkExpiry(t *testing.T) {
462	d := newTestDaemon(t, nil, stubClock{})
463	key := "session:empty-park:attachment"
464	d.mu.Lock()
465	oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
466	d.mu.Unlock()
467
468	state := newGraphicsOutputStateWithLease(oldBase, oldFence)
469	seed, err := state.prepare(kittenGraphicsSnapshot(t), true)
470	require.NoError(t, err)
471	seed.markSendAttempted()
472	seed.commit()
473	finalDelete, err := state.prepare(nil, false)
474	require.NoError(t, err)
475	require.NotEmpty(t, finalDelete.data)
476	finalDelete.markSendAttempted()
477	finalDelete.commit()
478	require.Empty(t, state.assets)
479	require.Empty(t, state.placements)
480
481	parked := &parkedAttachment{
482		sess: &session{sessionCore: sessionCore{name: "parked"}},
483		ac:   &attachedClient{output: attachmentOutputWithGraphics(state)},
484		done: make(chan struct{}),
485	}
486	d.mu.Lock()
487	d.resume.parked[1] = parked
488	d.mu.Unlock()
489	d.expireParked(1, parked)
490
491	d.mu.Lock()
492	_, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
493	newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
494	d.mu.Unlock()
495	require.True(t, quarantined, "an apparently empty parked state still has unacknowledged terminal history")
496	require.NotEqual(t, oldBase, newBase, "park expiry must not recycle IDs after the final delete")
497}
498
499func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
500	d := newTestDaemon(t, nil, stubClock{})
501	d.mu.Lock()
502	for block := uint64(0); block < graphicsIDNamespaceCount; block++ {
503		d.graphics.reserved[block] = struct{}{}
504	}
505	ac := d.prepareAttachedClientLocked(
506		&session{sessionCore: sessionCore{id: "work"}},
507		&closeTrackingTransport{},
508		domain.Geometry{Size: defaultSize},
509		attachClientOptions{
510			capabilitiesSet:      true,
511			terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
512		},
513	)
514	d.mu.Unlock()
515
516	require.Nil(t, ac.output.graphicsOutput)
517	require.False(t, ac.terminalCapabilities.SupportsKittyGraphics())
518	require.NotNil(t, ac.output, "namespace exhaustion must preserve the ordinary text renderer")
519}
520
521func TestGraphicsNamespaceQuarantineFencesTimedOutLateDelete(t *testing.T) {
522	clock := &signalClock{timers: make(chan *signalTimer, 1)}
523	d := newTestDaemon(t, nil, clock)
524	key := "session:stale:attachment"
525	d.mu.Lock()
526	oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
527	d.mu.Unlock()
528	oldState := newGraphicsOutputStateWithLease(oldBase, oldFence)
529	seed, err := oldState.prepare(kittenGraphicsSnapshot(t), true)
530	require.NoError(t, err)
531	seed.commit()
532	var oldID uint64
533	for _, asset := range oldState.assets {
534		oldID = asset.id
535		break
536	}
537	require.NotZero(t, oldID)
538
539	transport := newLateGraphicsCleanupTransport()
540	ac := &attachedClient{tr: transport, output: attachmentOutputWithGraphics(oldState)}
541	cleanupDone := make(chan struct{})
542	go func() {
543		_ = d.cleanupAttachmentOutput(ac)
544		close(cleanupDone)
545	}()
546	awaitTestCompletion(t, transport.started, "timed-out cleanup did not reach the stale transport")
547	timer := awaitTestValue(t, clock.timers, "cleanup watchdog timer was not armed")
548	timer.ch <- time.Time{}
549	awaitTestCompletion(t, cleanupDone, "timed-out cleanup did not retire the attachment")
550
551	d.mu.Lock()
552	newBase, newFence := d.reserveGraphicsNamespaceLeaseLocked(key)
553	_, quarantined := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
554	d.mu.Unlock()
555	require.True(t, quarantined, "timed-out cleanup must keep its namespace quarantined")
556	require.NotEqual(t, oldBase, newBase, "new allocation must skip the quarantined namespace")
557
558	newState := newGraphicsOutputStateWithLease(newBase, newFence)
559	fresh, err := newState.prepare(kittenGraphicsSnapshot(t), true)
560	require.NoError(t, err)
561	var newID uint64
562	for _, asset := range fresh.state.assets {
563		newID = asset.id
564		break
565	}
566	require.NotZero(t, newID)
567	require.NotEqual(t, oldID, newID, "fresh attachment IDs must not share the quarantined block")
568
569	close(transport.release)
570	staleFrame := awaitTestValue(t, transport.sends, "late stale cleanup did not execute")
571	awaitTestCompletion(t, transport.finished, "late stale cleanup did not return")
572	staleOutput := unmarshalTestOutput(t, staleFrame.Payload)
573	require.Contains(t, string(staleOutput.Data), "i="+strconv.FormatUint(oldID, 10))
574	require.NotContains(t, string(staleOutput.Data), "i="+strconv.FormatUint(newID, 10), "late cleanup must never target the fresh attachment")
575	d.mu.Lock()
576	_, retained := d.graphics.quarantines[(oldBase-1)/graphicsIDNamespaceSize]
577	d.mu.Unlock()
578	require.True(t, retained, "late transport success is not a terminal ACK and must not release the namespace")
579}
580
581func TestGraphicsOutputKeysIncludeSourceSceneContent(t *testing.T) {
582	makeSnapshot := func(data []byte) *graphics.Snapshot {
583		scene := graphics.NewScene(graphics.Limits{})
584		asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: data, Width: 1, Height: 1})
585		require.NoError(t, err)
586		_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
587		require.NoError(t, err)
588		return scene.Snapshot()
589	}
590	state := newGraphicsOutputState()
591	first, err := state.prepare(makeSnapshot([]byte("first")), true)
592	require.NoError(t, err)
593	first.commit()
594	second, err := state.prepare(makeSnapshot([]byte("second")), false)
595	require.NoError(t, err)
596	require.Contains(t, string(second.data), "a=t,i=", "a new a1/p1 scene must not alias the stale outer image")
597}
598
599func TestGraphicsOutputPreSendAbortDoesNotCreateCleanupRecords(t *testing.T) {
600	state := newGraphicsOutputState()
601	prepared, err := state.prepare(kittenGraphicsSnapshot(t), true)
602	require.NoError(t, err)
603	prepared.abort()
604
605	cleanup, err := state.prepare(nil, true)
606	require.NoError(t, err)
607	require.Empty(t, cleanup.data, "IDs allocated by a preparation that never reached the transport are not owned by the terminal")
608}
609
610func TestGraphicsOutputOversizeSuppressesGraphicsWithoutCompositionError(t *testing.T) {
611	scene := graphics.NewScene(graphics.Limits{})
612	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: bytes.Repeat([]byte{'x'}, maxGraphicsOutputBytes), Width: 1, Height: 1})
613	require.NoError(t, err)
614	_, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
615	require.NoError(t, err)
616	state := &capturedRenderState{panes: []capturedPaneRenderState{{
617		graphics:  scene.Snapshot(),
618		placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
619	}}}
620	ac := &attachedClient{
621		output:               attachmentOutputWithGraphics(newGraphicsOutputState()),
622		terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
623	}
624	prepared, err := graphicsOutputData(state, ac, true)
625	require.NoError(t, err, "ANSI composition must continue when optional graphics exceed the bound")
626	require.Empty(t, prepared.data, "oversized graphics are suppressed")
627}
628
629func TestGraphicsOutputClipsPlacementToPaneContent(t *testing.T) {
630	scene := graphics.NewScene(graphics.Limits{})
631	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("asset"), Width: 10, Height: 10})
632	require.NoError(t, err)
633	_, err = scene.PlaceAsset(asset, graphics.PixelRect{X: -2, Y: -1, Width: 10, Height: 10})
634	require.NoError(t, err)
635	state := &capturedRenderState{panes: []capturedPaneRenderState{{
636		graphics:         scene.Snapshot(),
637		graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 4, Rows: 3}, PixelWidth: 4, PixelHeight: 3},
638		placement:        layout.Placement{Content: domain.Rect{X: 3, Y: 4, Width: 4, Height: 3}},
639	}}}
640	ac := &attachedClient{
641		output:               attachmentOutputWithGraphics(newGraphicsOutputState()),
642		terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
643	}
644	prepared, err := graphicsOutputData(state, ac, true)
645	require.NoError(t, err)
646	text := string(prepared.data)
647	require.Contains(t, text, "\x1b[6;4H", "placement must move to the clipped pane-local destination")
648	require.Contains(t, text, ",x=2,y=1,w=4,h=3", "lowercase coordinates must preserve the source crop")
649	require.NotContains(t, text, ",X=2,Y=1", "source coordinates must not become destination offsets")
650}
651
652func TestGraphicsOutputClipsEachAttachmentToOuterContentFrame(t *testing.T) {
653	scene := graphics.NewScene(graphics.Limits{})
654	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("attachment-frame"), Width: 80, Height: 24})
655	require.NoError(t, err)
656	_, err = scene.Place(graphics.PlacementSpec{
657		Asset:       asset,
658		Destination: graphics.PixelRect{Width: 800, Height: 480},
659		Cells:       graphics.CellRect{Width: 80, Height: 24},
660	})
661	require.NoError(t, err)
662
663	makeState := func(window domain.Size) *capturedRenderState {
664		return &capturedRenderState{
665			window: window,
666			layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 24}, valid: true},
667			panes: []capturedPaneRenderState{{
668				id:               "pane",
669				graphics:         scene.Snapshot(),
670				graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
671				placement:        layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 24}},
672			}},
673		}
674	}
675
676	small, err := graphicsOutputData(makeState(domain.Size{Cols: 40, Rows: 10}), graphicsPaneTestClient(), true)
677	require.NoError(t, err)
678	require.Len(t, small.state.placements, 1)
679	for _, placement := range small.state.placements {
680		require.Equal(t, graphics.PixelRect{Width: 400, Height: 160}, placement.dest, "small attachment must clip pixels to its content frame")
681		require.Equal(t, graphics.CellRect{Width: 40, Height: 8}, placement.cells, "status bar row must stay outside the graphics cells")
682	}
683	require.Contains(t, string(small.data), ",c=40,r=8")
684	require.NotContains(t, string(small.data), ",c=80,r=24")
685
686	large, err := graphicsOutputData(makeState(domain.Size{Cols: 100, Rows: 30}), graphicsPaneTestClient(), true)
687	require.NoError(t, err)
688	require.Len(t, large.state.placements, 1)
689	for _, placement := range large.state.placements {
690		require.Equal(t, graphics.PixelRect{Width: 800, Height: 480}, placement.dest, "a larger attachment must retain the pane portion it can display")
691	}
692	require.Contains(t, string(large.data), ",c=80,r=24")
693}
694
695func TestGraphicsOutputCellOnlyViewportPreservesSourceMapping(t *testing.T) {
696	scene := graphics.NewScene(graphics.Limits{})
697	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("cell-only"), Width: 100, Height: 50})
698	require.NoError(t, err)
699	_, err = scene.Place(graphics.PlacementSpec{
700		Asset:       asset,
701		Destination: graphics.PixelRect{X: 10, Y: 20, Width: 200, Height: 100},
702		Cells:       graphics.CellRect{X: 2, Y: 3, Width: 20, Height: 10},
703	})
704	require.NoError(t, err)
705
706	viewport, ok := graphicsOutputViewportRect(domain.Rect{X: 7, Y: 5, Width: 5, Height: 5}, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
707	require.True(t, ok)
708	require.Equal(t, graphics.PixelRect{}, viewport.Pixels, "cell-only geometry must not become a pixel viewport")
709	require.Equal(t, graphics.CellRect{X: 7, Y: 5, Width: 5, Height: 5}, viewport.Cells)
710
711	_, placements := graphicsSnapshotRecords(scene.Snapshot(), viewport)
712	require.Len(t, placements, 1)
713	for _, placement := range placements {
714		require.Equal(t, graphics.PixelRect{X: 25, Y: 10, Width: 25, Height: 25}, placement.source)
715		require.Equal(t, graphics.PixelRect{X: 60, Y: 40, Width: 50, Height: 50}, placement.dest)
716	}
717
718	pixelOnly := graphics.NewScene(graphics.Limits{})
719	pixelAsset, err := pixelOnly.AddAsset(graphics.AssetBlob{Encoded: []byte("pixel-only"), Width: 10, Height: 10})
720	require.NoError(t, err)
721	_, err = pixelOnly.PlaceAsset(pixelAsset, graphics.PixelRect{Width: 10, Height: 10})
722	require.NoError(t, err)
723	_, unsuitable := graphicsSnapshotRecords(pixelOnly.Snapshot(), viewport)
724	require.Empty(t, unsuitable, "pixel-space fragments without cell metadata must be suppressed")
725}
726
727func TestGraphicsOutputComposesPaneOriginAndPixelGeometry(t *testing.T) {
728	placement := graphicsOutputPlacement{
729		id:     1,
730		source: graphics.PixelRect{Width: 20, Height: 10},
731		dest:   graphics.PixelRect{X: 103, Y: 50, Width: 20, Height: 10},
732		cells:  graphics.CellRect{Width: 2, Height: 1},
733	}
734	record := placementRecordWithTransform(1, placement, graphicsOutputTransform{
735		originX:        3,
736		originY:        2,
737		sourceGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
738	})
739	text := string(record)
740	require.Contains(t, text, "\x1b7\x1b[5;14H", "destination cells must be expressed through cursor position")
741	require.Contains(t, text, ",X=3,Y=10", "destination pixel remainder must use uppercase offsets")
742	require.NotContains(t, text, ",x=13,y=4", "destination cells must never become source crop controls")
743	require.Contains(t, text, ",C=1", "placement must not move or scroll the text cursor")
744	require.True(t, strings.HasSuffix(text, "\x1b8"), "placement must restore the outer text cursor")
745}
746
747func TestGraphicsOutputPreservesKittenIcatCenteredPlacement(t *testing.T) {
748	placement := graphicsOutputPlacement{
749		id:     1,
750		source: graphics.PixelRect{Width: 103, Height: 128},
751		dest:   graphics.PixelRect{X: 343, Width: 103, Height: 128},
752	}
753	record := placementRecordWithTransform(1, placement, graphicsOutputTransform{
754		originY:        1,
755		sourceGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
756	})
757	text := string(record)
758	require.Contains(t, text, "\x1b[2;35H", "icat's centered cursor column must survive composition")
759	require.Contains(t, text, ",w=103,h=128", "natural image dimensions must be preserved")
760	require.Contains(t, text, ",X=3", "sub-cell destination offset must be preserved")
761	require.NotContains(t, text, ",x=34", "center position must not crop the image source")
762}
763
764func TestGraphicsOutputFailedSendRetainsSpeculativeIDsForCleanup(t *testing.T) {
765	state := newGraphicsOutputState()
766	first, err := state.prepare(kittenGraphicsSnapshot(t), true)
767	require.NoError(t, err)
768	first.markSendAttempted()
769	first.abort()
770
771	retry, err := state.prepare(kittenGraphicsSnapshot(t), true)
772	require.NoError(t, err)
773	text := string(retry.data)
774	require.Less(t, strings.Index(text, "a=d,d=p,p="), strings.Index(text, "a=d,d=i,i="))
775	require.Less(t, strings.Index(text, "a=d,d=i,i="), strings.Index(text, "a=t,i="))
776	require.Contains(t, text, "a=p,i=")
777}
778
779func TestGraphicsOutputCleanupRemovesPlacementsBeforeImages(t *testing.T) {
780	state := newGraphicsOutputState()
781	snapshot := kittenGraphicsSnapshot(t)
782	prepared, err := state.prepare(snapshot, true)
783	require.NoError(t, err)
784	prepared.commit()
785
786	// A nil snapshot models the attachment losing its visible graphics scene.
787	cleared, err := state.prepare(nil, false)
788	require.NoError(t, err)
789	require.Less(t, strings.Index(string(cleared.data), "a=d,d=p,p="), strings.Index(string(cleared.data), "a=d,d=i,i="))
790	cleared.commit()
791	require.Empty(t, state.assets)
792	require.Empty(t, state.placements)
793}
794
795func graphicsPaneTestScene(t *testing.T, data string, destination graphics.PixelRect, layer int64) *graphics.Snapshot {
796	t.Helper()
797	scene := graphics.NewScene(graphics.Limits{})
798	asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte(data), Width: 20, Height: 10})
799	require.NoError(t, err)
800	_, err = scene.Place(graphics.PlacementSpec{Asset: asset, Destination: destination, Layer: layer, HasLayer: true})
801	require.NoError(t, err)
802	return scene.Snapshot()
803}
804
805func graphicsPaneTestState(tab domain.TabStableID, panes ...capturedPaneRenderState) *capturedRenderState {
806	for i := range panes {
807		if panes[i].graphicsGeometry.PixelsKnown() || panes[i].placement.Content.Width <= 0 || panes[i].placement.Content.Height <= 0 {
808			continue
809		}
810		content := panes[i].placement.Content
811		panes[i].graphicsGeometry = domain.Geometry{
812			Size:        domain.Size{Cols: content.Width, Rows: content.Height},
813			PixelWidth:  content.Width,
814			PixelHeight: content.Height,
815		}
816	}
817	return &capturedRenderState{view: attachmentView{tabID: tab}, panes: panes}
818}
819
820func graphicsPaneTestClient() *attachedClient {
821	return &attachedClient{
822		output:               attachmentOutputWithGraphics(newGraphicsOutputState()),
823		terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
824	}
825}
826
827func TestGraphicsOutputComposesSplitPanesWithStableOrderAndChildIDIsolation(t *testing.T) {
828	left := graphicsPaneTestScene(t, "left", graphics.PixelRect{X: -2, Y: 0, Width: 8, Height: 4}, 2)
829	right := graphicsPaneTestScene(t, "right", graphics.PixelRect{X: 1, Y: 0, Width: 8, Height: 4}, -1)
830	state := graphicsPaneTestState("tab-a",
831		capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 0, Width: 20, Height: 4}}},
832		capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
833	)
834	ac := graphicsPaneTestClient()
835	prepared, err := graphicsOutputData(state, ac, true)
836	require.NoError(t, err)
837	require.Len(t, prepared.state.assets, 2, "raw a1 IDs from separate child scenes must not alias")
838	require.Len(t, prepared.state.placements, 2)
839	text := string(prepared.data)
840	require.Less(t, strings.Index(text, "\x1b[2;1H"), strings.Index(text, "\x1b[2;23H"), "pane traversal order must be deterministic")
841	var ids []uint64
842	for _, asset := range prepared.state.assets {
843		ids = append(ids, asset.id)
844	}
845	require.NotEqual(t, ids[0], ids[1])
846}
847
848func TestGraphicsOutputSplitResizeReplacesTranslatedPlacementWithoutSceneMutation(t *testing.T) {
849	snapshot := graphicsPaneTestScene(t, "resize", graphics.PixelRect{Width: 8, Height: 4}, 0)
850	ac := graphicsPaneTestClient()
851	firstState := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "left", graphics: snapshot, placement: layout.Placement{ID: "left", Content: domain.Rect{Width: 20, Height: 4}}})
852	first, err := graphicsOutputData(firstState, ac, true)
853	require.NoError(t, err)
854	first.commit()
855
856	resized := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "left", graphics: snapshot, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 7, Width: 20, Height: 4}}})
857	second, err := graphicsOutputData(resized, ac, false)
858	require.NoError(t, err)
859	require.Contains(t, string(second.data), "a=d,d=p,p=")
860	require.Contains(t, string(second.data), "a=p,i=")
861	require.NotContains(t, string(second.data), "a=t,i=", "layout-only recomputation must retain the immutable asset")
862}
863
864func TestGraphicsOutputPaneReorderRetainsAssetsButReplaysPlacementsInLayoutOrder(t *testing.T) {
865	left := graphicsPaneTestScene(t, "left-reorder", graphics.PixelRect{Width: 8, Height: 4}, 0)
866	right := graphicsPaneTestScene(t, "right-reorder", graphics.PixelRect{Width: 8, Height: 4}, 0)
867	ac := graphicsPaneTestClient()
868	firstState := graphicsPaneTestState("tab-a",
869		capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{Width: 20, Height: 4}}},
870		capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
871	)
872	first, err := graphicsOutputData(firstState, ac, true)
873	require.NoError(t, err)
874	first.commit()
875
876	reordered := graphicsPaneTestState("tab-a",
877		capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{Width: 20, Height: 4}}},
878		capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
879	)
880	second, err := graphicsOutputData(reordered, ac, false)
881	require.NoError(t, err)
882	require.NotContains(t, string(second.data), "a=t,i=", "moving panes must not re-upload immutable assets")
883	require.Contains(t, string(second.data), "a=d,d=p,p=")
884	require.Contains(t, string(second.data), "a=p,i=")
885	require.Less(t, strings.Index(string(second.data), "\x1b[2;1H"), strings.Index(string(second.data), "\x1b[2;22H"))
886}
887
888func TestGraphicsOutputTabSwitchReplaysSameRawChildIDsInNewSourceNamespace(t *testing.T) {
889	snapshot := graphicsPaneTestScene(t, "tab", graphics.PixelRect{Width: 8, Height: 4}, 0)
890	ac := graphicsPaneTestClient()
891	first, err := graphicsOutputData(graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}}), ac, true)
892	require.NoError(t, err)
893	first.commit()
894	second, err := graphicsOutputData(graphicsPaneTestState("tab-b", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}}), ac, false)
895	require.NoError(t, err)
896	require.Contains(t, string(second.data), "a=d,d=p,p=")
897	require.Contains(t, string(second.data), "a=d,d=i,i=")
898	require.Contains(t, string(second.data), "a=t,i=")
899}
900
901func TestGraphicsOutputFloatingPaneCoversTiledGraphicsAndRetainsItsOwnScene(t *testing.T) {
902	tiled := graphicsPaneTestScene(t, "tiled", graphics.PixelRect{Width: 20, Height: 6}, 0)
903	floating := graphicsPaneTestScene(t, "floating", graphics.PixelRect{Width: 3, Height: 2}, 3)
904	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "tiled", graphics: tiled, placement: layout.Placement{ID: "tiled", Content: domain.Rect{Width: 20, Height: 6}}})
905	state.floating = capturedFloatingRenderState{
906		visible: true,
907		pane: capturedPaneRenderState{id: "floating", graphics: floating, graphicsGeometry: domain.Geometry{
908			Size: domain.Size{Cols: 3, Rows: 2}, PixelWidth: 3, PixelHeight: 2,
909		}},
910		geometry: floatingGeometry{Bounds: domain.Rect{X: 5, Y: 1, Width: 6, Height: 4}, Inner: domain.Rect{X: 6, Y: 2, Width: 3, Height: 2}},
911	}
912	ac := graphicsPaneTestClient()
913	prepared, err := graphicsOutputData(state, ac, true)
914	require.NoError(t, err)
915	require.Len(t, prepared.state.assets, 2)
916	require.GreaterOrEqual(t, len(prepared.state.placements), 2, "floating and uncovered tiled fragments both remain visible")
917	for _, placement := range prepared.state.placements {
918		if strings.Contains(placement.asset, "floating") {
919			continue
920		}
921		cover := graphics.PixelRect{X: 5, Y: 1, Width: 6, Height: 4}
922		_, overlaps := placement.dest.Intersect(cover)
923		require.False(t, overlaps, "tiled placement must not survive under floating coverage")
924	}
925}
926
927func TestGraphicsOutputOverlayDismissalHidesAndRestoresImmutableSnapshots(t *testing.T) {
928	snapshot := graphicsPaneTestScene(t, "overlay", graphics.PixelRect{Width: 8, Height: 4}, 0)
929	ac := graphicsPaneTestClient()
930	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}})
931	first, err := graphicsOutputData(state, ac, true)
932	require.NoError(t, err)
933	first.commit()
934
935	state.overlays.copyActive = true
936	hidden, err := graphicsOutputData(state, ac, true)
937	require.NoError(t, err)
938	require.Contains(t, string(hidden.data), "a=d,d=p,p=")
939	hidden.commit()
940	state.overlays.copyActive = false
941	restored, err := graphicsOutputData(state, ac, false)
942	require.NoError(t, err)
943	require.Contains(t, string(restored.data), "a=t,i=")
944	require.Equal(t, uint64(1), snapshot.Usage().Assets, "composition must not mutate the child scene")
945}
946
947func TestGraphicsOutputMixedTextAndGraphicsKeepsANSIBeforeGraphics(t *testing.T) {
948	snapshot := graphicsPaneTestScene(t, "mixed", graphics.PixelRect{Width: 8, Height: 4}, 0)
949	ac := graphicsPaneTestClient()
950	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}})
951	prepared, err := graphicsOutputData(state, ac, true)
952	require.NoError(t, err)
953	ansi := []byte("text-before-graphics")
954	combined := append(append([]byte(nil), ansi...), prepared.data...)
955	require.Less(t, bytes.Index(combined, ansi), bytes.Index(combined, []byte("\x1b_Ga=t,")))
956}
957
958func TestGraphicsOutputCollapsedAndHiddenPanesDoNotUploadRetainedAssets(t *testing.T) {
959	snapshot := graphicsPaneTestScene(t, "collapsed", graphics.PixelRect{Width: 8, Height: 4}, 0)
960	ac := graphicsPaneTestClient()
961	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{
962		id:        "pane",
963		graphics:  snapshot,
964		placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}},
965	})
966	first, err := graphicsOutputData(state, ac, true)
967	require.NoError(t, err)
968	first.commit()
969
970	state.panes[0].placement.Collapsed = true
971	collapsed, err := graphicsOutputData(state, ac, false)
972	require.NoError(t, err)
973	require.Empty(t, collapsed.state.assets)
974	require.Empty(t, collapsed.state.placements)
975	require.Contains(t, string(collapsed.data), "a=d,d=p,p=")
976	collapsed.commit()
977
978	state.panes = nil
979	hidden, err := graphicsOutputData(state, ac, false)
980	require.NoError(t, err)
981	require.Empty(t, hidden.data, "a collapsed scene was already retired")
982	hidden.commit()
983}
984
985func TestGraphicsOutputViewportProjectionDropsUnplacedAssets(t *testing.T) {
986	scene := graphics.NewScene(graphics.Limits{})
987	placed, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("placed"), Width: 4, Height: 2})
988	require.NoError(t, err)
989	_, err = scene.AddAsset(graphics.AssetBlob{Encoded: []byte("retained"), Width: 4, Height: 2})
990	require.NoError(t, err)
991	_, err = scene.PlaceAsset(placed, graphics.PixelRect{Width: 4, Height: 2})
992	require.NoError(t, err)
993
994	state := graphicsPaneTestState("tab-a", capturedPaneRenderState{
995		id:        "pane",
996		graphics:  scene.Snapshot(),
997		placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 4}},
998	})
999	ac := graphicsPaneTestClient()
1000	prepared, err := graphicsOutputData(state, ac, true)
1001	require.NoError(t, err)
1002	require.Len(t, prepared.state.assets, 1, "viewport projection must not upload an unplaced retained image")
1003	require.Len(t, prepared.state.placements, 1)
1004}

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