test(snapshot): simplify pane rearrangement restore coverage
- commit
- da9eec17f5b9f0890cd2e65b81abe6678a5399e8
- author
- brice · · 2026-07-27 14:30 UTC
- parent
- db8c344
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +125 -99
M internal/usecase/daemon/snapshot_restore_test.go +125 -99
| @@ -40,11 +40,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap | |||
| 40 | 40 | if r.publishErr != nil { | |
| 41 | 41 | return r.publishErr | |
| 42 | 42 | } | |
| 43 | - | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) | |
| 44 | - | for _, object := range p.Objects { | |
| 45 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 46 | - | } | |
| 47 | - | r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} | |
| 43 | + | r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p)) | |
| 48 | 44 | found := false | |
| 49 | 45 | for _, name := range r.names { | |
| 50 | 46 | found = found || name == p.Name | |
| @@ -182,11 +178,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u | |||
| 182 | 178 | manifest.Generation = generation | |
| 183 | 179 | encoded, err := snapcodec.MarshalManifest(manifest) | |
| 184 | 180 | require.NoError(t, err) | |
| 185 | - | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 186 | - | for _, object := range publication.Objects { | |
| 187 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 188 | - | } | |
| 189 | - | return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects} | |
| 181 | + | publication.Generation = generation | |
| 182 | + | publication.Manifest = encoded | |
| 183 | + | return cloneAcceptanceGeneration(snapshotGeneration(publication)) | |
| 190 | 184 | } | |
| 191 | 185 | ||
| 192 | 186 | func TestValidateRestoreSessionSnapshot(t *testing.T) { | |
| @@ -257,8 +251,82 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 257 | 251 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | |
| 258 | 252 | } | |
| 259 | 253 | ||
| 260 | - | func TestSnapshotCaptureRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |
| 261 | - | initial := &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | |
| 254 | + | type paneLayoutSnapshotFixture struct { | |
| 255 | + | snapshot snapcodec.Session | |
| 256 | + | tree *layout.Tree | |
| 257 | + | tabStableID string | |
| 258 | + | paneStableIDs map[layout.PaneID]string | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | type restoredPaneLayout struct { | |
| 262 | + | tree *layout.Tree | |
| 263 | + | tabStableID string | |
| 264 | + | paneStableIDs map[layout.PaneID]string | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |
| 268 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |
| 269 | + | require.Len(t, fixture.snapshot.Tabs, 1) | |
| 270 | + | decodedTab := fixture.snapshot.Tabs[0] | |
| 271 | + | ||
| 272 | + | require.Equal(t, fixture.tabStableID, decodedTab.StableID) | |
| 273 | + | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved") | |
| 274 | + | assertConsumedAndExpelledColumns(t, decodedTab.Tree) | |
| 275 | + | require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |
| 276 | + | require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID") | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |
| 280 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |
| 281 | + | restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{}) | |
| 282 | + | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot) | |
| 283 | + | require.NoError(t, err) | |
| 284 | + | require.Len(t, restoredTabs, 1) | |
| 285 | + | defer closeRestoredTabs(restoredTabs) | |
| 286 | + | ||
| 287 | + | restored := captureRestoredPaneLayout(restoredTabs[0]) | |
| 288 | + | require.Equal(t, fixture.tabStableID, restored.tabStableID) | |
| 289 | + | assertConsumedAndExpelledColumns(t, restored.tree) | |
| 290 | + | require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree") | |
| 291 | + | require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { | |
| 295 | + | t.Helper() | |
| 296 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) | |
| 297 | + | h.session.createdAt = 77 | |
| 298 | + | h.session.incarnation = domain.IncarnationID{7} | |
| 299 | + | tabStableID := "tab-stable-rearranged" | |
| 300 | + | paneStableIDs := map[layout.PaneID]string{ | |
| 301 | + | "pane-1": "pane-stable-1", | |
| 302 | + | "pane-2": "pane-stable-2", | |
| 303 | + | "pane-3": "pane-stable-3", | |
| 304 | + | "pane-4": "pane-stable-4", | |
| 305 | + | "pane-5": "pane-stable-5", | |
| 306 | + | } | |
| 307 | + | setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs) | |
| 308 | + | applyPaneLayoutSnapshotOperations(t, h) | |
| 309 | + | ||
| 310 | + | h.tab.mu.Lock() | |
| 311 | + | wantTree := h.tab.tree.Clone() | |
| 312 | + | h.tab.mu.Unlock() | |
| 313 | + | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | |
| 314 | + | require.True(t, ok) | |
| 315 | + | publication, err := h.daemon.incrementalPublication(capture) | |
| 316 | + | require.NoError(t, err) | |
| 317 | + | decoded, err := sessionFromGeneration(snapshotGeneration(publication)) | |
| 318 | + | require.NoError(t, err) | |
| 319 | + | ||
| 320 | + | return paneLayoutSnapshotFixture{ | |
| 321 | + | snapshot: decoded, | |
| 322 | + | tree: wantTree, | |
| 323 | + | tabStableID: tabStableID, | |
| 324 | + | paneStableIDs: paneStableIDs, | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func paneLayoutSnapshotTree() *layout.Tree { | |
| 329 | + | return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | |
| 262 | 330 | {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, | |
| 263 | 331 | {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ | |
| 264 | 332 | {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, | |
| @@ -269,110 +337,68 @@ func TestSnapshotCaptureRestorePreservesConsumedAndExpelledPaneLayout(t *testing | |||
| 269 | 337 | {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, | |
| 270 | 338 | }}, | |
| 271 | 339 | }}, Focus: "pane-4"} | |
| 272 | - | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, initial) | |
| 273 | - | h.session.createdAt = 77 | |
| 274 | - | h.session.incarnation = domain.IncarnationID{7} | |
| 275 | - | h.tab.mu.Lock() | |
| 276 | - | h.tab.stableID = "tab-stable-rearranged" | |
| 277 | - | h.tab.nextPaneID = 6 | |
| 278 | - | paneStableIDs := map[layout.PaneID]string{ | |
| 279 | - | "pane-1": "pane-stable-1", | |
| 280 | - | "pane-2": "pane-stable-2", | |
| 281 | - | "pane-3": "pane-stable-3", | |
| 282 | - | "pane-4": "pane-stable-4", | |
| 283 | - | "pane-5": "pane-stable-5", | |
| 284 | - | } | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) { | |
| 343 | + | tb.mu.Lock() | |
| 344 | + | defer tb.mu.Unlock() | |
| 345 | + | tb.stableID = tabStableID | |
| 346 | + | tb.nextPaneID = 6 | |
| 285 | 347 | for id, stableID := range paneStableIDs { | |
| 286 | - | h.tab.panes[id].mu.Lock() | |
| 287 | - | h.tab.panes[id].stableID = stableID | |
| 288 | - | h.tab.panes[id].mu.Unlock() | |
| 348 | + | tb.panes[id].mu.Lock() | |
| 349 | + | tb.panes[id].stableID = stableID | |
| 350 | + | tb.panes[id].mu.Unlock() | |
| 289 | 351 | } | |
| 290 | - | h.tab.mu.Unlock() | |
| 352 | + | } | |
| 291 | 353 | ||
| 292 | - | operations := []struct { | |
| 354 | + | func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) { | |
| 355 | + | t.Helper() | |
| 356 | + | for _, operation := range []struct { | |
| 293 | 357 | name string | |
| 294 | 358 | pane layout.PaneID | |
| 295 | 359 | direction layout.Direction | |
| 296 | 360 | }{ | |
| 297 | 361 | {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, | |
| 298 | 362 | {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, | |
| 299 | - | } | |
| 300 | - | for _, operation := range operations { | |
| 301 | - | t.Run(operation.name, func(t *testing.T) { | |
| 302 | - | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
| 303 | - | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, | |
| 304 | - | }) | |
| 305 | - | require.NoError(t, err) | |
| 363 | + | } { | |
| 364 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
| 365 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, | |
| 306 | 366 | }) | |
| 367 | + | require.NoError(t, err, operation.name) | |
| 307 | 368 | } | |
| 369 | + | } | |
| 308 | 370 | ||
| 309 | - | h.tab.mu.Lock() | |
| 310 | - | wantTree := h.tab.tree.Clone() | |
| 311 | - | h.tab.mu.Unlock() | |
| 312 | - | require.Equal(t, layout.Horizontal, wantTree.Root.Dir) | |
| 313 | - | require.Len(t, wantTree.Root.Children, 3) | |
| 314 | - | require.Equal(t, layout.Vertical, wantTree.Root.Children[0].Dir) | |
| 315 | - | require.Equal(t, layout.Leaf, wantTree.Root.Children[1].Kind) | |
| 316 | - | require.Equal(t, layout.Stack, wantTree.Root.Children[2].Kind) | |
| 317 | - | require.Equal(t, layout.PaneID("pane-5"), wantTree.Root.Children[2].Expanded) | |
| 318 | - | require.Equal(t, layout.PaneID("pane-3"), wantTree.Focus) | |
| 319 | - | ||
| 320 | - | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | |
| 321 | - | require.True(t, ok) | |
| 322 | - | publication, err := h.daemon.incrementalPublication(capture) | |
| 323 | - | require.NoError(t, err) | |
| 324 | - | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 325 | - | for _, object := range publication.Objects { | |
| 326 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 327 | - | } | |
| 328 | - | generation := ports.SnapshotGeneration{ | |
| 329 | - | IncarnationID: publication.IncarnationID, | |
| 330 | - | Name: publication.Name, | |
| 331 | - | Generation: publication.Generation, | |
| 332 | - | Manifest: append([]byte(nil), publication.Manifest...), | |
| 333 | - | Objects: objects, | |
| 334 | - | } | |
| 335 | - | decoded, err := sessionFromGeneration(generation) | |
| 336 | - | require.NoError(t, err) | |
| 337 | - | require.Len(t, decoded.Tabs, 1) | |
| 338 | - | decodedTab := decoded.Tabs[0] | |
| 339 | - | require.Equal(t, "tab-stable-rearranged", decodedTab.StableID) | |
| 340 | - | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus) | |
| 341 | - | require.Equal(t, wantTree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |
| 342 | - | require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(decodedTab.Tree.Root)) | |
| 343 | - | require.Equal(t, layout.PaneID("pane-5"), decodedTab.Tree.Root.Children[2].Expanded) | |
| 344 | - | decodedStableIDs := make(map[layout.PaneID]string, len(decodedTab.Panes)) | |
| 345 | - | for _, restoredPane := range decodedTab.Panes { | |
| 346 | - | decodedStableIDs[restoredPane.ID] = restoredPane.StableID | |
| 347 | - | } | |
| 348 | - | require.Equal(t, paneStableIDs, decodedStableIDs) | |
| 371 | + | func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) { | |
| 372 | + | t.Helper() | |
| 373 | + | require.NotNil(t, tree) | |
| 374 | + | require.NotNil(t, tree.Root) | |
| 375 | + | require.Equal(t, layout.Horizontal, tree.Root.Dir) | |
| 376 | + | require.Len(t, tree.Root.Children, 3) | |
| 377 | + | require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column") | |
| 378 | + | require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column") | |
| 379 | + | require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present") | |
| 380 | + | require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved") | |
| 381 | + | require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved") | |
| 382 | + | } | |
| 349 | 383 | ||
| 350 | - | restorePTY := &paneRearrangePTY{} | |
| 351 | - | restoreDaemon := newTestDaemon(t, newFactory(t, restorePTY), stubClock{}) | |
| 352 | - | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, decoded) | |
| 353 | - | require.NoError(t, err) | |
| 354 | - | require.Len(t, restoredTabs, 1) | |
| 355 | - | defer closeRestoredTabs(restoredTabs) | |
| 356 | - | restoredTab := restoredTabs[0] | |
| 357 | - | require.Equal(t, "tab-stable-rearranged", restoredTab.stableID) | |
| 358 | - | require.Equal(t, wantTree, restoredTab.tree, "runtime restore must preserve the complete rearranged tree") | |
| 359 | - | require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(restoredTab.tree.Root)) | |
| 360 | - | require.Equal(t, layout.PaneID("pane-3"), restoredTab.tree.Focus) | |
| 361 | - | require.Equal(t, layout.PaneID("pane-5"), restoredTab.tree.Root.Children[2].Expanded) | |
| 362 | - | for id, stableID := range paneStableIDs { | |
| 363 | - | require.Equal(t, stableID, restoredTab.panes[id].stableID, "pane %s stable ID", id) | |
| 384 | + | func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string { | |
| 385 | + | stableIDs := make(map[layout.PaneID]string, len(panes)) | |
| 386 | + | for _, p := range panes { | |
| 387 | + | stableIDs[p.ID] = p.StableID | |
| 364 | 388 | } | |
| 389 | + | return stableIDs | |
| 365 | 390 | } | |
| 366 | 391 | ||
| 367 | - | func snapshotLayoutWeights(node *layout.Node) []float64 { | |
| 368 | - | if node == nil { | |
| 369 | - | return nil | |
| 370 | - | } | |
| 371 | - | weights := []float64{node.Weight} | |
| 372 | - | for _, child := range node.Children { | |
| 373 | - | weights = append(weights, snapshotLayoutWeights(child)...) | |
| 392 | + | func captureRestoredPaneLayout(tb *tab) restoredPaneLayout { | |
| 393 | + | tb.mu.Lock() | |
| 394 | + | defer tb.mu.Unlock() | |
| 395 | + | stableIDs := make(map[layout.PaneID]string, len(tb.panes)) | |
| 396 | + | for id, p := range tb.panes { | |
| 397 | + | p.mu.Lock() | |
| 398 | + | stableIDs[id] = p.stableID | |
| 399 | + | p.mu.Unlock() | |
| 374 | 400 | } | |
| 375 | - | return weights | |
| 401 | + | return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs} | |
| 376 | 402 | } | |
| 377 | 403 | ||
| 378 | 404 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |
| @@ -40,11 +40,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap | |||||
| 40 | if r.publishErr != nil { | 40 | if r.publishErr != nil { | ||
| 41 | return r.publishErr | 41 | return r.publishErr | ||
| 42 | } | 42 | } | ||
| 43 | - | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) | 43 | + | r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p)) |
| 44 | - | for _, object := range p.Objects { | |||
| 45 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |||
| 46 | - | } | |||
| 47 | - | r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} | |||
| 48 | found := false | 44 | found := false | ||
| 49 | for _, name := range r.names { | 45 | for _, name := range r.names { | ||
| 50 | found = found || name == p.Name | 46 | found = found || name == p.Name | ||
| @@ -182,11 +178,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u | |||||
| 182 | manifest.Generation = generation | 178 | manifest.Generation = generation | ||
| 183 | encoded, err := snapcodec.MarshalManifest(manifest) | 179 | encoded, err := snapcodec.MarshalManifest(manifest) | ||
| 184 | require.NoError(t, err) | 180 | require.NoError(t, err) | ||
| 185 | - | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | 181 | + | publication.Generation = generation |
| 186 | - | for _, object := range publication.Objects { | 182 | + | publication.Manifest = encoded |
| 187 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | 183 | + | return cloneAcceptanceGeneration(snapshotGeneration(publication)) |
| 188 | - | } | |||
| 189 | - | return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects} | |||
| 190 | } | 184 | } | ||
| 191 | 185 | ||||
| 192 | func TestValidateRestoreSessionSnapshot(t *testing.T) { | 186 | func TestValidateRestoreSessionSnapshot(t *testing.T) { | ||
| @@ -257,8 +251,82 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||||
| 257 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | 251 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | ||
| 258 | } | 252 | } | ||
| 259 | 253 | ||||
| 260 | - | func TestSnapshotCaptureRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | 254 | + | type paneLayoutSnapshotFixture struct { |
| 261 | - | initial := &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | 255 | + | snapshot snapcodec.Session |
| 256 | + | tree *layout.Tree | |||
| 257 | + | tabStableID string | |||
| 258 | + | paneStableIDs map[layout.PaneID]string | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | type restoredPaneLayout struct { | |||
| 262 | + | tree *layout.Tree | |||
| 263 | + | tabStableID string | |||
| 264 | + | paneStableIDs map[layout.PaneID]string | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |||
| 268 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |||
| 269 | + | require.Len(t, fixture.snapshot.Tabs, 1) | |||
| 270 | + | decodedTab := fixture.snapshot.Tabs[0] | |||
| 271 | + | ||||
| 272 | + | require.Equal(t, fixture.tabStableID, decodedTab.StableID) | |||
| 273 | + | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved") | |||
| 274 | + | assertConsumedAndExpelledColumns(t, decodedTab.Tree) | |||
| 275 | + | require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |||
| 276 | + | require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID") | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |||
| 280 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |||
| 281 | + | restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{}) | |||
| 282 | + | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot) | |||
| 283 | + | require.NoError(t, err) | |||
| 284 | + | require.Len(t, restoredTabs, 1) | |||
| 285 | + | defer closeRestoredTabs(restoredTabs) | |||
| 286 | + | ||||
| 287 | + | restored := captureRestoredPaneLayout(restoredTabs[0]) | |||
| 288 | + | require.Equal(t, fixture.tabStableID, restored.tabStableID) | |||
| 289 | + | assertConsumedAndExpelledColumns(t, restored.tree) | |||
| 290 | + | require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree") | |||
| 291 | + | require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { | |||
| 295 | + | t.Helper() | |||
| 296 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) | |||
| 297 | + | h.session.createdAt = 77 | |||
| 298 | + | h.session.incarnation = domain.IncarnationID{7} | |||
| 299 | + | tabStableID := "tab-stable-rearranged" | |||
| 300 | + | paneStableIDs := map[layout.PaneID]string{ | |||
| 301 | + | "pane-1": "pane-stable-1", | |||
| 302 | + | "pane-2": "pane-stable-2", | |||
| 303 | + | "pane-3": "pane-stable-3", | |||
| 304 | + | "pane-4": "pane-stable-4", | |||
| 305 | + | "pane-5": "pane-stable-5", | |||
| 306 | + | } | |||
| 307 | + | setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs) | |||
| 308 | + | applyPaneLayoutSnapshotOperations(t, h) | |||
| 309 | + | ||||
| 310 | + | h.tab.mu.Lock() | |||
| 311 | + | wantTree := h.tab.tree.Clone() | |||
| 312 | + | h.tab.mu.Unlock() | |||
| 313 | + | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | |||
| 314 | + | require.True(t, ok) | |||
| 315 | + | publication, err := h.daemon.incrementalPublication(capture) | |||
| 316 | + | require.NoError(t, err) | |||
| 317 | + | decoded, err := sessionFromGeneration(snapshotGeneration(publication)) | |||
| 318 | + | require.NoError(t, err) | |||
| 319 | + | ||||
| 320 | + | return paneLayoutSnapshotFixture{ | |||
| 321 | + | snapshot: decoded, | |||
| 322 | + | tree: wantTree, | |||
| 323 | + | tabStableID: tabStableID, | |||
| 324 | + | paneStableIDs: paneStableIDs, | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func paneLayoutSnapshotTree() *layout.Tree { | |||
| 329 | + | return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | |||
| 262 | {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, | 330 | {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, | ||
| 263 | {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ | 331 | {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ | ||
| 264 | {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, | 332 | {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, | ||
| @@ -269,110 +337,68 @@ func TestSnapshotCaptureRestorePreservesConsumedAndExpelledPaneLayout(t *testing | |||||
| 269 | {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, | 337 | {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, | ||
| 270 | }}, | 338 | }}, | ||
| 271 | }}, Focus: "pane-4"} | 339 | }}, Focus: "pane-4"} | ||
| 272 | - | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, initial) | 340 | + | } |
| 273 | - | h.session.createdAt = 77 | 341 | + | |
| 274 | - | h.session.incarnation = domain.IncarnationID{7} | 342 | + | func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) { |
| 275 | - | h.tab.mu.Lock() | 343 | + | tb.mu.Lock() |
| 276 | - | h.tab.stableID = "tab-stable-rearranged" | 344 | + | defer tb.mu.Unlock() |
| 277 | - | h.tab.nextPaneID = 6 | 345 | + | tb.stableID = tabStableID |
| 278 | - | paneStableIDs := map[layout.PaneID]string{ | 346 | + | tb.nextPaneID = 6 |
| 279 | - | "pane-1": "pane-stable-1", | |||
| 280 | - | "pane-2": "pane-stable-2", | |||
| 281 | - | "pane-3": "pane-stable-3", | |||
| 282 | - | "pane-4": "pane-stable-4", | |||
| 283 | - | "pane-5": "pane-stable-5", | |||
| 284 | - | } | |||
| 285 | for id, stableID := range paneStableIDs { | 347 | for id, stableID := range paneStableIDs { | ||
| 286 | - | h.tab.panes[id].mu.Lock() | 348 | + | tb.panes[id].mu.Lock() |
| 287 | - | h.tab.panes[id].stableID = stableID | 349 | + | tb.panes[id].stableID = stableID |
| 288 | - | h.tab.panes[id].mu.Unlock() | 350 | + | tb.panes[id].mu.Unlock() |
| 289 | } | 351 | } | ||
| 290 | - | h.tab.mu.Unlock() | 352 | + | } |
| 291 | 353 | ||||
| 292 | - | operations := []struct { | 354 | + | func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) { |
| 355 | + | t.Helper() | |||
| 356 | + | for _, operation := range []struct { | |||
| 293 | name string | 357 | name string | ||
| 294 | pane layout.PaneID | 358 | pane layout.PaneID | ||
| 295 | direction layout.Direction | 359 | direction layout.Direction | ||
| 296 | }{ | 360 | }{ | ||
| 297 | {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, | 361 | {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, | ||
| 298 | {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, | 362 | {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, | ||
| 299 | - | } | 363 | + | } { |
| 300 | - | for _, operation := range operations { | 364 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ |
| 301 | - | t.Run(operation.name, func(t *testing.T) { | 365 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, |
| 302 | - | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 303 | - | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, | |||
| 304 | - | }) | |||
| 305 | - | require.NoError(t, err) | |||
| 306 | }) | 366 | }) | ||
| 367 | + | require.NoError(t, err, operation.name) | |||
| 307 | } | 368 | } | ||
| 369 | + | } | |||
| 308 | 370 | ||||
| 309 | - | h.tab.mu.Lock() | 371 | + | func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) { |
| 310 | - | wantTree := h.tab.tree.Clone() | 372 | + | t.Helper() |
| 311 | - | h.tab.mu.Unlock() | 373 | + | require.NotNil(t, tree) |
| 312 | - | require.Equal(t, layout.Horizontal, wantTree.Root.Dir) | 374 | + | require.NotNil(t, tree.Root) |
| 313 | - | require.Len(t, wantTree.Root.Children, 3) | 375 | + | require.Equal(t, layout.Horizontal, tree.Root.Dir) |
| 314 | - | require.Equal(t, layout.Vertical, wantTree.Root.Children[0].Dir) | 376 | + | require.Len(t, tree.Root.Children, 3) |
| 315 | - | require.Equal(t, layout.Leaf, wantTree.Root.Children[1].Kind) | 377 | + | require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column") |
| 316 | - | require.Equal(t, layout.Stack, wantTree.Root.Children[2].Kind) | 378 | + | require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column") |
| 317 | - | require.Equal(t, layout.PaneID("pane-5"), wantTree.Root.Children[2].Expanded) | 379 | + | require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present") |
| 318 | - | require.Equal(t, layout.PaneID("pane-3"), wantTree.Focus) | 380 | + | require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved") |
| 319 | - | 381 | + | require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved") | |
| 320 | - | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | 382 | + | } |
| 321 | - | require.True(t, ok) | |||
| 322 | - | publication, err := h.daemon.incrementalPublication(capture) | |||
| 323 | - | require.NoError(t, err) | |||
| 324 | - | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |||
| 325 | - | for _, object := range publication.Objects { | |||
| 326 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |||
| 327 | - | } | |||
| 328 | - | generation := ports.SnapshotGeneration{ | |||
| 329 | - | IncarnationID: publication.IncarnationID, | |||
| 330 | - | Name: publication.Name, | |||
| 331 | - | Generation: publication.Generation, | |||
| 332 | - | Manifest: append([]byte(nil), publication.Manifest...), | |||
| 333 | - | Objects: objects, | |||
| 334 | - | } | |||
| 335 | - | decoded, err := sessionFromGeneration(generation) | |||
| 336 | - | require.NoError(t, err) | |||
| 337 | - | require.Len(t, decoded.Tabs, 1) | |||
| 338 | - | decodedTab := decoded.Tabs[0] | |||
| 339 | - | require.Equal(t, "tab-stable-rearranged", decodedTab.StableID) | |||
| 340 | - | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus) | |||
| 341 | - | require.Equal(t, wantTree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |||
| 342 | - | require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(decodedTab.Tree.Root)) | |||
| 343 | - | require.Equal(t, layout.PaneID("pane-5"), decodedTab.Tree.Root.Children[2].Expanded) | |||
| 344 | - | decodedStableIDs := make(map[layout.PaneID]string, len(decodedTab.Panes)) | |||
| 345 | - | for _, restoredPane := range decodedTab.Panes { | |||
| 346 | - | decodedStableIDs[restoredPane.ID] = restoredPane.StableID | |||
| 347 | - | } | |||
| 348 | - | require.Equal(t, paneStableIDs, decodedStableIDs) | |||
| 349 | 383 | ||||
| 350 | - | restorePTY := &paneRearrangePTY{} | 384 | + | func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string { |
| 351 | - | restoreDaemon := newTestDaemon(t, newFactory(t, restorePTY), stubClock{}) | 385 | + | stableIDs := make(map[layout.PaneID]string, len(panes)) |
| 352 | - | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, decoded) | 386 | + | for _, p := range panes { |
| 353 | - | require.NoError(t, err) | 387 | + | stableIDs[p.ID] = p.StableID |
| 354 | - | require.Len(t, restoredTabs, 1) | |||
| 355 | - | defer closeRestoredTabs(restoredTabs) | |||
| 356 | - | restoredTab := restoredTabs[0] | |||
| 357 | - | require.Equal(t, "tab-stable-rearranged", restoredTab.stableID) | |||
| 358 | - | require.Equal(t, wantTree, restoredTab.tree, "runtime restore must preserve the complete rearranged tree") | |||
| 359 | - | require.Equal(t, snapshotLayoutWeights(wantTree.Root), snapshotLayoutWeights(restoredTab.tree.Root)) | |||
| 360 | - | require.Equal(t, layout.PaneID("pane-3"), restoredTab.tree.Focus) | |||
| 361 | - | require.Equal(t, layout.PaneID("pane-5"), restoredTab.tree.Root.Children[2].Expanded) | |||
| 362 | - | for id, stableID := range paneStableIDs { | |||
| 363 | - | require.Equal(t, stableID, restoredTab.panes[id].stableID, "pane %s stable ID", id) | |||
| 364 | } | 388 | } | ||
| 389 | + | return stableIDs | |||
| 365 | } | 390 | } | ||
| 366 | 391 | ||||
| 367 | - | func snapshotLayoutWeights(node *layout.Node) []float64 { | 392 | + | func captureRestoredPaneLayout(tb *tab) restoredPaneLayout { |
| 368 | - | if node == nil { | 393 | + | tb.mu.Lock() |
| 369 | - | return nil | 394 | + | defer tb.mu.Unlock() |
| 370 | - | } | 395 | + | stableIDs := make(map[layout.PaneID]string, len(tb.panes)) |
| 371 | - | weights := []float64{node.Weight} | 396 | + | for id, p := range tb.panes { |
| 372 | - | for _, child := range node.Children { | 397 | + | p.mu.Lock() |
| 373 | - | weights = append(weights, snapshotLayoutWeights(child)...) | 398 | + | stableIDs[id] = p.stableID |
| 399 | + | p.mu.Unlock() | |||
| 374 | } | 400 | } | ||
| 375 | - | return weights | 401 | + | return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs} |
| 376 | } | 402 | } | ||
| 377 | 403 | ||||
| 378 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | 404 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | ||