1package daemon
2
3import (
4 "sync"
5 "testing"
6
7 "github.com/stretchr/testify/require"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/layout"
11)
12
13func catalogueUpdateNames(updates []domain.CatalogueMetadataUpdate) []string {
14 names := make([]string, 0, len(updates))
15 for _, update := range updates {
16 names = append(names, update.Name)
17 }
18 return names
19}
20
21type moveCatalogueRecorder struct {
22 daemon *Daemon
23 source *session
24 destination *session
25 mu sync.Mutex
26 updates []domain.CatalogueMetadataUpdate
27 lockViolation bool
28}
29
30func (c *moveCatalogueRecorder) Records() ([]domain.CatalogueRecord, error) { return nil, nil }
31func (c *moveCatalogueRecorder) Record(string) (domain.CatalogueRecord, bool, error) {
32 return domain.CatalogueRecord{}, false, nil
33}
34func (c *moveCatalogueRecorder) Create(domain.CatalogueRecord) error { return nil }
35func (c *moveCatalogueRecorder) Replace(string, domain.CatalogueRecord) error { return nil }
36func (c *moveCatalogueRecorder) Rename(string, domain.CatalogueRecord) error { return nil }
37func (c *moveCatalogueRecorder) Delete(string) error { return nil }
38func (c *moveCatalogueRecorder) Sync() error { return nil }
39func (c *moveCatalogueRecorder) Close() error { return nil }
40func (c *moveCatalogueRecorder) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
41 violated := false
42 if c.daemon.mu.TryLock() {
43 c.daemon.mu.Unlock()
44 } else {
45 violated = true
46 }
47 if c.source.mu.TryLock() {
48 c.source.mu.Unlock()
49 } else {
50 violated = true
51 }
52 if c.destination.mu.TryLock() {
53 c.destination.mu.Unlock()
54 } else {
55 violated = true
56 }
57 c.mu.Lock()
58 c.updates = append(c.updates, update)
59 c.lockViolation = c.lockViolation || violated
60 c.mu.Unlock()
61 return nil
62}
63
64func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T) {
65 movedPTY, releaseMoved := newBlockingPTY(t)
66 remainingPTY, releaseRemaining := newBlockingPTY(t)
67 destinationPTY, releaseDestination := newBlockingPTY(t)
68 activePTY, releaseActive := newBlockingPTY(t)
69 defer releaseMoved()
70 defer releaseRemaining()
71 defer releaseDestination()
72 defer releaseActive()
73
74 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
75 sourceTab := source.tabs[0]
76 sourceTab.stableID = "source-tab"
77 moved := sourceTab.focusedPane()
78 remaining := newPaneWithStableID("pane-2", "remaining-pane", remainingPTY, domain.Size{Cols: 80, Rows: 23})
79 sourceTab.mu.Lock()
80 sourceTab.tree = &layout.Tree{
81 Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
82 layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
83 }},
84 Focus: "pane-1",
85 }
86 sourceTab.panes[remaining.id] = remaining
87 sourceTab.mu.Unlock()
88 publishPaneOwner(remaining, source, sourceTab, 0)
89
90 destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
91 destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})
92 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}}
93 publishTiledPaneOwners(destination, destinationActive)
94 publishTiledPaneOwners(destination, destinationTab)
95 d.mu.Lock()
96 d.sessions[destination.id] = destination
97 d.mu.Unlock()
98 source.ephemeral = false
99 destination.ephemeral = false
100 source.snapEligible.Store(true)
101 destination.snapEligible.Store(true)
102 catalogue := &moveCatalogueRecorder{daemon: d, source: source, destination: destination}
103 d.catalogue = catalogue
104 d.persistEnabled = true
105 source.snapshotMu.Lock()
106 beforeSourceSnapshot := source.snapshotGeneration
107 source.snapshotMu.Unlock()
108 destination.snapshotMu.Lock()
109 beforeDestinationSnapshot := destination.snapshotGeneration
110 destination.snapshotMu.Unlock()
111
112 sourceTab.mu.Lock()
113 beforeSourceGeneration := sourceTab.layoutGeneration
114 sourceTab.mu.Unlock()
115 destinationTab.mu.Lock()
116 beforeDestinationGeneration := destinationTab.layoutGeneration
117 destinationTab.mu.Unlock()
118 moved.mu.Lock()
119 beforeOwnerGeneration := moved.ownerGeneration
120 moved.mu.Unlock()
121
122 require.NoError(t, d.movePane(movePaneRequest{
123 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
124 SourceTabID: domain.TabStableID(sourceTab.stableID),
125 SourcePaneID: domain.PaneStableID(moved.stableID),
126 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
127 DestinationTabID: domain.TabStableID(destinationTab.stableID),
128 }))
129
130 source.mu.Lock()
131 sourceTabs := append([]*tab(nil), source.tabs...)
132 source.mu.Unlock()
133 require.Len(t, sourceTabs, 1)
134 require.Same(t, sourceTab, sourceTabs[0])
135 sourceTab.mu.Lock()
136 remainingPane := sourceTab.panes[remaining.id]
137 sourceFocus := sourceTab.tree.Focus
138 sourceGeneration := sourceTab.layoutGeneration
139 sourceTab.mu.Unlock()
140 require.Same(t, remaining, remainingPane)
141 require.Equal(t, layout.PaneID("pane-2"), sourceFocus, "source focus must use the valid fallback leaf")
142 destination.mu.Lock()
143 destinationActiveTab := destination.tabs[testAttachmentTabIndexLocked(destination)]
144 destination.mu.Unlock()
145 require.Same(t, destinationActive, destinationActiveTab, "destination active tab must remain unchanged")
146 destinationTab.mu.Lock()
147 destinationMovedPane := destinationTab.panes[moved.id]
148 destinationFocus := destinationTab.tree.Focus
149 destinationGeneration := destinationTab.layoutGeneration
150 destinationTab.mu.Unlock()
151 require.Same(t, moved, destinationMovedPane)
152 require.Equal(t, destinationFocus, moved.id)
153 moved.mu.Lock()
154 ownerGeneration := moved.ownerGeneration
155 moved.mu.Unlock()
156 require.Equal(t, beforeOwnerGeneration+1, ownerGeneration, "move publishes exactly one new owner generation")
157 require.Equal(t, beforeSourceGeneration+1, sourceGeneration)
158 require.Equal(t, beforeDestinationGeneration+1, destinationGeneration)
159 source.snapshotMu.Lock()
160 require.GreaterOrEqual(t, source.snapshotGeneration, beforeSourceSnapshot+1)
161 require.True(t, source.snapDirty.Load())
162 source.snapshotMu.Unlock()
163 destination.snapshotMu.Lock()
164 require.GreaterOrEqual(t, destination.snapshotGeneration, beforeDestinationSnapshot+1)
165 require.True(t, destination.snapDirty.Load())
166 destination.snapshotMu.Unlock()
167 catalogue.mu.Lock()
168 updates := append([]domain.CatalogueMetadataUpdate(nil), catalogue.updates...)
169 lockViolation := catalogue.lockViolation
170 catalogue.mu.Unlock()
171 require.False(t, lockViolation, "catalogue publication ran under an architecture lock")
172 require.ElementsMatch(t, []string{source.name, destination.name}, catalogueUpdateNames(updates))
173}
174
175func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
176 movedPTY, releaseMoved := newBlockingPTY(t)
177 destinationPTY, releaseDestination := newBlockingPTY(t)
178 defer releaseMoved()
179 defer releaseDestination()
180
181 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
182 sourceTab := source.tabs[0]
183 sourceTab.stableID = "source-tab"
184 moved := sourceTab.focusedPane()
185 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
186 destinationTab := destination.tabs[0]
187 publishTiledPaneOwners(destination, destinationTab)
188 d.mu.Lock()
189 d.sessions[destination.id] = destination
190 d.mu.Unlock()
191
192 sourceTab.mu.Lock()
193 beforeSource := sourceTab.tree.Clone()
194 sourceTab.mu.Unlock()
195 destinationTab.mu.Lock()
196 beforeDestination := destinationTab.tree.Clone()
197 destinationTab.mu.Unlock()
198 beforeOwner := moved.ownerSnapshot()
199 staleIncarnation := source.incarnation
200 staleIncarnation[0]++
201 err := d.movePane(movePaneRequest{
202 Source: moveSessionLocator{ID: source.id, Incarnation: staleIncarnation},
203 SourceTabID: domain.TabStableID(sourceTab.stableID),
204 SourcePaneID: domain.PaneStableID(moved.stableID),
205 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
206 DestinationTabID: domain.TabStableID(destinationTab.stableID),
207 })
208 require.ErrorIs(t, err, errMovePaneInvalid)
209 sourceTab.mu.Lock()
210 afterSource := sourceTab.tree.Clone()
211 sourceMovedPane := sourceTab.panes[moved.id]
212 sourceTab.mu.Unlock()
213 destinationTab.mu.Lock()
214 afterDestination := destinationTab.tree.Clone()
215 destinationMovedPane := destinationTab.panes[moved.id]
216 destinationTab.mu.Unlock()
217 require.Equal(t, beforeSource, afterSource)
218 require.Equal(t, beforeDestination, afterDestination)
219 require.Same(t, beforeOwner, moved.ownerSnapshot())
220 require.Same(t, moved, sourceMovedPane)
221 require.NotSame(t, moved, destinationMovedPane)
222}
223
224func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
225 movedPTY, releaseMoved := newBlockingPTY(t)
226 destinationPTY, releaseDestination := newBlockingPTY(t)
227 defer releaseMoved()
228 defer releaseDestination()
229
230 d, source, _, _ := newManualSessionWithPTYs(t, movedPTY)
231 sourceTab := source.tabs[0]
232 sourceTab.stableID = "source-tab"
233 moved := sourceTab.focusedPane()
234 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}}
235 destinationTab := destination.tabs[0]
236 publishTiledPaneOwners(destination, destinationTab)
237 d.mu.Lock()
238 d.sessions[destination.id] = destination
239 d.mu.Unlock()
240
241 destinationTab.mu.Lock()
242 beforeDestination := destinationTab.tree.Clone()
243 destinationTab.mu.Unlock()
244 beforeOwner := moved.ownerSnapshot()
245 d.afterMovePaneSourceSnapshot = func() {
246 sourceTab.mu.Lock()
247 sourceTab.layoutGeneration++
248 sourceTab.mu.Unlock()
249 }
250 defer func() { d.afterMovePaneSourceSnapshot = nil }()
251
252 err := d.movePane(movePaneRequest{
253 Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation},
254 SourceTabID: domain.TabStableID(sourceTab.stableID),
255 SourcePaneID: domain.PaneStableID(moved.stableID),
256 Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation},
257 DestinationTabID: domain.TabStableID(destinationTab.stableID),
258 })
259 require.Error(t, err)
260 destinationTab.mu.Lock()
261 afterDestination := destinationTab.tree.Clone()
262 destinationTab.mu.Unlock()
263 sourceTab.mu.Lock()
264 sourceMovedPane := sourceTab.panes[moved.id]
265 sourceTab.mu.Unlock()
266 require.Equal(t, beforeDestination, afterDestination)
267 require.Same(t, beforeOwner, moved.ownerSnapshot())
268 require.Same(t, moved, sourceMovedPane)
269}