Merge pull request #145 from bnema/feat/v01-reusable-terminal-snapshot
feat(vt): add reusable screen snapshots
4 files · +541 -6
M internal/app/daemon_integration_test.go +10 -6
| @@ -1220,10 +1220,14 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) { | |||
| 1220 | 1220 | ||
| 1221 | 1221 | _, err = killTr.Recv() | |
| 1222 | 1222 | require.ErrorIs(t, err, io.EOF) | |
| 1223 | - | select { | |
| 1224 | - | case err := <-served: | |
| 1225 | - | require.NoError(t, err) | |
| 1226 | - | case <-time.After(5 * time.Second): | |
| 1227 | - | t.Fatal("daemon did not shut down after kill all") | |
| 1228 | - | } | |
| 1223 | + | var serveErr error | |
| 1224 | + | require.Eventually(t, func() bool { | |
| 1225 | + | select { | |
| 1226 | + | case serveErr = <-served: | |
| 1227 | + | return true | |
| 1228 | + | default: | |
| 1229 | + | return false | |
| 1230 | + | } | |
| 1231 | + | }, 15*time.Second, 10*time.Millisecond, "daemon did not shut down after kill all") | |
| 1232 | + | require.NoError(t, serveErr) | |
| 1229 | 1233 | } | |
| @@ -1220,10 +1220,14 @@ func TestIntegration_KillAllShutsDownDaemon(t *testing.T) { | |||||
| 1220 | 1220 | ||||
| 1221 | _, err = killTr.Recv() | 1221 | _, err = killTr.Recv() | ||
| 1222 | require.ErrorIs(t, err, io.EOF) | 1222 | require.ErrorIs(t, err, io.EOF) | ||
| 1223 | - | select { | 1223 | + | var serveErr error |
| 1224 | - | case err := <-served: | 1224 | + | require.Eventually(t, func() bool { |
| 1225 | - | require.NoError(t, err) | 1225 | + | select { |
| 1226 | - | case <-time.After(5 * time.Second): | 1226 | + | case serveErr = <-served: |
| 1227 | - | t.Fatal("daemon did not shut down after kill all") | 1227 | + | return true |
| 1228 | - | } | 1228 | + | default: |
| 1229 | + | return false | |||
| 1230 | + | } | |||
| 1231 | + | }, 15*time.Second, 10*time.Millisecond, "daemon did not shut down after kill all") | |||
| 1232 | + | require.NoError(t, serveErr) | |||
| 1229 | } | 1233 | } | ||
M pkg/vt/doc.go +5 -0
| @@ -5,4 +5,9 @@ | |||
| 5 | 5 | // sequences. Wide runes that fit are stored as a head cell plus a continuation | |
| 6 | 6 | // cell, and resize or edit operations repair row-boundary splits instead of | |
| 7 | 7 | // leaving orphaned halves. | |
| 8 | + | // | |
| 9 | + | // Screen is single-owner. Snapshot returns an owned capture of the active | |
| 10 | + | // visible viewport and renderer-relevant metadata; callers must serialize it | |
| 11 | + | // with Write, Resize, and host mutations. Terminal history remains available | |
| 12 | + | // through HistoryView and HistorySnapshotView rather than ScreenSnapshot. | |
| 8 | 13 | package vt | |
| @@ -5,4 +5,9 @@ | |||||
| 5 | // sequences. Wide runes that fit are stored as a head cell plus a continuation | 5 | // sequences. Wide runes that fit are stored as a head cell plus a continuation | ||
| 6 | // cell, and resize or edit operations repair row-boundary splits instead of | 6 | // cell, and resize or edit operations repair row-boundary splits instead of | ||
| 7 | // leaving orphaned halves. | 7 | // leaving orphaned halves. | ||
| 8 | + | // | |||
| 9 | + | // Screen is single-owner. Snapshot returns an owned capture of the active | |||
| 10 | + | // visible viewport and renderer-relevant metadata; callers must serialize it | |||
| 11 | + | // with Write, Resize, and host mutations. Terminal history remains available | |||
| 12 | + | // through HistoryView and HistorySnapshotView rather than ScreenSnapshot. | |||
| 8 | package vt | 13 | package vt | ||
A pkg/vt/screen_snapshot.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | // CursorSnapshot is the cursor state captured with a visible screen snapshot. | |
| 6 | + | type CursorSnapshot struct { | |
| 7 | + | Row int | |
| 8 | + | Col int | |
| 9 | + | Visible bool | |
| 10 | + | Style int | |
| 11 | + | StyleSet bool | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // ModeSnapshot is the renderer-relevant VT mode state captured with a screen. | |
| 15 | + | type ModeSnapshot struct { | |
| 16 | + | AlternateScreen bool | |
| 17 | + | BracketedPaste bool | |
| 18 | + | SynchronizedUpdate bool | |
| 19 | + | ColorSchemeMode bool | |
| 20 | + | MouseTracking int | |
| 21 | + | MouseSGR bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ScreenSnapshot is an owned immutable-by-convention capture of the active | |
| 25 | + | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | |
| 26 | + | // mutated and remains valid while the snapshot is retained. | |
| 27 | + | type ScreenSnapshot struct { | |
| 28 | + | frame renderer.Frame | |
| 29 | + | bounds []LineBound | |
| 30 | + | cursor CursorSnapshot | |
| 31 | + | modes ModeSnapshot | |
| 32 | + | title string | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Snapshot captures the active visible viewport without mutating Screen, | |
| 36 | + | // history, or pending damage. Screen remains single-owner; callers must | |
| 37 | + | // serialize Snapshot with Write, Resize, and host mutations. | |
| 38 | + | func (s *Screen) Snapshot() ScreenSnapshot { | |
| 39 | + | if s == nil { | |
| 40 | + | return ScreenSnapshot{} | |
| 41 | + | } | |
| 42 | + | cursorStyle, cursorStyleSet := s.CursorStyle() | |
| 43 | + | mouseTracking, mouseSGR := s.MouseMode() | |
| 44 | + | return ScreenSnapshot{ | |
| 45 | + | frame: s.Frame.Clone(), | |
| 46 | + | bounds: s.LineBounds(), | |
| 47 | + | cursor: CursorSnapshot{ | |
| 48 | + | Row: s.CursorRow(), | |
| 49 | + | Col: s.CursorCol(), | |
| 50 | + | Visible: s.CursorVisible(), | |
| 51 | + | Style: cursorStyle, | |
| 52 | + | StyleSet: cursorStyleSet, | |
| 53 | + | }, | |
| 54 | + | modes: ModeSnapshot{ | |
| 55 | + | AlternateScreen: s.AltScreenActive(), | |
| 56 | + | BracketedPaste: s.BracketedPasteMode(), | |
| 57 | + | SynchronizedUpdate: s.SyncUpdateActive(), | |
| 58 | + | ColorSchemeMode: s.ColorSchemeMode(), | |
| 59 | + | MouseTracking: mouseTracking, | |
| 60 | + | MouseSGR: mouseSGR, | |
| 61 | + | }, | |
| 62 | + | title: s.TerminalTitle(), | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (s ScreenSnapshot) Columns() int { return s.frame.Width } | |
| 67 | + | func (s ScreenSnapshot) Rows() int { return s.frame.Height } | |
| 68 | + | ||
| 69 | + | func (s ScreenSnapshot) Row(y int) []renderer.Cell { | |
| 70 | + | return append([]renderer.Cell(nil), s.BorrowedRow(y)...) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (s ScreenSnapshot) BorrowedRow(y int) []renderer.Cell { | |
| 74 | + | if y < 0 || y >= s.frame.Height { | |
| 75 | + | return nil | |
| 76 | + | } | |
| 77 | + | return s.frame.Row(y) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (s ScreenSnapshot) Bound(y int) LineBound { | |
| 81 | + | if y < 0 || y >= len(s.bounds) { | |
| 82 | + | return LineBound{} | |
| 83 | + | } | |
| 84 | + | return s.bounds[y] | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | |
| 88 | + | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | |
| 89 | + | func (s ScreenSnapshot) Title() string { return s.title } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | // CursorSnapshot is the cursor state captured with a visible screen snapshot. | |||
| 6 | + | type CursorSnapshot struct { | |||
| 7 | + | Row int | |||
| 8 | + | Col int | |||
| 9 | + | Visible bool | |||
| 10 | + | Style int | |||
| 11 | + | StyleSet bool | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // ModeSnapshot is the renderer-relevant VT mode state captured with a screen. | |||
| 15 | + | type ModeSnapshot struct { | |||
| 16 | + | AlternateScreen bool | |||
| 17 | + | BracketedPaste bool | |||
| 18 | + | SynchronizedUpdate bool | |||
| 19 | + | ColorSchemeMode bool | |||
| 20 | + | MouseTracking int | |||
| 21 | + | MouseSGR bool | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // ScreenSnapshot is an owned immutable-by-convention capture of the active | |||
| 25 | + | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | |||
| 26 | + | // mutated and remains valid while the snapshot is retained. | |||
| 27 | + | type ScreenSnapshot struct { | |||
| 28 | + | frame renderer.Frame | |||
| 29 | + | bounds []LineBound | |||
| 30 | + | cursor CursorSnapshot | |||
| 31 | + | modes ModeSnapshot | |||
| 32 | + | title string | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Snapshot captures the active visible viewport without mutating Screen, | |||
| 36 | + | // history, or pending damage. Screen remains single-owner; callers must | |||
| 37 | + | // serialize Snapshot with Write, Resize, and host mutations. | |||
| 38 | + | func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 39 | + | if s == nil { | |||
| 40 | + | return ScreenSnapshot{} | |||
| 41 | + | } | |||
| 42 | + | cursorStyle, cursorStyleSet := s.CursorStyle() | |||
| 43 | + | mouseTracking, mouseSGR := s.MouseMode() | |||
| 44 | + | return ScreenSnapshot{ | |||
| 45 | + | frame: s.Frame.Clone(), | |||
| 46 | + | bounds: s.LineBounds(), | |||
| 47 | + | cursor: CursorSnapshot{ | |||
| 48 | + | Row: s.CursorRow(), | |||
| 49 | + | Col: s.CursorCol(), | |||
| 50 | + | Visible: s.CursorVisible(), | |||
| 51 | + | Style: cursorStyle, | |||
| 52 | + | StyleSet: cursorStyleSet, | |||
| 53 | + | }, | |||
| 54 | + | modes: ModeSnapshot{ | |||
| 55 | + | AlternateScreen: s.AltScreenActive(), | |||
| 56 | + | BracketedPaste: s.BracketedPasteMode(), | |||
| 57 | + | SynchronizedUpdate: s.SyncUpdateActive(), | |||
| 58 | + | ColorSchemeMode: s.ColorSchemeMode(), | |||
| 59 | + | MouseTracking: mouseTracking, | |||
| 60 | + | MouseSGR: mouseSGR, | |||
| 61 | + | }, | |||
| 62 | + | title: s.TerminalTitle(), | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (s ScreenSnapshot) Columns() int { return s.frame.Width } | |||
| 67 | + | func (s ScreenSnapshot) Rows() int { return s.frame.Height } | |||
| 68 | + | ||||
| 69 | + | func (s ScreenSnapshot) Row(y int) []renderer.Cell { | |||
| 70 | + | return append([]renderer.Cell(nil), s.BorrowedRow(y)...) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (s ScreenSnapshot) BorrowedRow(y int) []renderer.Cell { | |||
| 74 | + | if y < 0 || y >= s.frame.Height { | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | return s.frame.Row(y) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (s ScreenSnapshot) Bound(y int) LineBound { | |||
| 81 | + | if y < 0 || y >= len(s.bounds) { | |||
| 82 | + | return LineBound{} | |||
| 83 | + | } | |||
| 84 | + | return s.bounds[y] | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | |||
| 88 | + | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | |||
| 89 | + | func (s ScreenSnapshot) Title() string { return s.title } | |||
A pkg/vt/screen_snapshot_test.go +437 -0
| @@ -0,0 +1,437 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | screen func() *Screen | |
| 14 | + | wantColumns int | |
| 15 | + | wantRows int | |
| 16 | + | wantTitle string | |
| 17 | + | wantCursor CursorSnapshot | |
| 18 | + | wantModes ModeSnapshot | |
| 19 | + | checkRows func(t *testing.T, snapshot ScreenSnapshot) | |
| 20 | + | }{ | |
| 21 | + | { | |
| 22 | + | name: "nil receiver is the zero snapshot", | |
| 23 | + | screen: func() *Screen { return nil }, | |
| 24 | + | wantCursor: CursorSnapshot{}, | |
| 25 | + | wantModes: ModeSnapshot{}, | |
| 26 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 27 | + | require.Nil(t, snapshot.Row(0)) | |
| 28 | + | require.Nil(t, snapshot.BorrowedRow(0)) | |
| 29 | + | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| 30 | + | }, | |
| 31 | + | }, | |
| 32 | + | { | |
| 33 | + | name: "normal viewport captures cursor modes and title", | |
| 34 | + | screen: func() *Screen { | |
| 35 | + | screen := NewScreen(6, 3) | |
| 36 | + | screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h")) | |
| 37 | + | return screen | |
| 38 | + | }, | |
| 39 | + | wantColumns: 6, | |
| 40 | + | wantRows: 3, | |
| 41 | + | wantTitle: "editor", | |
| 42 | + | wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true}, | |
| 43 | + | wantModes: ModeSnapshot{ | |
| 44 | + | BracketedPaste: true, | |
| 45 | + | SynchronizedUpdate: true, | |
| 46 | + | ColorSchemeMode: true, | |
| 47 | + | MouseTracking: 1002, | |
| 48 | + | MouseSGR: true, | |
| 49 | + | }, | |
| 50 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 51 | + | require.Len(t, snapshot.BorrowedRow(0), 6) | |
| 52 | + | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| 53 | + | }, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "zero by zero preserves screen metadata", | |
| 57 | + | screen: func() *Screen { | |
| 58 | + | screen := NewScreen(2, 2) | |
| 59 | + | screen.Write([]byte("\x1b]2;collapsed\x07\x1b[?2004h")) | |
| 60 | + | screen.Resize(0, 0) | |
| 61 | + | return screen | |
| 62 | + | }, | |
| 63 | + | wantTitle: "collapsed", | |
| 64 | + | wantCursor: CursorSnapshot{Visible: true}, | |
| 65 | + | wantModes: ModeSnapshot{BracketedPaste: true}, | |
| 66 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 67 | + | require.Nil(t, snapshot.Row(0)) | |
| 68 | + | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| 69 | + | }, | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | name: "zero columns retains empty physical rows and bounds", | |
| 73 | + | screen: func() *Screen { | |
| 74 | + | screen := NewScreen(2, 2) | |
| 75 | + | screen.Write([]byte("\x1b]2;zero-columns\x07")) | |
| 76 | + | screen.Resize(0, 2) | |
| 77 | + | return screen | |
| 78 | + | }, | |
| 79 | + | wantRows: 2, | |
| 80 | + | wantTitle: "zero-columns", | |
| 81 | + | wantCursor: CursorSnapshot{Visible: true}, | |
| 82 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 83 | + | require.NotNil(t, snapshot.BorrowedRow(0)) | |
| 84 | + | require.Empty(t, snapshot.BorrowedRow(0)) | |
| 85 | + | require.Nil(t, snapshot.Row(0)) | |
| 86 | + | require.Empty(t, snapshot.Row(1)) | |
| 87 | + | require.Equal(t, LineBound{}, snapshot.Bound(1)) | |
| 88 | + | require.Nil(t, snapshot.Row(2)) | |
| 89 | + | }, | |
| 90 | + | }, | |
| 91 | + | { | |
| 92 | + | name: "zero rows retains columns without rows", | |
| 93 | + | screen: func() *Screen { | |
| 94 | + | screen := NewScreen(2, 2) | |
| 95 | + | screen.Write([]byte("\x1b]2;zero-rows\x07")) | |
| 96 | + | screen.Resize(2, 0) | |
| 97 | + | return screen | |
| 98 | + | }, | |
| 99 | + | wantColumns: 2, | |
| 100 | + | wantTitle: "zero-rows", | |
| 101 | + | wantCursor: CursorSnapshot{Visible: true}, | |
| 102 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 103 | + | require.Nil(t, snapshot.BorrowedRow(0)) | |
| 104 | + | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| 105 | + | }, | |
| 106 | + | }, | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | for _, test := range tests { | |
| 110 | + | t.Run(test.name, func(t *testing.T) { | |
| 111 | + | snapshot := test.screen().Snapshot() | |
| 112 | + | require.Equal(t, test.wantColumns, snapshot.Columns()) | |
| 113 | + | require.Equal(t, test.wantRows, snapshot.Rows()) | |
| 114 | + | require.Equal(t, test.wantTitle, snapshot.Title()) | |
| 115 | + | require.Equal(t, test.wantCursor, snapshot.Cursor()) | |
| 116 | + | require.Equal(t, test.wantModes, snapshot.Modes()) | |
| 117 | + | test.checkRows(t, snapshot) | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestScreenSnapshotColorSchemeModeExcludesHostPreference(t *testing.T) { | |
| 123 | + | tests := []struct { | |
| 124 | + | name string | |
| 125 | + | run func(*Screen) | |
| 126 | + | want bool | |
| 127 | + | }{ | |
| 128 | + | { | |
| 129 | + | name: "host preference alone is not DEC 2031 mode", | |
| 130 | + | run: func(screen *Screen) { screen.SetColorScheme(true) }, | |
| 131 | + | want: false, | |
| 132 | + | }, | |
| 133 | + | { | |
| 134 | + | name: "DEC 2031 mode is captured independently", | |
| 135 | + | run: func(screen *Screen) { | |
| 136 | + | screen.SetColorScheme(false) | |
| 137 | + | screen.Write([]byte("\x1b[?2031h")) | |
| 138 | + | }, | |
| 139 | + | want: true, | |
| 140 | + | }, | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | for _, test := range tests { | |
| 144 | + | t.Run(test.name, func(t *testing.T) { | |
| 145 | + | screen := NewScreen(4, 2) | |
| 146 | + | test.run(screen) | |
| 147 | + | require.Equal(t, test.want, screen.Snapshot().Modes().ColorSchemeMode) | |
| 148 | + | }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestScreenSnapshotTitle(t *testing.T) { | |
| 153 | + | tests := []struct { | |
| 154 | + | name string | |
| 155 | + | feed string | |
| 156 | + | want string | |
| 157 | + | }{ | |
| 158 | + | {name: "empty title", want: ""}, | |
| 159 | + | {name: "latest title replaces the previous value", feed: "\x1b]2;first\x07\x1b]0;second\x07", want: "second"}, | |
| 160 | + | {name: "UTF-8 title is preserved", feed: "\x1b]2;éditeur 界\x07", want: "éditeur 界"}, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | for _, test := range tests { | |
| 164 | + | t.Run(test.name, func(t *testing.T) { | |
| 165 | + | screen := NewScreen(8, 2) | |
| 166 | + | screen.Write([]byte(test.feed)) | |
| 167 | + | require.Equal(t, test.want, screen.Snapshot().Title()) | |
| 168 | + | }) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func TestScreenSnapshotFidelity(t *testing.T) { | |
| 173 | + | tests := []struct { | |
| 174 | + | name string | |
| 175 | + | make func() (*Screen, []renderer.Cell, []LineBound) | |
| 176 | + | wantModes ModeSnapshot | |
| 177 | + | }{ | |
| 178 | + | { | |
| 179 | + | name: "styles wide continuation and bounds are exact", | |
| 180 | + | make: func() (*Screen, []renderer.Cell, []LineBound) { | |
| 181 | + | screen := NewScreen(4, 2) | |
| 182 | + | style := renderer.Style{ | |
| 183 | + | Bold: true, Italic: true, Inverse: true, | |
| 184 | + | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough, | |
| 185 | + | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 186 | + | Background: 17, | |
| 187 | + | UnderlineStyle: renderer.UnderlineCurly, | |
| 188 | + | HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 189 | + | } | |
| 190 | + | cells := []renderer.Cell{ | |
| 191 | + | {Rune: '界', Style: style}, | |
| 192 | + | {Continuation: true, Style: style}, | |
| 193 | + | {Rune: 'x', Style: renderer.DefaultStyle()}, | |
| 194 | + | renderer.BlankCell(), | |
| 195 | + | } | |
| 196 | + | copy(screen.Frame.Row(0), cells) | |
| 197 | + | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | |
| 198 | + | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | |
| 199 | + | }, | |
| 200 | + | }, | |
| 201 | + | { | |
| 202 | + | name: "active alternate screen is captured", | |
| 203 | + | make: func() (*Screen, []renderer.Cell, []LineBound) { | |
| 204 | + | screen := NewScreen(3, 1) | |
| 205 | + | screen.Write([]byte("primary\x1b[?1049halt")) | |
| 206 | + | cells := append([]renderer.Cell(nil), screen.Frame.Row(0)...) | |
| 207 | + | return screen, cells, screen.LineBounds() | |
| 208 | + | }, | |
| 209 | + | wantModes: ModeSnapshot{AlternateScreen: true}, | |
| 210 | + | }, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | for _, test := range tests { | |
| 214 | + | t.Run(test.name, func(t *testing.T) { | |
| 215 | + | screen, wantCells, wantBounds := test.make() | |
| 216 | + | snapshot := screen.Snapshot() | |
| 217 | + | require.Equal(t, screen.Frame.Width, snapshot.Columns()) | |
| 218 | + | require.Equal(t, screen.Frame.Height, snapshot.Rows()) | |
| 219 | + | require.Equal(t, wantCells, snapshot.Row(0)) | |
| 220 | + | require.Equal(t, test.wantModes, snapshot.Modes()) | |
| 221 | + | for row, want := range wantBounds { | |
| 222 | + | require.Equal(t, want, snapshot.Bound(row)) | |
| 223 | + | } | |
| 224 | + | }) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) { | |
| 229 | + | screen := NewScreen(3, 2) | |
| 230 | + | screen.Write([]byte("ABC")) | |
| 231 | + | screen.Write([]byte("DEF")) | |
| 232 | + | screen.Write([]byte("G")) | |
| 233 | + | ||
| 234 | + | snapshot := screen.Snapshot() | |
| 235 | + | for y, want := range []string{"DEF", "G "} { | |
| 236 | + | require.Equal(t, want, rowText(snapshot.Row(y))) | |
| 237 | + | require.Equal(t, screen.Frame.Row(y), snapshot.Row(y)) | |
| 238 | + | require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y)) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestScreenSnapshotOwnership(t *testing.T) { | |
| 243 | + | tests := []struct { | |
| 244 | + | name string | |
| 245 | + | mutate func(*Screen) | |
| 246 | + | }{ | |
| 247 | + | {name: "later write", mutate: func(screen *Screen) { screen.Write([]byte("later")) }}, | |
| 248 | + | {name: "later scroll", mutate: func(screen *Screen) { screen.Write([]byte("1111222233334444")) }}, | |
| 249 | + | {name: "later resize", mutate: func(screen *Screen) { screen.Resize(2, 4) }}, | |
| 250 | + | {name: "later alternate entry", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt")) }}, | |
| 251 | + | {name: "later alternate entry and exit", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt\x1b[?1049l")) }}, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | for _, test := range tests { | |
| 255 | + | t.Run(test.name, func(t *testing.T) { | |
| 256 | + | screen := NewScreen(4, 2) | |
| 257 | + | screen.Write([]byte("abcdEF")) | |
| 258 | + | screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true} | |
| 259 | + | snapshot := screen.Snapshot() | |
| 260 | + | wantRows := [][]renderer.Cell{snapshot.Row(0), snapshot.Row(1)} | |
| 261 | + | wantBounds := []LineBound{snapshot.Bound(0), snapshot.Bound(1)} | |
| 262 | + | ||
| 263 | + | test.mutate(screen) | |
| 264 | + | ||
| 265 | + | require.Equal(t, wantRows[0], snapshot.Row(0)) | |
| 266 | + | require.Equal(t, wantRows[1], snapshot.Row(1)) | |
| 267 | + | require.Equal(t, wantBounds[0], snapshot.Bound(0)) | |
| 268 | + | require.Equal(t, wantBounds[1], snapshot.Bound(1)) | |
| 269 | + | ||
| 270 | + | liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...) | |
| 271 | + | secondSnapshot := screen.Snapshot() | |
| 272 | + | secondSnapshotBefore := secondSnapshot.Row(0) | |
| 273 | + | ||
| 274 | + | callerCopy := snapshot.Row(0) | |
| 275 | + | callerCopy[0] = renderer.Cell{Rune: 'z'} | |
| 276 | + | ||
| 277 | + | require.Equal(t, wantRows[0], snapshot.BorrowedRow(0)) | |
| 278 | + | require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen") | |
| 279 | + | require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot") | |
| 280 | + | }) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestScreenSnapshotBoundsAreIndependent(t *testing.T) { | |
| 285 | + | tests := []struct { | |
| 286 | + | name string | |
| 287 | + | mutate func(*Screen) | |
| 288 | + | }{ | |
| 289 | + | { | |
| 290 | + | name: "mutating a single boundary entry after the snapshot", | |
| 291 | + | mutate: func(screen *Screen) { | |
| 292 | + | screen.buffer.boundaries[0] = LineBound{End: 1, Soft: false} | |
| 293 | + | }, | |
| 294 | + | }, | |
| 295 | + | { | |
| 296 | + | name: "replacing the whole boundaries slice after the snapshot", | |
| 297 | + | mutate: func(screen *Screen) { | |
| 298 | + | screen.buffer.boundaries = []LineBound{{End: 1, Soft: false}, {End: 1, Soft: false}} | |
| 299 | + | }, | |
| 300 | + | }, | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | for _, test := range tests { | |
| 304 | + | t.Run(test.name, func(t *testing.T) { | |
| 305 | + | screen := NewScreen(4, 2) | |
| 306 | + | screen.Write([]byte("abcdEF")) | |
| 307 | + | screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true} | |
| 308 | + | snapshot := screen.Snapshot() | |
| 309 | + | want := snapshot.Bound(0) | |
| 310 | + | ||
| 311 | + | test.mutate(screen) | |
| 312 | + | ||
| 313 | + | require.Equal(t, want, snapshot.Bound(0), "snapshot bounds must not alias Screen.buffer.boundaries") | |
| 314 | + | }) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |
| 319 | + | tests := []struct { | |
| 320 | + | name string | |
| 321 | + | make func() *Screen | |
| 322 | + | }{ | |
| 323 | + | { | |
| 324 | + | name: "screen without history", | |
| 325 | + | make: func() *Screen { | |
| 326 | + | screen := NewScreen(4, 2) | |
| 327 | + | screen.ClearDamage() | |
| 328 | + | screen.Write([]byte("abcdef")) | |
| 329 | + | return screen | |
| 330 | + | }, | |
| 331 | + | }, | |
| 332 | + | { | |
| 333 | + | name: "screen with sealed chunks and mutable tail", | |
| 334 | + | make: func() *Screen { | |
| 335 | + | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2}) | |
| 336 | + | screen.Write([]byte("111122223333444455")) | |
| 337 | + | return screen | |
| 338 | + | }, | |
| 339 | + | }, | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | for _, test := range tests { | |
| 343 | + | t.Run(test.name, func(t *testing.T) { | |
| 344 | + | screen := test.make() | |
| 345 | + | beforeDamage := screen.CaptureDamage() | |
| 346 | + | var beforeHistory HistorySnapshotView | |
| 347 | + | var beforeRows [][]renderer.Cell | |
| 348 | + | var beforeBounds []LineBound | |
| 349 | + | beforeCap, beforeCellCap := 0, 0 | |
| 350 | + | if screen.History() != nil { | |
| 351 | + | beforeHistory = screen.History().SnapshotView() | |
| 352 | + | beforeView := screen.History().View() | |
| 353 | + | beforeRows = make([][]renderer.Cell, beforeView.Len()) | |
| 354 | + | beforeBounds = make([]LineBound, beforeView.Len()) | |
| 355 | + | for i := range beforeRows { | |
| 356 | + | beforeRows[i] = beforeView.Row(i) | |
| 357 | + | beforeBounds[i] = beforeView.Bound(i) | |
| 358 | + | } | |
| 359 | + | beforeCap = screen.History().Cap() | |
| 360 | + | beforeCellCap = screen.History().CellCap() | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | _ = screen.Snapshot() | |
| 364 | + | ||
| 365 | + | afterDamage := screen.CaptureDamage() | |
| 366 | + | require.Equal(t, beforeDamage, afterDamage) | |
| 367 | + | if screen.History() != nil { | |
| 368 | + | afterHistory := screen.History().SnapshotView() | |
| 369 | + | afterView := screen.History().View() | |
| 370 | + | afterRows := make([][]renderer.Cell, afterView.Len()) | |
| 371 | + | afterBounds := make([]LineBound, afterView.Len()) | |
| 372 | + | for i := range afterRows { | |
| 373 | + | afterRows[i] = afterView.Row(i) | |
| 374 | + | afterBounds[i] = afterView.Bound(i) | |
| 375 | + | } | |
| 376 | + | require.Equal(t, beforeHistory.ChunkCount(), afterHistory.ChunkCount()) | |
| 377 | + | require.Equal(t, beforeHistory.Len(), afterHistory.Len()) | |
| 378 | + | require.Equal(t, beforeHistory.Cells(), afterHistory.Cells()) | |
| 379 | + | require.Equal(t, beforeHistory.Tail().Len(), afterHistory.Tail().Len()) | |
| 380 | + | require.Equal(t, beforeRows, afterRows) | |
| 381 | + | require.Equal(t, beforeBounds, afterBounds) | |
| 382 | + | require.Equal(t, beforeCap, screen.History().Cap()) | |
| 383 | + | require.Equal(t, beforeCellCap, screen.History().CellCap()) | |
| 384 | + | } | |
| 385 | + | }) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | func TestScreenSnapshotPreservesStaleDamageAcknowledge(t *testing.T) { | |
| 390 | + | tests := []struct { | |
| 391 | + | name string | |
| 392 | + | mutate func(*Screen) | |
| 393 | + | }{ | |
| 394 | + | {name: "write after capture", mutate: func(screen *Screen) { screen.Write([]byte("b")) }}, | |
| 395 | + | {name: "resize after capture", mutate: func(screen *Screen) { screen.Resize(6, 2) }}, | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | for _, test := range tests { | |
| 399 | + | t.Run(test.name, func(t *testing.T) { | |
| 400 | + | screen := NewScreen(4, 2) | |
| 401 | + | screen.ClearDamage() | |
| 402 | + | screen.Write([]byte("a")) | |
| 403 | + | captured := screen.CaptureDamage() | |
| 404 | + | ||
| 405 | + | _ = screen.Snapshot() | |
| 406 | + | test.mutate(screen) | |
| 407 | + | ||
| 408 | + | require.False(t, screen.AcknowledgeDamage(captured.Generation)) | |
| 409 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage()) | |
| 410 | + | ||
| 411 | + | screen.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}) | |
| 412 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage(), "full redraw stays sticky until exact acknowledgement") | |
| 413 | + | }) | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func TestScreenSnapshotPreservesExactDamageAcknowledge(t *testing.T) { | |
| 418 | + | tests := []struct { | |
| 419 | + | name string | |
| 420 | + | }{ | |
| 421 | + | {name: "snapshot does not stale an exact damage capture"}, | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | for _, test := range tests { | |
| 425 | + | t.Run(test.name, func(t *testing.T) { | |
| 426 | + | screen := NewScreen(4, 2) | |
| 427 | + | screen.ClearDamage() | |
| 428 | + | screen.Write([]byte("a")) | |
| 429 | + | captured := screen.CaptureDamage() | |
| 430 | + | ||
| 431 | + | _ = screen.Snapshot() | |
| 432 | + | ||
| 433 | + | require.True(t, screen.AcknowledgeDamage(captured.Generation)) | |
| 434 | + | require.Empty(t, screen.Damage()) | |
| 435 | + | }) | |
| 436 | + | } | |
| 437 | + | } | |
| @@ -0,0 +1,437 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | screen func() *Screen | |||
| 14 | + | wantColumns int | |||
| 15 | + | wantRows int | |||
| 16 | + | wantTitle string | |||
| 17 | + | wantCursor CursorSnapshot | |||
| 18 | + | wantModes ModeSnapshot | |||
| 19 | + | checkRows func(t *testing.T, snapshot ScreenSnapshot) | |||
| 20 | + | }{ | |||
| 21 | + | { | |||
| 22 | + | name: "nil receiver is the zero snapshot", | |||
| 23 | + | screen: func() *Screen { return nil }, | |||
| 24 | + | wantCursor: CursorSnapshot{}, | |||
| 25 | + | wantModes: ModeSnapshot{}, | |||
| 26 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 27 | + | require.Nil(t, snapshot.Row(0)) | |||
| 28 | + | require.Nil(t, snapshot.BorrowedRow(0)) | |||
| 29 | + | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |||
| 30 | + | }, | |||
| 31 | + | }, | |||
| 32 | + | { | |||
| 33 | + | name: "normal viewport captures cursor modes and title", | |||
| 34 | + | screen: func() *Screen { | |||
| 35 | + | screen := NewScreen(6, 3) | |||
| 36 | + | screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h")) | |||
| 37 | + | return screen | |||
| 38 | + | }, | |||
| 39 | + | wantColumns: 6, | |||
| 40 | + | wantRows: 3, | |||
| 41 | + | wantTitle: "editor", | |||
| 42 | + | wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true}, | |||
| 43 | + | wantModes: ModeSnapshot{ | |||
| 44 | + | BracketedPaste: true, | |||
| 45 | + | SynchronizedUpdate: true, | |||
| 46 | + | ColorSchemeMode: true, | |||
| 47 | + | MouseTracking: 1002, | |||
| 48 | + | MouseSGR: true, | |||
| 49 | + | }, | |||
| 50 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 51 | + | require.Len(t, snapshot.BorrowedRow(0), 6) | |||
| 52 | + | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |||
| 53 | + | }, | |||
| 54 | + | }, | |||
| 55 | + | { | |||
| 56 | + | name: "zero by zero preserves screen metadata", | |||
| 57 | + | screen: func() *Screen { | |||
| 58 | + | screen := NewScreen(2, 2) | |||
| 59 | + | screen.Write([]byte("\x1b]2;collapsed\x07\x1b[?2004h")) | |||
| 60 | + | screen.Resize(0, 0) | |||
| 61 | + | return screen | |||
| 62 | + | }, | |||
| 63 | + | wantTitle: "collapsed", | |||
| 64 | + | wantCursor: CursorSnapshot{Visible: true}, | |||
| 65 | + | wantModes: ModeSnapshot{BracketedPaste: true}, | |||
| 66 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 67 | + | require.Nil(t, snapshot.Row(0)) | |||
| 68 | + | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |||
| 69 | + | }, | |||
| 70 | + | }, | |||
| 71 | + | { | |||
| 72 | + | name: "zero columns retains empty physical rows and bounds", | |||
| 73 | + | screen: func() *Screen { | |||
| 74 | + | screen := NewScreen(2, 2) | |||
| 75 | + | screen.Write([]byte("\x1b]2;zero-columns\x07")) | |||
| 76 | + | screen.Resize(0, 2) | |||
| 77 | + | return screen | |||
| 78 | + | }, | |||
| 79 | + | wantRows: 2, | |||
| 80 | + | wantTitle: "zero-columns", | |||
| 81 | + | wantCursor: CursorSnapshot{Visible: true}, | |||
| 82 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 83 | + | require.NotNil(t, snapshot.BorrowedRow(0)) | |||
| 84 | + | require.Empty(t, snapshot.BorrowedRow(0)) | |||
| 85 | + | require.Nil(t, snapshot.Row(0)) | |||
| 86 | + | require.Empty(t, snapshot.Row(1)) | |||
| 87 | + | require.Equal(t, LineBound{}, snapshot.Bound(1)) | |||
| 88 | + | require.Nil(t, snapshot.Row(2)) | |||
| 89 | + | }, | |||
| 90 | + | }, | |||
| 91 | + | { | |||
| 92 | + | name: "zero rows retains columns without rows", | |||
| 93 | + | screen: func() *Screen { | |||
| 94 | + | screen := NewScreen(2, 2) | |||
| 95 | + | screen.Write([]byte("\x1b]2;zero-rows\x07")) | |||
| 96 | + | screen.Resize(2, 0) | |||
| 97 | + | return screen | |||
| 98 | + | }, | |||
| 99 | + | wantColumns: 2, | |||
| 100 | + | wantTitle: "zero-rows", | |||
| 101 | + | wantCursor: CursorSnapshot{Visible: true}, | |||
| 102 | + | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 103 | + | require.Nil(t, snapshot.BorrowedRow(0)) | |||
| 104 | + | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |||
| 105 | + | }, | |||
| 106 | + | }, | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | for _, test := range tests { | |||
| 110 | + | t.Run(test.name, func(t *testing.T) { | |||
| 111 | + | snapshot := test.screen().Snapshot() | |||
| 112 | + | require.Equal(t, test.wantColumns, snapshot.Columns()) | |||
| 113 | + | require.Equal(t, test.wantRows, snapshot.Rows()) | |||
| 114 | + | require.Equal(t, test.wantTitle, snapshot.Title()) | |||
| 115 | + | require.Equal(t, test.wantCursor, snapshot.Cursor()) | |||
| 116 | + | require.Equal(t, test.wantModes, snapshot.Modes()) | |||
| 117 | + | test.checkRows(t, snapshot) | |||
| 118 | + | }) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestScreenSnapshotColorSchemeModeExcludesHostPreference(t *testing.T) { | |||
| 123 | + | tests := []struct { | |||
| 124 | + | name string | |||
| 125 | + | run func(*Screen) | |||
| 126 | + | want bool | |||
| 127 | + | }{ | |||
| 128 | + | { | |||
| 129 | + | name: "host preference alone is not DEC 2031 mode", | |||
| 130 | + | run: func(screen *Screen) { screen.SetColorScheme(true) }, | |||
| 131 | + | want: false, | |||
| 132 | + | }, | |||
| 133 | + | { | |||
| 134 | + | name: "DEC 2031 mode is captured independently", | |||
| 135 | + | run: func(screen *Screen) { | |||
| 136 | + | screen.SetColorScheme(false) | |||
| 137 | + | screen.Write([]byte("\x1b[?2031h")) | |||
| 138 | + | }, | |||
| 139 | + | want: true, | |||
| 140 | + | }, | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | for _, test := range tests { | |||
| 144 | + | t.Run(test.name, func(t *testing.T) { | |||
| 145 | + | screen := NewScreen(4, 2) | |||
| 146 | + | test.run(screen) | |||
| 147 | + | require.Equal(t, test.want, screen.Snapshot().Modes().ColorSchemeMode) | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestScreenSnapshotTitle(t *testing.T) { | |||
| 153 | + | tests := []struct { | |||
| 154 | + | name string | |||
| 155 | + | feed string | |||
| 156 | + | want string | |||
| 157 | + | }{ | |||
| 158 | + | {name: "empty title", want: ""}, | |||
| 159 | + | {name: "latest title replaces the previous value", feed: "\x1b]2;first\x07\x1b]0;second\x07", want: "second"}, | |||
| 160 | + | {name: "UTF-8 title is preserved", feed: "\x1b]2;éditeur 界\x07", want: "éditeur 界"}, | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | for _, test := range tests { | |||
| 164 | + | t.Run(test.name, func(t *testing.T) { | |||
| 165 | + | screen := NewScreen(8, 2) | |||
| 166 | + | screen.Write([]byte(test.feed)) | |||
| 167 | + | require.Equal(t, test.want, screen.Snapshot().Title()) | |||
| 168 | + | }) | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestScreenSnapshotFidelity(t *testing.T) { | |||
| 173 | + | tests := []struct { | |||
| 174 | + | name string | |||
| 175 | + | make func() (*Screen, []renderer.Cell, []LineBound) | |||
| 176 | + | wantModes ModeSnapshot | |||
| 177 | + | }{ | |||
| 178 | + | { | |||
| 179 | + | name: "styles wide continuation and bounds are exact", | |||
| 180 | + | make: func() (*Screen, []renderer.Cell, []LineBound) { | |||
| 181 | + | screen := NewScreen(4, 2) | |||
| 182 | + | style := renderer.Style{ | |||
| 183 | + | Bold: true, Italic: true, Inverse: true, | |||
| 184 | + | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough, | |||
| 185 | + | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 186 | + | Background: 17, | |||
| 187 | + | UnderlineStyle: renderer.UnderlineCurly, | |||
| 188 | + | HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 189 | + | } | |||
| 190 | + | cells := []renderer.Cell{ | |||
| 191 | + | {Rune: '界', Style: style}, | |||
| 192 | + | {Continuation: true, Style: style}, | |||
| 193 | + | {Rune: 'x', Style: renderer.DefaultStyle()}, | |||
| 194 | + | renderer.BlankCell(), | |||
| 195 | + | } | |||
| 196 | + | copy(screen.Frame.Row(0), cells) | |||
| 197 | + | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | |||
| 198 | + | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | |||
| 199 | + | }, | |||
| 200 | + | }, | |||
| 201 | + | { | |||
| 202 | + | name: "active alternate screen is captured", | |||
| 203 | + | make: func() (*Screen, []renderer.Cell, []LineBound) { | |||
| 204 | + | screen := NewScreen(3, 1) | |||
| 205 | + | screen.Write([]byte("primary\x1b[?1049halt")) | |||
| 206 | + | cells := append([]renderer.Cell(nil), screen.Frame.Row(0)...) | |||
| 207 | + | return screen, cells, screen.LineBounds() | |||
| 208 | + | }, | |||
| 209 | + | wantModes: ModeSnapshot{AlternateScreen: true}, | |||
| 210 | + | }, | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | for _, test := range tests { | |||
| 214 | + | t.Run(test.name, func(t *testing.T) { | |||
| 215 | + | screen, wantCells, wantBounds := test.make() | |||
| 216 | + | snapshot := screen.Snapshot() | |||
| 217 | + | require.Equal(t, screen.Frame.Width, snapshot.Columns()) | |||
| 218 | + | require.Equal(t, screen.Frame.Height, snapshot.Rows()) | |||
| 219 | + | require.Equal(t, wantCells, snapshot.Row(0)) | |||
| 220 | + | require.Equal(t, test.wantModes, snapshot.Modes()) | |||
| 221 | + | for row, want := range wantBounds { | |||
| 222 | + | require.Equal(t, want, snapshot.Bound(row)) | |||
| 223 | + | } | |||
| 224 | + | }) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) { | |||
| 229 | + | screen := NewScreen(3, 2) | |||
| 230 | + | screen.Write([]byte("ABC")) | |||
| 231 | + | screen.Write([]byte("DEF")) | |||
| 232 | + | screen.Write([]byte("G")) | |||
| 233 | + | ||||
| 234 | + | snapshot := screen.Snapshot() | |||
| 235 | + | for y, want := range []string{"DEF", "G "} { | |||
| 236 | + | require.Equal(t, want, rowText(snapshot.Row(y))) | |||
| 237 | + | require.Equal(t, screen.Frame.Row(y), snapshot.Row(y)) | |||
| 238 | + | require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y)) | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func TestScreenSnapshotOwnership(t *testing.T) { | |||
| 243 | + | tests := []struct { | |||
| 244 | + | name string | |||
| 245 | + | mutate func(*Screen) | |||
| 246 | + | }{ | |||
| 247 | + | {name: "later write", mutate: func(screen *Screen) { screen.Write([]byte("later")) }}, | |||
| 248 | + | {name: "later scroll", mutate: func(screen *Screen) { screen.Write([]byte("1111222233334444")) }}, | |||
| 249 | + | {name: "later resize", mutate: func(screen *Screen) { screen.Resize(2, 4) }}, | |||
| 250 | + | {name: "later alternate entry", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt")) }}, | |||
| 251 | + | {name: "later alternate entry and exit", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt\x1b[?1049l")) }}, | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | for _, test := range tests { | |||
| 255 | + | t.Run(test.name, func(t *testing.T) { | |||
| 256 | + | screen := NewScreen(4, 2) | |||
| 257 | + | screen.Write([]byte("abcdEF")) | |||
| 258 | + | screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true} | |||
| 259 | + | snapshot := screen.Snapshot() | |||
| 260 | + | wantRows := [][]renderer.Cell{snapshot.Row(0), snapshot.Row(1)} | |||
| 261 | + | wantBounds := []LineBound{snapshot.Bound(0), snapshot.Bound(1)} | |||
| 262 | + | ||||
| 263 | + | test.mutate(screen) | |||
| 264 | + | ||||
| 265 | + | require.Equal(t, wantRows[0], snapshot.Row(0)) | |||
| 266 | + | require.Equal(t, wantRows[1], snapshot.Row(1)) | |||
| 267 | + | require.Equal(t, wantBounds[0], snapshot.Bound(0)) | |||
| 268 | + | require.Equal(t, wantBounds[1], snapshot.Bound(1)) | |||
| 269 | + | ||||
| 270 | + | liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...) | |||
| 271 | + | secondSnapshot := screen.Snapshot() | |||
| 272 | + | secondSnapshotBefore := secondSnapshot.Row(0) | |||
| 273 | + | ||||
| 274 | + | callerCopy := snapshot.Row(0) | |||
| 275 | + | callerCopy[0] = renderer.Cell{Rune: 'z'} | |||
| 276 | + | ||||
| 277 | + | require.Equal(t, wantRows[0], snapshot.BorrowedRow(0)) | |||
| 278 | + | require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen") | |||
| 279 | + | require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot") | |||
| 280 | + | }) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func TestScreenSnapshotBoundsAreIndependent(t *testing.T) { | |||
| 285 | + | tests := []struct { | |||
| 286 | + | name string | |||
| 287 | + | mutate func(*Screen) | |||
| 288 | + | }{ | |||
| 289 | + | { | |||
| 290 | + | name: "mutating a single boundary entry after the snapshot", | |||
| 291 | + | mutate: func(screen *Screen) { | |||
| 292 | + | screen.buffer.boundaries[0] = LineBound{End: 1, Soft: false} | |||
| 293 | + | }, | |||
| 294 | + | }, | |||
| 295 | + | { | |||
| 296 | + | name: "replacing the whole boundaries slice after the snapshot", | |||
| 297 | + | mutate: func(screen *Screen) { | |||
| 298 | + | screen.buffer.boundaries = []LineBound{{End: 1, Soft: false}, {End: 1, Soft: false}} | |||
| 299 | + | }, | |||
| 300 | + | }, | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | for _, test := range tests { | |||
| 304 | + | t.Run(test.name, func(t *testing.T) { | |||
| 305 | + | screen := NewScreen(4, 2) | |||
| 306 | + | screen.Write([]byte("abcdEF")) | |||
| 307 | + | screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true} | |||
| 308 | + | snapshot := screen.Snapshot() | |||
| 309 | + | want := snapshot.Bound(0) | |||
| 310 | + | ||||
| 311 | + | test.mutate(screen) | |||
| 312 | + | ||||
| 313 | + | require.Equal(t, want, snapshot.Bound(0), "snapshot bounds must not alias Screen.buffer.boundaries") | |||
| 314 | + | }) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||
| 319 | + | tests := []struct { | |||
| 320 | + | name string | |||
| 321 | + | make func() *Screen | |||
| 322 | + | }{ | |||
| 323 | + | { | |||
| 324 | + | name: "screen without history", | |||
| 325 | + | make: func() *Screen { | |||
| 326 | + | screen := NewScreen(4, 2) | |||
| 327 | + | screen.ClearDamage() | |||
| 328 | + | screen.Write([]byte("abcdef")) | |||
| 329 | + | return screen | |||
| 330 | + | }, | |||
| 331 | + | }, | |||
| 332 | + | { | |||
| 333 | + | name: "screen with sealed chunks and mutable tail", | |||
| 334 | + | make: func() *Screen { | |||
| 335 | + | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2}) | |||
| 336 | + | screen.Write([]byte("111122223333444455")) | |||
| 337 | + | return screen | |||
| 338 | + | }, | |||
| 339 | + | }, | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | for _, test := range tests { | |||
| 343 | + | t.Run(test.name, func(t *testing.T) { | |||
| 344 | + | screen := test.make() | |||
| 345 | + | beforeDamage := screen.CaptureDamage() | |||
| 346 | + | var beforeHistory HistorySnapshotView | |||
| 347 | + | var beforeRows [][]renderer.Cell | |||
| 348 | + | var beforeBounds []LineBound | |||
| 349 | + | beforeCap, beforeCellCap := 0, 0 | |||
| 350 | + | if screen.History() != nil { | |||
| 351 | + | beforeHistory = screen.History().SnapshotView() | |||
| 352 | + | beforeView := screen.History().View() | |||
| 353 | + | beforeRows = make([][]renderer.Cell, beforeView.Len()) | |||
| 354 | + | beforeBounds = make([]LineBound, beforeView.Len()) | |||
| 355 | + | for i := range beforeRows { | |||
| 356 | + | beforeRows[i] = beforeView.Row(i) | |||
| 357 | + | beforeBounds[i] = beforeView.Bound(i) | |||
| 358 | + | } | |||
| 359 | + | beforeCap = screen.History().Cap() | |||
| 360 | + | beforeCellCap = screen.History().CellCap() | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | _ = screen.Snapshot() | |||
| 364 | + | ||||
| 365 | + | afterDamage := screen.CaptureDamage() | |||
| 366 | + | require.Equal(t, beforeDamage, afterDamage) | |||
| 367 | + | if screen.History() != nil { | |||
| 368 | + | afterHistory := screen.History().SnapshotView() | |||
| 369 | + | afterView := screen.History().View() | |||
| 370 | + | afterRows := make([][]renderer.Cell, afterView.Len()) | |||
| 371 | + | afterBounds := make([]LineBound, afterView.Len()) | |||
| 372 | + | for i := range afterRows { | |||
| 373 | + | afterRows[i] = afterView.Row(i) | |||
| 374 | + | afterBounds[i] = afterView.Bound(i) | |||
| 375 | + | } | |||
| 376 | + | require.Equal(t, beforeHistory.ChunkCount(), afterHistory.ChunkCount()) | |||
| 377 | + | require.Equal(t, beforeHistory.Len(), afterHistory.Len()) | |||
| 378 | + | require.Equal(t, beforeHistory.Cells(), afterHistory.Cells()) | |||
| 379 | + | require.Equal(t, beforeHistory.Tail().Len(), afterHistory.Tail().Len()) | |||
| 380 | + | require.Equal(t, beforeRows, afterRows) | |||
| 381 | + | require.Equal(t, beforeBounds, afterBounds) | |||
| 382 | + | require.Equal(t, beforeCap, screen.History().Cap()) | |||
| 383 | + | require.Equal(t, beforeCellCap, screen.History().CellCap()) | |||
| 384 | + | } | |||
| 385 | + | }) | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func TestScreenSnapshotPreservesStaleDamageAcknowledge(t *testing.T) { | |||
| 390 | + | tests := []struct { | |||
| 391 | + | name string | |||
| 392 | + | mutate func(*Screen) | |||
| 393 | + | }{ | |||
| 394 | + | {name: "write after capture", mutate: func(screen *Screen) { screen.Write([]byte("b")) }}, | |||
| 395 | + | {name: "resize after capture", mutate: func(screen *Screen) { screen.Resize(6, 2) }}, | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | for _, test := range tests { | |||
| 399 | + | t.Run(test.name, func(t *testing.T) { | |||
| 400 | + | screen := NewScreen(4, 2) | |||
| 401 | + | screen.ClearDamage() | |||
| 402 | + | screen.Write([]byte("a")) | |||
| 403 | + | captured := screen.CaptureDamage() | |||
| 404 | + | ||||
| 405 | + | _ = screen.Snapshot() | |||
| 406 | + | test.mutate(screen) | |||
| 407 | + | ||||
| 408 | + | require.False(t, screen.AcknowledgeDamage(captured.Generation)) | |||
| 409 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage()) | |||
| 410 | + | ||||
| 411 | + | screen.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}) | |||
| 412 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage(), "full redraw stays sticky until exact acknowledgement") | |||
| 413 | + | }) | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func TestScreenSnapshotPreservesExactDamageAcknowledge(t *testing.T) { | |||
| 418 | + | tests := []struct { | |||
| 419 | + | name string | |||
| 420 | + | }{ | |||
| 421 | + | {name: "snapshot does not stale an exact damage capture"}, | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | for _, test := range tests { | |||
| 425 | + | t.Run(test.name, func(t *testing.T) { | |||
| 426 | + | screen := NewScreen(4, 2) | |||
| 427 | + | screen.ClearDamage() | |||
| 428 | + | screen.Write([]byte("a")) | |||
| 429 | + | captured := screen.CaptureDamage() | |||
| 430 | + | ||||
| 431 | + | _ = screen.Snapshot() | |||
| 432 | + | ||||
| 433 | + | require.True(t, screen.AcknowledgeDamage(captured.Generation)) | |||
| 434 | + | require.Empty(t, screen.Damage()) | |||
| 435 | + | }) | |||
| 436 | + | } | |||
| 437 | + | } | |||