fix(vt): finalize stable row identity fixes
- commit
- b1ef94ed346dd455c99e935ea88b5248c7c07e8b
- author
- brice · · 2026-08-03 19:23 UTC
- parent
- 14c1427
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +45 -40
M internal/usecase/daemon/status_test.go +9 -2
| @@ -634,8 +634,10 @@ func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB | |||
| 634 | 634 | func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) { | |
| 635 | 635 | t.Helper() | |
| 636 | 636 | var got []byte | |
| 637 | + | p.mu.Lock() | |
| 637 | 638 | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 638 | 639 | p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 640 | + | p.mu.Unlock() | |
| 639 | 641 | require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got)) | |
| 640 | 642 | require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got)) | |
| 641 | 643 | } | |
| @@ -654,17 +656,22 @@ func assertPaneColorScheme(t *testing.T, p *pane, light bool) { | |||
| 654 | 656 | want = "\x1b[?997;2n" | |
| 655 | 657 | } | |
| 656 | 658 | var got []byte | |
| 659 | + | p.mu.Lock() | |
| 657 | 660 | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 658 | 661 | p.screen.Write([]byte("\x1b[?996n")) | |
| 662 | + | p.mu.Unlock() | |
| 659 | 663 | require.Equal(t, want, string(got)) | |
| 660 | 664 | } | |
| 661 | 665 | ||
| 662 | 666 | func assertSessionColorSchemeUnknown(t *testing.T, sess *session) { | |
| 663 | 667 | t.Helper() | |
| 664 | 668 | for _, tb := range sess.tabs { | |
| 669 | + | p := tb.focusedPane() | |
| 665 | 670 | var got []byte | |
| 666 | - | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 667 | - | tb.focusedPane().screen.Write([]byte("\x1b[?996n")) | |
| 671 | + | p.mu.Lock() | |
| 672 | + | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| 673 | + | p.screen.Write([]byte("\x1b[?996n")) | |
| 674 | + | p.mu.Unlock() | |
| 668 | 675 | require.Empty(t, got) | |
| 669 | 676 | } | |
| 670 | 677 | } | |
| @@ -634,8 +634,10 @@ func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB | |||||
| 634 | func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) { | 634 | func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) { | ||
| 635 | t.Helper() | 635 | t.Helper() | ||
| 636 | var got []byte | 636 | var got []byte | ||
| 637 | + | p.mu.Lock() | |||
| 637 | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | 638 | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | ||
| 638 | p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | 639 | p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | ||
| 640 | + | p.mu.Unlock() | |||
| 639 | require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got)) | 641 | require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got)) | ||
| 640 | require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got)) | 642 | require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got)) | ||
| 641 | } | 643 | } | ||
| @@ -654,17 +656,22 @@ func assertPaneColorScheme(t *testing.T, p *pane, light bool) { | |||||
| 654 | want = "\x1b[?997;2n" | 656 | want = "\x1b[?997;2n" | ||
| 655 | } | 657 | } | ||
| 656 | var got []byte | 658 | var got []byte | ||
| 659 | + | p.mu.Lock() | |||
| 657 | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | 660 | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | ||
| 658 | p.screen.Write([]byte("\x1b[?996n")) | 661 | p.screen.Write([]byte("\x1b[?996n")) | ||
| 662 | + | p.mu.Unlock() | |||
| 659 | require.Equal(t, want, string(got)) | 663 | require.Equal(t, want, string(got)) | ||
| 660 | } | 664 | } | ||
| 661 | 665 | ||||
| 662 | func assertSessionColorSchemeUnknown(t *testing.T, sess *session) { | 666 | func assertSessionColorSchemeUnknown(t *testing.T, sess *session) { | ||
| 663 | t.Helper() | 667 | t.Helper() | ||
| 664 | for _, tb := range sess.tabs { | 668 | for _, tb := range sess.tabs { | ||
| 669 | + | p := tb.focusedPane() | |||
| 665 | var got []byte | 670 | var got []byte | ||
| 666 | - | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | 671 | + | p.mu.Lock() |
| 667 | - | tb.focusedPane().screen.Write([]byte("\x1b[?996n")) | 672 | + | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } |
| 673 | + | p.screen.Write([]byte("\x1b[?996n")) | |||
| 674 | + | p.mu.Unlock() | |||
| 668 | require.Empty(t, got) | 675 | require.Empty(t, got) | ||
| 669 | } | 676 | } | ||
| 670 | } | 677 | } | ||
M internal/usecase/snapshot/codec_test.go +2 -2
| @@ -224,8 +224,8 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |||
| 224 | 224 | ||
| 225 | 225 | func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | |
| 226 | 226 | sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | |
| 227 | - | if len(sealed) == 0 { | |
| 228 | - | t.Fatal("expected a sealed chunk") | |
| 227 | + | if len(sealed) < 2 { | |
| 228 | + | t.Fatalf("expected at least two sealed chunks, got %d", len(sealed)) | |
| 229 | 229 | } | |
| 230 | 230 | multiChunk := append([]byte(nil), sealed[0][:17]...) | |
| 231 | 231 | binary.BigEndian.PutUint32(multiChunk[5:9], 2) | |
| @@ -224,8 +224,8 @@ func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) { | |||||
| 224 | 224 | ||||
| 225 | func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | 225 | func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) { | ||
| 226 | sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | 226 | sealed, emptyTail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}}) | ||
| 227 | - | if len(sealed) == 0 { | 227 | + | if len(sealed) < 2 { |
| 228 | - | t.Fatal("expected a sealed chunk") | 228 | + | t.Fatalf("expected at least two sealed chunks, got %d", len(sealed)) |
| 229 | } | 229 | } | ||
| 230 | multiChunk := append([]byte(nil), sealed[0][:17]...) | 230 | multiChunk := append([]byte(nil), sealed[0][:17]...) | ||
| 231 | binary.BigEndian.PutUint32(multiChunk[5:9], 2) | 231 | binary.BigEndian.PutUint32(multiChunk[5:9], 2) | ||
M pkg/vt/history_codec.go +11 -9
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "math" | |
| 8 | + | "slices" | |
| 8 | 9 | "unicode/utf8" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -133,13 +134,13 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) { | |||
| 133 | 134 | } | |
| 134 | 135 | ||
| 135 | 136 | func maxHistoryRowID(ids map[RowID]struct{}) RowID { | |
| 136 | - | var max RowID | |
| 137 | + | var maxID RowID | |
| 137 | 138 | for id := range ids { | |
| 138 | - | if id > max { | |
| 139 | - | max = id | |
| 139 | + | if id > maxID { | |
| 140 | + | maxID = id | |
| 140 | 141 | } | |
| 141 | 142 | } | |
| 142 | - | return max | |
| 143 | + | return maxID | |
| 143 | 144 | } | |
| 144 | 145 | ||
| 145 | 146 | func historyViewRowCount(view HistoryView) int { | |
| @@ -289,11 +290,6 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 289 | 290 | return HistoryView{}, historyDecodeStats{}, false | |
| 290 | 291 | } | |
| 291 | 292 | rowID := RowID(id) | |
| 292 | - | for i := 0; i < seenCount; i++ { | |
| 293 | - | if seenIDs[i] == rowID { | |
| 294 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | 293 | seenIDs[seenCount] = rowID | |
| 298 | 294 | seenCount++ | |
| 299 | 295 | if rowID > maxID { | |
| @@ -336,6 +332,12 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||
| 336 | 332 | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | |
| 337 | 333 | } | |
| 338 | 334 | } | |
| 335 | + | slices.Sort(seenIDs[:seenCount]) | |
| 336 | + | for i := 1; i < seenCount; i++ { | |
| 337 | + | if seenIDs[i] == seenIDs[i-1] { | |
| 338 | + | return HistoryView{}, historyDecodeStats{}, false | |
| 339 | + | } | |
| 340 | + | } | |
| 339 | 341 | if len(p.data) != 0 || RowID(nextRowID) <= maxID { | |
| 340 | 342 | return HistoryView{}, historyDecodeStats{}, false | |
| 341 | 343 | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "math" | 7 | "math" | ||
| 8 | + | "slices" | |||
| 8 | "unicode/utf8" | 9 | "unicode/utf8" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/vev/pkg/renderer" | 11 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -133,13 +134,13 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) { | |||||
| 133 | } | 134 | } | ||
| 134 | 135 | ||||
| 135 | func maxHistoryRowID(ids map[RowID]struct{}) RowID { | 136 | func maxHistoryRowID(ids map[RowID]struct{}) RowID { | ||
| 136 | - | var max RowID | 137 | + | var maxID RowID |
| 137 | for id := range ids { | 138 | for id := range ids { | ||
| 138 | - | if id > max { | 139 | + | if id > maxID { |
| 139 | - | max = id | 140 | + | maxID = id |
| 140 | } | 141 | } | ||
| 141 | } | 142 | } | ||
| 142 | - | return max | 143 | + | return maxID |
| 143 | } | 144 | } | ||
| 144 | 145 | ||||
| 145 | func historyViewRowCount(view HistoryView) int { | 146 | func historyViewRowCount(view HistoryView) int { | ||
| @@ -289,11 +290,6 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 289 | return HistoryView{}, historyDecodeStats{}, false | 290 | return HistoryView{}, historyDecodeStats{}, false | ||
| 290 | } | 291 | } | ||
| 291 | rowID := RowID(id) | 292 | rowID := RowID(id) | ||
| 292 | - | for i := 0; i < seenCount; i++ { | |||
| 293 | - | if seenIDs[i] == rowID { | |||
| 294 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 295 | - | } | |||
| 296 | - | } | |||
| 297 | seenIDs[seenCount] = rowID | 293 | seenIDs[seenCount] = rowID | ||
| 298 | seenCount++ | 294 | seenCount++ | ||
| 299 | if rowID > maxID { | 295 | if rowID > maxID { | ||
| @@ -336,6 +332,12 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, | |||||
| 336 | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | 332 | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | ||
| 337 | } | 333 | } | ||
| 338 | } | 334 | } | ||
| 335 | + | slices.Sort(seenIDs[:seenCount]) | |||
| 336 | + | for i := 1; i < seenCount; i++ { | |||
| 337 | + | if seenIDs[i] == seenIDs[i-1] { | |||
| 338 | + | return HistoryView{}, historyDecodeStats{}, false | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 339 | if len(p.data) != 0 || RowID(nextRowID) <= maxID { | 341 | if len(p.data) != 0 || RowID(nextRowID) <= maxID { | ||
| 340 | return HistoryView{}, historyDecodeStats{}, false | 342 | return HistoryView{}, historyDecodeStats{}, false | ||
| 341 | } | 343 | } | ||
M pkg/vt/history_codec_test.go +8 -25
| @@ -67,7 +67,7 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) { | |||
| 67 | 67 | } | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | - | func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) { | |
| 70 | + | func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) { | |
| 71 | 71 | // The previous layouts are deliberately incompatible with stable IDs. | |
| 72 | 72 | legacy := []byte{ | |
| 73 | 73 | 'V', 'T', 'H', '1', 2, | |
| @@ -88,15 +88,6 @@ func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) { | |||
| 88 | 88 | t.Fatalf("accepted legacy history version %d", version) | |
| 89 | 89 | } | |
| 90 | 90 | } | |
| 91 | - | ||
| 92 | - | for i := 0; i < len(legacy); i++ { | |
| 93 | - | if _, err := UnmarshalHistory(legacy[:i]); err == nil { | |
| 94 | - | t.Fatalf("accepted truncated prefix of length %d", i) | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | if _, err := UnmarshalHistory(append(append([]byte(nil), legacy...), 0xff)); err == nil { | |
| 98 | - | t.Fatal("accepted trailing garbage") | |
| 99 | - | } | |
| 100 | 91 | } | |
| 101 | 92 | ||
| 102 | 93 | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |
| @@ -172,21 +163,13 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||
| 172 | 163 | t.Fatalf("marshal history: %v", err) | |
| 173 | 164 | } | |
| 174 | 165 | ||
| 175 | - | tests := []struct { | |
| 176 | - | name string | |
| 177 | - | data []byte | |
| 178 | - | }{ | |
| 179 | - | {name: "empty", data: nil}, | |
| 180 | - | {name: "truncated prefix", data: encoded[:len(encoded)-1]}, | |
| 181 | - | {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)}, | |
| 166 | + | for i := range len(encoded) { | |
| 167 | + | if _, err := UnmarshalHistory(encoded[:i]); err == nil { | |
| 168 | + | t.Fatalf("accepted truncated version 3 prefix of length %d", i) | |
| 169 | + | } | |
| 182 | 170 | } | |
| 183 | - | ||
| 184 | - | for _, tt := range tests { | |
| 185 | - | t.Run(tt.name, func(t *testing.T) { | |
| 186 | - | if _, err := UnmarshalHistory(tt.data); err == nil { | |
| 187 | - | t.Fatal("unmarshal accepted malformed history payload") | |
| 188 | - | } | |
| 189 | - | }) | |
| 171 | + | if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil { | |
| 172 | + | t.Fatal("accepted trailing garbage after version 3 payload") | |
| 190 | 173 | } | |
| 191 | 174 | } | |
| 192 | 175 | ||
| @@ -335,7 +318,7 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||
| 335 | 318 | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | |
| 336 | 319 | data = append(data, make([]byte, width*historyCellBytes)...) | |
| 337 | 320 | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |
| 338 | - | data = append(data, 0) | |
| 321 | + | data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended. | |
| 339 | 322 | } | |
| 340 | 323 | rowCount -= chunkRows | |
| 341 | 324 | } | |
| @@ -67,7 +67,7 @@ func TestHistoryCodecRoundTripsBounds(t *testing.T) { | |||||
| 67 | } | 67 | } | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | - | func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) { | 70 | + | func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) { |
| 71 | // The previous layouts are deliberately incompatible with stable IDs. | 71 | // The previous layouts are deliberately incompatible with stable IDs. | ||
| 72 | legacy := []byte{ | 72 | legacy := []byte{ | ||
| 73 | 'V', 'T', 'H', '1', 2, | 73 | 'V', 'T', 'H', '1', 2, | ||
| @@ -88,15 +88,6 @@ func TestHistoryCodecRejectsMalformedAndLegacyPayloads(t *testing.T) { | |||||
| 88 | t.Fatalf("accepted legacy history version %d", version) | 88 | t.Fatalf("accepted legacy history version %d", version) | ||
| 89 | } | 89 | } | ||
| 90 | } | 90 | } | ||
| 91 | - | ||||
| 92 | - | for i := 0; i < len(legacy); i++ { | |||
| 93 | - | if _, err := UnmarshalHistory(legacy[:i]); err == nil { | |||
| 94 | - | t.Fatalf("accepted truncated prefix of length %d", i) | |||
| 95 | - | } | |||
| 96 | - | } | |||
| 97 | - | if _, err := UnmarshalHistory(append(append([]byte(nil), legacy...), 0xff)); err == nil { | |||
| 98 | - | t.Fatal("accepted trailing garbage") | |||
| 99 | - | } | |||
| 100 | } | 91 | } | ||
| 101 | 92 | ||||
| 102 | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | 93 | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | ||
| @@ -172,21 +163,13 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||||
| 172 | t.Fatalf("marshal history: %v", err) | 163 | t.Fatalf("marshal history: %v", err) | ||
| 173 | } | 164 | } | ||
| 174 | 165 | ||||
| 175 | - | tests := []struct { | 166 | + | for i := range len(encoded) { |
| 176 | - | name string | 167 | + | if _, err := UnmarshalHistory(encoded[:i]); err == nil { |
| 177 | - | data []byte | 168 | + | t.Fatalf("accepted truncated version 3 prefix of length %d", i) |
| 178 | - | }{ | 169 | + | } |
| 179 | - | {name: "empty", data: nil}, | |||
| 180 | - | {name: "truncated prefix", data: encoded[:len(encoded)-1]}, | |||
| 181 | - | {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)}, | |||
| 182 | } | 170 | } | ||
| 183 | - | 171 | + | if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil { | |
| 184 | - | for _, tt := range tests { | 172 | + | t.Fatal("accepted trailing garbage after version 3 payload") |
| 185 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 186 | - | if _, err := UnmarshalHistory(tt.data); err == nil { | |||
| 187 | - | t.Fatal("unmarshal accepted malformed history payload") | |||
| 188 | - | } | |||
| 189 | - | }) | |||
| 190 | } | 173 | } | ||
| 191 | } | 174 | } | ||
| 192 | 175 | ||||
| @@ -335,7 +318,7 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||||
| 335 | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | 318 | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | ||
| 336 | data = append(data, make([]byte, width*historyCellBytes)...) | 319 | data = append(data, make([]byte, width*historyCellBytes)...) | ||
| 337 | data = binary.BigEndian.AppendUint32(data, uint32(width)) | 320 | data = binary.BigEndian.AppendUint32(data, uint32(width)) | ||
| 338 | - | data = append(data, 0) | 321 | + | data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended. |
| 339 | } | 322 | } | ||
| 340 | rowCount -= chunkRows | 323 | rowCount -= chunkRows | ||
| 341 | } | 324 | } | ||
M pkg/vt/row_id_test.go +12 -0
| @@ -49,6 +49,18 @@ func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) { | |||
| 49 | 49 | } | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | func TestScreenRowIDRefreshesOnSnapExpandedFullClear(t *testing.T) { | |
| 53 | + | s := NewScreen(2, 1) | |
| 54 | + | s.Write([]byte("界")) | |
| 55 | + | before := s.RowID(0) | |
| 56 | + | ||
| 57 | + | s.clearRow(0, 1, 2) | |
| 58 | + | ||
| 59 | + | if got := s.RowID(0); got == before { | |
| 60 | + | t.Fatalf("row ID = %d after snap-expanded full clear, want a fresh ID", got) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 52 | 64 | func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | |
| 53 | 65 | s := NewScreen(4, 3) | |
| 54 | 66 | before := s.RowIDs() | |
| @@ -49,6 +49,18 @@ func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) { | |||||
| 49 | } | 49 | } | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | func TestScreenRowIDRefreshesOnSnapExpandedFullClear(t *testing.T) { | |||
| 53 | + | s := NewScreen(2, 1) | |||
| 54 | + | s.Write([]byte("界")) | |||
| 55 | + | before := s.RowID(0) | |||
| 56 | + | ||||
| 57 | + | s.clearRow(0, 1, 2) | |||
| 58 | + | ||||
| 59 | + | if got := s.RowID(0); got == before { | |||
| 60 | + | t.Fatalf("row ID = %d after snap-expanded full clear, want a fresh ID", got) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 52 | func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | 64 | func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | ||
| 53 | s := NewScreen(4, 3) | 65 | s := NewScreen(4, 3) | ||
| 54 | before := s.RowIDs() | 66 | before := s.RowIDs() | ||
M pkg/vt/screen_write.go +1 -1
| @@ -184,7 +184,6 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 184 | 184 | if y < 0 || y >= s.Frame.Height { | |
| 185 | 185 | return x0, 0 | |
| 186 | 186 | } | |
| 187 | - | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |
| 188 | 187 | if x0 < 0 { | |
| 189 | 188 | x0 = 0 | |
| 190 | 189 | } | |
| @@ -203,6 +202,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 203 | 202 | if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation { | |
| 204 | 203 | x1++ | |
| 205 | 204 | } | |
| 205 | + | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |
| 206 | 206 | blank := s.eraseCell() | |
| 207 | 207 | for x := x0; x < x1; x++ { | |
| 208 | 208 | s.Frame.Set(x, y, blank) | |
| @@ -184,7 +184,6 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||||
| 184 | if y < 0 || y >= s.Frame.Height { | 184 | if y < 0 || y >= s.Frame.Height { | ||
| 185 | return x0, 0 | 185 | return x0, 0 | ||
| 186 | } | 186 | } | ||
| 187 | - | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |||
| 188 | if x0 < 0 { | 187 | if x0 < 0 { | ||
| 189 | x0 = 0 | 188 | x0 = 0 | ||
| 190 | } | 189 | } | ||
| @@ -203,6 +202,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||||
| 203 | if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation { | 202 | if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation { | ||
| 204 | x1++ | 203 | x1++ | ||
| 205 | } | 204 | } | ||
| 205 | + | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |||
| 206 | blank := s.eraseCell() | 206 | blank := s.eraseCell() | ||
| 207 | for x := x0; x < x1; x++ { | 207 | for x := x0; x < x1; x++ { | ||
| 208 | s.Frame.Set(x, y, blank) | 208 | s.Frame.Set(x, y, blank) | ||
M pkg/vt/snapshot.go +2 -1
| @@ -53,7 +53,8 @@ func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) { | |||
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | // MarshalSealedHistory serializes a SealAndView result as oldest-first, | |
| 56 | - | // self-contained sealed blobs plus a mandatory empty canonical tail blob. | |
| 56 | + | // self-contained sealed blobs plus a mandatory empty tail blob. The empty tail | |
| 57 | + | // carries NextRowID and is not the canonical default empty encoding. | |
| 57 | 58 | func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |
| 58 | 59 | if view.rows != historyViewRowCount(view) { | |
| 59 | 60 | return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory) | |
| @@ -53,7 +53,8 @@ func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) { | |||||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | // MarshalSealedHistory serializes a SealAndView result as oldest-first, | 55 | // MarshalSealedHistory serializes a SealAndView result as oldest-first, | ||
| 56 | - | // self-contained sealed blobs plus a mandatory empty canonical tail blob. | 56 | + | // self-contained sealed blobs plus a mandatory empty tail blob. The empty tail |
| 57 | + | // carries NextRowID and is not the canonical default empty encoding. | |||
| 57 | func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | 58 | func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | ||
| 58 | if view.rows != historyViewRowCount(view) { | 59 | if view.rows != historyViewRowCount(view) { | ||
| 59 | return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory) | 60 | return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory) | ||