detached 0fef5f3

test(daemon): cover floating exit repaint

commit
0fef5f38ec121932b95a58f65afaede8efaecf75
author
brice · · 2026-07-10 00:16 UTC
committer
brice · · 2026-07-10 05:40 UTC
parent
19cbd1f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +105 -0
M internal/usecase/daemon/floating_test.go +105 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "io"
56 "sync"
67 "testing"
78 "time"
@@ -213,6 +214,110 @@ func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
213214 pty.AssertNumberOfCalls(t, "Close", 1)
214215 }
215216
217+func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
218+ newCase := func(t *testing.T, state floatingState) (*Daemon, *session, *attachedClient, chan ports.Frame, *tab, *pane, func()) {
219+ t.Helper()
220+ normalPTY, releaseNormal := newBlockingPTY(t)
221+ d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
222+ tb := sess.activeTab()
223+ require.NotNil(t, tb)
224+ tb.mu.Lock()
225+ normal := tb.focusedPane()
226+ normal.mu.Lock()
227+ normal.screen.Write([]byte("underlying-cell"))
228+ normal.mu.Unlock()
229+
230+ floatingPTY := portsmocks.NewMockPTY(t)
231+ releaseEOF := make(chan struct{})
232+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
233+ <-releaseEOF
234+ return 0, io.EOF
235+ }).Once()
236+ floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
237+ generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, state == floatingVisible)
238+ require.True(t, tb.installFloatingLocked(floating, generation))
239+ if state == floatingHidden {
240+ require.Equal(t, floatingHidden, tb.floating.state)
241+ }
242+ tb.mu.Unlock()
243+
244+ // Establish a renderer shadow first. The EOF repaint must reset it and
245+ // redraw the underlying cell rather than depending on popup damage.
246+ d.paint(sess, ac, true)
247+ baseline := awaitFrame(t, sends, ports.MsgOutput)
248+ baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
249+ require.NoError(t, err)
250+ require.Contains(t, string(baselineOutput.Data), "underlying-cell")
251+
252+ reaped := make(chan struct{})
253+ floating.onExit = func() {
254+ d.reapFloating(sess, tb, floating, generation)
255+ close(reaped)
256+ }
257+ d.sessWg.Add(1)
258+ go d.ptyReader(sess, tb, floating)
259+ return d, sess, ac, sends, tb, floating, func() {
260+ close(releaseEOF)
261+ <-reaped
262+ d.sessWg.Wait()
263+ releaseNormal()
264+ }
265+ }
266+
267+ t.Run("visible matching EOF clears and fully repaints underlying content", func(t *testing.T) {
268+ _, _, _, sends, tb, floating, exit := newCase(t, floatingVisible)
269+ exit()
270+
271+ tb.mu.Lock()
272+ require.Equal(t, floatingUninitialized, tb.floating.state)
273+ require.Nil(t, tb.floating.pane)
274+ tb.mu.Unlock()
275+ repaint := awaitFrame(t, sends, ports.MsgOutput)
276+ output, err := ports.UnmarshalOutput(repaint.Payload)
277+ require.NoError(t, err)
278+ require.Zero(t, output.BaseStateNum, "visible EOF must force a dependency-free full repaint")
279+ require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup")
280+ require.NotNil(t, floating)
281+ })
282+
283+ t.Run("hidden matching EOF clears without repaint", func(t *testing.T) {
284+ _, _, _, sends, tb, _, exit := newCase(t, floatingHidden)
285+ exit()
286+
287+ tb.mu.Lock()
288+ require.Equal(t, floatingUninitialized, tb.floating.state)
289+ require.Nil(t, tb.floating.pane)
290+ tb.mu.Unlock()
291+ select {
292+ case frame := <-sends:
293+ t.Fatalf("hidden EOF unexpectedly repainted: %#v", frame)
294+ default:
295+ }
296+ })
297+
298+ t.Run("stale EOF is ignored without repaint", func(t *testing.T) {
299+ _, _, _, sends, tb, stale, exit := newCase(t, floatingHidden)
300+ tb.mu.Lock()
301+ generation := tb.floating.generation
302+ require.True(t, tb.clearFloatingLocked(stale, generation))
303+ current := newPane(layout.PaneID("replacement"), newBlockingPanePTY(t), domain.Size{Cols: 20, Rows: 8})
304+ currentGeneration := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true)
305+ require.True(t, tb.installFloatingLocked(current, currentGeneration))
306+ tb.mu.Unlock()
307+ exit()
308+
309+ tb.mu.Lock()
310+ require.Same(t, current, tb.floating.pane)
311+ require.Equal(t, floatingVisible, tb.floating.state)
312+ tb.mu.Unlock()
313+ select {
314+ case frame := <-sends:
315+ t.Fatalf("stale EOF unexpectedly repainted: %#v", frame)
316+ default:
317+ }
318+ })
319+}
320+
216321 func newFloatingTestTab(t *testing.T) *tab {
217322 t.Helper()
218323 tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24})
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "io"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -213,6 +214,110 @@ func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
213 pty.AssertNumberOfCalls(t, "Close", 1)214 pty.AssertNumberOfCalls(t, "Close", 1)
214 }215 }
215 216
217+func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
218+ newCase := func(t *testing.T, state floatingState) (*Daemon, *session, *attachedClient, chan ports.Frame, *tab, *pane, func()) {
219+ t.Helper()
220+ normalPTY, releaseNormal := newBlockingPTY(t)
221+ d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
222+ tb := sess.activeTab()
223+ require.NotNil(t, tb)
224+ tb.mu.Lock()
225+ normal := tb.focusedPane()
226+ normal.mu.Lock()
227+ normal.screen.Write([]byte("underlying-cell"))
228+ normal.mu.Unlock()
229+
230+ floatingPTY := portsmocks.NewMockPTY(t)
231+ releaseEOF := make(chan struct{})
232+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
233+ <-releaseEOF
234+ return 0, io.EOF
235+ }).Once()
236+ floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
237+ generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, state == floatingVisible)
238+ require.True(t, tb.installFloatingLocked(floating, generation))
239+ if state == floatingHidden {
240+ require.Equal(t, floatingHidden, tb.floating.state)
241+ }
242+ tb.mu.Unlock()
243+
244+ // Establish a renderer shadow first. The EOF repaint must reset it and
245+ // redraw the underlying cell rather than depending on popup damage.
246+ d.paint(sess, ac, true)
247+ baseline := awaitFrame(t, sends, ports.MsgOutput)
248+ baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
249+ require.NoError(t, err)
250+ require.Contains(t, string(baselineOutput.Data), "underlying-cell")
251+
252+ reaped := make(chan struct{})
253+ floating.onExit = func() {
254+ d.reapFloating(sess, tb, floating, generation)
255+ close(reaped)
256+ }
257+ d.sessWg.Add(1)
258+ go d.ptyReader(sess, tb, floating)
259+ return d, sess, ac, sends, tb, floating, func() {
260+ close(releaseEOF)
261+ <-reaped
262+ d.sessWg.Wait()
263+ releaseNormal()
264+ }
265+ }
266+
267+ t.Run("visible matching EOF clears and fully repaints underlying content", func(t *testing.T) {
268+ _, _, _, sends, tb, floating, exit := newCase(t, floatingVisible)
269+ exit()
270+
271+ tb.mu.Lock()
272+ require.Equal(t, floatingUninitialized, tb.floating.state)
273+ require.Nil(t, tb.floating.pane)
274+ tb.mu.Unlock()
275+ repaint := awaitFrame(t, sends, ports.MsgOutput)
276+ output, err := ports.UnmarshalOutput(repaint.Payload)
277+ require.NoError(t, err)
278+ require.Zero(t, output.BaseStateNum, "visible EOF must force a dependency-free full repaint")
279+ require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup")
280+ require.NotNil(t, floating)
281+ })
282+
283+ t.Run("hidden matching EOF clears without repaint", func(t *testing.T) {
284+ _, _, _, sends, tb, _, exit := newCase(t, floatingHidden)
285+ exit()
286+
287+ tb.mu.Lock()
288+ require.Equal(t, floatingUninitialized, tb.floating.state)
289+ require.Nil(t, tb.floating.pane)
290+ tb.mu.Unlock()
291+ select {
292+ case frame := <-sends:
293+ t.Fatalf("hidden EOF unexpectedly repainted: %#v", frame)
294+ default:
295+ }
296+ })
297+
298+ t.Run("stale EOF is ignored without repaint", func(t *testing.T) {
299+ _, _, _, sends, tb, stale, exit := newCase(t, floatingHidden)
300+ tb.mu.Lock()
301+ generation := tb.floating.generation
302+ require.True(t, tb.clearFloatingLocked(stale, generation))
303+ current := newPane(layout.PaneID("replacement"), newBlockingPanePTY(t), domain.Size{Cols: 20, Rows: 8})
304+ currentGeneration := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true)
305+ require.True(t, tb.installFloatingLocked(current, currentGeneration))
306+ tb.mu.Unlock()
307+ exit()
308+
309+ tb.mu.Lock()
310+ require.Same(t, current, tb.floating.pane)
311+ require.Equal(t, floatingVisible, tb.floating.state)
312+ tb.mu.Unlock()
313+ select {
314+ case frame := <-sends:
315+ t.Fatalf("stale EOF unexpectedly repainted: %#v", frame)
316+ default:
317+ }
318+ })
319+}
320+
216 func newFloatingTestTab(t *testing.T) *tab {321 func newFloatingTestTab(t *testing.T) *tab {
217 t.Helper()322 t.Helper()
218 tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24})323 tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24})

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