1package daemon
2
3import (
4 "testing"
5
6 renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/protocol"
9 scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/stretchr/testify/require"
11)
12
13func TestCopyCacheFailedPublicationAndRetry(t *testing.T) {
14 for _, failure := range []string{"prepare", "send"} {
15 t.Run(failure, func(t *testing.T) {
16 d, sess, ac, sends := newManualSessionWithPTYs(t)
17 healthy := ac.transport()
18 state := cacheState("live", 1)
19 state.floating = capturedFloatingRenderState{}
20 state.overlays = capturedOverlayRenderState{
21 copyActive: true, copyPaneID: "pane",
22 copyMode: scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("COPY")}, 6, 1), "")),
23 }
24 committed := composeFrame(state, composeCacheInput{})
25 require.True(t, committed.cache.valid)
26 ac.render.cache = committed.cache
27 before := cloneComposeCache(ac.render.cache)
28 state.bars.topRight = "retry"
29 state.attachment = ac
30 state.route.Target = protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
31 state.view.revision = ac.viewSnapshot().revision
32 pending := composeFrame(state, ac.render.cache, ac.render.spare)
33 if failure == "prepare" {
34 pending.frame = renderer.Frame{Width: 1}
35 } else {
36 ac.replaceTransport(cacheFailTransport{})
37 }
38 ac.sendMu.Lock()
39 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
40 require.Equal(t, before, cloneComposeCache(ac.render.cache))
41 require.Zero(t, ac.output.next)
42 if failure == "send" {
43 sess.mu.Lock()
44 sess.registerAttachmentLocked(ac)
45 sess.mu.Unlock()
46 ac.setSession(sess)
47 ac.replaceTransport(healthy)
48 }
49 pending = composeFrame(state, ac.render.cache, ac.render.spare)
50 state.view.revision = ac.viewSnapshot().revision
51 ac.sendMu.Lock()
52 require.True(t, d.publishCapturedFrameForTest(sess, ac, &state, pending))
53 require.Equal(t, pending.cache, ac.render.cache)
54 require.NotContains(t, frameText(ac.render.cache.frame), "COPY")
55 output := unmarshalTestOutput(t, (<-sends).Payload)
56 terminal := renderer.NewScreen(pending.frame.Width, pending.frame.Height)
57 terminal.Write(output.Data)
58 require.Contains(t, frameText(captureTestFrame(terminal)), "COPY", "retry must render copy cells despite interleaved ANSI styling")
59 require.Equal(t, uint64(1), ac.output.next)
60 })
61 }
62}
63
64func TestCopyCacheSearchTransition(t *testing.T) {
65 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
66 sess.tabs[0].focusedPane().screen.Write([]byte("alpha"))
67 ack := func() {
68 mustOutputData(t, sends)
69 ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)
70 }
71 d.enterCopyMode(sess, ac)
72 ack()
73 require.True(t, ac.render.cache.valid)
74 base := captureTestFrame(ac.render.cache.frame)
75 d.handleInput(sess, ac, []byte("/"))
76 ack()
77 require.NotNil(t, ac.overlays.copySearch)
78 require.False(t, ac.render.cache.valid)
79 require.Equal(t, base, captureTestFrame(ac.render.cache.frame), "search modal must not contaminate the live base")
80 d.handleInput(sess, ac, []byte("\x1b"))
81 ack()
82 require.Nil(t, ac.overlays.copySearch)
83 require.NotNil(t, ac.overlays.copyMode)
84 require.True(t, ac.render.cache.valid)
85 require.Equal(t, base, captureTestFrame(ac.render.cache.frame))
86}
87
88func TestCopyExitRefreshesAfterOtherAttachmentConsumesDamage(t *testing.T) {
89 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
90 p := sess.tabs[0].focusedPane()
91 p.screen.Write([]byte("before"))
92 d.enterCopyMode(sess, ac)
93 mustOutputData(t, sends)
94 ac.ackOutputState(ac.output.currentEpoch(), ac.output.next)
95 copyBefore := captureTestFrame(ac.render.cache.frame)
96
97 tr, peerSends := newCapturingTransport(t)
98 peer := &attachedClient{tr: tr, output: newOutputStateStream(), geometry: domain.Geometry{Size: ac.geometry.Size}}
99 peer.output.attachment = peer
100 peer.initOverlays()
101 peer.setSession(sess)
102 sess.mu.Lock()
103 require.True(t, sess.registerAttachmentLocked(peer))
104 sess.mu.Unlock()
105 p.mu.Lock()
106 p.screen.Write([]byte("\x1b[1;1Hafter-peer"))
107 p.mu.Unlock()
108 // A real peer capture/emission consumes the shared pane damage first.
109 d.paint(sess, peer, true, nil)
110 mustOutputData(t, peerSends)
111 p.mu.Lock()
112 require.Empty(t, p.screen.Damage())
113 p.mu.Unlock()
114 require.Contains(t, frameText(peer.render.cache.frame), "after-peer")
115 require.Equal(t, copyBefore, captureTestFrame(ac.render.cache.frame), "peer publication cannot mutate another attachment's cache")
116
117 // The copy document is still frozen, but leaving it must recapture live
118 // cells even though the other attachment already acknowledged the damage.
119 d.copyWheel(sess, ac, 1)
120 mustOutputData(t, sends)
121 require.Nil(t, ac.overlays.copyMode)
122 require.Contains(t, frameText(ac.render.cache.frame), "after-peer")
123 require.NoError(t, ac.render.cache.frame.CheckInvariants())
124}