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