test(vt): specify immutable history chunks
- commit
- eb218a3740558146668059a4747b42ce67902a0f
- author
- brice · · 2026-07-12 14:40 UTC
- parent
- 4c50de8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +273 -0
A pkg/vt/history_codec_test.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |
| 10 | + | indexed := renderer.Style{ | |
| 11 | + | Bold: true, | |
| 12 | + | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough, | |
| 13 | + | Foreground: 123, | |
| 14 | + | Background: 45, | |
| 15 | + | UnderlineStyle: renderer.UnderlineDashed, | |
| 16 | + | HasUnderlineColor: true, | |
| 17 | + | UnderlineColor: 201, | |
| 18 | + | } | |
| 19 | + | rgb := renderer.Style{ | |
| 20 | + | Italic: true, | |
| 21 | + | Inverse: true, | |
| 22 | + | Attrs: renderer.AttrBlink, | |
| 23 | + | HasForegroundRGB: true, | |
| 24 | + | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |
| 25 | + | HasBackgroundRGB: true, | |
| 26 | + | BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |
| 27 | + | UnderlineStyle: renderer.UnderlineCurly, | |
| 28 | + | HasUnderlineColorRGB: true, | |
| 29 | + | UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9}, | |
| 30 | + | } | |
| 31 | + | tests := []struct { | |
| 32 | + | name string | |
| 33 | + | rows [][]renderer.Cell | |
| 34 | + | }{ | |
| 35 | + | { | |
| 36 | + | name: "indexed RGB blank and wide continuation cells are exact", | |
| 37 | + | rows: [][]renderer.Cell{ | |
| 38 | + | { | |
| 39 | + | {Rune: 'I', Style: indexed}, | |
| 40 | + | {Rune: 'R', Style: rgb}, | |
| 41 | + | {Rune: ' ', Style: renderer.DefaultStyle()}, | |
| 42 | + | {Rune: '界', Style: rgb}, | |
| 43 | + | {Continuation: true, Style: rgb}, | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | {Rune: ' ', Style: renderer.DefaultStyle()}, | |
| 47 | + | {Rune: 'X', Style: indexed}, | |
| 48 | + | }, | |
| 49 | + | }, | |
| 50 | + | }, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 56 | + | for _, row := range tt.rows { | |
| 57 | + | history.Append(row) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | encoded, err := MarshalHistory(history.View()) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatalf("marshal history: %v", err) | |
| 63 | + | } | |
| 64 | + | decoded, err := UnmarshalHistory(encoded) | |
| 65 | + | if err != nil { | |
| 66 | + | t.Fatalf("unmarshal history: %v", err) | |
| 67 | + | } | |
| 68 | + | assertHistoryRowsEqual(t, decoded, tt.rows) | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |
| 74 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | |
| 75 | + | history.Append(historyRow("AAAA")) | |
| 76 | + | history.Append(historyRow("BBBB")) | |
| 77 | + | encoded, err := MarshalHistory(history.View()) | |
| 78 | + | if err != nil { | |
| 79 | + | t.Fatalf("marshal history: %v", err) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | tests := []struct { | |
| 83 | + | name string | |
| 84 | + | data []byte | |
| 85 | + | }{ | |
| 86 | + | {name: "empty", data: nil}, | |
| 87 | + | {name: "truncated prefix", data: encoded[:len(encoded)-1]}, | |
| 88 | + | {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)}, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | for _, tt := range tests { | |
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |
| 93 | + | if _, err := UnmarshalHistory(tt.data); err == nil { | |
| 94 | + | t.Fatal("unmarshal accepted malformed history payload") | |
| 95 | + | } | |
| 96 | + | }) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) { | |
| 101 | + | t.Helper() | |
| 102 | + | if got := view.Len(); got != len(want) { | |
| 103 | + | t.Fatalf("row count = %d, want %d", got, len(want)) | |
| 104 | + | } | |
| 105 | + | for y, wantRow := range want { | |
| 106 | + | gotRow := view.Row(y) | |
| 107 | + | if len(gotRow) != len(wantRow) { | |
| 108 | + | t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow)) | |
| 109 | + | } | |
| 110 | + | for x, wantCell := range wantRow { | |
| 111 | + | if !gotRow[x].Equal(wantCell) { | |
| 112 | + | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |||
| 10 | + | indexed := renderer.Style{ | |||
| 11 | + | Bold: true, | |||
| 12 | + | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough, | |||
| 13 | + | Foreground: 123, | |||
| 14 | + | Background: 45, | |||
| 15 | + | UnderlineStyle: renderer.UnderlineDashed, | |||
| 16 | + | HasUnderlineColor: true, | |||
| 17 | + | UnderlineColor: 201, | |||
| 18 | + | } | |||
| 19 | + | rgb := renderer.Style{ | |||
| 20 | + | Italic: true, | |||
| 21 | + | Inverse: true, | |||
| 22 | + | Attrs: renderer.AttrBlink, | |||
| 23 | + | HasForegroundRGB: true, | |||
| 24 | + | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 25 | + | HasBackgroundRGB: true, | |||
| 26 | + | BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 27 | + | UnderlineStyle: renderer.UnderlineCurly, | |||
| 28 | + | HasUnderlineColorRGB: true, | |||
| 29 | + | UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 30 | + | } | |||
| 31 | + | tests := []struct { | |||
| 32 | + | name string | |||
| 33 | + | rows [][]renderer.Cell | |||
| 34 | + | }{ | |||
| 35 | + | { | |||
| 36 | + | name: "indexed RGB blank and wide continuation cells are exact", | |||
| 37 | + | rows: [][]renderer.Cell{ | |||
| 38 | + | { | |||
| 39 | + | {Rune: 'I', Style: indexed}, | |||
| 40 | + | {Rune: 'R', Style: rgb}, | |||
| 41 | + | {Rune: ' ', Style: renderer.DefaultStyle()}, | |||
| 42 | + | {Rune: '界', Style: rgb}, | |||
| 43 | + | {Continuation: true, Style: rgb}, | |||
| 44 | + | }, | |||
| 45 | + | { | |||
| 46 | + | {Rune: ' ', Style: renderer.DefaultStyle()}, | |||
| 47 | + | {Rune: 'X', Style: indexed}, | |||
| 48 | + | }, | |||
| 49 | + | }, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 56 | + | for _, row := range tt.rows { | |||
| 57 | + | history.Append(row) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | encoded, err := MarshalHistory(history.View()) | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Fatalf("marshal history: %v", err) | |||
| 63 | + | } | |||
| 64 | + | decoded, err := UnmarshalHistory(encoded) | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatalf("unmarshal history: %v", err) | |||
| 67 | + | } | |||
| 68 | + | assertHistoryRowsEqual(t, decoded, tt.rows) | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||
| 74 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | |||
| 75 | + | history.Append(historyRow("AAAA")) | |||
| 76 | + | history.Append(historyRow("BBBB")) | |||
| 77 | + | encoded, err := MarshalHistory(history.View()) | |||
| 78 | + | if err != nil { | |||
| 79 | + | t.Fatalf("marshal history: %v", err) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | tests := []struct { | |||
| 83 | + | name string | |||
| 84 | + | data []byte | |||
| 85 | + | }{ | |||
| 86 | + | {name: "empty", data: nil}, | |||
| 87 | + | {name: "truncated prefix", data: encoded[:len(encoded)-1]}, | |||
| 88 | + | {name: "trailing byte", data: append(append([]byte(nil), encoded...), 0)}, | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | for _, tt := range tests { | |||
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 93 | + | if _, err := UnmarshalHistory(tt.data); err == nil { | |||
| 94 | + | t.Fatal("unmarshal accepted malformed history payload") | |||
| 95 | + | } | |||
| 96 | + | }) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) { | |||
| 101 | + | t.Helper() | |||
| 102 | + | if got := view.Len(); got != len(want) { | |||
| 103 | + | t.Fatalf("row count = %d, want %d", got, len(want)) | |||
| 104 | + | } | |||
| 105 | + | for y, wantRow := range want { | |||
| 106 | + | gotRow := view.Row(y) | |||
| 107 | + | if len(gotRow) != len(wantRow) { | |||
| 108 | + | t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow)) | |||
| 109 | + | } | |||
| 110 | + | for x, wantCell := range wantRow { | |||
| 111 | + | if !gotRow[x].Equal(wantCell) { | |||
| 112 | + | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | } | |||
A pkg/vt/history_test.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | rows []string | |
| 13 | + | want []string | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "oldest chunks are evicted before newer chunks", | |
| 17 | + | rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"}, | |
| 18 | + | want: []string{"cccc", "dddd", "eeee", "ffff"}, | |
| 19 | + | }, | |
| 20 | + | { | |
| 21 | + | name: "rows preserve append order across chunk boundaries", | |
| 22 | + | rows: []string{"0000", "1111", "2222", "3333"}, | |
| 23 | + | want: []string{"0000", "1111", "2222", "3333"}, | |
| 24 | + | }, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | for _, tt := range tests { | |
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |
| 29 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | |
| 30 | + | for _, text := range tt.rows { | |
| 31 | + | history.Append(historyRow(text)) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | view := history.View() | |
| 35 | + | if got := historyViewTexts(view); !equalStrings(got, tt.want) { | |
| 36 | + | t.Fatalf("view rows = %#v, want %#v", got, tt.want) | |
| 37 | + | } | |
| 38 | + | if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want { | |
| 39 | + | t.Fatalf("chunk count = %d, want %d", got, want) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestHistoryTailRotationAndStableViews(t *testing.T) { | |
| 46 | + | tests := []struct { | |
| 47 | + | name string | |
| 48 | + | }{ | |
| 49 | + | {name: "a full tail seals and a later append cannot change an existing view"}, | |
| 50 | + | {name: "rows passed to append are copied before the screen recycles them"}, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2}) | |
| 56 | + | first := historyRow("AAAA") | |
| 57 | + | history.Append(first) | |
| 58 | + | history.Append(historyRow("BBBB")) | |
| 59 | + | view := history.View() | |
| 60 | + | if got, want := view.ChunkCount(), 1; got != want { | |
| 61 | + | t.Fatalf("sealed chunk count = %d, want %d", got, want) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | first[0].Rune = 'X' | |
| 65 | + | history.Append(historyRow("CCCC")) | |
| 66 | + | ||
| 67 | + | if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) { | |
| 68 | + | t.Fatalf("stable view rows = %#v, want %#v", got, want) | |
| 69 | + | } | |
| 70 | + | if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) { | |
| 71 | + | t.Fatalf("current view rows = %#v, want %#v", got, want) | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) { | |
| 78 | + | tests := []struct { | |
| 79 | + | name string | |
| 80 | + | }{ | |
| 81 | + | {name: "unchanged sealed chunks are shared by successive views"}, | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | for _, tt := range tests { | |
| 85 | + | t.Run(tt.name, func(t *testing.T) { | |
| 86 | + | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 87 | + | history.Append(historyRow("AAAA")) | |
| 88 | + | history.Append(historyRow("BBBB")) | |
| 89 | + | before := history.View() | |
| 90 | + | ||
| 91 | + | history.Append(historyRow("CCCC")) | |
| 92 | + | after := history.View() | |
| 93 | + | ||
| 94 | + | if before.ChunkCount() == 0 || after.ChunkCount() == 0 { | |
| 95 | + | t.Fatal("sealed chunk missing from view") | |
| 96 | + | } | |
| 97 | + | if before.Chunk(0) != after.Chunk(0) { | |
| 98 | + | t.Fatal("unchanged sealed chunk was copied instead of reused") | |
| 99 | + | } | |
| 100 | + | }) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) { | |
| 105 | + | tests := []struct { | |
| 106 | + | name string | |
| 107 | + | write []byte | |
| 108 | + | want []string | |
| 109 | + | }{ | |
| 110 | + | { | |
| 111 | + | name: "primary screen evictions enter terminal history", | |
| 112 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |
| 113 | + | want: []string{"AAAA"}, | |
| 114 | + | }, | |
| 115 | + | { | |
| 116 | + | name: "alternate screen scrolling never enters terminal history", | |
| 117 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"), | |
| 118 | + | want: []string{"AAAA"}, | |
| 119 | + | }, | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | for _, tt := range tests { | |
| 123 | + | t.Run(tt.name, func(t *testing.T) { | |
| 124 | + | screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 125 | + | screen.Write(tt.write) | |
| 126 | + | ||
| 127 | + | if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) { | |
| 128 | + | t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want) | |
| 129 | + | } | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func historyRow(text string) []renderer.Cell { | |
| 135 | + | row := make([]renderer.Cell, len(text)) | |
| 136 | + | for i, r := range text { | |
| 137 | + | row[i] = renderer.BlankCell() | |
| 138 | + | row[i].Rune = r | |
| 139 | + | } | |
| 140 | + | return row | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func historyViewTexts(view HistoryView) []string { | |
| 144 | + | rows := make([]string, view.Len()) | |
| 145 | + | for i := range rows { | |
| 146 | + | rows[i] = rowText(view.Row(i)) | |
| 147 | + | } | |
| 148 | + | return rows | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func rowText(row []renderer.Cell) string { | |
| 152 | + | runes := make([]rune, len(row)) | |
| 153 | + | for i, cell := range row { | |
| 154 | + | runes[i] = cell.Rune | |
| 155 | + | } | |
| 156 | + | return string(runes) | |
| 157 | + | } | |
| @@ -0,0 +1,157 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | rows []string | |||
| 13 | + | want []string | |||
| 14 | + | }{ | |||
| 15 | + | { | |||
| 16 | + | name: "oldest chunks are evicted before newer chunks", | |||
| 17 | + | rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"}, | |||
| 18 | + | want: []string{"cccc", "dddd", "eeee", "ffff"}, | |||
| 19 | + | }, | |||
| 20 | + | { | |||
| 21 | + | name: "rows preserve append order across chunk boundaries", | |||
| 22 | + | rows: []string{"0000", "1111", "2222", "3333"}, | |||
| 23 | + | want: []string{"0000", "1111", "2222", "3333"}, | |||
| 24 | + | }, | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | for _, tt := range tests { | |||
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 29 | + | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | |||
| 30 | + | for _, text := range tt.rows { | |||
| 31 | + | history.Append(historyRow(text)) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | view := history.View() | |||
| 35 | + | if got := historyViewTexts(view); !equalStrings(got, tt.want) { | |||
| 36 | + | t.Fatalf("view rows = %#v, want %#v", got, tt.want) | |||
| 37 | + | } | |||
| 38 | + | if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want { | |||
| 39 | + | t.Fatalf("chunk count = %d, want %d", got, want) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestHistoryTailRotationAndStableViews(t *testing.T) { | |||
| 46 | + | tests := []struct { | |||
| 47 | + | name string | |||
| 48 | + | }{ | |||
| 49 | + | {name: "a full tail seals and a later append cannot change an existing view"}, | |||
| 50 | + | {name: "rows passed to append are copied before the screen recycles them"}, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2}) | |||
| 56 | + | first := historyRow("AAAA") | |||
| 57 | + | history.Append(first) | |||
| 58 | + | history.Append(historyRow("BBBB")) | |||
| 59 | + | view := history.View() | |||
| 60 | + | if got, want := view.ChunkCount(), 1; got != want { | |||
| 61 | + | t.Fatalf("sealed chunk count = %d, want %d", got, want) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | first[0].Rune = 'X' | |||
| 65 | + | history.Append(historyRow("CCCC")) | |||
| 66 | + | ||||
| 67 | + | if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) { | |||
| 68 | + | t.Fatalf("stable view rows = %#v, want %#v", got, want) | |||
| 69 | + | } | |||
| 70 | + | if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) { | |||
| 71 | + | t.Fatalf("current view rows = %#v, want %#v", got, want) | |||
| 72 | + | } | |||
| 73 | + | }) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) { | |||
| 78 | + | tests := []struct { | |||
| 79 | + | name string | |||
| 80 | + | }{ | |||
| 81 | + | {name: "unchanged sealed chunks are shared by successive views"}, | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | for _, tt := range tests { | |||
| 85 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 86 | + | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 87 | + | history.Append(historyRow("AAAA")) | |||
| 88 | + | history.Append(historyRow("BBBB")) | |||
| 89 | + | before := history.View() | |||
| 90 | + | ||||
| 91 | + | history.Append(historyRow("CCCC")) | |||
| 92 | + | after := history.View() | |||
| 93 | + | ||||
| 94 | + | if before.ChunkCount() == 0 || after.ChunkCount() == 0 { | |||
| 95 | + | t.Fatal("sealed chunk missing from view") | |||
| 96 | + | } | |||
| 97 | + | if before.Chunk(0) != after.Chunk(0) { | |||
| 98 | + | t.Fatal("unchanged sealed chunk was copied instead of reused") | |||
| 99 | + | } | |||
| 100 | + | }) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) { | |||
| 105 | + | tests := []struct { | |||
| 106 | + | name string | |||
| 107 | + | write []byte | |||
| 108 | + | want []string | |||
| 109 | + | }{ | |||
| 110 | + | { | |||
| 111 | + | name: "primary screen evictions enter terminal history", | |||
| 112 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |||
| 113 | + | want: []string{"AAAA"}, | |||
| 114 | + | }, | |||
| 115 | + | { | |||
| 116 | + | name: "alternate screen scrolling never enters terminal history", | |||
| 117 | + | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"), | |||
| 118 | + | want: []string{"AAAA"}, | |||
| 119 | + | }, | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | for _, tt := range tests { | |||
| 123 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 124 | + | screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 125 | + | screen.Write(tt.write) | |||
| 126 | + | ||||
| 127 | + | if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) { | |||
| 128 | + | t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want) | |||
| 129 | + | } | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func historyRow(text string) []renderer.Cell { | |||
| 135 | + | row := make([]renderer.Cell, len(text)) | |||
| 136 | + | for i, r := range text { | |||
| 137 | + | row[i] = renderer.BlankCell() | |||
| 138 | + | row[i].Rune = r | |||
| 139 | + | } | |||
| 140 | + | return row | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func historyViewTexts(view HistoryView) []string { | |||
| 144 | + | rows := make([]string, view.Len()) | |||
| 145 | + | for i := range rows { | |||
| 146 | + | rows[i] = rowText(view.Row(i)) | |||
| 147 | + | } | |||
| 148 | + | return rows | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func rowText(row []renderer.Cell) string { | |||
| 152 | + | runes := make([]rune, len(row)) | |||
| 153 | + | for i, cell := range row { | |||
| 154 | + | runes[i] = cell.Rune | |||
| 155 | + | } | |||
| 156 | + | return string(runes) | |||
| 157 | + | } | |||