test(daemon): drive deferred paint flush through the MsgAck handler
Review findings on PR #47: exercise the flush end-to-end via runConnLoop instead of calling advanceOutputAck/takeDeferredPaint directly, and make requireNoOutputFrame drain non-output frames for the full window instead of returning on the first frame.
- commit
- 2824f92be0a9c1f29a1736007e57d0acaaedabb5
- author
- brice · · 2026-07-08 17:26 UTC
- parent
- 7e0e278
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +31 -11
M internal/usecase/daemon/paint_gate_test.go +31 -11
| @@ -1,21 +1,27 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "io" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 8 | 10 | "github.com/stretchr/testify/require" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) { | |
| 12 | 14 | t.Helper() | |
| 13 | - | select { | |
| 14 | - | case f := <-sends: | |
| 15 | - | if f.Type == ports.MsgOutput { | |
| 16 | - | t.Fatalf("unexpected output frame: %+v", f) | |
| 15 | + | deadline := time.After(50 * time.Millisecond) | |
| 16 | + | for { | |
| 17 | + | select { | |
| 18 | + | case f := <-sends: | |
| 19 | + | if f.Type == ports.MsgOutput { | |
| 20 | + | t.Fatalf("unexpected output frame: %+v", f) | |
| 21 | + | } | |
| 22 | + | case <-deadline: | |
| 23 | + | return | |
| 17 | 24 | } | |
| 18 | - | case <-time.After(50 * time.Millisecond): | |
| 19 | 25 | } | |
| 20 | 26 | } | |
| 21 | 27 | ||
| @@ -44,13 +50,27 @@ func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) { | |||
| 44 | 50 | d.paint(sess, ac, false) | |
| 45 | 51 | requireNoOutputFrame(t, sends) | |
| 46 | 52 | ||
| 47 | - | // Acks catch up: the deferred paint becomes flushable and emits exactly one | |
| 48 | - | // cumulative frame (the renderer shadow only advanced on send, so the skip | |
| 49 | - | // coalesces into this paint). | |
| 50 | - | ac.advanceOutputAck(maxUnackedOutputStates) | |
| 51 | - | require.True(t, ac.takeDeferredPaint(), "deferred paint must flush once acks catch up") | |
| 52 | - | d.paint(sess, ac, false) | |
| 53 | + | // Drive a real MsgAck through the connection loop so the flush is exercised | |
| 54 | + | // end-to-end: UnmarshalAck → advanceOutputAck → takeDeferredPaint → paint. | |
| 55 | + | // One ack frame, then Recv blocks until the test ends. | |
| 56 | + | recvCh := make(chan ports.Frame, 1) | |
| 57 | + | done := make(chan struct{}) | |
| 58 | + | t.Cleanup(func() { close(done) }) | |
| 59 | + | tr, ok := ac.transport().(*portsmocks.MockTransport) | |
| 60 | + | require.True(t, ok, "manual session transport must be the capturing mock") | |
| 61 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 62 | + | select { | |
| 63 | + | case f := <-recvCh: | |
| 64 | + | return f, nil | |
| 65 | + | case <-done: | |
| 66 | + | return ports.Frame{}, io.EOF | |
| 67 | + | } | |
| 68 | + | }).Maybe() | |
| 69 | + | recvCh <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: maxUnackedOutputStates})} | |
| 70 | + | go d.runConnLoop(ac) | |
| 53 | 71 | ||
| 72 | + | // The cumulative flush paint must be triggered by the MsgAck handler itself | |
| 73 | + | // (the renderer shadow only advanced on send, so the skip coalesces here). | |
| 54 | 74 | awaitFrame(t, sends, ports.MsgOutput) | |
| 55 | 75 | requireNoOutputFrame(t, sends) | |
| 56 | 76 | ac.sendMu.Lock() | |
| @@ -1,21 +1,27 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "io" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 9 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 8 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) { | 13 | func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) { | ||
| 12 | t.Helper() | 14 | t.Helper() | ||
| 13 | - | select { | 15 | + | deadline := time.After(50 * time.Millisecond) |
| 14 | - | case f := <-sends: | 16 | + | for { |
| 15 | - | if f.Type == ports.MsgOutput { | 17 | + | select { |
| 16 | - | t.Fatalf("unexpected output frame: %+v", f) | 18 | + | case f := <-sends: |
| 19 | + | if f.Type == ports.MsgOutput { | |||
| 20 | + | t.Fatalf("unexpected output frame: %+v", f) | |||
| 21 | + | } | |||
| 22 | + | case <-deadline: | |||
| 23 | + | return | |||
| 17 | } | 24 | } | ||
| 18 | - | case <-time.After(50 * time.Millisecond): | |||
| 19 | } | 25 | } | ||
| 20 | } | 26 | } | ||
| 21 | 27 | ||||
| @@ -44,13 +50,27 @@ func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) { | |||||
| 44 | d.paint(sess, ac, false) | 50 | d.paint(sess, ac, false) | ||
| 45 | requireNoOutputFrame(t, sends) | 51 | requireNoOutputFrame(t, sends) | ||
| 46 | 52 | ||||
| 47 | - | // Acks catch up: the deferred paint becomes flushable and emits exactly one | 53 | + | // Drive a real MsgAck through the connection loop so the flush is exercised |
| 48 | - | // cumulative frame (the renderer shadow only advanced on send, so the skip | 54 | + | // end-to-end: UnmarshalAck → advanceOutputAck → takeDeferredPaint → paint. |
| 49 | - | // coalesces into this paint). | 55 | + | // One ack frame, then Recv blocks until the test ends. |
| 50 | - | ac.advanceOutputAck(maxUnackedOutputStates) | 56 | + | recvCh := make(chan ports.Frame, 1) |
| 51 | - | require.True(t, ac.takeDeferredPaint(), "deferred paint must flush once acks catch up") | 57 | + | done := make(chan struct{}) |
| 52 | - | d.paint(sess, ac, false) | 58 | + | t.Cleanup(func() { close(done) }) |
| 59 | + | tr, ok := ac.transport().(*portsmocks.MockTransport) | |||
| 60 | + | require.True(t, ok, "manual session transport must be the capturing mock") | |||
| 61 | + | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |||
| 62 | + | select { | |||
| 63 | + | case f := <-recvCh: | |||
| 64 | + | return f, nil | |||
| 65 | + | case <-done: | |||
| 66 | + | return ports.Frame{}, io.EOF | |||
| 67 | + | } | |||
| 68 | + | }).Maybe() | |||
| 69 | + | recvCh <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: maxUnackedOutputStates})} | |||
| 70 | + | go d.runConnLoop(ac) | |||
| 53 | 71 | ||||
| 72 | + | // The cumulative flush paint must be triggered by the MsgAck handler itself | |||
| 73 | + | // (the renderer shadow only advanced on send, so the skip coalesces here). | |||
| 54 | awaitFrame(t, sends, ports.MsgOutput) | 74 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 55 | requireNoOutputFrame(t, sends) | 75 | requireNoOutputFrame(t, sends) | ||
| 56 | ac.sendMu.Lock() | 76 | ac.sendMu.Lock() | ||