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}