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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "io" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -213,6 +214,110 @@ func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) { | |||
| 213 | 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 | 321 | func newFloatingTestTab(t *testing.T) *tab { | |
| 217 | 322 | t.Helper() | |
| 218 | 323 | 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}) | ||