test(daemon): cover inactive pane damage cleanup
- commit
- a930cfbaf96fdefe4eb7cb379ddf858f8d01db61
- author
- brice · · 2026-07-11 18:37 UTC
- parent
- bfbfc09
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +70 -0
M internal/usecase/daemon/render_test.go +70 -0
| @@ -110,6 +110,76 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 110 | 110 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 111 | 111 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 112 | 112 | ||
| 113 | + | func TestClearNonRenderablePaneDamage(t *testing.T) { | |
| 114 | + | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | |
| 115 | + | t.Helper() | |
| 116 | + | p, release := newBlockingPTY(t) | |
| 117 | + | t.Cleanup(release) | |
| 118 | + | d, sess, _, sends := newManualSessionWithPTYs(t, p) | |
| 119 | + | tb := sess.tabs[0] | |
| 120 | + | return d, sess, tb, tb.focusedPane(), sends | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | t.Run("clears headless, inactive, collapsed, and hidden panes without output", func(t *testing.T) { | |
| 124 | + | for _, tt := range []struct { | |
| 125 | + | name string | |
| 126 | + | setup func(*Daemon, *session, *tab, *pane) | |
| 127 | + | }{ | |
| 128 | + | {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { sess.client = nil }}, | |
| 129 | + | {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) { | |
| 130 | + | other := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 131 | + | sess.tabs = append([]*tab{other}, sess.tabs...) | |
| 132 | + | sess.active = 0 | |
| 133 | + | require.NotSame(t, other, tb) | |
| 134 | + | }}, | |
| 135 | + | {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) { | |
| 136 | + | collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 137 | + | tb.panes[collapsed.id] = collapsed | |
| 138 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id), layout.NewLeaf(collapsed.id)}, Expanded: p.id}, Focus: p.id} | |
| 139 | + | // Exercise the non-expanded pane rather than the original visible one. | |
| 140 | + | p.id = collapsed.id | |
| 141 | + | tb.panes[collapsed.id] = p | |
| 142 | + | }}, | |
| 143 | + | {name: "hidden floating", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) { | |
| 144 | + | tb.floating = floatingSlot{state: floatingHidden, pane: p, generation: 1} | |
| 145 | + | delete(tb.panes, p.id) | |
| 146 | + | }}, | |
| 147 | + | } { | |
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |
| 149 | + | d, sess, tb, p, sends := newFixture(t) | |
| 150 | + | tt.setup(d, sess, tb, p) | |
| 151 | + | p.screen.Write([]byte("damage")) | |
| 152 | + | d.clearNonRenderablePaneDamage(sess, tb, p) | |
| 153 | + | require.Empty(t, p.screen.Damage()) | |
| 154 | + | select { | |
| 155 | + | case frame := <-sends: | |
| 156 | + | t.Fatalf("non-renderable output must not compose or send: %#v", frame) | |
| 157 | + | default: | |
| 158 | + | } | |
| 159 | + | }) | |
| 160 | + | } | |
| 161 | + | }) | |
| 162 | + | ||
| 163 | + | t.Run("retains active and picker-preview pane damage", func(t *testing.T) { | |
| 164 | + | d, sess, tb, p, _ := newFixture(t) | |
| 165 | + | p.screen.Write([]byte("active")) | |
| 166 | + | d.clearNonRenderablePaneDamage(sess, tb, p) | |
| 167 | + | require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition") | |
| 168 | + | p.screen.ClearDamage() | |
| 169 | + | ||
| 170 | + | sess.client = nil | |
| 171 | + | viewer := &attachedClient{} | |
| 172 | + | viewer.initOverlays() | |
| 173 | + | viewer.overlays.pickerMu.Lock() | |
| 174 | + | viewer.overlays.pickerPreview = tb | |
| 175 | + | viewer.overlays.pickerMu.Unlock() | |
| 176 | + | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | |
| 177 | + | p.screen.Write([]byte("preview")) | |
| 178 | + | d.clearNonRenderablePaneDamage(sess, tb, p) | |
| 179 | + | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | |
| 180 | + | }) | |
| 181 | + | } | |
| 182 | + | ||
| 113 | 183 | func TestPTYWriteErrorIsLogged(t *testing.T) { | |
| 114 | 184 | var logs bytes.Buffer | |
| 115 | 185 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil))) | |
| @@ -110,6 +110,76 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||||
| 110 | // stubClock returns timers whose channel never fires, so a scheduler under it | 110 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 111 | // blocks in its debounce loop until the session context is cancelled. Used by | 111 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| 112 | 112 | ||||
| 113 | + | func TestClearNonRenderablePaneDamage(t *testing.T) { | |||
| 114 | + | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | |||
| 115 | + | t.Helper() | |||
| 116 | + | p, release := newBlockingPTY(t) | |||
| 117 | + | t.Cleanup(release) | |||
| 118 | + | d, sess, _, sends := newManualSessionWithPTYs(t, p) | |||
| 119 | + | tb := sess.tabs[0] | |||
| 120 | + | return d, sess, tb, tb.focusedPane(), sends | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | t.Run("clears headless, inactive, collapsed, and hidden panes without output", func(t *testing.T) { | |||
| 124 | + | for _, tt := range []struct { | |||
| 125 | + | name string | |||
| 126 | + | setup func(*Daemon, *session, *tab, *pane) | |||
| 127 | + | }{ | |||
| 128 | + | {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { sess.client = nil }}, | |||
| 129 | + | {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) { | |||
| 130 | + | other := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |||
| 131 | + | sess.tabs = append([]*tab{other}, sess.tabs...) | |||
| 132 | + | sess.active = 0 | |||
| 133 | + | require.NotSame(t, other, tb) | |||
| 134 | + | }}, | |||
| 135 | + | {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) { | |||
| 136 | + | collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23}) | |||
| 137 | + | tb.panes[collapsed.id] = collapsed | |||
| 138 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id), layout.NewLeaf(collapsed.id)}, Expanded: p.id}, Focus: p.id} | |||
| 139 | + | // Exercise the non-expanded pane rather than the original visible one. | |||
| 140 | + | p.id = collapsed.id | |||
| 141 | + | tb.panes[collapsed.id] = p | |||
| 142 | + | }}, | |||
| 143 | + | {name: "hidden floating", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) { | |||
| 144 | + | tb.floating = floatingSlot{state: floatingHidden, pane: p, generation: 1} | |||
| 145 | + | delete(tb.panes, p.id) | |||
| 146 | + | }}, | |||
| 147 | + | } { | |||
| 148 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | + | d, sess, tb, p, sends := newFixture(t) | |||
| 150 | + | tt.setup(d, sess, tb, p) | |||
| 151 | + | p.screen.Write([]byte("damage")) | |||
| 152 | + | d.clearNonRenderablePaneDamage(sess, tb, p) | |||
| 153 | + | require.Empty(t, p.screen.Damage()) | |||
| 154 | + | select { | |||
| 155 | + | case frame := <-sends: | |||
| 156 | + | t.Fatalf("non-renderable output must not compose or send: %#v", frame) | |||
| 157 | + | default: | |||
| 158 | + | } | |||
| 159 | + | }) | |||
| 160 | + | } | |||
| 161 | + | }) | |||
| 162 | + | ||||
| 163 | + | t.Run("retains active and picker-preview pane damage", func(t *testing.T) { | |||
| 164 | + | d, sess, tb, p, _ := newFixture(t) | |||
| 165 | + | p.screen.Write([]byte("active")) | |||
| 166 | + | d.clearNonRenderablePaneDamage(sess, tb, p) | |||
| 167 | + | require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition") | |||
| 168 | + | p.screen.ClearDamage() | |||
| 169 | + | ||||
| 170 | + | sess.client = nil | |||
| 171 | + | viewer := &attachedClient{} | |||
| 172 | + | viewer.initOverlays() | |||
| 173 | + | viewer.overlays.pickerMu.Lock() | |||
| 174 | + | viewer.overlays.pickerPreview = tb | |||
| 175 | + | viewer.overlays.pickerMu.Unlock() | |||
| 176 | + | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | |||
| 177 | + | p.screen.Write([]byte("preview")) | |||
| 178 | + | d.clearNonRenderablePaneDamage(sess, tb, p) | |||
| 179 | + | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | |||
| 180 | + | }) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 113 | func TestPTYWriteErrorIsLogged(t *testing.T) { | 183 | func TestPTYWriteErrorIsLogged(t *testing.T) { | ||
| 114 | var logs bytes.Buffer | 184 | var logs bytes.Buffer | ||
| 115 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil))) | 185 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil))) | ||