detached 5f5218d

test(snapshot): gate v3 history delivery

commit
5f5218dda755a86b5ecbfbc2d60a12cc375c493e
author
brice · · 2026-07-12 17:18 UTC
parent
ed9b078
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +402 -0
A internal/usecase/daemon/snapshot_acceptance_test.go +249 -0
@@ -0,0 +1,249 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13+ "github.com/bnema/vev/internal/usecase/visualsearch"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+)
17+
18+func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) {
19+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
20+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
21+ WithSnapshotStore(store)(d)
22+ startSnapshotEncodeWorker(t, d)
23+
24+ sess := newSnapshotTestSession(t, "acceptance", false, "/work")
25+ first := sess.tabs[0]
26+ first.size = domain.Size{Cols: 80, Rows: 24}
27+ first.stableID = "tab-one"
28+ first.nextPaneID = 3
29+ first.panes["pane-1"].stableID = "pane-one"
30+ secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24})
31+ secondPane.history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'w'}, {Rune: 'o'}})
32+ secondPane.screen.Write([]byte("two"))
33+ first.panes["pane-2"] = secondPane
34+ first.tree = &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
35+ layout.NewLeaf("pane-1"),
36+ layout.NewLeaf("pane-2"),
37+ }}}
38+
39+ second := newTab(snapshotAcceptancePTY(t), domain.Size{Cols: 90, Rows: 30})
40+ second.stableID = "tab-two"
41+ second.nextPaneID = 2
42+ second.panes["pane-1"].stableID = "pane-three"
43+ second.panes["pane-1"].history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'h'}, {Rune: 'r'}, {Rune: 'e'}, {Rune: 'e'}})
44+ second.panes["pane-1"].screen.Write([]byte("three"))
45+ sess.tabs = []*tab{first, second}
46+ sess.active = 1
47+
48+ require.True(t, d.captureSession(sess))
49+ encoded := <-store.writes
50+ captured, err := snapcodec.Unmarshal(encoded)
51+ require.NoError(t, err)
52+ assertCapturedSessionTopology(t, captured)
53+
54+ restoreFactory := &restorePTYFactory{}
55+ restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{})
56+ require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured))
57+ restoredDaemon.mu.Lock()
58+ restored := restoredDaemon.findByNameLocked("acceptance")
59+ restoredDaemon.mu.Unlock()
60+ require.NotNil(t, restored)
61+ require.Equal(t, 1, restored.active)
62+ require.Len(t, restored.tabs, 2)
63+
64+ for _, tt := range []struct {
65+ name string
66+ tab int
67+ pane layout.PaneID
68+ wantRow string
69+ wantFocus layout.PaneID
70+ }{
71+ {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"},
72+ {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"},
73+ } {
74+ t.Run(tt.name, func(t *testing.T) {
75+ tab := restored.tabs[tt.tab]
76+ tab.mu.Lock()
77+ defer tab.mu.Unlock()
78+ require.Equal(t, tt.wantFocus, tab.tree.Focus)
79+ pane := tab.panes[tt.pane]
80+ require.NotNil(t, pane)
81+ pane.mu.Lock()
82+ defer pane.mu.Unlock()
83+ require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0)))
84+ })
85+ }
86+}
87+
88+func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) {
89+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
90+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
91+ WithSnapshotStore(store)(d)
92+ startSnapshotEncodeWorker(t, d)
93+
94+ entered := make(chan struct{})
95+ release := make(chan struct{})
96+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
97+ close(entered)
98+ <-release
99+ return snapcodec.Marshal(s)
100+ }
101+ sess := newSnapshotTestSession(t, "frozen", false, "/work")
102+ pane := sess.tabs[0].panes["pane-1"]
103+ pane.history = vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
104+ pane.history.Append([]renderer.Cell{{Rune: 'o'}, {Rune: 'l'}, {Rune: 'd'}})
105+ pane.screen = vt.NewScreen(8, 3)
106+ pane.screen.Write([]byte("before"))
107+
108+ require.True(t, d.captureSession(sess))
109+ <-entered
110+ pane.mu.Lock()
111+ pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}})
112+ pane.history.Append([]renderer.Cell{{Rune: 'e'}, {Rune: 'v'}, {Rune: 'i'}, {Rune: 'c'}, {Rune: 't'}})
113+ pane.screen.Write([]byte("\rafter"))
114+ pane.mu.Unlock()
115+ close(release)
116+
117+ captured, err := snapcodec.Unmarshal(<-store.writes)
118+ require.NoError(t, err)
119+ restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail)
120+ require.NoError(t, err)
121+ require.Equal(t, "old", rowText(restoredHistory.View().Row(0)))
122+ frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible)
123+ require.NoError(t, err)
124+ require.Contains(t, rowText(frame.Row(0)), "before")
125+}
126+
127+func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) {
128+ sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work")
129+ pane := sess.tabs[0].panes["pane-1"]
130+ pane.history = vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
131+ for _, text := range []string{"first", "second"} {
132+ pane.history.Append(cells(text))
133+ }
134+ pane.screen = vt.NewScreen(8, 1)
135+ pane.screen.Write([]byte("screen"))
136+
137+ document := scopy.NewSnapshot(pane.history, pane.screen.Frame)
138+ for _, text := range []string{"third", "fourth"} {
139+ pane.history.Append(cells(text))
140+ }
141+
142+ for _, tt := range []struct {
143+ name string
144+ rows func() [][]renderer.Cell
145+ want []string
146+ }{
147+ {name: "bounded live history is oldest first after eviction", rows: func() [][]renderer.Cell {
148+ view := pane.history.View()
149+ rows := make([][]renderer.Cell, view.Len())
150+ for i := range rows {
151+ rows[i] = view.Row(i)
152+ }
153+ return rows
154+ }, want: []string{"third", "fourth"}},
155+ {name: "captured document retains evicted rows", rows: func() [][]renderer.Cell {
156+ rows := make([][]renderer.Cell, document.Len()-document.Height)
157+ for i := range rows {
158+ rows[i] = document.Row(i)
159+ }
160+ return rows
161+ }, want: []string{"first", "second"}},
162+ } {
163+ t.Run(tt.name, func(t *testing.T) {
164+ rows := tt.rows()
165+ got := make([]string, len(rows))
166+ for i, row := range rows {
167+ got[i] = rowText(row)
168+ }
169+ require.Equal(t, tt.want, got)
170+ })
171+ }
172+
173+ search := visualsearch.New(document)
174+ for _, r := range "first" {
175+ search.Insert(r)
176+ }
177+ match, ok := search.Selected()
178+ require.True(t, ok)
179+ require.Equal(t, "first", match.Text)
180+}
181+
182+func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY {
183+ t.Helper()
184+ pty := portsmocks.NewMockPTY(t)
185+ pty.EXPECT().Pid().Return(0).Maybe()
186+ pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
187+ return pty
188+}
189+
190+func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) {
191+ t.Helper()
192+ require.Equal(t, "acceptance", got.Name)
193+ require.Equal(t, uint16(1), got.Active)
194+ require.Len(t, got.Tabs, 2)
195+ for _, tt := range []struct {
196+ name string
197+ tab int
198+ stableID string
199+ focus layout.PaneID
200+ nextPaneID uint64
201+ paneIDs []layout.PaneID
202+ wantSplit bool
203+ }{
204+ {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true},
205+ {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}},
206+ } {
207+ t.Run(tt.name, func(t *testing.T) {
208+ tab := got.Tabs[tt.tab]
209+ require.Equal(t, tt.stableID, tab.StableID)
210+ require.Equal(t, tt.focus, tab.Focus)
211+ require.Equal(t, tt.nextPaneID, tab.NextPaneID)
212+ require.NotNil(t, tab.Tree)
213+ require.Equal(t, tt.focus, tab.Tree.Focus)
214+ if tt.wantSplit {
215+ require.Equal(t, layout.Split, tab.Tree.Root.Kind)
216+ }
217+ ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
218+ for _, pane := range tab.Panes {
219+ ids[pane.ID] = struct{}{}
220+ }
221+ for _, id := range tt.paneIDs {
222+ _, ok := ids[id]
223+ require.True(t, ok, "missing pane %q", id)
224+ }
225+ _, focused := ids[tab.Focus]
226+ require.True(t, focused, "focus %q is dangling", tab.Focus)
227+ assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids)
228+ })
229+ }
230+}
231+
232+func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) {
233+ t.Helper()
234+ if node == nil {
235+ return
236+ }
237+ if node.Kind == layout.Leaf {
238+ _, ok := panes[node.Leaf]
239+ require.True(t, ok, "tree leaf %q is dangling", node.Leaf)
240+ return
241+ }
242+ if node.Kind == layout.Stack && node.Expanded != "" {
243+ _, ok := panes[node.Expanded]
244+ require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded)
245+ }
246+ for _, child := range node.Children {
247+ assertTreeReferencesCapturedPanes(t, child, panes)
248+ }
249+}
@@ -0,0 +1,249 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13+ "github.com/bnema/vev/internal/usecase/visualsearch"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+)
17+
18+func TestSnapshotCaptureMarshalUnmarshalRestorePreservesTopologyAndTerminalState(t *testing.T) {
19+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
20+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
21+ WithSnapshotStore(store)(d)
22+ startSnapshotEncodeWorker(t, d)
23+
24+ sess := newSnapshotTestSession(t, "acceptance", false, "/work")
25+ first := sess.tabs[0]
26+ first.size = domain.Size{Cols: 80, Rows: 24}
27+ first.stableID = "tab-one"
28+ first.nextPaneID = 3
29+ first.panes["pane-1"].stableID = "pane-one"
30+ secondPane := newPaneWithStableID("pane-2", "pane-two", snapshotAcceptancePTY(t), domain.Size{Cols: 40, Rows: 24})
31+ secondPane.history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'w'}, {Rune: 'o'}})
32+ secondPane.screen.Write([]byte("two"))
33+ first.panes["pane-2"] = secondPane
34+ first.tree = &layout.Tree{Focus: "pane-2", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
35+ layout.NewLeaf("pane-1"),
36+ layout.NewLeaf("pane-2"),
37+ }}}
38+
39+ second := newTab(snapshotAcceptancePTY(t), domain.Size{Cols: 90, Rows: 30})
40+ second.stableID = "tab-two"
41+ second.nextPaneID = 2
42+ second.panes["pane-1"].stableID = "pane-three"
43+ second.panes["pane-1"].history.Append([]renderer.Cell{{Rune: 't'}, {Rune: 'h'}, {Rune: 'r'}, {Rune: 'e'}, {Rune: 'e'}})
44+ second.panes["pane-1"].screen.Write([]byte("three"))
45+ sess.tabs = []*tab{first, second}
46+ sess.active = 1
47+
48+ require.True(t, d.captureSession(sess))
49+ encoded := <-store.writes
50+ captured, err := snapcodec.Unmarshal(encoded)
51+ require.NoError(t, err)
52+ assertCapturedSessionTopology(t, captured)
53+
54+ restoreFactory := &restorePTYFactory{}
55+ restoredDaemon := newTestDaemon(t, restoreFactory, stubClock{})
56+ require.NoError(t, restoredDaemon.restoreSession(t.Context(), captured))
57+ restoredDaemon.mu.Lock()
58+ restored := restoredDaemon.findByNameLocked("acceptance")
59+ restoredDaemon.mu.Unlock()
60+ require.NotNil(t, restored)
61+ require.Equal(t, 1, restored.active)
62+ require.Len(t, restored.tabs, 2)
63+
64+ for _, tt := range []struct {
65+ name string
66+ tab int
67+ pane layout.PaneID
68+ wantRow string
69+ wantFocus layout.PaneID
70+ }{
71+ {name: "first tab focused second pane", tab: 0, pane: "pane-2", wantRow: "two", wantFocus: "pane-2"},
72+ {name: "second tab focused only pane", tab: 1, pane: "pane-1", wantRow: "three", wantFocus: "pane-1"},
73+ } {
74+ t.Run(tt.name, func(t *testing.T) {
75+ tab := restored.tabs[tt.tab]
76+ tab.mu.Lock()
77+ defer tab.mu.Unlock()
78+ require.Equal(t, tt.wantFocus, tab.tree.Focus)
79+ pane := tab.panes[tt.pane]
80+ require.NotNil(t, pane)
81+ pane.mu.Lock()
82+ defer pane.mu.Unlock()
83+ require.Equal(t, tt.wantRow, rowText(pane.history.View().Row(0)))
84+ })
85+ }
86+}
87+
88+func TestSnapshotCaptureRetainsImmutableValuesAfterTailMutationAndEviction(t *testing.T) {
89+ store := &channelSnapshotStore{writes: make(chan []byte, 1)}
90+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
91+ WithSnapshotStore(store)(d)
92+ startSnapshotEncodeWorker(t, d)
93+
94+ entered := make(chan struct{})
95+ release := make(chan struct{})
96+ d.snapshotMarshal = func(s snapcodec.Session) ([]byte, error) {
97+ close(entered)
98+ <-release
99+ return snapcodec.Marshal(s)
100+ }
101+ sess := newSnapshotTestSession(t, "frozen", false, "/work")
102+ pane := sess.tabs[0].panes["pane-1"]
103+ pane.history = vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
104+ pane.history.Append([]renderer.Cell{{Rune: 'o'}, {Rune: 'l'}, {Rune: 'd'}})
105+ pane.screen = vt.NewScreen(8, 3)
106+ pane.screen.Write([]byte("before"))
107+
108+ require.True(t, d.captureSession(sess))
109+ <-entered
110+ pane.mu.Lock()
111+ pane.history.Append([]renderer.Cell{{Rune: 'n'}, {Rune: 'e'}, {Rune: 'w'}})
112+ pane.history.Append([]renderer.Cell{{Rune: 'e'}, {Rune: 'v'}, {Rune: 'i'}, {Rune: 'c'}, {Rune: 't'}})
113+ pane.screen.Write([]byte("\rafter"))
114+ pane.mu.Unlock()
115+ close(release)
116+
117+ captured, err := snapcodec.Unmarshal(<-store.writes)
118+ require.NoError(t, err)
119+ restoredHistory, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1}, captured.Tabs[0].Panes[0].SealedChunks, captured.Tabs[0].Panes[0].Tail)
120+ require.NoError(t, err)
121+ require.Equal(t, "old", rowText(restoredHistory.View().Row(0)))
122+ frame, err := vt.UnmarshalVisible(captured.Tabs[0].Panes[0].Visible)
123+ require.NoError(t, err)
124+ require.Contains(t, rowText(frame.Row(0)), "before")
125+}
126+
127+func TestDaemonHistoryEvictionLeavesCopyAndSearchSnapshotsUsable(t *testing.T) {
128+ sess := newSnapshotTestSession(t, "copy-lifetime", false, "/work")
129+ pane := sess.tabs[0].panes["pane-1"]
130+ pane.history = vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
131+ for _, text := range []string{"first", "second"} {
132+ pane.history.Append(cells(text))
133+ }
134+ pane.screen = vt.NewScreen(8, 1)
135+ pane.screen.Write([]byte("screen"))
136+
137+ document := scopy.NewSnapshot(pane.history, pane.screen.Frame)
138+ for _, text := range []string{"third", "fourth"} {
139+ pane.history.Append(cells(text))
140+ }
141+
142+ for _, tt := range []struct {
143+ name string
144+ rows func() [][]renderer.Cell
145+ want []string
146+ }{
147+ {name: "bounded live history is oldest first after eviction", rows: func() [][]renderer.Cell {
148+ view := pane.history.View()
149+ rows := make([][]renderer.Cell, view.Len())
150+ for i := range rows {
151+ rows[i] = view.Row(i)
152+ }
153+ return rows
154+ }, want: []string{"third", "fourth"}},
155+ {name: "captured document retains evicted rows", rows: func() [][]renderer.Cell {
156+ rows := make([][]renderer.Cell, document.Len()-document.Height)
157+ for i := range rows {
158+ rows[i] = document.Row(i)
159+ }
160+ return rows
161+ }, want: []string{"first", "second"}},
162+ } {
163+ t.Run(tt.name, func(t *testing.T) {
164+ rows := tt.rows()
165+ got := make([]string, len(rows))
166+ for i, row := range rows {
167+ got[i] = rowText(row)
168+ }
169+ require.Equal(t, tt.want, got)
170+ })
171+ }
172+
173+ search := visualsearch.New(document)
174+ for _, r := range "first" {
175+ search.Insert(r)
176+ }
177+ match, ok := search.Selected()
178+ require.True(t, ok)
179+ require.Equal(t, "first", match.Text)
180+}
181+
182+func snapshotAcceptancePTY(t *testing.T) *portsmocks.MockPTY {
183+ t.Helper()
184+ pty := portsmocks.NewMockPTY(t)
185+ pty.EXPECT().Pid().Return(0).Maybe()
186+ pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
187+ return pty
188+}
189+
190+func assertCapturedSessionTopology(t *testing.T, got snapcodec.Session) {
191+ t.Helper()
192+ require.Equal(t, "acceptance", got.Name)
193+ require.Equal(t, uint16(1), got.Active)
194+ require.Len(t, got.Tabs, 2)
195+ for _, tt := range []struct {
196+ name string
197+ tab int
198+ stableID string
199+ focus layout.PaneID
200+ nextPaneID uint64
201+ paneIDs []layout.PaneID
202+ wantSplit bool
203+ }{
204+ {name: "first split tab", tab: 0, stableID: "tab-one", focus: "pane-2", nextPaneID: 3, paneIDs: []layout.PaneID{"pane-1", "pane-2"}, wantSplit: true},
205+ {name: "second leaf tab", tab: 1, stableID: "tab-two", focus: "pane-1", nextPaneID: 2, paneIDs: []layout.PaneID{"pane-1"}},
206+ } {
207+ t.Run(tt.name, func(t *testing.T) {
208+ tab := got.Tabs[tt.tab]
209+ require.Equal(t, tt.stableID, tab.StableID)
210+ require.Equal(t, tt.focus, tab.Focus)
211+ require.Equal(t, tt.nextPaneID, tab.NextPaneID)
212+ require.NotNil(t, tab.Tree)
213+ require.Equal(t, tt.focus, tab.Tree.Focus)
214+ if tt.wantSplit {
215+ require.Equal(t, layout.Split, tab.Tree.Root.Kind)
216+ }
217+ ids := make(map[layout.PaneID]struct{}, len(tab.Panes))
218+ for _, pane := range tab.Panes {
219+ ids[pane.ID] = struct{}{}
220+ }
221+ for _, id := range tt.paneIDs {
222+ _, ok := ids[id]
223+ require.True(t, ok, "missing pane %q", id)
224+ }
225+ _, focused := ids[tab.Focus]
226+ require.True(t, focused, "focus %q is dangling", tab.Focus)
227+ assertTreeReferencesCapturedPanes(t, tab.Tree.Root, ids)
228+ })
229+ }
230+}
231+
232+func assertTreeReferencesCapturedPanes(t *testing.T, node *layout.Node, panes map[layout.PaneID]struct{}) {
233+ t.Helper()
234+ if node == nil {
235+ return
236+ }
237+ if node.Kind == layout.Leaf {
238+ _, ok := panes[node.Leaf]
239+ require.True(t, ok, "tree leaf %q is dangling", node.Leaf)
240+ return
241+ }
242+ if node.Kind == layout.Stack && node.Expanded != "" {
243+ _, ok := panes[node.Expanded]
244+ require.True(t, ok, "stack expanded pane %q is dangling", node.Expanded)
245+ }
246+ for _, child := range node.Children {
247+ assertTreeReferencesCapturedPanes(t, child, panes)
248+ }
249+}
A internal/usecase/snapshot/preflight_acceptance_test.go +45 -0
@@ -0,0 +1,45 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {
12+ sealed, tail := historyBlobs(t, nil)
13+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
14+ for _, tt := range []struct {
15+ name string
16+ tab Tab
17+ }{
18+ {
19+ name: "tree leaf",
20+ tab: Tab{
21+ Focus: "pane",
22+ Tree: layout.NewTree("missing"),
23+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},
24+ },
25+ },
26+ {
27+ name: "tab focus",
28+ tab: Tab{
29+ Focus: "missing",
30+ Tree: layout.NewTree("pane"),
31+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},
32+ },
33+ },
34+ } {
35+ t.Run(tt.name, func(t *testing.T) {
36+ encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}})
37+ if err != nil {
38+ t.Fatalf("Marshal: %v", err)
39+ }
40+ if err := preflightSession(encoded[16:]); !errors.Is(err, ErrUnknownPane) {
41+ t.Fatalf("preflightSession() error = %v, want %v", err, ErrUnknownPane)
42+ }
43+ })
44+ }
45+}
@@ -0,0 +1,45 @@
1+package snapshot
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/usecase/layout"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {
12+ sealed, tail := historyBlobs(t, nil)
13+ visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
14+ for _, tt := range []struct {
15+ name string
16+ tab Tab
17+ }{
18+ {
19+ name: "tree leaf",
20+ tab: Tab{
21+ Focus: "pane",
22+ Tree: layout.NewTree("missing"),
23+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},
24+ },
25+ },
26+ {
27+ name: "tab focus",
28+ tab: Tab{
29+ Focus: "missing",
30+ Tree: layout.NewTree("pane"),
31+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},
32+ },
33+ },
34+ } {
35+ t.Run(tt.name, func(t *testing.T) {
36+ encoded, err := Marshal(Session{Name: "hostile", Tabs: []Tab{tt.tab}})
37+ if err != nil {
38+ t.Fatalf("Marshal: %v", err)
39+ }
40+ if err := preflightSession(encoded[16:]); !errors.Is(err, ErrUnknownPane) {
41+ t.Fatalf("preflightSession() error = %v, want %v", err, ErrUnknownPane)
42+ }
43+ })
44+ }
45+}
A pkg/vt/history_acceptance_test.go +108 -0
@@ -0,0 +1,108 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *testing.T) {
10+ indexed := renderer.Style{
11+ Bold: true,
12+ Italic: true,
13+ Inverse: true,
14+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
15+ Foreground: 196,
16+ Background: 17,
17+ HasUnderlineColor: true,
18+ UnderlineColor: 203,
19+ }
20+ rgb := renderer.Style{
21+ Bold: true,
22+ Italic: true,
23+ Inverse: true,
24+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
25+ HasForegroundRGB: true,
26+ ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
27+ HasBackgroundRGB: true,
28+ BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6},
29+ HasUnderlineColorRGB: true,
30+ UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9},
31+ }
32+ underlines := []renderer.UnderlineStyle{
33+ renderer.UnderlineNone,
34+ renderer.UnderlineSingle,
35+ renderer.UnderlineDouble,
36+ renderer.UnderlineCurly,
37+ renderer.UnderlineDotted,
38+ renderer.UnderlineDashed,
39+ }
40+ makeRow := func(r rune, style renderer.Style) []renderer.Cell {
41+ style.UnderlineStyle = underlines[int(r)%len(underlines)]
42+ return []renderer.Cell{
43+ {Rune: r, Style: style},
44+ {Rune: ' ', Style: renderer.DefaultStyle()},
45+ {Rune: '界', Style: style},
46+ {Continuation: true, Style: style},
47+ }
48+ }
49+
50+ tests := []struct {
51+ name string
52+ run func(*testing.T)
53+ }{
54+ {
55+ name: "history view retains sealed chunks and mutable tail",
56+ run: func(t *testing.T) {
57+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
58+ // A through F exercise every underline style; G remains in the
59+ // mutable tail so both history ownership paths are encoded.
60+ want := [][]renderer.Cell{
61+ makeRow('A', indexed), makeRow('B', rgb), makeRow('C', indexed),
62+ makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed),
63+ }
64+ for _, row := range want {
65+ history.Append(row)
66+ }
67+ if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks {
68+ t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks)
69+ }
70+ encoded, err := MarshalHistory(history.View())
71+ if err != nil {
72+ t.Fatalf("MarshalHistory: %v", err)
73+ }
74+ got, err := UnmarshalHistory(encoded)
75+ if err != nil {
76+ t.Fatalf("UnmarshalHistory: %v", err)
77+ }
78+ assertHistoryRowsEqual(t, got, want)
79+ },
80+ },
81+ {
82+ name: "visible frame retains blanks wide heads continuations and styles",
83+ run: func(t *testing.T) {
84+ want := renderer.NewFrame(4, 2)
85+ copy(want.Row(0), makeRow('D', rgb))
86+ copy(want.Row(1), makeRow('E', indexed))
87+ encoded, err := MarshalVisible(want)
88+ if err != nil {
89+ t.Fatalf("MarshalVisible: %v", err)
90+ }
91+ got, err := UnmarshalVisible(encoded)
92+ if err != nil {
93+ t.Fatalf("UnmarshalVisible: %v", err)
94+ }
95+ for y := range want.Height {
96+ for x, cell := range want.Row(y) {
97+ if !got.At(x, y).Equal(cell) {
98+ t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, got.At(x, y), cell)
99+ }
100+ }
101+ }
102+ },
103+ },
104+ }
105+ for _, tt := range tests {
106+ t.Run(tt.name, tt.run)
107+ }
108+}
@@ -0,0 +1,108 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestSnapshotCodecsPreserveEveryTerminalCellAcrossSealedTailAndVisible(t *testing.T) {
10+ indexed := renderer.Style{
11+ Bold: true,
12+ Italic: true,
13+ Inverse: true,
14+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
15+ Foreground: 196,
16+ Background: 17,
17+ HasUnderlineColor: true,
18+ UnderlineColor: 203,
19+ }
20+ rgb := renderer.Style{
21+ Bold: true,
22+ Italic: true,
23+ Inverse: true,
24+ Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
25+ HasForegroundRGB: true,
26+ ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
27+ HasBackgroundRGB: true,
28+ BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6},
29+ HasUnderlineColorRGB: true,
30+ UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9},
31+ }
32+ underlines := []renderer.UnderlineStyle{
33+ renderer.UnderlineNone,
34+ renderer.UnderlineSingle,
35+ renderer.UnderlineDouble,
36+ renderer.UnderlineCurly,
37+ renderer.UnderlineDotted,
38+ renderer.UnderlineDashed,
39+ }
40+ makeRow := func(r rune, style renderer.Style) []renderer.Cell {
41+ style.UnderlineStyle = underlines[int(r)%len(underlines)]
42+ return []renderer.Cell{
43+ {Rune: r, Style: style},
44+ {Rune: ' ', Style: renderer.DefaultStyle()},
45+ {Rune: '界', Style: style},
46+ {Continuation: true, Style: style},
47+ }
48+ }
49+
50+ tests := []struct {
51+ name string
52+ run func(*testing.T)
53+ }{
54+ {
55+ name: "history view retains sealed chunks and mutable tail",
56+ run: func(t *testing.T) {
57+ history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
58+ // A through F exercise every underline style; G remains in the
59+ // mutable tail so both history ownership paths are encoded.
60+ want := [][]renderer.Cell{
61+ makeRow('A', indexed), makeRow('B', rgb), makeRow('C', indexed),
62+ makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed),
63+ }
64+ for _, row := range want {
65+ history.Append(row)
66+ }
67+ if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks {
68+ t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks)
69+ }
70+ encoded, err := MarshalHistory(history.View())
71+ if err != nil {
72+ t.Fatalf("MarshalHistory: %v", err)
73+ }
74+ got, err := UnmarshalHistory(encoded)
75+ if err != nil {
76+ t.Fatalf("UnmarshalHistory: %v", err)
77+ }
78+ assertHistoryRowsEqual(t, got, want)
79+ },
80+ },
81+ {
82+ name: "visible frame retains blanks wide heads continuations and styles",
83+ run: func(t *testing.T) {
84+ want := renderer.NewFrame(4, 2)
85+ copy(want.Row(0), makeRow('D', rgb))
86+ copy(want.Row(1), makeRow('E', indexed))
87+ encoded, err := MarshalVisible(want)
88+ if err != nil {
89+ t.Fatalf("MarshalVisible: %v", err)
90+ }
91+ got, err := UnmarshalVisible(encoded)
92+ if err != nil {
93+ t.Fatalf("UnmarshalVisible: %v", err)
94+ }
95+ for y := range want.Height {
96+ for x, cell := range want.Row(y) {
97+ if !got.At(x, y).Equal(cell) {
98+ t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, got.At(x, y), cell)
99+ }
100+ }
101+ }
102+ },
103+ },
104+ }
105+ for _, tt := range tests {
106+ t.Run(tt.name, tt.run)
107+ }
108+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close