1package daemon
2
3import (
4 "go/ast"
5 "go/parser"
6 "go/token"
7 "io"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 vt "github.com/bnema/vev-vt"
15 renderer "github.com/bnema/vev-vt/ansi"
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"
19 "github.com/bnema/vev/internal/testutil/replaytest"
20 scopy "github.com/bnema/vev/internal/usecase/copy"
21 "github.com/bnema/vev/internal/usecase/layout"
22 "github.com/bnema/vev/internal/usecase/notices"
23 "github.com/bnema/vev/internal/usecase/palette"
24 promptui "github.com/bnema/vev/internal/usecase/prompt"
25 themeui "github.com/bnema/vev/internal/usecase/theme"
26 "github.com/bnema/vev/internal/usecase/ui"
27 "github.com/bnema/vev/internal/usecase/visualsearch"
28 "github.com/stretchr/testify/require"
29)
30
31// scriptedReplayTransport is intentionally a wire.Transport only: replay
32// assertions must not depend on any adapter's framing implementation.
33type scriptedReplayTransport struct {
34 t *testing.T
35 frames []wire.Envelope
36 next int
37}
38
39func (s *scriptedReplayTransport) Send(got wire.Envelope) error {
40 s.t.Helper()
41 if s.next >= len(s.frames) {
42 s.t.Errorf("unexpected frame %#v", got)
43 return nil
44 }
45 want := s.frames[s.next]
46 s.next++
47 require.Equal(s.t, want.Payload, got.Payload)
48 return nil
49}
50func (*scriptedReplayTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
51func (*scriptedReplayTransport) Close() error { return nil }
52
53func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
54 frames := replaytest.Transcript()
55 transport := &scriptedReplayTransport{t: t, frames: frames}
56 terminal := vt.NewScreen(8, 3)
57 for _, frame := range frames {
58 require.NoError(t, transport.Send(frame))
59 output := unmarshalTestOutput(t, frame.Payload)
60 require.Equal(t, frame.Payload, mustServerEnvelope(output).Payload, "output payload must remain byte exact")
61 terminal.Write(output.Data)
62 }
63 require.Equal(t, len(frames), transport.next)
64 require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal), "terminal replay is the final renderer shadow")
65}
66
67func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
68 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
69 p.screen.ClearDamage()
70 p.screen.Write([]byte("old"))
71
72 p.mu.Lock()
73 captured := capturePaneRenderStateLockedInto(p, domain.Rect{Width: 3, Height: 1}, capturedPaneRenderState{})
74 p.mu.Unlock()
75
76 require.Equal(t, 3, captured.frame.Width)
77 require.Equal(t, 1, captured.frame.Height)
78 require.NotEmpty(t, p.screen.Damage(), "capture is only a transactional snapshot")
79 p.mu.Lock()
80 p.screen.Write([]byte("new"))
81 p.mu.Unlock()
82 require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
83}
84
85func TestCapturePaneRenderStateIsAlwaysNonDestructive(t *testing.T) {
86 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
87 p.screen.ClearDamage()
88 p.screen.Write([]byte("preview"))
89
90 p.mu.Lock()
91 _ = capturePaneRenderStateLockedInto(p, domain.Rect{Width: 8, Height: 2}, capturedPaneRenderState{})
92 p.mu.Unlock()
93
94 require.NotEmpty(t, p.screen.Damage())
95}
96
97func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) {
98 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
99 p.screen.ClearDamage()
100 p.screen.Write([]byte("x"))
101 p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
102
103 p.mu.Lock()
104 captured := capturePaneRenderStateLockedInto(p, domain.Rect{Width: 8, Height: 2}, capturedPaneRenderState{})
105 p.mu.Unlock()
106
107 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
108}
109
110func TestCapturePaneRenderStateCollapsedRetainsDamage(t *testing.T) {
111 p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
112 p.screen.ClearDamage()
113 p.screen.Write([]byte("hidden"))
114
115 p.mu.Lock()
116 captured := capturePaneRenderStateLockedInto(p, domain.Rect{}, capturedPaneRenderState{})
117 p.mu.Unlock()
118
119 require.Zero(t, captured.frame.Width)
120 require.Zero(t, captured.frame.Height)
121 require.NotEmpty(t, p.screen.Damage(), "capture keeps hidden-pane damage pending until emission commits")
122}
123
124func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
125 pty, release := newBlockingPTY(t)
126 t.Cleanup(release)
127 d, sess, _, sends := newManualSessionWithPTYs(t, pty)
128 ac := sess.snapshotAttachments()[0]
129 // Resolve the initial attachment view before filling the output window; a
130 // first target repair is an epoch boundary by design.
131 require.NotNil(t, sess.tabForAttachment(ac))
132 ac.sendMu.Lock()
133 ac.output.next = ac.output.maxOutstanding
134 ac.output.acked = 0
135 ac.output.syncCapacityLocked()
136 ac.sendMu.Unlock()
137 p := sess.tabs[0].focusedPane()
138 p.mu.Lock()
139 p.screen.ClearDamage()
140 p.screen.Write([]byte("blocked"))
141 p.mu.Unlock()
142
143 d.paint(sess, ac, false, nil)
144
145 p.mu.Lock()
146 require.NotEmpty(t, p.screen.Damage())
147 p.mu.Unlock()
148 select {
149 case frame := <-sends:
150 t.Fatalf("ACK-blocked paint sent %#v", frame)
151 default:
152 }
153}
154
155func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
156 state := &capturedRenderState{
157 layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, valid: true},
158 panes: []capturedPaneRenderState{{
159 id: "p", frame: func() renderer.Frame {
160 f := renderer.NewFrame(4, 1)
161 for i, r := range "old " {
162 f.Set(i, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
163 }
164 return f
165 }(), placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, focused: true,
166 damage: []renderer.Damage{renderer.FullRedraw()},
167 }},
168 bars: barState{},
169 cursor: capturedCursorInputs{visible: true, renderable: true, content: domain.Rect{Width: 4, Height: 1}},
170 }
171 cache := composeCacheInput{}
172 out := composeFrame(*state, cache)
173 state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
174 require.Equal(t, "old ", rowText(out.frame.Row(1)))
175 require.False(t, out.cursor.hidden)
176}
177
178func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
179 files, err := filepath.Glob("*.go")
180 require.NoError(t, err)
181 for _, name := range files {
182 parsed, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
183 require.NoError(t, err)
184 for _, declaration := range parsed.Decls {
185 switch declaration := declaration.(type) {
186 case *ast.FuncDecl:
187 forbidden := declaration.Name.Name == "composeFloatingFrame" || declaration.Name.Name == "copyTargetRectLocked" || strings.HasPrefix(declaration.Name.Name, "composeClientFrame") || strings.HasPrefix(declaration.Name.Name, "composeTabFrame")
188 require.False(t, forbidden, "%s declares forbidden legacy render helper %s", name, declaration.Name.Name)
189 case *ast.GenDecl:
190 for _, spec := range declaration.Specs {
191 typeSpec, ok := spec.(*ast.TypeSpec)
192 require.False(t, ok && typeSpec.Name.Name == "composedFrameCache", "%s declares forbidden legacy render cache", name)
193 }
194 }
195 }
196 }
197 data, err := os.ReadFile("render_pipeline.go")
198 require.NoError(t, err)
199 body := string(data)
200 start := strings.Index(body, "func composeFrame(")
201 require.NotEqual(t, -1, start)
202 end := strings.Index(body[start:], "\nfunc ")
203 if end > 0 {
204 body = body[start : start+end]
205 } else {
206 body = body[start:]
207 }
208 for _, forbidden := range []string{".Lock()", ".Unlock()", ".Send(", ".prepare("} {
209 require.NotContains(t, body, forbidden, "composeFrame must remain ownership- and transport-free")
210 }
211}
212
213func TestCursorCandidateDoesNotPublishDuringPreparation(t *testing.T) {
214 output := &attachmentOutput{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}
215 desired := cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
216
217 candidate := output.prepareCursorTail(desired, false)
218
219 require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, output.lastCursor)
220 require.NotEmpty(t, candidate.data)
221 require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, candidate.next)
222}
223
224func TestPublishFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
225 d, sess, ac, sends := newManualSessionWithPTYs(t)
226 healthy := ac.transport()
227 beforeCursor := cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}
228 ac.output.lastCursor = beforeCursor
229 ac.replaceTransport(cacheFailTransport{})
230 state := cacheState("failed", 1)
231 state.attachment = ac
232 composed := composeFrame(state, ac.render.cache, ac.render.spare)
233 composed.cursor = cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
234
235 ac.sendMu.Lock()
236 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, composed))
237
238 require.Equal(t, beforeCursor, ac.output.lastCursor)
239 require.Zero(t, ac.output.next)
240 probe, err := ac.output.renderer.Prepare(composed.frame, nil, false)
241 require.NoError(t, err)
242 require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")
243
244 sess.mu.Lock()
245 sess.registerAttachmentLocked(ac)
246 sess.mu.Unlock()
247 ac.setSession(sess)
248 ac.replaceTransport(healthy)
249 state.attachment = ac
250 ac.sendMu.Lock()
251 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, composed))
252 require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.output.lastCursor)
253 require.Equal(t, uint64(1), ac.output.next)
254 out := unmarshalTestOutput(t, (<-sends).Payload)
255 require.Zero(t, out.Base)
256 require.Equal(t, uint64(1), out.New)
257}
258
259func TestPublishFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) {
260 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
261 state := cacheState("steady", 1)
262 state.attachment = ac
263 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
264 state.view.tabID = domain.TabStableID(sess.tabs[0].stableID)
265 state.view.revision = ac.viewSnapshot().revision
266 initial := composeFrame(state, ac.render.cache, ac.render.spare)
267 ac.sendMu.Lock()
268 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, initial))
269 <-sends
270
271 p := newPane("collapsed", nil, domain.Size{Cols: 1, Rows: 1})
272 p.mu.Lock()
273 p.screen.ClearDamage()
274 p.screen.Write([]byte("x"))
275 capture := p.screen.CaptureDamage()
276 p.mu.Unlock()
277
278 beforeNext := ac.output.next
279 beforeCursor := ac.output.lastCursor
280 state.receipts = []damageReceipt{{pane: p, generation: capture.Generation}}
281 noByte := composedRenderFrame{
282 frame: initial.frame,
283 cursor: initial.cursor,
284 cache: initial.cache,
285 }
286 noByte.cache.layoutFingerprint = "no-byte-committed"
287 ac.sendMu.Lock()
288 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, noByte))
289
290 require.Equal(t, beforeNext, ac.output.next)
291 require.Equal(t, beforeCursor, ac.output.lastCursor)
292 require.Equal(t, "no-byte-committed", ac.render.cache.layoutFingerprint)
293 p.mu.Lock()
294 require.Empty(t, p.screen.Damage())
295 p.mu.Unlock()
296 select {
297 case frame := <-sends:
298 t.Fatalf("no-byte transaction sent state frame %#v", frame)
299 default:
300 }
301
302 state.focusedPaneID = "pane-2"
303 state.panes[0].stableID = "pane-2"
304 ac.sendMu.Lock()
305 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, noByte))
306 var frame wire.Envelope
307 select {
308 case frame = <-sends:
309 case <-time.After(time.Second):
310 t.Fatal("timed out waiting for UI view update")
311 }
312 require.Equal(t, "UIViewUpdate", envelopeMessageName(t, frame.Payload))
313 update, ok := decodeServerMessage(t, frame).(protocol.UIViewUpdate)
314 require.True(t, ok)
315 require.Equal(t, state.focusedPaneID, update.Context.FocusedPaneID)
316}
317
318func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
319 paneFrame := renderer.NewFrame(12, 5)
320 for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
321 for x, r := range text {
322 paneFrame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
323 }
324 }
325 floatingFrame := renderer.NewFrame(4, 1)
326 for x, r := range "FLOAT"[:4] {
327 floatingFrame.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
328 }
329 modalInner := renderer.NewFrame(6, 1)
330 for x, r := range "PROMPT" {
331 modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
332 }
333 state := capturedRenderState{
334 route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "sess"}},
335 view: attachmentView{tabID: "tab-1"},
336 reset: true,
337 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
338 panes: []capturedPaneRenderState{{id: "p", stableID: "pane-1", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
339 floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
340 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
341 overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},
342 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
343 styles: resolveStyles(nil),
344 }
345 composed := composeFrame(state, composeCacheInput{})
346 require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT ", " ", " sess B"}, frameRows(composed.frame))
347 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
348
349 stream := newOutputStateStream()
350 prepared, err := stream.prepareFrame(nil, &state, composed.frame, composed.damage, composed.reset, composed.cursor)
351 require.NoError(t, err)
352 var outputFrame wire.Envelope
353 require.NoError(t, prepared.send(0, outputFrameSender(func(frame wire.Envelope) error {
354 outputFrame = frame
355 return nil
356 })))
357 output := unmarshalTestOutput(t, outputFrame.Payload)
358 require.NoError(t, err)
359 terminalBytes := output.Data
360 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
361 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
362 client.Write(terminalBytes)
363 require.Equal(t, frameRows(composed.frame), frameRows(client))
364 again, err := stream.renderer.Draw(composed.frame, nil)
365 require.NoError(t, err)
366 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
367}
368
369func frameRows(frame renderer.CellSource) []string {
370 rows := make([]string, frame.Rows())
371 cells := make([]renderer.Cell, frame.Columns())
372 for y := range rows {
373 for x := range cells {
374 cells[x] = frame.Cell(x, y)
375 }
376 rows[y] = rowText(cells)
377 }
378 return rows
379}
380
381func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
382 for _, tt := range []struct {
383 name string
384 placement layout.Placement
385 initial []string
386 scrolled []string
387 damage []renderer.Damage
388 wantBytes string
389 }{
390 {name: "safe full width", placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 3}}, initial: []string{"AAAA", "BBBB", "CCCC"}, scrolled: []string{"BBBB", "CCCC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}}, wantBytes: "\x1b[0m\x1b[2;4r\x1b[4;1H\n\x1b[r\x1b[4;1HN\x1b[K\x1b[0m"},
391 {name: "unsafe partial width", placement: layout.Placement{ID: "p", Content: domain.Rect{X: 2, Width: 2, Height: 3}}, initial: []string{"AA", "BB", "CC"}, scrolled: []string{"BB", "CC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageText, X: 2, Y: 0, Width: 2, Height: 3}}, wantBytes: "\x1b[2;3HBB\x1b[3;3HCC\x1b[4;3HN \x1b[0m"},
392 } {
393 t.Run(tt.name, func(t *testing.T) {
394 makeState := func(rows []string, damage []renderer.Damage, reset bool) capturedRenderState {
395 f := renderer.NewFrame(tt.placement.Content.Width, tt.placement.Content.Height)
396 for y, text := range rows {
397 for x, r := range text {
398 f.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
399 }
400 }
401 return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 3}, valid: true, focus: "p", placements: []layout.Placement{tt.placement}}, panes: []capturedPaneRenderState{{id: "p", frame: f, placement: tt.placement, focused: true, damage: damage}}, cursor: capturedCursorInputs{visible: false}}
402 }
403 stream := newOutputStateStream()
404 first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
405 firstBytes, err := stream.render(first.frame, first.damage, first.reset)
406 require.NoError(t, err)
407 client := vt.NewScreen(4, 5)
408 client.Write(firstBytes)
409
410 second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
411 secondBytes, err := stream.render(second.frame, second.damage, second.reset)
412 require.NoError(t, err)
413 require.Equal(t, tt.wantBytes, string(secondBytes))
414 client.Write(secondBytes)
415 require.Equal(t, frameRows(second.frame), frameRows(client))
416 again, err := stream.renderer.Draw(second.frame, nil)
417 require.NoError(t, err)
418 require.Empty(t, again, "renderer shadow must equal replayed terminal state")
419 })
420 }
421}
422
423func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
424 muted := renderer.Style{Foreground: 1, Background: 2}
425 active := renderer.Style{Foreground: 3, Background: 4}
426 interior := renderer.Style{Foreground: 5, Background: 6}
427 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
428 presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8})
429
430 for _, tt := range []struct {
431 name string
432 focused bool
433 border renderer.Style
434 }{
435 {name: "unfocused", focused: false, border: muted},
436 {name: "focused", focused: true, border: active},
437 } {
438 t.Run(tt.name, func(t *testing.T) {
439 state := capturedRenderState{
440 styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
441 overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
442 }
443 frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil)
444 bounds := presentation.Bounds
445 require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
446 require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
447 })
448 }
449}
450
451func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
452 const (
453 width = 100
454 contentRows = 6
455 )
456 styles := resolveStyles(nil)
457 theme := backdropTheme()
458 leftPlacement := layout.Placement{
459 ID: "left",
460 TitleBar: domain.Rect{Width: 49, Height: 1},
461 Content: domain.Rect{Y: 1, Width: 49, Height: 5},
462 }
463 rightPlacement := layout.Placement{
464 ID: "right",
465 TitleBar: domain.Rect{X: 50, Width: 50, Height: 1},
466 Content: domain.Rect{X: 50, Y: 1, Width: 50, Height: 5},
467 }
468 state := capturedRenderState{
469 reset: true,
470 layout: capturedTabLayout{
471 area: domain.Rect{Width: width, Height: contentRows},
472 focus: "left",
473 placements: []layout.Placement{leftPlacement, rightPlacement},
474 dividers: []layout.Divider{{Rect: domain.Rect{X: 49, Width: 1, Height: contentRows}, Dir: layout.Horizontal}},
475 fingerprint: "modal-backdrop",
476 valid: true,
477 },
478 panes: []capturedPaneRenderState{
479 {id: "left", title: "left", frame: cachePaneFrame(49, 5, 'L'), placement: leftPlacement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}},
480 {id: "right", title: "right", frame: cachePaneFrame(50, 5, 'R'), placement: rightPlacement, damage: []renderer.Damage{renderer.FullRedraw()}},
481 },
482 overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
483 styles: styles,
484 theme: theme,
485 }
486 base := composeFrame(state, composeCacheInput{})
487 require.NotEmpty(t, base.cache.toastFootprints)
488
489 state.reset = false
490 state.panes[0].damage = nil
491 state.panes[1].damage = nil
492 state.overlays.promptActive = true
493 state.overlays.prompt = capturedModal{
494 active: true,
495 title: "Prompt",
496 presentation: ui.Presentation{
497 Bounds: domain.Rect{X: 80, Y: 5, Width: 10, Height: 2},
498 Inner: domain.Rect{X: 81, Y: 6, Width: 8, Height: 1},
499 Borders: ui.BorderAll,
500 },
501 inner: renderer.NewFrame(8, 1),
502 focused: true,
503 }
504 composed := composeFrame(state, base.cache)
505 dimmer := themeui.NewDimmer(theme)
506 toast := base.cache.toastFootprints[0]
507
508 for name, point := range map[string][2]int{
509 "top bar": {0, 0},
510 "pane chrome": {0, 1},
511 "pane content": {0, 2},
512 "divider": {49, 2},
513 "bottom bar": {0, contentRows + 1},
514 "toast": {toast.X, toast.Y},
515 } {
516 t.Run(name, func(t *testing.T) {
517 x, y := point[0], point[1]
518 require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical())
519 })
520 }
521 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
522 require.False(t, composed.cache.valid)
523 require.Equal(t, captureTestFrame(base.cache.frame), captureTestFrame(composed.cache.frame), "the reusable cache remains toast-free and unadorned")
524}
525
526func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
527 const (
528 width = 100
529 contentRows = 6
530 )
531 placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: width, Height: contentRows}}
532 theme := backdropTheme()
533 state := capturedRenderState{
534 reset: true,
535 layout: capturedTabLayout{area: domain.Rect{Width: width, Height: contentRows}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "floating-toast-backdrop", valid: true},
536 panes: []capturedPaneRenderState{{
537 id: "pane", frame: cachePaneFrame(width, contentRows, 'P'), placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
538 }},
539 overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
540 styles: resolveStyles(nil),
541 theme: theme,
542 }
543 toastOnly := composeFrame(state, composeCacheInput{})
544 require.NotEmpty(t, toastOnly.cache.toastFootprints)
545 toast := toastOnly.cache.toastFootprints[0]
546
547 state.floating = capturedFloatingRenderState{
548 visible: true,
549 focused: true,
550 pane: capturedPaneRenderState{id: "floating", frame: renderer.NewFrame(8, 1)},
551 geometry: floatingGeometry{
552 Mode: ui.PresentationFloating,
553 Bounds: domain.Rect{X: 40, Y: 2, Width: 10, Height: 3},
554 Inner: domain.Rect{X: 41, Y: 3, Width: 8, Height: 1},
555 },
556 generation: 1,
557 }
558 composed := composeFrame(state, composeCacheInput{})
559 dimmer := themeui.NewDimmer(theme)
560
561 for name, point := range map[string][2]int{
562 "top bar": {0, 0},
563 "bottom bar": {0, contentRows + 1},
564 "toast": {toast.X, toast.Y},
565 } {
566 t.Run(name, func(t *testing.T) {
567 x, y := point[0], point[1]
568 require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style).Canonical(), composed.frame.At(x, y).Style.Canonical())
569 })
570 }
571 require.Equal(t, captureTestFrame(toastOnly.cache.frame), captureTestFrame(composed.cache.frame), "floating decoration must not enter the reusable base cache")
572}
573
574func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
575 theme := backdropTheme()
576 lowerStyle := renderer.Style{Foreground: 1, Background: 2}
577 higherStyle := renderer.Style{Foreground: 3, Background: 4}
578 modalAt := func(x int, r rune, style renderer.Style) capturedModal {
579 inner := renderer.NewFrame(1, 1)
580 inner.Set(0, 0, renderer.Cell{Rune: r, Style: style})
581 bounds := domain.Rect{X: x, Y: 1, Width: 1, Height: 1}
582 return capturedModal{active: true, presentation: ui.Presentation{Bounds: bounds, Inner: bounds}, inner: inner}
583 }
584
585 for _, tt := range []struct {
586 name string
587 lower string
588 higher string
589 }{
590 {name: "notices above copy search", lower: "copy search", higher: "notices"},
591 {name: "palette above notices", lower: "notices", higher: "palette"},
592 {name: "prompt above palette", lower: "palette", higher: "prompt"},
593 } {
594 t.Run(tt.name, func(t *testing.T) {
595 state := capturedRenderState{theme: theme, styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}}
596 setModal := func(name string, modal capturedModal) {
597 switch name {
598 case "copy search":
599 state.overlays.copySearch = modal
600 case "notices":
601 state.overlays.noticesOverlay = modal
602 case "palette":
603 state.overlays.palette = modal
604 case "prompt":
605 state.overlays.prompt = modal
606 }
607 }
608 setModal(tt.lower, modalAt(1, 'L', lowerStyle))
609 setModal(tt.higher, modalAt(3, 'H', higherStyle))
610
611 frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil)
612
613 require.Equal(t, 'L', frame.At(1, 1).Rune)
614 require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle).Canonical(), frame.At(1, 1).Style.Canonical(), "the lower-priority modal is part of the higher modal backdrop")
615 require.Equal(t, 'H', frame.At(3, 1).Rune)
616 require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost")
617 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
618 })
619 }
620}
621
622func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
623 state := capturedRenderState{
624 layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}},
625 styles: fallbackChromeStyles,
626 }
627 snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
628 snap := &overlayRenderSnapshot{
629 copySearchModel: visualsearch.New(snapshot),
630 noticesOverlayActive: true,
631 noticesOverlayModel: notices.New(nil, time.Time{}),
632 paletteActive: true,
633 paletteModel: palette.New(nil),
634 promptActive: true,
635 promptModel: promptui.New(" Prompt ", ""),
636 }
637
638 captureOverlayLayers(&state, snap, domain.PaletteConfig{})
639
640 for name, modal := range map[string]capturedModal{
641 "copy search": state.overlays.copySearch,
642 "notices": state.overlays.noticesOverlay,
643 "palette": state.overlays.palette,
644 "prompt": state.overlays.prompt,
645 } {
646 t.Run(name, func(t *testing.T) {
647 require.True(t, modal.active)
648 require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode)
649 require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height})
650 })
651 }
652}
653
654func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) {
655 base := renderer.NewFrame(4, 3)
656 inner := renderer.NewFrame(4, 2)
657 for y, text := range []string{"ABCD", "EFGH"} {
658 for x, r := range text {
659 inner.Set(x, y, renderer.Cell{Rune: r})
660 }
661 }
662
663 copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner)
664
665 require.Equal(t, "BCD ", rowText(base.Row(2)))
666}
667
668func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) {
669 presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4})
670 require.Zero(t, presentation.Bounds.Height)
671 state := capturedRenderState{
672 styles: themeui.Styles{PickerBase: renderer.DefaultStyle()},
673 overlays: capturedOverlayRenderState{
674 copySearch: capturedModal{active: true, title: "Search", presentation: presentation},
675 },
676 }
677
678 _, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil)
679
680 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
681}
682
683func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
684 terminal := renderer.DefaultStyle()
685 muted := renderer.Style{Foreground: 1}
686 active := renderer.Style{Foreground: 2}
687 warn := renderer.Style{Foreground: 3}
688 styles := themeui.Styles{PickerBase: terminal, BorderMuted: muted, BorderActive: active, BorderWarn: warn}
689
690 got := noticeStylesFrom(styles)
691
692 require.True(t, got.Text.Equal(terminal), "toast content keeps the terminal background")
693 require.Equal(t, active, got.BoxError)
694 require.Equal(t, muted, got.BoxInfo)
695 // BoxWarn must use the dedicated BorderWarn role, not fall back to the
696 // same muted role BoxInfo uses - that was the pre-existing bug this
697 // role fixes (Warn and Info toasts were visually identical).
698 require.Equal(t, warn, got.BoxWarn)
699 require.NotEqual(t, got.BoxInfo, got.BoxWarn)
700}
701
702func TestPublishFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) {
703 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
704 token := sess.captureAttachmentCapability(ac, ac.transport())
705 effect, admitted := ac.beginAttachmentEffect(token)
706 require.True(t, admitted)
707 ac.lifecycle.mu.Lock()
708 ac.lifecycle.capability.transport = transportSnapshot{}
709 ac.lifecycle.mu.Unlock()
710
711 state := cacheState("stale", 1)
712 state.attachment = ac
713 composed := composeFrame(state, composeCacheInput{})
714 ac.sendMu.Lock()
715 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, composed, &runtimeMarkBatch{attachmentEffect: effect}))
716 d.attachmentCleanupWg.Wait()
717
718 require.Zero(t, ac.output.next, "rejected transport effect must not commit output state")
719 select {
720 case frame := <-sends:
721 t.Fatalf("rejected transport effect was sent: %s", envelopeMessageName(t, frame.Payload))
722 default:
723 }
724}