test(copy): cover unwrapping across the history/screen boundary
- commit
- e522e52a44f834264e7491ed49d56db8dbcce0b7
- author
- brice · · 2026-07-25 09:47 UTC
- parent
- b33d82f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +98 -0
M internal/usecase/copy/mode_test.go +98 -0
| @@ -319,3 +319,101 @@ func TestCopyModeRenderStylesStatusFiller(t *testing.T) { | |||
| 319 | 319 | ||
| 320 | 320 | require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface") | |
| 321 | 321 | } | |
| 322 | + | ||
| 323 | + | func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) { | |
| 324 | + | row := func(s string, w int) []renderer.Cell { | |
| 325 | + | cells := make([]renderer.Cell, w) | |
| 326 | + | for i := range cells { | |
| 327 | + | cells[i] = renderer.BlankCell() | |
| 328 | + | } | |
| 329 | + | for i, r := range []rune(s) { | |
| 330 | + | if i >= w { | |
| 331 | + | break | |
| 332 | + | } | |
| 333 | + | cells[i] = renderer.Cell{Rune: r} | |
| 334 | + | } | |
| 335 | + | return cells | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | const width = 4 | |
| 339 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |
| 340 | + | // The last history row wrapped into what is now the first live screen row. | |
| 341 | + | if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil { | |
| 342 | + | t.Fatalf("append: %v", err) | |
| 343 | + | } | |
| 344 | + | screen := renderer.NewFrame(width, 2) | |
| 345 | + | copy(screen.Row(0), row("ef", width)) | |
| 346 | + | copy(screen.Row(1), row("gh", width)) | |
| 347 | + | ||
| 348 | + | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}) | |
| 349 | + | doc := NewDocument(snapshot, "") | |
| 350 | + | ||
| 351 | + | ranges := []CellRange{ | |
| 352 | + | {Row: 0, Start: 0, End: width - 1}, | |
| 353 | + | {Row: 1, Start: 0, End: width - 1}, | |
| 354 | + | {Row: 2, Start: 0, End: width - 1}, | |
| 355 | + | } | |
| 356 | + | const want = "abcdef\ngh" | |
| 357 | + | if got := doc.Extract(ranges); got != want { | |
| 358 | + | t.Errorf("Extract() = %q, want %q", got, want) | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no | |
| 363 | + | // hand-written bounds: a real vt.Screen wraps one logical line itself, its | |
| 364 | + | // LineBound travels through eviction into history or stays on the live grid, | |
| 365 | + | // and Extract must give the line back exactly as the application printed it. | |
| 366 | + | func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) { | |
| 367 | + | const ( | |
| 368 | + | width = 8 | |
| 369 | + | height = 4 | |
| 370 | + | line = "abcdefghij" | |
| 371 | + | ) | |
| 372 | + | cases := []struct { | |
| 373 | + | name string | |
| 374 | + | // input is written verbatim to the VT; the wrap and every scroll it | |
| 375 | + | // implies are the emulator's own decisions. | |
| 376 | + | input string | |
| 377 | + | // wantHistoryLen pins where the history/screen seam falls, so a change | |
| 378 | + | // in scroll behavior fails loudly instead of silently retargeting the | |
| 379 | + | // case onto rows the seam no longer separates. | |
| 380 | + | wantHistoryLen int | |
| 381 | + | // wrapRow is the first snapshot row of the wrapped logical line. | |
| 382 | + | wrapRow int | |
| 383 | + | }{ | |
| 384 | + | { | |
| 385 | + | name: "wrapped line straddles the history and screen seam", | |
| 386 | + | input: line + "\r\nx\r\ny\r\n", | |
| 387 | + | wantHistoryLen: 1, | |
| 388 | + | wrapRow: 0, | |
| 389 | + | }, | |
| 390 | + | { | |
| 391 | + | name: "wrapped line sits entirely in sealed history", | |
| 392 | + | input: line + "\r\nx\r\ny\r\nz\r\n", | |
| 393 | + | wantHistoryLen: 2, | |
| 394 | + | wrapRow: 0, | |
| 395 | + | }, | |
| 396 | + | { | |
| 397 | + | name: "wrapped line sits entirely on the live screen", | |
| 398 | + | input: "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line, | |
| 399 | + | wantHistoryLen: 2, | |
| 400 | + | wrapRow: 3, | |
| 401 | + | }, | |
| 402 | + | } | |
| 403 | + | for _, tc := range cases { | |
| 404 | + | t.Run(tc.name, func(t *testing.T) { | |
| 405 | + | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096}) | |
| 406 | + | screen.Write([]byte(tc.input)) | |
| 407 | + | ||
| 408 | + | snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds()) | |
| 409 | + | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | |
| 410 | + | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | |
| 411 | + | ||
| 412 | + | got := doc.Extract([]CellRange{ | |
| 413 | + | {Row: tc.wrapRow, Start: 0, End: width - 1}, | |
| 414 | + | {Row: tc.wrapRow + 1, Start: 0, End: width - 1}, | |
| 415 | + | }) | |
| 416 | + | require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding") | |
| 417 | + | }) | |
| 418 | + | } | |
| 419 | + | } | |
| @@ -319,3 +319,101 @@ func TestCopyModeRenderStylesStatusFiller(t *testing.T) { | |||||
| 319 | 319 | ||||
| 320 | require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface") | 320 | require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface") | ||
| 321 | } | 321 | } | ||
| 322 | + | ||||
| 323 | + | func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) { | |||
| 324 | + | row := func(s string, w int) []renderer.Cell { | |||
| 325 | + | cells := make([]renderer.Cell, w) | |||
| 326 | + | for i := range cells { | |||
| 327 | + | cells[i] = renderer.BlankCell() | |||
| 328 | + | } | |||
| 329 | + | for i, r := range []rune(s) { | |||
| 330 | + | if i >= w { | |||
| 331 | + | break | |||
| 332 | + | } | |||
| 333 | + | cells[i] = renderer.Cell{Rune: r} | |||
| 334 | + | } | |||
| 335 | + | return cells | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | const width = 4 | |||
| 339 | + | history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024}) | |||
| 340 | + | // The last history row wrapped into what is now the first live screen row. | |||
| 341 | + | if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil { | |||
| 342 | + | t.Fatalf("append: %v", err) | |||
| 343 | + | } | |||
| 344 | + | screen := renderer.NewFrame(width, 2) | |||
| 345 | + | copy(screen.Row(0), row("ef", width)) | |||
| 346 | + | copy(screen.Row(1), row("gh", width)) | |||
| 347 | + | ||||
| 348 | + | snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}) | |||
| 349 | + | doc := NewDocument(snapshot, "") | |||
| 350 | + | ||||
| 351 | + | ranges := []CellRange{ | |||
| 352 | + | {Row: 0, Start: 0, End: width - 1}, | |||
| 353 | + | {Row: 1, Start: 0, End: width - 1}, | |||
| 354 | + | {Row: 2, Start: 0, End: width - 1}, | |||
| 355 | + | } | |||
| 356 | + | const want = "abcdef\ngh" | |||
| 357 | + | if got := doc.Extract(ranges); got != want { | |||
| 358 | + | t.Errorf("Extract() = %q, want %q", got, want) | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | // TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no | |||
| 363 | + | // hand-written bounds: a real vt.Screen wraps one logical line itself, its | |||
| 364 | + | // LineBound travels through eviction into history or stays on the live grid, | |||
| 365 | + | // and Extract must give the line back exactly as the application printed it. | |||
| 366 | + | func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) { | |||
| 367 | + | const ( | |||
| 368 | + | width = 8 | |||
| 369 | + | height = 4 | |||
| 370 | + | line = "abcdefghij" | |||
| 371 | + | ) | |||
| 372 | + | cases := []struct { | |||
| 373 | + | name string | |||
| 374 | + | // input is written verbatim to the VT; the wrap and every scroll it | |||
| 375 | + | // implies are the emulator's own decisions. | |||
| 376 | + | input string | |||
| 377 | + | // wantHistoryLen pins where the history/screen seam falls, so a change | |||
| 378 | + | // in scroll behavior fails loudly instead of silently retargeting the | |||
| 379 | + | // case onto rows the seam no longer separates. | |||
| 380 | + | wantHistoryLen int | |||
| 381 | + | // wrapRow is the first snapshot row of the wrapped logical line. | |||
| 382 | + | wrapRow int | |||
| 383 | + | }{ | |||
| 384 | + | { | |||
| 385 | + | name: "wrapped line straddles the history and screen seam", | |||
| 386 | + | input: line + "\r\nx\r\ny\r\n", | |||
| 387 | + | wantHistoryLen: 1, | |||
| 388 | + | wrapRow: 0, | |||
| 389 | + | }, | |||
| 390 | + | { | |||
| 391 | + | name: "wrapped line sits entirely in sealed history", | |||
| 392 | + | input: line + "\r\nx\r\ny\r\nz\r\n", | |||
| 393 | + | wantHistoryLen: 2, | |||
| 394 | + | wrapRow: 0, | |||
| 395 | + | }, | |||
| 396 | + | { | |||
| 397 | + | name: "wrapped line sits entirely on the live screen", | |||
| 398 | + | input: "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line, | |||
| 399 | + | wantHistoryLen: 2, | |||
| 400 | + | wrapRow: 3, | |||
| 401 | + | }, | |||
| 402 | + | } | |||
| 403 | + | for _, tc := range cases { | |||
| 404 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 405 | + | screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64, MaxCells: 4096}) | |||
| 406 | + | screen.Write([]byte(tc.input)) | |||
| 407 | + | ||||
| 408 | + | snapshot := NewSnapshot(screen.History(), screen.Frame, screen.LineBounds()) | |||
| 409 | + | require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved") | |||
| 410 | + | doc := NewDocument(snapshot, domain.DefaultWordSeparators) | |||
| 411 | + | ||||
| 412 | + | got := doc.Extract([]CellRange{ | |||
| 413 | + | {Row: tc.wrapRow, Start: 0, End: width - 1}, | |||
| 414 | + | {Row: tc.wrapRow + 1, Start: 0, End: width - 1}, | |||
| 415 | + | }) | |||
| 416 | + | require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding") | |||
| 417 | + | }) | |||
| 418 | + | } | |||
| 419 | + | } | |||