test(daemon): cover detached target previews
- commit
- 1a1a903cf06abd471340c42c325c63ca07a04769
- author
- brice · · 2026-07-11 21:44 UTC
- parent
- 2653724
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +48 -0
M internal/usecase/daemon/render_coordinator_test.go +48 -0
| @@ -798,6 +798,54 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) { | |||
| 798 | 798 | } | |
| 799 | 799 | } | |
| 800 | 800 | ||
| 801 | + | func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *testing.T) { | |
| 802 | + | h := newCoordinatorHarness(t) | |
| 803 | + | target, viewer := &attachedClient{}, &attachedClient{} | |
| 804 | + | h.ackReady.Store(false) | |
| 805 | + | h.rc.attach(target) | |
| 806 | + | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | |
| 807 | + | h.rc.noteDetach(target) | |
| 808 | + | ||
| 809 | + | // A detached target only accepts PTY/session-owned invalidations. The old | |
| 810 | + | // attachment remains stale and must not revive its render path. | |
| 811 | + | h.rc.invalidateForAttachment(target, renderInvalidation{class: invalidateOutput, producer: "stale attachment"}) | |
| 812 | + | require.Empty(t, h.armedTimers(t)) | |
| 813 | + | ||
| 814 | + | for _, producer := range []string{"first PTY output", "second PTY output"} { | |
| 815 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: producer}) | |
| 816 | + | timers := h.armedTimers(t) | |
| 817 | + | require.Len(t, timers, 1) | |
| 818 | + | timers[0].ch <- time.Time{} | |
| 819 | + | preview := awaitWake(t, h.previews) | |
| 820 | + | preview.attachment = nil | |
| 821 | + | preview.attachmentEpoch = 0 | |
| 822 | + | require.Equal(t, renderWake{coalesced: 1}, preview) | |
| 823 | + | requireNoWake(t, h.wakes) | |
| 824 | + | ||
| 825 | + | h.rc.mu.Lock() | |
| 826 | + | require.False(t, h.rc.pending) | |
| 827 | + | require.False(t, h.rc.pendingPreview) | |
| 828 | + | require.False(t, h.rc.ackDeferred) | |
| 829 | + | require.False(t, h.rc.deadlineDue) | |
| 830 | + | require.Zero(t, h.rc.coalesced) | |
| 831 | + | require.True(t, h.rc.detached) | |
| 832 | + | require.Nil(t, h.rc.attachment) | |
| 833 | + | h.rc.mu.Unlock() | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | // A later attach retains the normal first-paint reset path. | |
| 837 | + | replacement := &attachedClient{} | |
| 838 | + | h.ackReady.Store(true) | |
| 839 | + | h.rc.attach(replacement) | |
| 840 | + | h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "first paint"}) | |
| 841 | + | timers := h.armedTimers(t) | |
| 842 | + | require.Len(t, timers, 1) | |
| 843 | + | timers[0].ch <- time.Time{} | |
| 844 | + | wake := awaitWake(t, h.wakes) | |
| 845 | + | require.True(t, wake.reset) | |
| 846 | + | require.Same(t, replacement, wake.attachment) | |
| 847 | + | } | |
| 848 | + | ||
| 801 | 849 | func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | |
| 802 | 850 | cases := []struct { | |
| 803 | 851 | name string | |
| @@ -798,6 +798,54 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) { | |||||
| 798 | } | 798 | } | ||
| 799 | } | 799 | } | ||
| 800 | 800 | ||||
| 801 | + | func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *testing.T) { | |||
| 802 | + | h := newCoordinatorHarness(t) | |||
| 803 | + | target, viewer := &attachedClient{}, &attachedClient{} | |||
| 804 | + | h.ackReady.Store(false) | |||
| 805 | + | h.rc.attach(target) | |||
| 806 | + | h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w }) | |||
| 807 | + | h.rc.noteDetach(target) | |||
| 808 | + | ||||
| 809 | + | // A detached target only accepts PTY/session-owned invalidations. The old | |||
| 810 | + | // attachment remains stale and must not revive its render path. | |||
| 811 | + | h.rc.invalidateForAttachment(target, renderInvalidation{class: invalidateOutput, producer: "stale attachment"}) | |||
| 812 | + | require.Empty(t, h.armedTimers(t)) | |||
| 813 | + | ||||
| 814 | + | for _, producer := range []string{"first PTY output", "second PTY output"} { | |||
| 815 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: producer}) | |||
| 816 | + | timers := h.armedTimers(t) | |||
| 817 | + | require.Len(t, timers, 1) | |||
| 818 | + | timers[0].ch <- time.Time{} | |||
| 819 | + | preview := awaitWake(t, h.previews) | |||
| 820 | + | preview.attachment = nil | |||
| 821 | + | preview.attachmentEpoch = 0 | |||
| 822 | + | require.Equal(t, renderWake{coalesced: 1}, preview) | |||
| 823 | + | requireNoWake(t, h.wakes) | |||
| 824 | + | ||||
| 825 | + | h.rc.mu.Lock() | |||
| 826 | + | require.False(t, h.rc.pending) | |||
| 827 | + | require.False(t, h.rc.pendingPreview) | |||
| 828 | + | require.False(t, h.rc.ackDeferred) | |||
| 829 | + | require.False(t, h.rc.deadlineDue) | |||
| 830 | + | require.Zero(t, h.rc.coalesced) | |||
| 831 | + | require.True(t, h.rc.detached) | |||
| 832 | + | require.Nil(t, h.rc.attachment) | |||
| 833 | + | h.rc.mu.Unlock() | |||
| 834 | + | } | |||
| 835 | + | ||||
| 836 | + | // A later attach retains the normal first-paint reset path. | |||
| 837 | + | replacement := &attachedClient{} | |||
| 838 | + | h.ackReady.Store(true) | |||
| 839 | + | h.rc.attach(replacement) | |||
| 840 | + | h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "first paint"}) | |||
| 841 | + | timers := h.armedTimers(t) | |||
| 842 | + | require.Len(t, timers, 1) | |||
| 843 | + | timers[0].ch <- time.Time{} | |||
| 844 | + | wake := awaitWake(t, h.wakes) | |||
| 845 | + | require.True(t, wake.reset) | |||
| 846 | + | require.Same(t, replacement, wake.attachment) | |||
| 847 | + | } | |||
| 848 | + | ||||
| 801 | func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | 849 | func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) { | ||
| 802 | cases := []struct { | 850 | cases := []struct { | ||
| 803 | name string | 851 | name string | ||