1package daemon
2
3import (
4 "testing"
5
6 vt "github.com/bnema/vev-vt"
7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/domain/layout"
10 "github.com/stretchr/testify/require"
11)
12
13func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing.T) {
14 baseState := toastDamageState(nil, nil, true)
15 base := composeFrame(baseState, composeCacheInput{})
16 stream := newOutputStateStream()
17 terminal := vt.NewScreen(base.frame.Width, base.frame.Height)
18 replayToastFrame(t, stream, terminal, base)
19
20 one := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
21 shownState := toastDamageState([]domain.Notification{one}, nil, false)
22 shown := composeFrame(shownState, base.cache)
23 requireNoFullRedraw(t, shown.damage)
24 requireToastFootprintsDamaged(t, shown.damage, shown.cache.toastFootprints)
25 replayToastFrame(t, stream, terminal, shown)
26
27 // A one-cell PTY update while the toast is stable redraws only the pane cell
28 // and toast footprint, never the whole terminal.
29 updatedState := toastDamageState([]domain.Notification{one}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
30 updatedState.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
31 updated := composeFrame(updatedState, shown.cache)
32 requireNoFullRedraw(t, updated.damage)
33 require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
34 requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
35 requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)
36 incremental, err := stream.render(updated.frame, updated.damage, updated.reset)
37 require.NoError(t, err)
38 fullRenderer := renderer.New(renderer.Capabilities{})
39 full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})
40 require.NoError(t, err)
41 require.Less(t, len(incremental)*2, len(full), "one-cell update with a stable toast must stay bounded below full-screen output")
42 terminal.Write(incremental)
43 require.Equal(t, frameRows(updated.frame), frameRows(terminal))
44
45 // Stacking and overflow stay visually identical while their complete old and
46 // new coverage remains incremental damage.
47 two := domain.Notification{Code: domain.NoticePaneSpawn, Severity: domain.NoticeWarn, Message: "second", Count: 1}
48 three := domain.Notification{Code: domain.NoticeConfigReload, Severity: domain.NoticeError, Message: "third", Count: 1}
49 stackedState := toastDamageState([]domain.Notification{two, one, three}, nil, false)
50 stackedState.overlays.noticeOverflow = 2
51 stacked := composeFrame(stackedState, updated.cache)
52 requireNoFullRedraw(t, stacked.damage)
53 requireToastFootprintsDamaged(t, stacked.damage, updated.cache.toastFootprints)
54 requireToastFootprintsDamaged(t, stacked.damage, stacked.cache.toastFootprints)
55 replayToastFrame(t, stream, terminal, stacked)
56
57 // Coalescing changes the title count in place. Both old and new coverage is
58 // damaged even where their geometry happens to be identical.
59 coalescedTwo := two
60 coalescedTwo.Count = 12
61 coalescedState := toastDamageState([]domain.Notification{coalescedTwo, one, three}, nil, false)
62 coalescedState.overlays.noticeOverflow = 2
63 coalesced := composeFrame(coalescedState, stacked.cache)
64 requireNoFullRedraw(t, coalesced.damage)
65 requireToastFootprintsDamaged(t, coalesced.damage, stacked.cache.toastFootprints)
66 requireToastFootprintsDamaged(t, coalesced.damage, coalesced.cache.toastFootprints)
67 replayToastFrame(t, stream, terminal, coalesced)
68
69 // Dismissal and expiry have the same rendering transition: no current toast
70 // exists, so the previous footprint alone must restore every exposed cell.
71 for _, tt := range []struct {
72 name string
73 from composedRenderFrame
74 }{
75 {name: "dismiss", from: coalesced},
76 {name: "expiry", from: shown},
77 } {
78 t.Run(tt.name, func(t *testing.T) {
79 cleared := composeFrame(toastDamageState(nil, nil, false), tt.from.cache)
80 requireNoFullRedraw(t, cleared.damage)
81 requireToastFootprintsDamaged(t, cleared.damage, tt.from.cache.toastFootprints)
82 require.Empty(t, cleared.cache.toastFootprints)
83 })
84 }
85
86 cleared := composeFrame(toastDamageState(nil, nil, false), coalesced.cache)
87 replayToastFrame(t, stream, terminal, cleared)
88 require.Equal(t, frameRows(coalesced.cache.frame), frameRows(cleared.frame), "dismissal must restore the toast-free base without artifacts")
89}
90
91func BenchmarkComposeFrameStableToastOneCellUpdate(b *testing.B) {
92 base := composeFrame(toastDamageState(nil, nil, true), composeCacheInput{})
93 notice := domain.Notification{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}
94 shown := composeFrame(toastDamageState([]domain.Notification{notice}, nil, false), base.cache, composeCacheInput{})
95 cache, scratch := shown.cache, base.cache
96 state := toastDamageState([]domain.Notification{notice}, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
97
98 b.ReportAllocs()
99 for i := 0; b.Loop(); i++ {
100 cell := renderer.Cell{Rune: rune('A' + i%26), Style: renderer.DefaultStyle()}
101 state.panes[0].frame.Set(0, 0, cell)
102 out := composeFrame(state, cache, scratch)
103 if hasFullRedraw(out.damage) {
104 b.Fatal("stable toast forced a full redraw")
105 }
106 scratch, cache = cache, out.cache
107 }
108}
109
110func toastDamageState(notices []domain.Notification, damage []renderer.Damage, reset bool) capturedRenderState {
111 paneFrame := renderer.NewFrame(80, 20)
112 for y := 0; y < paneFrame.Height; y++ {
113 for x := 0; x < paneFrame.Width; x++ {
114 paneFrame.Set(x, y, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
115 }
116 }
117 placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 80, Height: 20}}
118 return capturedRenderState{
119 reset: reset,
120 layout: capturedTabLayout{area: domain.Rect{Width: 80, Height: 20}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "toast-damage", valid: true},
121 panes: []capturedPaneRenderState{{id: "pane", frame: paneFrame, placement: placement, focused: true, damage: damage}},
122 overlays: capturedOverlayRenderState{notices: notices},
123 styles: resolveStyles(nil),
124 }
125}
126
127func replayToastFrame(t *testing.T, stream *attachmentOutput, terminal *vt.Screen, composed composedRenderFrame) {
128 t.Helper()
129 data, err := stream.render(composed.frame, composed.damage, composed.reset)
130 require.NoError(t, err)
131 terminal.Write(data)
132 require.Equal(t, frameRows(composed.frame), frameRows(terminal))
133}
134
135func requireNoFullRedraw(t *testing.T, damage []renderer.Damage) {
136 t.Helper()
137 for _, d := range damage {
138 require.NotEqual(t, renderer.DamageFullRedraw, d.Kind)
139 }
140}
141
142func requireToastFootprintsDamaged(t *testing.T, damage []renderer.Damage, footprints []domain.Rect) {
143 t.Helper()
144 for _, footprint := range footprints {
145 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
146 }
147}
148
149func hasFullRedraw(damage []renderer.Damage) bool {
150 for _, d := range damage {
151 if d.Kind == renderer.DamageFullRedraw {
152 return true
153 }
154 }
155 return false
156}