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/terminalcap"
17 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"
19 "github.com/bnema/vev/internal/usecase/layout"
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 first := d.reserveGraphicsNamespaceLocked("session:work:attachment")
357 second := d.reserveGraphicsNamespaceLocked("session:work:attachment")
358 d.mu.Unlock()
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.releaseGraphicsNamespace(first)
363 d.mu.Lock()
364 reused := d.reserveGraphicsNamespaceLocked("session:work:attachment")
365 d.mu.Unlock()
366 require.Equal(t, first, reused, "released namespaces should deterministically return to their preferred block")
367}
368
369func TestGraphicsOutputExhaustionResetsInsideItsNamespace(t *testing.T) {
370 base := graphicsIDNamespaceSize + 1
371 state := newGraphicsOutputStateWithBase(base)
372 state.nextID = base + graphicsIDNamespaceSize - 1
373 snapshot := kittenGraphicsSnapshot(t)
374 prepared, err := state.prepare(snapshot, false)
375 require.NoError(t, err, "namespace exhaustion must recover through an attachment-local replay")
376 require.NotNil(t, prepared)
377 limit := base + graphicsIDNamespaceSize - 1
378 for _, asset := range prepared.state.assets {
379 require.GreaterOrEqual(t, asset.id, base)
380 require.LessOrEqual(t, asset.id, limit)
381 }
382 for _, placement := range prepared.state.placements {
383 require.GreaterOrEqual(t, placement.id, base)
384 require.LessOrEqual(t, placement.id, limit)
385 }
386 require.Equal(t, base+2, prepared.state.nextID, "the replay should restart at the reserved block base")
387}
388
389func TestGraphicsNamespaceQuarantinesOnParkExpiryAndFailedCleanup(t *testing.T) {
390 d := newTestDaemon(t, nil, stubClock{})
391 base := graphicsIDNamespaceSize + 1
392 block := (base - 1) / graphicsIDNamespaceSize
393
394 parkedState := newGraphicsOutputStateWithBase(base)
395 parked := &parkedAttachment{
396 sess: &session{sessionCore: sessionCore{name: "parked"}},
397 ac: &attachedClient{output: attachmentOutputWithGraphics(parkedState)},
398 done: make(chan struct{}),
399 }
400 d.mu.Lock()
401 d.graphicsNamespaces[block] = struct{}{}
402 d.resume.parked[1] = parked
403 d.mu.Unlock()
404 d.expireParked(1, parked)
405 require.Nil(t, parked.ac.output.graphicsOutput)
406 d.mu.Lock()
407 _, reserved := d.graphicsNamespaces[block]
408 d.mu.Unlock()
409 require.False(t, reserved, "park expiry with no outer objects may release the attachment namespace")
410
411 failedState := newGraphicsOutputStateWithBase(base)
412 seed, err := failedState.prepare(kittenGraphicsSnapshot(t), true)
413 require.NoError(t, err)
414 seed.commit()
415 failed := &attachedClient{
416 tr: failingOutputTransport{},
417 output: attachmentOutputWithGraphics(failedState),
418 }
419 d.mu.Lock()
420 d.graphicsNamespaces[block] = struct{}{}
421 d.mu.Unlock()
422 require.Error(t, d.cleanupAttachmentOutput(failed), "failing transport must surface the cleanup send error")
423 require.Nil(t, failed.output.graphicsOutput)
424 d.mu.Lock()
425 _, reserved = d.graphicsNamespaces[block]
426 d.mu.Unlock()
427 require.True(t, reserved, "failed terminal cleanup must quarantine the namespace")
428}
429
430func TestGraphicsNamespaceStaysQuarantinedAfterSocketSendSuccessWithoutTerminalFlush(t *testing.T) {
431 d := newTestDaemon(t, nil, stubClock{})
432 key := "session:unflushed:attachment"
433 d.mu.Lock()
434 oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
435 d.mu.Unlock()
436
437 state := newGraphicsOutputStateWithLease(oldBase, oldFence)
438 seed, err := state.prepare(kittenGraphicsSnapshot(t), true)
439 require.NoError(t, err)
440 seed.markSendAttempted()
441 seed.commit()
442
443 // This transport models only successful delivery to the client socket. It
444 // deliberately provides no evidence that the client flushed the side effect
445 // to its outer terminal before being lost.
446 transport := &closeTrackingTransport{}
447 ac := &attachedClient{tr: transport, output: attachmentOutputWithGraphics(state)}
448 require.NoError(t, d.cleanupAttachmentOutput(ac))
449 require.Nil(t, ac.output.graphicsOutput)
450 sends := transport.Sends()
451 require.Len(t, sends, 1)
452 cleanup := unmarshalTestOutput(t, sends[0].Payload)
453 require.Zero(t, cleanup.New, "graphics cleanup is an unacknowledged side-effect frame")
454
455 d.mu.Lock()
456 _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
457 newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
458 d.mu.Unlock()
459 require.True(t, quarantined, "socket-send success cannot prove terminal cleanup")
460 require.NotEqual(t, oldBase, newBase, "an unflushed cleanup must not expose the old IDs to another attachment")
461}
462
463func TestGraphicsNamespaceStaysQuarantinedAfterFinalDeleteAndParkExpiry(t *testing.T) {
464 d := newTestDaemon(t, nil, stubClock{})
465 key := "session:empty-park:attachment"
466 d.mu.Lock()
467 oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
468 d.mu.Unlock()
469
470 state := newGraphicsOutputStateWithLease(oldBase, oldFence)
471 seed, err := state.prepare(kittenGraphicsSnapshot(t), true)
472 require.NoError(t, err)
473 seed.markSendAttempted()
474 seed.commit()
475 finalDelete, err := state.prepare(nil, false)
476 require.NoError(t, err)
477 require.NotEmpty(t, finalDelete.data)
478 finalDelete.markSendAttempted()
479 finalDelete.commit()
480 require.Empty(t, state.assets)
481 require.Empty(t, state.placements)
482
483 parked := &parkedAttachment{
484 sess: &session{sessionCore: sessionCore{name: "parked"}},
485 ac: &attachedClient{output: attachmentOutputWithGraphics(state)},
486 done: make(chan struct{}),
487 }
488 d.mu.Lock()
489 d.resume.parked[1] = parked
490 d.mu.Unlock()
491 d.expireParked(1, parked)
492
493 d.mu.Lock()
494 _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
495 newBase, _ := d.reserveGraphicsNamespaceLeaseLocked(key)
496 d.mu.Unlock()
497 require.True(t, quarantined, "an apparently empty parked state still has unacknowledged terminal history")
498 require.NotEqual(t, oldBase, newBase, "park expiry must not recycle IDs after the final delete")
499}
500
501func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
502 d := newTestDaemon(t, nil, stubClock{})
503 d.mu.Lock()
504 for block := uint64(0); block < graphicsIDNamespaceCount; block++ {
505 d.graphicsNamespaces[block] = struct{}{}
506 }
507 ac := d.prepareAttachedClientLocked(
508 &session{sessionCore: sessionCore{id: "work"}},
509 &closeTrackingTransport{},
510 domain.Geometry{Size: defaultSize},
511 attachClientOptions{
512 capabilitiesSet: true,
513 terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
514 },
515 )
516 d.mu.Unlock()
517
518 require.Nil(t, ac.output.graphicsOutput)
519 require.False(t, ac.terminalCapabilities.SupportsKittyGraphics())
520 require.NotNil(t, ac.output, "namespace exhaustion must preserve the ordinary text renderer")
521}
522
523func TestGraphicsNamespaceQuarantineFencesTimedOutLateDelete(t *testing.T) {
524 clock := &signalClock{timers: make(chan *signalTimer, 1)}
525 d := newTestDaemon(t, nil, clock)
526 key := "session:stale:attachment"
527 d.mu.Lock()
528 oldBase, oldFence := d.reserveGraphicsNamespaceLeaseLocked(key)
529 d.mu.Unlock()
530 oldState := newGraphicsOutputStateWithLease(oldBase, oldFence)
531 seed, err := oldState.prepare(kittenGraphicsSnapshot(t), true)
532 require.NoError(t, err)
533 seed.commit()
534 var oldID uint64
535 for _, asset := range oldState.assets {
536 oldID = asset.id
537 break
538 }
539 require.NotZero(t, oldID)
540
541 transport := newLateGraphicsCleanupTransport()
542 ac := &attachedClient{tr: transport, output: attachmentOutputWithGraphics(oldState)}
543 cleanupDone := make(chan struct{})
544 go func() {
545 _ = d.cleanupAttachmentOutput(ac)
546 close(cleanupDone)
547 }()
548 awaitTestCompletion(t, transport.started, "timed-out cleanup did not reach the stale transport")
549 timer := awaitTestValue(t, clock.timers, "cleanup watchdog timer was not armed")
550 timer.ch <- time.Time{}
551 awaitTestCompletion(t, cleanupDone, "timed-out cleanup did not retire the attachment")
552
553 d.mu.Lock()
554 newBase, newFence := d.reserveGraphicsNamespaceLeaseLocked(key)
555 _, quarantined := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
556 d.mu.Unlock()
557 require.True(t, quarantined, "timed-out cleanup must keep its namespace quarantined")
558 require.NotEqual(t, oldBase, newBase, "new allocation must skip the quarantined namespace")
559
560 newState := newGraphicsOutputStateWithLease(newBase, newFence)
561 fresh, err := newState.prepare(kittenGraphicsSnapshot(t), true)
562 require.NoError(t, err)
563 var newID uint64
564 for _, asset := range fresh.state.assets {
565 newID = asset.id
566 break
567 }
568 require.NotZero(t, newID)
569 require.NotEqual(t, oldID, newID, "fresh attachment IDs must not share the quarantined block")
570
571 close(transport.release)
572 staleFrame := awaitTestValue(t, transport.sends, "late stale cleanup did not execute")
573 awaitTestCompletion(t, transport.finished, "late stale cleanup did not return")
574 staleOutput := unmarshalTestOutput(t, staleFrame.Payload)
575 require.Contains(t, string(staleOutput.Data), "i="+strconv.FormatUint(oldID, 10))
576 require.NotContains(t, string(staleOutput.Data), "i="+strconv.FormatUint(newID, 10), "late cleanup must never target the fresh attachment")
577 d.mu.Lock()
578 _, retained := d.graphicsNamespaceQuarantines[(oldBase-1)/graphicsIDNamespaceSize]
579 d.mu.Unlock()
580 require.True(t, retained, "late transport success is not a terminal ACK and must not release the namespace")
581}
582
583func TestGraphicsOutputKeysIncludeSourceSceneContent(t *testing.T) {
584 makeSnapshot := func(data []byte) *graphics.Snapshot {
585 scene := graphics.NewScene(graphics.Limits{})
586 asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: data, Width: 1, Height: 1})
587 require.NoError(t, err)
588 _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
589 require.NoError(t, err)
590 return scene.Snapshot()
591 }
592 state := newGraphicsOutputState()
593 first, err := state.prepare(makeSnapshot([]byte("first")), true)
594 require.NoError(t, err)
595 first.commit()
596 second, err := state.prepare(makeSnapshot([]byte("second")), false)
597 require.NoError(t, err)
598 require.Contains(t, string(second.data), "a=t,i=", "a new a1/p1 scene must not alias the stale outer image")
599}
600
601func TestGraphicsOutputPreSendAbortDoesNotCreateCleanupRecords(t *testing.T) {
602 state := newGraphicsOutputState()
603 prepared, err := state.prepare(kittenGraphicsSnapshot(t), true)
604 require.NoError(t, err)
605 prepared.abort()
606
607 cleanup, err := state.prepare(nil, true)
608 require.NoError(t, err)
609 require.Empty(t, cleanup.data, "IDs allocated by a preparation that never reached the transport are not owned by the terminal")
610}
611
612func TestGraphicsOutputOversizeSuppressesGraphicsWithoutCompositionError(t *testing.T) {
613 scene := graphics.NewScene(graphics.Limits{})
614 asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: bytes.Repeat([]byte{'x'}, maxGraphicsOutputBytes), Width: 1, Height: 1})
615 require.NoError(t, err)
616 _, err = scene.PlaceAsset(asset, graphics.PixelRect{Width: 1, Height: 1})
617 require.NoError(t, err)
618 state := &capturedRenderState{panes: []capturedPaneRenderState{{
619 graphics: scene.Snapshot(),
620 placement: layout.Placement{Content: domain.Rect{Width: 1, Height: 1}},
621 }}}
622 ac := &attachedClient{
623 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
624 terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
625 }
626 prepared, err := graphicsOutputData(state, ac, true)
627 require.NoError(t, err, "ANSI composition must continue when optional graphics exceed the bound")
628 require.Empty(t, prepared.data, "oversized graphics are suppressed")
629}
630
631func TestGraphicsOutputClipsPlacementToPaneContent(t *testing.T) {
632 scene := graphics.NewScene(graphics.Limits{})
633 asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("asset"), Width: 10, Height: 10})
634 require.NoError(t, err)
635 _, err = scene.PlaceAsset(asset, graphics.PixelRect{X: -2, Y: -1, Width: 10, Height: 10})
636 require.NoError(t, err)
637 state := &capturedRenderState{panes: []capturedPaneRenderState{{
638 graphics: scene.Snapshot(),
639 graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 4, Rows: 3}, PixelWidth: 4, PixelHeight: 3},
640 placement: layout.Placement{Content: domain.Rect{X: 3, Y: 4, Width: 4, Height: 3}},
641 }}}
642 ac := &attachedClient{
643 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
644 terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
645 }
646 prepared, err := graphicsOutputData(state, ac, true)
647 require.NoError(t, err)
648 text := string(prepared.data)
649 require.Contains(t, text, "\x1b[6;4H", "placement must move to the clipped pane-local destination")
650 require.Contains(t, text, ",x=2,y=1,w=4,h=3", "lowercase coordinates must preserve the source crop")
651 require.NotContains(t, text, ",X=2,Y=1", "source coordinates must not become destination offsets")
652}
653
654func TestGraphicsOutputClipsEachAttachmentToOuterContentFrame(t *testing.T) {
655 scene := graphics.NewScene(graphics.Limits{})
656 asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("attachment-frame"), Width: 80, Height: 24})
657 require.NoError(t, err)
658 _, err = scene.Place(graphics.PlacementSpec{
659 Asset: asset,
660 Destination: graphics.PixelRect{Width: 800, Height: 480},
661 Cells: graphics.CellRect{Width: 80, Height: 24},
662 })
663 require.NoError(t, err)
664
665 makeState := func(window domain.Size) *capturedRenderState {
666 return &capturedRenderState{
667 window: window,
668 layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 24}, valid: true},
669 panes: []capturedPaneRenderState{{
670 id: "pane",
671 graphics: scene.Snapshot(),
672 graphicsGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
673 placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 24}},
674 }},
675 }
676 }
677
678 small, err := graphicsOutputData(makeState(domain.Size{Cols: 40, Rows: 10}), graphicsPaneTestClient(), true)
679 require.NoError(t, err)
680 require.Len(t, small.state.placements, 1)
681 for _, placement := range small.state.placements {
682 require.Equal(t, graphics.PixelRect{Width: 400, Height: 160}, placement.dest, "small attachment must clip pixels to its content frame")
683 require.Equal(t, graphics.CellRect{Width: 40, Height: 8}, placement.cells, "status bar row must stay outside the graphics cells")
684 }
685 require.Contains(t, string(small.data), ",c=40,r=8")
686 require.NotContains(t, string(small.data), ",c=80,r=24")
687
688 large, err := graphicsOutputData(makeState(domain.Size{Cols: 100, Rows: 30}), graphicsPaneTestClient(), true)
689 require.NoError(t, err)
690 require.Len(t, large.state.placements, 1)
691 for _, placement := range large.state.placements {
692 require.Equal(t, graphics.PixelRect{Width: 800, Height: 480}, placement.dest, "a larger attachment must retain the pane portion it can display")
693 }
694 require.Contains(t, string(large.data), ",c=80,r=24")
695}
696
697func TestGraphicsOutputCellOnlyViewportPreservesSourceMapping(t *testing.T) {
698 scene := graphics.NewScene(graphics.Limits{})
699 asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("cell-only"), Width: 100, Height: 50})
700 require.NoError(t, err)
701 _, err = scene.Place(graphics.PlacementSpec{
702 Asset: asset,
703 Destination: graphics.PixelRect{X: 10, Y: 20, Width: 200, Height: 100},
704 Cells: graphics.CellRect{X: 2, Y: 3, Width: 20, Height: 10},
705 })
706 require.NoError(t, err)
707
708 viewport, ok := graphicsOutputViewportRect(domain.Rect{X: 7, Y: 5, Width: 5, Height: 5}, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
709 require.True(t, ok)
710 require.Equal(t, graphics.PixelRect{}, viewport.Pixels, "cell-only geometry must not become a pixel viewport")
711 require.Equal(t, graphics.CellRect{X: 7, Y: 5, Width: 5, Height: 5}, viewport.Cells)
712
713 _, placements := graphicsSnapshotRecords(scene.Snapshot(), viewport)
714 require.Len(t, placements, 1)
715 for _, placement := range placements {
716 require.Equal(t, graphics.PixelRect{X: 25, Y: 10, Width: 25, Height: 25}, placement.source)
717 require.Equal(t, graphics.PixelRect{X: 60, Y: 40, Width: 50, Height: 50}, placement.dest)
718 }
719
720 pixelOnly := graphics.NewScene(graphics.Limits{})
721 pixelAsset, err := pixelOnly.AddAsset(graphics.AssetBlob{Encoded: []byte("pixel-only"), Width: 10, Height: 10})
722 require.NoError(t, err)
723 _, err = pixelOnly.PlaceAsset(pixelAsset, graphics.PixelRect{Width: 10, Height: 10})
724 require.NoError(t, err)
725 _, unsuitable := graphicsSnapshotRecords(pixelOnly.Snapshot(), viewport)
726 require.Empty(t, unsuitable, "pixel-space fragments without cell metadata must be suppressed")
727}
728
729func TestGraphicsOutputComposesPaneOriginAndPixelGeometry(t *testing.T) {
730 placement := graphicsOutputPlacement{
731 id: 1,
732 source: graphics.PixelRect{Width: 20, Height: 10},
733 dest: graphics.PixelRect{X: 103, Y: 50, Width: 20, Height: 10},
734 cells: graphics.CellRect{Width: 2, Height: 1},
735 }
736 record := placementRecordWithTransform(1, placement, graphicsOutputTransform{
737 originX: 3,
738 originY: 2,
739 sourceGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
740 })
741 text := string(record)
742 require.Contains(t, text, "\x1b7\x1b[5;14H", "destination cells must be expressed through cursor position")
743 require.Contains(t, text, ",X=3,Y=10", "destination pixel remainder must use uppercase offsets")
744 require.NotContains(t, text, ",x=13,y=4", "destination cells must never become source crop controls")
745 require.Contains(t, text, ",C=1", "placement must not move or scroll the text cursor")
746 require.True(t, strings.HasSuffix(text, "\x1b8"), "placement must restore the outer text cursor")
747}
748
749func TestGraphicsOutputPreservesKittenIcatCenteredPlacement(t *testing.T) {
750 placement := graphicsOutputPlacement{
751 id: 1,
752 source: graphics.PixelRect{Width: 103, Height: 128},
753 dest: graphics.PixelRect{X: 343, Width: 103, Height: 128},
754 }
755 record := placementRecordWithTransform(1, placement, graphicsOutputTransform{
756 originY: 1,
757 sourceGeometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480},
758 })
759 text := string(record)
760 require.Contains(t, text, "\x1b[2;35H", "icat's centered cursor column must survive composition")
761 require.Contains(t, text, ",w=103,h=128", "natural image dimensions must be preserved")
762 require.Contains(t, text, ",X=3", "sub-cell destination offset must be preserved")
763 require.NotContains(t, text, ",x=34", "center position must not crop the image source")
764}
765
766func TestGraphicsOutputFailedSendRetainsSpeculativeIDsForCleanup(t *testing.T) {
767 state := newGraphicsOutputState()
768 first, err := state.prepare(kittenGraphicsSnapshot(t), true)
769 require.NoError(t, err)
770 first.markSendAttempted()
771 first.abort()
772
773 retry, err := state.prepare(kittenGraphicsSnapshot(t), true)
774 require.NoError(t, err)
775 text := string(retry.data)
776 require.Less(t, strings.Index(text, "a=d,d=p,p="), strings.Index(text, "a=d,d=i,i="))
777 require.Less(t, strings.Index(text, "a=d,d=i,i="), strings.Index(text, "a=t,i="))
778 require.Contains(t, text, "a=p,i=")
779}
780
781func TestGraphicsOutputCleanupRemovesPlacementsBeforeImages(t *testing.T) {
782 state := newGraphicsOutputState()
783 snapshot := kittenGraphicsSnapshot(t)
784 prepared, err := state.prepare(snapshot, true)
785 require.NoError(t, err)
786 prepared.commit()
787
788 // A nil snapshot models the attachment losing its visible graphics scene.
789 cleared, err := state.prepare(nil, false)
790 require.NoError(t, err)
791 require.Less(t, strings.Index(string(cleared.data), "a=d,d=p,p="), strings.Index(string(cleared.data), "a=d,d=i,i="))
792 cleared.commit()
793 require.Empty(t, state.assets)
794 require.Empty(t, state.placements)
795}
796
797func graphicsPaneTestScene(t *testing.T, data string, destination graphics.PixelRect, layer int64) *graphics.Snapshot {
798 t.Helper()
799 scene := graphics.NewScene(graphics.Limits{})
800 asset, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte(data), Width: 20, Height: 10})
801 require.NoError(t, err)
802 _, err = scene.Place(graphics.PlacementSpec{Asset: asset, Destination: destination, Layer: layer, HasLayer: true})
803 require.NoError(t, err)
804 return scene.Snapshot()
805}
806
807func graphicsPaneTestState(tab domain.TabStableID, panes ...capturedPaneRenderState) *capturedRenderState {
808 for i := range panes {
809 if panes[i].graphicsGeometry.PixelsKnown() || panes[i].placement.Content.Width <= 0 || panes[i].placement.Content.Height <= 0 {
810 continue
811 }
812 content := panes[i].placement.Content
813 panes[i].graphicsGeometry = domain.Geometry{
814 Size: domain.Size{Cols: content.Width, Rows: content.Height},
815 PixelWidth: content.Width,
816 PixelHeight: content.Height,
817 }
818 }
819 return &capturedRenderState{view: attachmentView{tabID: tab}, panes: panes}
820}
821
822func graphicsPaneTestClient() *attachedClient {
823 return &attachedClient{
824 output: attachmentOutputWithGraphics(newGraphicsOutputState()),
825 terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true},
826 }
827}
828
829func TestGraphicsOutputComposesSplitPanesWithStableOrderAndChildIDIsolation(t *testing.T) {
830 left := graphicsPaneTestScene(t, "left", graphics.PixelRect{X: -2, Y: 0, Width: 8, Height: 4}, 2)
831 right := graphicsPaneTestScene(t, "right", graphics.PixelRect{X: 1, Y: 0, Width: 8, Height: 4}, -1)
832 state := graphicsPaneTestState("tab-a",
833 capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 0, Width: 20, Height: 4}}},
834 capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
835 )
836 ac := graphicsPaneTestClient()
837 prepared, err := graphicsOutputData(state, ac, true)
838 require.NoError(t, err)
839 require.Len(t, prepared.state.assets, 2, "raw a1 IDs from separate child scenes must not alias")
840 require.Len(t, prepared.state.placements, 2)
841 text := string(prepared.data)
842 require.Less(t, strings.Index(text, "\x1b[2;1H"), strings.Index(text, "\x1b[2;23H"), "pane traversal order must be deterministic")
843 var ids []uint64
844 for _, asset := range prepared.state.assets {
845 ids = append(ids, asset.id)
846 }
847 require.NotEqual(t, ids[0], ids[1])
848}
849
850func TestGraphicsOutputSplitResizeReplacesTranslatedPlacementWithoutSceneMutation(t *testing.T) {
851 snapshot := graphicsPaneTestScene(t, "resize", graphics.PixelRect{Width: 8, Height: 4}, 0)
852 ac := graphicsPaneTestClient()
853 firstState := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "left", graphics: snapshot, placement: layout.Placement{ID: "left", Content: domain.Rect{Width: 20, Height: 4}}})
854 first, err := graphicsOutputData(firstState, ac, true)
855 require.NoError(t, err)
856 first.commit()
857
858 resized := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "left", graphics: snapshot, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 7, Width: 20, Height: 4}}})
859 second, err := graphicsOutputData(resized, ac, false)
860 require.NoError(t, err)
861 require.Contains(t, string(second.data), "a=d,d=p,p=")
862 require.Contains(t, string(second.data), "a=p,i=")
863 require.NotContains(t, string(second.data), "a=t,i=", "layout-only recomputation must retain the immutable asset")
864}
865
866func TestGraphicsOutputPaneReorderRetainsAssetsButReplaysPlacementsInLayoutOrder(t *testing.T) {
867 left := graphicsPaneTestScene(t, "left-reorder", graphics.PixelRect{Width: 8, Height: 4}, 0)
868 right := graphicsPaneTestScene(t, "right-reorder", graphics.PixelRect{Width: 8, Height: 4}, 0)
869 ac := graphicsPaneTestClient()
870 firstState := graphicsPaneTestState("tab-a",
871 capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{Width: 20, Height: 4}}},
872 capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
873 )
874 first, err := graphicsOutputData(firstState, ac, true)
875 require.NoError(t, err)
876 first.commit()
877
878 reordered := graphicsPaneTestState("tab-a",
879 capturedPaneRenderState{id: "right", graphics: right, placement: layout.Placement{ID: "right", Content: domain.Rect{Width: 20, Height: 4}}},
880 capturedPaneRenderState{id: "left", graphics: left, placement: layout.Placement{ID: "left", Content: domain.Rect{X: 21, Width: 20, Height: 4}}},
881 )
882 second, err := graphicsOutputData(reordered, ac, false)
883 require.NoError(t, err)
884 require.NotContains(t, string(second.data), "a=t,i=", "moving panes must not re-upload immutable assets")
885 require.Contains(t, string(second.data), "a=d,d=p,p=")
886 require.Contains(t, string(second.data), "a=p,i=")
887 require.Less(t, strings.Index(string(second.data), "\x1b[2;1H"), strings.Index(string(second.data), "\x1b[2;22H"))
888}
889
890func TestGraphicsOutputTabSwitchReplaysSameRawChildIDsInNewSourceNamespace(t *testing.T) {
891 snapshot := graphicsPaneTestScene(t, "tab", graphics.PixelRect{Width: 8, Height: 4}, 0)
892 ac := graphicsPaneTestClient()
893 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)
894 require.NoError(t, err)
895 first.commit()
896 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)
897 require.NoError(t, err)
898 require.Contains(t, string(second.data), "a=d,d=p,p=")
899 require.Contains(t, string(second.data), "a=d,d=i,i=")
900 require.Contains(t, string(second.data), "a=t,i=")
901}
902
903func TestGraphicsOutputFloatingPaneCoversTiledGraphicsAndRetainsItsOwnScene(t *testing.T) {
904 tiled := graphicsPaneTestScene(t, "tiled", graphics.PixelRect{Width: 20, Height: 6}, 0)
905 floating := graphicsPaneTestScene(t, "floating", graphics.PixelRect{Width: 3, Height: 2}, 3)
906 state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "tiled", graphics: tiled, placement: layout.Placement{ID: "tiled", Content: domain.Rect{Width: 20, Height: 6}}})
907 state.floating = capturedFloatingRenderState{
908 visible: true,
909 pane: capturedPaneRenderState{id: "floating", graphics: floating, graphicsGeometry: domain.Geometry{
910 Size: domain.Size{Cols: 3, Rows: 2}, PixelWidth: 3, PixelHeight: 2,
911 }},
912 geometry: floatingGeometry{Bounds: domain.Rect{X: 5, Y: 1, Width: 6, Height: 4}, Inner: domain.Rect{X: 6, Y: 2, Width: 3, Height: 2}},
913 }
914 ac := graphicsPaneTestClient()
915 prepared, err := graphicsOutputData(state, ac, true)
916 require.NoError(t, err)
917 require.Len(t, prepared.state.assets, 2)
918 require.GreaterOrEqual(t, len(prepared.state.placements), 2, "floating and uncovered tiled fragments both remain visible")
919 for _, placement := range prepared.state.placements {
920 if strings.Contains(placement.asset, "floating") {
921 continue
922 }
923 cover := graphics.PixelRect{X: 5, Y: 1, Width: 6, Height: 4}
924 _, overlaps := placement.dest.Intersect(cover)
925 require.False(t, overlaps, "tiled placement must not survive under floating coverage")
926 }
927}
928
929func TestGraphicsOutputOverlayDismissalHidesAndRestoresImmutableSnapshots(t *testing.T) {
930 snapshot := graphicsPaneTestScene(t, "overlay", graphics.PixelRect{Width: 8, Height: 4}, 0)
931 ac := graphicsPaneTestClient()
932 state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}})
933 first, err := graphicsOutputData(state, ac, true)
934 require.NoError(t, err)
935 first.commit()
936
937 state.overlays.copyActive = true
938 hidden, err := graphicsOutputData(state, ac, true)
939 require.NoError(t, err)
940 require.Contains(t, string(hidden.data), "a=d,d=p,p=")
941 hidden.commit()
942 state.overlays.copyActive = false
943 restored, err := graphicsOutputData(state, ac, false)
944 require.NoError(t, err)
945 require.Contains(t, string(restored.data), "a=t,i=")
946 require.Equal(t, uint64(1), snapshot.Usage().Assets, "composition must not mutate the child scene")
947}
948
949func TestGraphicsOutputMixedTextAndGraphicsKeepsANSIBeforeGraphics(t *testing.T) {
950 snapshot := graphicsPaneTestScene(t, "mixed", graphics.PixelRect{Width: 8, Height: 4}, 0)
951 ac := graphicsPaneTestClient()
952 state := graphicsPaneTestState("tab-a", capturedPaneRenderState{id: "pane", graphics: snapshot, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}}})
953 prepared, err := graphicsOutputData(state, ac, true)
954 require.NoError(t, err)
955 ansi := []byte("text-before-graphics")
956 combined := append(append([]byte(nil), ansi...), prepared.data...)
957 require.Less(t, bytes.Index(combined, ansi), bytes.Index(combined, []byte("\x1b_Ga=t,")))
958}
959
960func TestGraphicsOutputCollapsedAndHiddenPanesDoNotUploadRetainedAssets(t *testing.T) {
961 snapshot := graphicsPaneTestScene(t, "collapsed", graphics.PixelRect{Width: 8, Height: 4}, 0)
962 ac := graphicsPaneTestClient()
963 state := graphicsPaneTestState("tab-a", capturedPaneRenderState{
964 id: "pane",
965 graphics: snapshot,
966 placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 20, Height: 4}},
967 })
968 first, err := graphicsOutputData(state, ac, true)
969 require.NoError(t, err)
970 first.commit()
971
972 state.panes[0].placement.Collapsed = true
973 collapsed, err := graphicsOutputData(state, ac, false)
974 require.NoError(t, err)
975 require.Empty(t, collapsed.state.assets)
976 require.Empty(t, collapsed.state.placements)
977 require.Contains(t, string(collapsed.data), "a=d,d=p,p=")
978 collapsed.commit()
979
980 state.panes = nil
981 hidden, err := graphicsOutputData(state, ac, false)
982 require.NoError(t, err)
983 require.Empty(t, hidden.data, "a collapsed scene was already retired")
984 hidden.commit()
985}
986
987func TestGraphicsOutputViewportProjectionDropsUnplacedAssets(t *testing.T) {
988 scene := graphics.NewScene(graphics.Limits{})
989 placed, err := scene.AddAsset(graphics.AssetBlob{Encoded: []byte("placed"), Width: 4, Height: 2})
990 require.NoError(t, err)
991 _, err = scene.AddAsset(graphics.AssetBlob{Encoded: []byte("retained"), Width: 4, Height: 2})
992 require.NoError(t, err)
993 _, err = scene.PlaceAsset(placed, graphics.PixelRect{Width: 4, Height: 2})
994 require.NoError(t, err)
995
996 state := graphicsPaneTestState("tab-a", capturedPaneRenderState{
997 id: "pane",
998 graphics: scene.Snapshot(),
999 placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 4}},
1000 })
1001 ac := graphicsPaneTestClient()
1002 prepared, err := graphicsOutputData(state, ac, true)
1003 require.NoError(t, err)
1004 require.Len(t, prepared.state.assets, 1, "viewport projection must not upload an unplaced retained image")
1005 require.Len(t, prepared.state.placements, 1)
1006}