detached a930cfb

test(daemon): cover inactive pane damage cleanup

commit
a930cfbaf96fdefe4eb7cb379ddf858f8d01db61
author
brice · · 2026-07-11 18:37 UTC
parent
bfbfc09
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
110110 // stubClock returns timers whose channel never fires, so a scheduler under it
111111 // blocks in its debounce loop until the session context is cancelled. Used by
112112
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+
113183 func TestPTYWriteErrorIsLogged(t *testing.T) {
114184 var logs bytes.Buffer
115185 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 it110 // 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 by111 // 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.Buffer184 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)))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close