1package daemon
2
3import (
4 "fmt"
5 "testing"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9 scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/stretchr/testify/require"
11)
12
13func TestCopyRowCompositionMatchesFrameReference(t *testing.T) {
14 for _, target := range []domain.Rect{
15 {X: 2, Y: 2, Width: 12, Height: 4},
16 {X: -3, Y: -1, Width: 12, Height: 4},
17 {X: 15, Y: 5, Width: 12, Height: 4},
18 {X: -20, Y: 2, Width: 12, Height: 4},
19 {X: 25, Y: 2, Width: 12, Height: 4},
20 {X: 2, Y: 20, Width: 12, Height: 4},
21 {X: 2, Y: 2, Width: 3, Height: 1},
22 } {
23 t.Run(fmt.Sprint(target), func(t *testing.T) {
24 // The viewport is taller than the document: blank rows must erase the
25 // old pane rather than leak its contents. Selection/search share the
26 // same row renderer as the owned-frame API.
27 rows := [][]renderer.Cell{testRow("alpha beta"), testRow("second row")}
28 mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshotFromRows(rows, 12, 4), ""))
29 mode.Search("alpha")
30 require.True(t, mode.StartCharacterSelection(scopy.Pos{Col: 1}))
31 require.True(t, mode.ExtendCharacterSelection(scopy.Pos{Row: 1, Col: 3}))
32 styles := resolveStyles(nil)
33 base := renderer.NewFrame(20, 7)
34 for y := range base.Height {
35 base.FillRow(y, 0, base.Width, renderer.Cell{Rune: '#'})
36 }
37 want := base.Clone()
38 src := mode.Render(styles.CopyStatus, styles.Selection)
39 // Reference placement deliberately uses independent per-cell clipping.
40 for y := 0; y < src.Height-1 && y < target.Height; y++ {
41 for x := 0; x < src.Width && x < target.Width; x++ {
42 dx, dy := target.X+x, target.Y+y
43 if dx >= 0 && dx < want.Width && dy >= 0 && dy < want.Height-1 {
44 want.Set(dx, dy, src.Cell(x, y))
45 }
46 }
47 }
48 want.FillRow(want.Height-1, 0, want.Width, renderer.Cell{Rune: ' ', Style: styles.SurfaceBar})
49 for x := range min(want.Width, src.Width) {
50 want.Set(x, want.Height-1, src.Cell(x, src.Height-1))
51 }
52 got, _ := composeCopyClientFrame(mode, target, base, styles)
53 require.Equal(t, captureTestFrame(want), captureTestFrame(got))
54 require.NoError(t, got.CheckInvariants())
55 })
56 }
57}
58
59func TestCopyScrollKeepsBaseUnadornedAndExitRefreshesLiveState(t *testing.T) {
60 f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 100})
61 ack := func() { f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) }
62 f.d.enterCopyMode(f.sess, f.ac)
63 ack()
64 require.True(t, f.ac.render.cache.valid)
65 committed := f.ac.render.cache.frame
66 before := captureTestFrame(committed)
67 f.d.copyWheel(f.sess, f.ac, -30)
68 ack()
69 require.True(t, f.ac.render.cache.valid)
70 require.Equal(t, before, captureTestFrame(committed), "scroll must not mutate the committed buffer")
71 require.NotContains(t, frameText(f.ac.render.cache.frame), "[SCROLL]")
72
73 // Live damage still updates the base behind the immutable history viewport.
74 f.activePane.mu.Lock()
75 f.activePane.screen.Write([]byte("\x1b[1;1HLIVE-UPDATED"))
76 f.activePane.mu.Unlock()
77 f.d.copyWheel(f.sess, f.ac, -3)
78 ack()
79 require.Contains(t, frameText(f.ac.render.cache.frame), "LIVE-UPDATED")
80 require.NotContains(t, frameText(f.ac.render.cache.frame), "[SCROLL]")
81
82 f.d.copyWheel(f.sess, f.ac, 1000)
83 ack()
84 require.Nil(t, f.ac.overlays.copyMode)
85 require.Contains(t, frameText(f.ac.render.cache.frame), "LIVE-UPDATED")
86 require.NoError(t, f.ac.render.cache.frame.CheckInvariants())
87}