test(pkg): convert ported tests to table-driven
Restructure pkg/renderer and pkg/vt tests to the project's table-driven convention: named cases (slice of structs) + t.Run(name, ...) subtests. Closely related scenarios (e.g. SGR attribute variants, cursor moves, CSI editing sequences, C1/OSC handling, wide-rune handling) are merged into single table-driven test functions with real data tables where the shape naturally allows it, and into named-case/run-closure tables where scenarios are too distinct to share a data shape. TestRuneWidth, which was only partially table-driven (no t.Run), now uses named subtests too. Every original assertion and test case is preserved — no behavior changes, no dropped cases. Benchmarks are untouched.
- commit
- 7b0740bc14960456d11e288a31f5cfd6e6c7a246
- author
- brice · · 2026-07-02 09:20 UTC
- parent
- 84a83dc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +2357 -1773
M pkg/renderer/renderer_test.go +763 -502
| @@ -41,37 +41,44 @@ func outputEndsWith(t *testing.T, data []byte, suffix string) { | |||
| 41 | 41 | // --------------------------------------------------------------------------- | |
| 42 | 42 | ||
| 43 | 43 | func TestFirstDraw(t *testing.T) { | |
| 44 | - | r := New(Capabilities{}) | |
| 45 | - | frame := NewFrame(5, 3) | |
| 46 | - | markFrame(&frame) | |
| 47 | - | ||
| 48 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}) | |
| 49 | - | if err != nil { | |
| 50 | - | t.Fatal(err) | |
| 51 | - | } | |
| 52 | - | if len(out) == 0 { | |
| 53 | - | t.Fatal("expected non-empty output on first draw") | |
| 54 | - | } | |
| 55 | - | outputContains(t, out, "\x1b[0m") | |
| 56 | - | // Should have cursor-position sequences for all three rows. | |
| 57 | - | if c := strings.Count(string(out), "\x1b["); c < 3 { | |
| 58 | - | t.Fatalf("expected at least 3 CSI sequences, got %d", c) | |
| 44 | + | tests := []struct { | |
| 45 | + | name string | |
| 46 | + | damage []Damage | |
| 47 | + | minCSICount int | |
| 48 | + | }{ | |
| 49 | + | { | |
| 50 | + | name: "explicit text damage", | |
| 51 | + | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}, | |
| 52 | + | minCSICount: 3, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "full redraw damage", | |
| 56 | + | damage: []Damage{FullRedraw()}, | |
| 57 | + | }, | |
| 59 | 58 | } | |
| 60 | - | } | |
| 61 | 59 | ||
| 62 | - | func TestFirstDrawFullRedraw(t *testing.T) { | |
| 63 | - | r := New(Capabilities{}) | |
| 64 | - | frame := NewFrame(5, 3) | |
| 65 | - | markFrame(&frame) | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | r := New(Capabilities{}) | |
| 63 | + | frame := NewFrame(5, 3) | |
| 64 | + | markFrame(&frame) | |
| 66 | 65 | ||
| 67 | - | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 68 | - | if err != nil { | |
| 69 | - | t.Fatal(err) | |
| 70 | - | } | |
| 71 | - | if len(out) == 0 { | |
| 72 | - | t.Fatal("expected non-empty output on full redraw") | |
| 66 | + | out, err := r.Draw(frame, tt.damage) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | if len(out) == 0 { | |
| 71 | + | t.Fatal("expected non-empty output on first draw") | |
| 72 | + | } | |
| 73 | + | outputContains(t, out, "\x1b[0m") | |
| 74 | + | if tt.minCSICount > 0 { | |
| 75 | + | // Should have cursor-position sequences for all three rows. | |
| 76 | + | if c := strings.Count(string(out), "\x1b["); c < tt.minCSICount { | |
| 77 | + | t.Fatalf("expected at least %d CSI sequences, got %d", tt.minCSICount, c) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | }) | |
| 73 | 81 | } | |
| 74 | - | outputContains(t, out, "\x1b[0m") | |
| 75 | 82 | } | |
| 76 | 83 | ||
| 77 | 84 | // --------------------------------------------------------------------------- | |
| @@ -79,47 +86,53 @@ func TestFirstDrawFullRedraw(t *testing.T) { | |||
| 79 | 86 | // --------------------------------------------------------------------------- | |
| 80 | 87 | ||
| 81 | 88 | func TestNoOp(t *testing.T) { | |
| 82 | - | r := New(Capabilities{}) | |
| 83 | - | frame := NewFrame(5, 3) | |
| 84 | - | markFrame(&frame) | |
| 85 | - | ||
| 86 | - | // First draw – populate shadow. | |
| 87 | - | out1, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}) | |
| 88 | - | if err != nil { | |
| 89 | - | t.Fatal(err) | |
| 90 | - | } | |
| 91 | - | if len(out1) == 0 { | |
| 92 | - | t.Fatal("first draw must produce output") | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // Second draw with no changes. | |
| 96 | - | out2, err := r.Draw(frame, nil) | |
| 97 | - | if err != nil { | |
| 98 | - | t.Fatal(err) | |
| 99 | - | } | |
| 100 | - | if len(out2) != 0 { | |
| 101 | - | t.Fatalf("expected no-op (empty output), got %q", string(out2)) | |
| 89 | + | tests := []struct { | |
| 90 | + | name string | |
| 91 | + | width int | |
| 92 | + | height int | |
| 93 | + | firstDamage []Damage | |
| 94 | + | secondDamage []Damage | |
| 95 | + | }{ | |
| 96 | + | { | |
| 97 | + | name: "nil damage on unchanged frame", | |
| 98 | + | width: 5, | |
| 99 | + | height: 3, | |
| 100 | + | firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}, | |
| 101 | + | secondDamage: nil, | |
| 102 | + | }, | |
| 103 | + | { | |
| 104 | + | name: "empty damage slice on unchanged frame", | |
| 105 | + | width: 3, | |
| 106 | + | height: 2, | |
| 107 | + | firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}, | |
| 108 | + | secondDamage: []Damage{}, | |
| 109 | + | }, | |
| 102 | 110 | } | |
| 103 | - | } | |
| 104 | 111 | ||
| 105 | - | func TestNoOpWithEmptyDamage(t *testing.T) { | |
| 106 | - | r := New(Capabilities{}) | |
| 107 | - | frame := NewFrame(3, 2) | |
| 108 | - | markFrame(&frame) | |
| 109 | - | ||
| 110 | - | // Populate shadow. | |
| 111 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 112 | - | if err != nil { | |
| 113 | - | t.Fatal(err) | |
| 114 | - | } | |
| 112 | + | for _, tt := range tests { | |
| 113 | + | t.Run(tt.name, func(t *testing.T) { | |
| 114 | + | r := New(Capabilities{}) | |
| 115 | + | frame := NewFrame(tt.width, tt.height) | |
| 116 | + | markFrame(&frame) | |
| 117 | + | ||
| 118 | + | // First draw – populate shadow. | |
| 119 | + | out1, err := r.Draw(frame, tt.firstDamage) | |
| 120 | + | if err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | if len(out1) == 0 { | |
| 124 | + | t.Fatal("first draw must produce output") | |
| 125 | + | } | |
| 115 | 126 | ||
| 116 | - | // Same frame, empty damage slice. | |
| 117 | - | out, err := r.Draw(frame, []Damage{}) | |
| 118 | - | if err != nil { | |
| 119 | - | t.Fatal(err) | |
| 120 | - | } | |
| 121 | - | if len(out) != 0 { | |
| 122 | - | t.Fatalf("expected empty output for no changes, got %q", string(out)) | |
| 127 | + | // Second draw with no changes. | |
| 128 | + | out2, err := r.Draw(frame, tt.secondDamage) | |
| 129 | + | if err != nil { | |
| 130 | + | t.Fatal(err) | |
| 131 | + | } | |
| 132 | + | if len(out2) != 0 { | |
| 133 | + | t.Fatalf("expected no-op (empty output), got %q", string(out2)) | |
| 134 | + | } | |
| 135 | + | }) | |
| 123 | 136 | } | |
| 124 | 137 | } | |
| 125 | 138 | ||
| @@ -127,103 +140,156 @@ func TestNoOpWithEmptyDamage(t *testing.T) { | |||
| 127 | 140 | // Style reset discipline | |
| 128 | 141 | // --------------------------------------------------------------------------- | |
| 129 | 142 | ||
| 130 | - | func TestStyleReset(t *testing.T) { | |
| 131 | - | r := New(Capabilities{}) | |
| 132 | - | frame := NewFrame(4, 2) | |
| 133 | - | ||
| 134 | - | // One styled cell, rest default. | |
| 135 | - | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}}) | |
| 136 | - | ||
| 137 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |
| 138 | - | if err != nil { | |
| 139 | - | t.Fatal(err) | |
| 140 | - | } | |
| 141 | - | // Must end with style reset. | |
| 142 | - | outputEndsWith(t, out, "\x1b[0m") | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | func TestStyleEqualUsesActiveColorMode(t *testing.T) { | |
| 146 | - | left := Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1} | |
| 147 | - | right := Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1} | |
| 148 | - | if !left.Equal(right) { | |
| 149 | - | t.Fatal("RGB foreground equality should ignore inactive indexed foreground") | |
| 143 | + | func TestStyleResetDiscipline(t *testing.T) { | |
| 144 | + | tests := []struct { | |
| 145 | + | name string | |
| 146 | + | run func(t *testing.T) | |
| 147 | + | }{ | |
| 148 | + | { | |
| 149 | + | name: "single styled cell among default cells", | |
| 150 | + | run: func(t *testing.T) { | |
| 151 | + | r := New(Capabilities{}) | |
| 152 | + | frame := NewFrame(4, 2) | |
| 153 | + | ||
| 154 | + | // One styled cell, rest default. | |
| 155 | + | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}}) | |
| 156 | + | ||
| 157 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |
| 158 | + | if err != nil { | |
| 159 | + | t.Fatal(err) | |
| 160 | + | } | |
| 161 | + | // Must end with style reset. | |
| 162 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 163 | + | }, | |
| 164 | + | }, | |
| 165 | + | { | |
| 166 | + | name: "after scroll damage", | |
| 167 | + | run: func(t *testing.T) { | |
| 168 | + | r := New(Capabilities{}) | |
| 169 | + | frame := NewFrame(4, 3) | |
| 170 | + | markFrame(&frame) | |
| 171 | + | ||
| 172 | + | // Populate shadow. | |
| 173 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |
| 174 | + | if err != nil { | |
| 175 | + | t.Fatal(err) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | |
| 179 | + | frame2 := NewFrame(4, 3) | |
| 180 | + | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | |
| 181 | + | for i := 8; i < 12; i++ { | |
| 182 | + | frame2.Cells[i] = BlankCell() // bottom row blank | |
| 183 | + | } | |
| 184 | + | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | |
| 185 | + | ||
| 186 | + | damage := []Damage{ | |
| 187 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 188 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | out, err := r.Draw(frame2, damage) | |
| 192 | + | if err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | // The output must end with a style reset (either from emitScrollUp or writeDamage). | |
| 196 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 197 | + | }, | |
| 198 | + | }, | |
| 150 | 199 | } | |
| 151 | 200 | ||
| 152 | - | right.ForegroundRGB.R = 13 | |
| 153 | - | if left.Equal(right) { | |
| 154 | - | t.Fatal("RGB foreground equality should compare active RGB values") | |
| 201 | + | for _, tt := range tests { | |
| 202 | + | t.Run(tt.name, tt.run) | |
| 155 | 203 | } | |
| 156 | 204 | } | |
| 157 | 205 | ||
| 158 | - | func TestRendererEmitsTruecolorSGR(t *testing.T) { | |
| 159 | - | r := New(Capabilities{}) | |
| 160 | - | frame := NewFrame(1, 1) | |
| 161 | - | frame.Set(0, 0, Cell{ | |
| 162 | - | Rune: 'X', | |
| 163 | - | Style: Style{ | |
| 164 | - | Foreground: -1, | |
| 165 | - | Background: -1, | |
| 166 | - | HasForegroundRGB: true, | |
| 167 | - | ForegroundRGB: RGB{R: 12, G: 34, B: 56}, | |
| 168 | - | HasBackgroundRGB: true, | |
| 169 | - | BackgroundRGB: RGB{R: 200, G: 100, B: 50}, | |
| 206 | + | func TestStyleEqualUsesActiveColorMode(t *testing.T) { | |
| 207 | + | tests := []struct { | |
| 208 | + | name string | |
| 209 | + | left Style | |
| 210 | + | right Style | |
| 211 | + | wantEqual bool | |
| 212 | + | }{ | |
| 213 | + | { | |
| 214 | + | name: "ignores inactive indexed foreground", | |
| 215 | + | left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, | |
| 216 | + | right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, | |
| 217 | + | wantEqual: true, | |
| 218 | + | }, | |
| 219 | + | { | |
| 220 | + | name: "compares active RGB values", | |
| 221 | + | left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, | |
| 222 | + | right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1}, | |
| 223 | + | wantEqual: false, | |
| 170 | 224 | }, | |
| 171 | - | }) | |
| 172 | - | ||
| 173 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}) | |
| 174 | - | if err != nil { | |
| 175 | - | t.Fatal(err) | |
| 176 | 225 | } | |
| 177 | 226 | ||
| 178 | - | outputContains(t, out, "\x1b[0;38;2;12;34;56;48;2;200;100;50m") | |
| 179 | - | outputContains(t, out, "X") | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | func TestRendererEmitsStyleChangesForAdjacentColorModes(t *testing.T) { | |
| 183 | - | r := New(Capabilities{}) | |
| 184 | - | frame := NewFrame(3, 1) | |
| 185 | - | frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}}) | |
| 186 | - | frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}}) | |
| 187 | - | frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()}) | |
| 188 | - | ||
| 189 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}) | |
| 190 | - | if err != nil { | |
| 191 | - | t.Fatal(err) | |
| 227 | + | for _, tt := range tests { | |
| 228 | + | t.Run(tt.name, func(t *testing.T) { | |
| 229 | + | if got := tt.left.Equal(tt.right); got != tt.wantEqual { | |
| 230 | + | t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual) | |
| 231 | + | } | |
| 232 | + | }) | |
| 192 | 233 | } | |
| 193 | - | ||
| 194 | - | outputContains(t, out, "\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD") | |
| 195 | 234 | } | |
| 196 | 235 | ||
| 197 | - | func TestStyleResetAfterScroll(t *testing.T) { | |
| 198 | - | r := New(Capabilities{}) | |
| 199 | - | frame := NewFrame(4, 3) | |
| 200 | - | markFrame(&frame) | |
| 201 | - | ||
| 202 | - | // Populate shadow. | |
| 203 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |
| 204 | - | if err != nil { | |
| 205 | - | t.Fatal(err) | |
| 236 | + | func TestRendererEmitsSGR(t *testing.T) { | |
| 237 | + | tests := []struct { | |
| 238 | + | name string | |
| 239 | + | width int | |
| 240 | + | height int | |
| 241 | + | setup func(frame Frame) | |
| 242 | + | damage []Damage | |
| 243 | + | wantAll []string | |
| 244 | + | }{ | |
| 245 | + | { | |
| 246 | + | name: "truecolor foreground and background", | |
| 247 | + | width: 1, | |
| 248 | + | height: 1, | |
| 249 | + | setup: func(frame Frame) { | |
| 250 | + | frame.Set(0, 0, Cell{ | |
| 251 | + | Rune: 'X', | |
| 252 | + | Style: Style{ | |
| 253 | + | Foreground: -1, | |
| 254 | + | Background: -1, | |
| 255 | + | HasForegroundRGB: true, | |
| 256 | + | ForegroundRGB: RGB{R: 12, G: 34, B: 56}, | |
| 257 | + | HasBackgroundRGB: true, | |
| 258 | + | BackgroundRGB: RGB{R: 200, G: 100, B: 50}, | |
| 259 | + | }, | |
| 260 | + | }) | |
| 261 | + | }, | |
| 262 | + | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |
| 263 | + | wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"}, | |
| 264 | + | }, | |
| 265 | + | { | |
| 266 | + | name: "style changes across adjacent color modes", | |
| 267 | + | width: 3, | |
| 268 | + | height: 1, | |
| 269 | + | setup: func(frame Frame) { | |
| 270 | + | frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}}) | |
| 271 | + | frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}}) | |
| 272 | + | frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()}) | |
| 273 | + | }, | |
| 274 | + | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}, | |
| 275 | + | wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"}, | |
| 276 | + | }, | |
| 206 | 277 | } | |
| 207 | 278 | ||
| 208 | - | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | |
| 209 | - | frame2 := NewFrame(4, 3) | |
| 210 | - | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | |
| 211 | - | for i := 8; i < 12; i++ { | |
| 212 | - | frame2.Cells[i] = BlankCell() // bottom row blank | |
| 213 | - | } | |
| 214 | - | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | |
| 279 | + | for _, tt := range tests { | |
| 280 | + | t.Run(tt.name, func(t *testing.T) { | |
| 281 | + | r := New(Capabilities{}) | |
| 282 | + | frame := NewFrame(tt.width, tt.height) | |
| 283 | + | tt.setup(frame) | |
| 215 | 284 | ||
| 216 | - | damage := []Damage{ | |
| 217 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 218 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 219 | - | } | |
| 285 | + | out, err := r.Draw(frame, tt.damage) | |
| 286 | + | if err != nil { | |
| 287 | + | t.Fatal(err) | |
| 288 | + | } | |
| 220 | 289 | ||
| 221 | - | out, err := r.Draw(frame2, damage) | |
| 222 | - | if err != nil { | |
| 223 | - | t.Fatal(err) | |
| 290 | + | outputContains(t, out, tt.wantAll...) | |
| 291 | + | }) | |
| 224 | 292 | } | |
| 225 | - | // The output must end with a style reset (either from emitScrollUp or writeDamage). | |
| 226 | - | outputEndsWith(t, out, "\x1b[0m") | |
| 227 | 293 | } | |
| 228 | 294 | ||
| 229 | 295 | // --------------------------------------------------------------------------- | |
| @@ -231,45 +297,55 @@ func TestStyleResetAfterScroll(t *testing.T) { | |||
| 231 | 297 | // --------------------------------------------------------------------------- | |
| 232 | 298 | ||
| 233 | 299 | func TestScrollFastPath(t *testing.T) { | |
| 234 | - | r := New(Capabilities{}) | |
| 235 | - | frame := NewFrame(5, 4) | |
| 236 | - | markFrame(&frame) | |
| 237 | - | ||
| 238 | - | // Populate shadow. | |
| 239 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}}) | |
| 240 | - | if err != nil { | |
| 241 | - | t.Fatal(err) | |
| 300 | + | tests := []struct { | |
| 301 | + | name string | |
| 302 | + | }{ | |
| 303 | + | {name: "scroll up by one row exposes new bottom row via scroll region"}, | |
| 242 | 304 | } | |
| 243 | 305 | ||
| 244 | - | // Build a new frame that has been scrolled up by 1 (like the VT model does). | |
| 245 | - | scrolled := NewFrame(5, 4) | |
| 246 | - | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 | |
| 247 | - | for i := 15; i < 20; i++ { | |
| 248 | - | scrolled.Cells[i] = BlankCell() // row 3 blanked | |
| 249 | - | } | |
| 250 | - | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | |
| 251 | - | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | |
| 252 | - | scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()}) | |
| 306 | + | for _, tt := range tests { | |
| 307 | + | t.Run(tt.name, func(t *testing.T) { | |
| 308 | + | r := New(Capabilities{}) | |
| 309 | + | frame := NewFrame(5, 4) | |
| 310 | + | markFrame(&frame) | |
| 311 | + | ||
| 312 | + | // Populate shadow. | |
| 313 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}}) | |
| 314 | + | if err != nil { | |
| 315 | + | t.Fatal(err) | |
| 316 | + | } | |
| 253 | 317 | ||
| 254 | - | damage := []Damage{ | |
| 255 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | |
| 256 | - | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | |
| 257 | - | } | |
| 318 | + | // Build a new frame that has been scrolled up by 1 (like the VT model does). | |
| 319 | + | scrolled := NewFrame(5, 4) | |
| 320 | + | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 | |
| 321 | + | for i := 15; i < 20; i++ { | |
| 322 | + | scrolled.Cells[i] = BlankCell() // row 3 blanked | |
| 323 | + | } | |
| 324 | + | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | |
| 325 | + | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | |
| 326 | + | scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()}) | |
| 258 | 327 | ||
| 259 | - | out, err := r.Draw(scrolled, damage) | |
| 260 | - | if err != nil { | |
| 261 | - | t.Fatal(err) | |
| 262 | - | } | |
| 263 | - | if len(out) == 0 { | |
| 264 | - | t.Fatal("expected non-empty output for scroll") | |
| 328 | + | damage := []Damage{ | |
| 329 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | |
| 330 | + | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | out, err := r.Draw(scrolled, damage) | |
| 334 | + | if err != nil { | |
| 335 | + | t.Fatal(err) | |
| 336 | + | } | |
| 337 | + | if len(out) == 0 { | |
| 338 | + | t.Fatal("expected non-empty output for scroll") | |
| 339 | + | } | |
| 340 | + | // Should contain scroll-region sequences. | |
| 341 | + | outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4 | |
| 342 | + | outputContains(t, out, "\x1b[r") // restore scroll region | |
| 343 | + | // Should contain the new text on the exposed row. | |
| 344 | + | outputContains(t, out, "N") | |
| 345 | + | outputContains(t, out, "e") | |
| 346 | + | outputContains(t, out, "w") | |
| 347 | + | }) | |
| 265 | 348 | } | |
| 266 | - | // Should contain scroll-region sequences. | |
| 267 | - | outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4 | |
| 268 | - | outputContains(t, out, "\x1b[r") // restore scroll region | |
| 269 | - | // Should contain the new text on the exposed row. | |
| 270 | - | outputContains(t, out, "N") | |
| 271 | - | outputContains(t, out, "e") | |
| 272 | - | outputContains(t, out, "w") | |
| 273 | 349 | } | |
| 274 | 350 | ||
| 275 | 351 | // --------------------------------------------------------------------------- | |
| @@ -277,40 +353,56 @@ func TestScrollFastPath(t *testing.T) { | |||
| 277 | 353 | // --------------------------------------------------------------------------- | |
| 278 | 354 | ||
| 279 | 355 | func TestSynchronizedOutput(t *testing.T) { | |
| 280 | - | r := New(Capabilities{SynchronizedOutput: true}) | |
| 281 | - | frame := NewFrame(3, 2) | |
| 282 | - | markFrame(&frame) | |
| 283 | - | ||
| 284 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 285 | - | if err != nil { | |
| 286 | - | t.Fatal(err) | |
| 287 | - | } | |
| 288 | - | if !strings.HasPrefix(string(out), SyncStartCSI) { | |
| 289 | - | t.Errorf("expected output to start with sync start CSI") | |
| 290 | - | } | |
| 291 | - | if !strings.HasSuffix(string(out), SyncEndCSI) { | |
| 292 | - | t.Errorf("expected output to end with sync end CSI, got %q", string(out)) | |
| 293 | - | } | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | func TestSynchronizedOutputNoOp(t *testing.T) { | |
| 297 | - | r := New(Capabilities{SynchronizedOutput: true}) | |
| 298 | - | frame := NewFrame(3, 2) | |
| 299 | - | markFrame(&frame) | |
| 300 | - | ||
| 301 | - | // First draw. | |
| 302 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 303 | - | if err != nil { | |
| 304 | - | t.Fatal(err) | |
| 356 | + | tests := []struct { | |
| 357 | + | name string | |
| 358 | + | run func(t *testing.T) | |
| 359 | + | }{ | |
| 360 | + | { | |
| 361 | + | name: "wraps output in sync CSI", | |
| 362 | + | run: func(t *testing.T) { | |
| 363 | + | r := New(Capabilities{SynchronizedOutput: true}) | |
| 364 | + | frame := NewFrame(3, 2) | |
| 365 | + | markFrame(&frame) | |
| 366 | + | ||
| 367 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 368 | + | if err != nil { | |
| 369 | + | t.Fatal(err) | |
| 370 | + | } | |
| 371 | + | if !strings.HasPrefix(string(out), SyncStartCSI) { | |
| 372 | + | t.Errorf("expected output to start with sync start CSI") | |
| 373 | + | } | |
| 374 | + | if !strings.HasSuffix(string(out), SyncEndCSI) { | |
| 375 | + | t.Errorf("expected output to end with sync end CSI, got %q", string(out)) | |
| 376 | + | } | |
| 377 | + | }, | |
| 378 | + | }, | |
| 379 | + | { | |
| 380 | + | name: "no-op returns empty output, not wrapped empty output", | |
| 381 | + | run: func(t *testing.T) { | |
| 382 | + | r := New(Capabilities{SynchronizedOutput: true}) | |
| 383 | + | frame := NewFrame(3, 2) | |
| 384 | + | markFrame(&frame) | |
| 385 | + | ||
| 386 | + | // First draw. | |
| 387 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 388 | + | if err != nil { | |
| 389 | + | t.Fatal(err) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | // No-op – must return nil, not wrapped empty output. | |
| 393 | + | out, err := r.Draw(frame, nil) | |
| 394 | + | if err != nil { | |
| 395 | + | t.Fatal(err) | |
| 396 | + | } | |
| 397 | + | if len(out) != 0 { | |
| 398 | + | t.Fatalf("expected empty output for no-op, got %q", string(out)) | |
| 399 | + | } | |
| 400 | + | }, | |
| 401 | + | }, | |
| 305 | 402 | } | |
| 306 | 403 | ||
| 307 | - | // No-op – must return nil, not wrapped empty output. | |
| 308 | - | out, err := r.Draw(frame, nil) | |
| 309 | - | if err != nil { | |
| 310 | - | t.Fatal(err) | |
| 311 | - | } | |
| 312 | - | if len(out) != 0 { | |
| 313 | - | t.Fatalf("expected empty output for no-op, got %q", string(out)) | |
| 404 | + | for _, tt := range tests { | |
| 405 | + | t.Run(tt.name, tt.run) | |
| 314 | 406 | } | |
| 315 | 407 | } | |
| 316 | 408 | ||
| @@ -320,27 +412,56 @@ func TestSynchronizedOutputNoOp(t *testing.T) { | |||
| 320 | 412 | ||
| 321 | 413 | func TestWrapSynchronized(t *testing.T) { | |
| 322 | 414 | content := []byte("\x1b[0mhello") | |
| 323 | - | wrapped := WrapSynchronized(content, true) | |
| 324 | - | if !strings.HasPrefix(string(wrapped), SyncStartCSI) { | |
| 325 | - | t.Errorf("missing sync start") | |
| 326 | - | } | |
| 327 | - | if !strings.HasSuffix(string(wrapped), SyncEndCSI) { | |
| 328 | - | t.Errorf("missing sync end") | |
| 329 | - | } | |
| 330 | - | if !strings.Contains(string(wrapped), "hello") { | |
| 331 | - | t.Errorf("missing original content") | |
| 332 | - | } | |
| 333 | 415 | ||
| 334 | - | // disabled | |
| 335 | - | plain := WrapSynchronized(content, false) | |
| 336 | - | if string(plain) != string(content) { | |
| 337 | - | t.Errorf("disabled wrapping should return content unchanged") | |
| 416 | + | tests := []struct { | |
| 417 | + | name string | |
| 418 | + | content []byte | |
| 419 | + | enabled bool | |
| 420 | + | check func(t *testing.T, got []byte) | |
| 421 | + | }{ | |
| 422 | + | { | |
| 423 | + | name: "enabled wraps with sync start/end and preserves content", | |
| 424 | + | content: content, | |
| 425 | + | enabled: true, | |
| 426 | + | check: func(t *testing.T, got []byte) { | |
| 427 | + | if !strings.HasPrefix(string(got), SyncStartCSI) { | |
| 428 | + | t.Errorf("missing sync start") | |
| 429 | + | } | |
| 430 | + | if !strings.HasSuffix(string(got), SyncEndCSI) { | |
| 431 | + | t.Errorf("missing sync end") | |
| 432 | + | } | |
| 433 | + | if !strings.Contains(string(got), "hello") { | |
| 434 | + | t.Errorf("missing original content") | |
| 435 | + | } | |
| 436 | + | }, | |
| 437 | + | }, | |
| 438 | + | { | |
| 439 | + | name: "disabled returns content unchanged", | |
| 440 | + | content: content, | |
| 441 | + | enabled: false, | |
| 442 | + | check: func(t *testing.T, got []byte) { | |
| 443 | + | if string(got) != string(content) { | |
| 444 | + | t.Errorf("disabled wrapping should return content unchanged") | |
| 445 | + | } | |
| 446 | + | }, | |
| 447 | + | }, | |
| 448 | + | { | |
| 449 | + | name: "empty input returns empty", | |
| 450 | + | content: nil, | |
| 451 | + | enabled: true, | |
| 452 | + | check: func(t *testing.T, got []byte) { | |
| 453 | + | if len(got) != 0 { | |
| 454 | + | t.Errorf("empty input should return empty") | |
| 455 | + | } | |
| 456 | + | }, | |
| 457 | + | }, | |
| 338 | 458 | } | |
| 339 | 459 | ||
| 340 | - | // empty input | |
| 341 | - | empty := WrapSynchronized(nil, true) | |
| 342 | - | if len(empty) != 0 { | |
| 343 | - | t.Errorf("empty input should return empty") | |
| 460 | + | for _, tt := range tests { | |
| 461 | + | t.Run(tt.name, func(t *testing.T) { | |
| 462 | + | got := WrapSynchronized(tt.content, tt.enabled) | |
| 463 | + | tt.check(t, got) | |
| 464 | + | }) | |
| 344 | 465 | } | |
| 345 | 466 | } | |
| 346 | 467 | ||
| @@ -349,110 +470,138 @@ func TestWrapSynchronized(t *testing.T) { | |||
| 349 | 470 | // --------------------------------------------------------------------------- | |
| 350 | 471 | ||
| 351 | 472 | func TestPartialDamage(t *testing.T) { | |
| 352 | - | r := New(Capabilities{}) | |
| 353 | - | frame := NewFrame(4, 2) | |
| 354 | - | markFrame(&frame) | |
| 355 | - | ||
| 356 | - | // Populate shadow. | |
| 357 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |
| 358 | - | if err != nil { | |
| 359 | - | t.Fatal(err) | |
| 473 | + | tests := []struct { | |
| 474 | + | name string | |
| 475 | + | }{ | |
| 476 | + | {name: "single changed cell repositions cursor and resets style"}, | |
| 360 | 477 | } | |
| 361 | 478 | ||
| 362 | - | // Change one cell. | |
| 363 | - | frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()}) | |
| 364 | - | damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}} | |
| 479 | + | for _, tt := range tests { | |
| 480 | + | t.Run(tt.name, func(t *testing.T) { | |
| 481 | + | r := New(Capabilities{}) | |
| 482 | + | frame := NewFrame(4, 2) | |
| 483 | + | markFrame(&frame) | |
| 484 | + | ||
| 485 | + | // Populate shadow. | |
| 486 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |
| 487 | + | if err != nil { | |
| 488 | + | t.Fatal(err) | |
| 489 | + | } | |
| 365 | 490 | ||
| 366 | - | out, err := r.Draw(frame, damage) | |
| 367 | - | if err != nil { | |
| 368 | - | t.Fatal(err) | |
| 369 | - | } | |
| 370 | - | if len(out) == 0 { | |
| 371 | - | t.Fatal("expected non-empty output for partial damage") | |
| 491 | + | // Change one cell. | |
| 492 | + | frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()}) | |
| 493 | + | damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}} | |
| 494 | + | ||
| 495 | + | out, err := r.Draw(frame, damage) | |
| 496 | + | if err != nil { | |
| 497 | + | t.Fatal(err) | |
| 498 | + | } | |
| 499 | + | if len(out) == 0 { | |
| 500 | + | t.Fatal("expected non-empty output for partial damage") | |
| 501 | + | } | |
| 502 | + | // Should position cursor at (2,1) — 1-indexed (3;2). | |
| 503 | + | outputContains(t, out, "\x1b[2;3H") | |
| 504 | + | outputContains(t, out, "Z") | |
| 505 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 506 | + | }) | |
| 372 | 507 | } | |
| 373 | - | // Should position cursor at (2,1) — 1-indexed (3;2). | |
| 374 | - | outputContains(t, out, "\x1b[2;3H") | |
| 375 | - | outputContains(t, out, "Z") | |
| 376 | - | outputEndsWith(t, out, "\x1b[0m") | |
| 377 | 508 | } | |
| 378 | 509 | ||
| 379 | 510 | // --------------------------------------------------------------------------- | |
| 380 | 511 | // Scheduler coalescing | |
| 381 | 512 | // --------------------------------------------------------------------------- | |
| 382 | 513 | ||
| 383 | - | func TestSchedulerCoalescing(t *testing.T) { | |
| 384 | - | s := NewScheduler(30 * time.Millisecond) | |
| 385 | - | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) | |
| 386 | - | defer cancel() | |
| 387 | - | ||
| 388 | - | frames := s.Run(ctx) | |
| 389 | - | ||
| 390 | - | // Fire several requests before the timer fires. | |
| 391 | - | s.Request() | |
| 392 | - | s.Request() | |
| 393 | - | s.Request() | |
| 394 | - | ||
| 395 | - | // Expect exactly one frame. | |
| 396 | - | select { | |
| 397 | - | case _, ok := <-frames: | |
| 398 | - | if !ok { | |
| 399 | - | t.Fatal("channel closed unexpectedly") | |
| 400 | - | } | |
| 401 | - | case <-ctx.Done(): | |
| 402 | - | t.Fatal("timeout waiting for frame") | |
| 403 | - | } | |
| 404 | - | ||
| 405 | - | // No second frame should arrive without another request. | |
| 406 | - | select { | |
| 407 | - | case <-frames: | |
| 408 | - | t.Fatal("unexpected second frame") | |
| 409 | - | case <-time.After(80 * time.Millisecond): | |
| 410 | - | // good – coalesced. | |
| 411 | - | } | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | func TestSchedulerMultipleFrames(t *testing.T) { | |
| 415 | - | s := NewScheduler(10 * time.Millisecond) | |
| 416 | - | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) | |
| 417 | - | defer cancel() | |
| 418 | - | ||
| 419 | - | frames := s.Run(ctx) | |
| 420 | - | ||
| 421 | - | s.Request() | |
| 422 | - | <-frames // consume first | |
| 423 | - | ||
| 424 | - | s.Request() | |
| 425 | - | <-frames // consume second | |
| 426 | - | ||
| 427 | - | // No pending request – should not deliver another frame. | |
| 428 | - | select { | |
| 429 | - | case <-frames: | |
| 430 | - | t.Fatal("unexpected frame without request") | |
| 431 | - | case <-time.After(40 * time.Millisecond): | |
| 432 | - | // good | |
| 514 | + | func TestScheduler(t *testing.T) { | |
| 515 | + | tests := []struct { | |
| 516 | + | name string | |
| 517 | + | run func(t *testing.T) | |
| 518 | + | }{ | |
| 519 | + | { | |
| 520 | + | name: "coalesces bursts of requests into a single frame", | |
| 521 | + | run: func(t *testing.T) { | |
| 522 | + | s := NewScheduler(30 * time.Millisecond) | |
| 523 | + | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) | |
| 524 | + | defer cancel() | |
| 525 | + | ||
| 526 | + | frames := s.Run(ctx) | |
| 527 | + | ||
| 528 | + | // Fire several requests before the timer fires. | |
| 529 | + | s.Request() | |
| 530 | + | s.Request() | |
| 531 | + | s.Request() | |
| 532 | + | ||
| 533 | + | // Expect exactly one frame. | |
| 534 | + | select { | |
| 535 | + | case _, ok := <-frames: | |
| 536 | + | if !ok { | |
| 537 | + | t.Fatal("channel closed unexpectedly") | |
| 538 | + | } | |
| 539 | + | case <-ctx.Done(): | |
| 540 | + | t.Fatal("timeout waiting for frame") | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | // No second frame should arrive without another request. | |
| 544 | + | select { | |
| 545 | + | case <-frames: | |
| 546 | + | t.Fatal("unexpected second frame") | |
| 547 | + | case <-time.After(80 * time.Millisecond): | |
| 548 | + | // good – coalesced. | |
| 549 | + | } | |
| 550 | + | }, | |
| 551 | + | }, | |
| 552 | + | { | |
| 553 | + | name: "delivers a frame per request without spurious extras", | |
| 554 | + | run: func(t *testing.T) { | |
| 555 | + | s := NewScheduler(10 * time.Millisecond) | |
| 556 | + | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) | |
| 557 | + | defer cancel() | |
| 558 | + | ||
| 559 | + | frames := s.Run(ctx) | |
| 560 | + | ||
| 561 | + | s.Request() | |
| 562 | + | <-frames // consume first | |
| 563 | + | ||
| 564 | + | s.Request() | |
| 565 | + | <-frames // consume second | |
| 566 | + | ||
| 567 | + | // No pending request – should not deliver another frame. | |
| 568 | + | select { | |
| 569 | + | case <-frames: | |
| 570 | + | t.Fatal("unexpected frame without request") | |
| 571 | + | case <-time.After(40 * time.Millisecond): | |
| 572 | + | // good | |
| 573 | + | } | |
| 574 | + | }, | |
| 575 | + | }, | |
| 576 | + | { | |
| 577 | + | name: "closes frames channel on context cancellation", | |
| 578 | + | run: func(t *testing.T) { | |
| 579 | + | s := NewScheduler(50 * time.Millisecond) | |
| 580 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 581 | + | ||
| 582 | + | frames := s.Run(ctx) | |
| 583 | + | s.Request() | |
| 584 | + | ||
| 585 | + | // Cancel before the timer fires. | |
| 586 | + | cancel() | |
| 587 | + | ||
| 588 | + | // Channel should close without ever delivering a frame. | |
| 589 | + | select { | |
| 590 | + | case _, ok := <-frames: | |
| 591 | + | if ok { | |
| 592 | + | // A frame may have been emitted before cancellation took effect; | |
| 593 | + | // that is acceptable. What matters is that the goroutine exits. | |
| 594 | + | _ = ok | |
| 595 | + | } | |
| 596 | + | case <-time.After(200 * time.Millisecond): | |
| 597 | + | // Channel didn't close – goroutine may be stuck. | |
| 598 | + | } | |
| 599 | + | }, | |
| 600 | + | }, | |
| 433 | 601 | } | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | func TestSchedulerContextCancellation(t *testing.T) { | |
| 437 | - | s := NewScheduler(50 * time.Millisecond) | |
| 438 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 439 | 602 | ||
| 440 | - | frames := s.Run(ctx) | |
| 441 | - | s.Request() | |
| 442 | - | ||
| 443 | - | // Cancel before the timer fires. | |
| 444 | - | cancel() | |
| 445 | - | ||
| 446 | - | // Channel should close without ever delivering a frame. | |
| 447 | - | select { | |
| 448 | - | case _, ok := <-frames: | |
| 449 | - | if ok { | |
| 450 | - | // A frame may have been emitted before cancellation took effect; | |
| 451 | - | // that is acceptable. What matters is that the goroutine exits. | |
| 452 | - | _ = ok | |
| 453 | - | } | |
| 454 | - | case <-time.After(200 * time.Millisecond): | |
| 455 | - | // Channel didn't close – goroutine may be stuck. | |
| 603 | + | for _, tt := range tests { | |
| 604 | + | t.Run(tt.name, tt.run) | |
| 456 | 605 | } | |
| 457 | 606 | } | |
| 458 | 607 | ||
| @@ -461,28 +610,38 @@ func TestSchedulerContextCancellation(t *testing.T) { | |||
| 461 | 610 | // --------------------------------------------------------------------------- | |
| 462 | 611 | ||
| 463 | 612 | func TestReset(t *testing.T) { | |
| 464 | - | r := New(Capabilities{}) | |
| 465 | - | frame := NewFrame(3, 2) | |
| 466 | - | markFrame(&frame) | |
| 467 | - | ||
| 468 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 469 | - | if err != nil { | |
| 470 | - | t.Fatal(err) | |
| 613 | + | tests := []struct { | |
| 614 | + | name string | |
| 615 | + | }{ | |
| 616 | + | {name: "clears shadow state and forces a full draw next time"}, | |
| 471 | 617 | } | |
| 472 | 618 | ||
| 473 | - | r.Reset() | |
| 619 | + | for _, tt := range tests { | |
| 620 | + | t.Run(tt.name, func(t *testing.T) { | |
| 621 | + | r := New(Capabilities{}) | |
| 622 | + | frame := NewFrame(3, 2) | |
| 623 | + | markFrame(&frame) | |
| 474 | 624 | ||
| 475 | - | if r.width != 0 || r.height != 0 || r.shadow != nil { | |
| 476 | - | t.Fatal("Reset should clear width/height/shadow") | |
| 477 | - | } | |
| 625 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 626 | + | if err != nil { | |
| 627 | + | t.Fatal(err) | |
| 628 | + | } | |
| 478 | 629 | ||
| 479 | - | // After reset, the next draw should be a full draw. | |
| 480 | - | out, err := r.Draw(frame, nil) | |
| 481 | - | if err != nil { | |
| 482 | - | t.Fatal(err) | |
| 483 | - | } | |
| 484 | - | if len(out) == 0 { | |
| 485 | - | t.Fatal("expected non-empty output after reset") | |
| 630 | + | r.Reset() | |
| 631 | + | ||
| 632 | + | if r.width != 0 || r.height != 0 || r.shadow != nil { | |
| 633 | + | t.Fatal("Reset should clear width/height/shadow") | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | // After reset, the next draw should be a full draw. | |
| 637 | + | out, err := r.Draw(frame, nil) | |
| 638 | + | if err != nil { | |
| 639 | + | t.Fatal(err) | |
| 640 | + | } | |
| 641 | + | if len(out) == 0 { | |
| 642 | + | t.Fatal("expected non-empty output after reset") | |
| 643 | + | } | |
| 644 | + | }) | |
| 486 | 645 | } | |
| 487 | 646 | } | |
| 488 | 647 | ||
| @@ -491,25 +650,35 @@ func TestReset(t *testing.T) { | |||
| 491 | 650 | // --------------------------------------------------------------------------- | |
| 492 | 651 | ||
| 493 | 652 | func TestFullRedraw(t *testing.T) { | |
| 494 | - | r := New(Capabilities{}) | |
| 495 | - | frame := NewFrame(3, 2) | |
| 496 | - | markFrame(&frame) | |
| 497 | - | ||
| 498 | - | // Populate. | |
| 499 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 500 | - | if err != nil { | |
| 501 | - | t.Fatal(err) | |
| 653 | + | tests := []struct { | |
| 654 | + | name string | |
| 655 | + | }{ | |
| 656 | + | {name: "forces full output even with an unchanged frame"}, | |
| 502 | 657 | } | |
| 503 | 658 | ||
| 504 | - | // FullRedraw forces full output even with same frame. | |
| 505 | - | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 506 | - | if err != nil { | |
| 507 | - | t.Fatal(err) | |
| 508 | - | } | |
| 509 | - | if len(out) == 0 { | |
| 510 | - | t.Fatal("expected output for FullRedraw") | |
| 659 | + | for _, tt := range tests { | |
| 660 | + | t.Run(tt.name, func(t *testing.T) { | |
| 661 | + | r := New(Capabilities{}) | |
| 662 | + | frame := NewFrame(3, 2) | |
| 663 | + | markFrame(&frame) | |
| 664 | + | ||
| 665 | + | // Populate. | |
| 666 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |
| 667 | + | if err != nil { | |
| 668 | + | t.Fatal(err) | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | // FullRedraw forces full output even with same frame. | |
| 672 | + | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |
| 673 | + | if err != nil { | |
| 674 | + | t.Fatal(err) | |
| 675 | + | } | |
| 676 | + | if len(out) == 0 { | |
| 677 | + | t.Fatal("expected output for FullRedraw") | |
| 678 | + | } | |
| 679 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 680 | + | }) | |
| 511 | 681 | } | |
| 512 | - | outputEndsWith(t, out, "\x1b[0m") | |
| 513 | 682 | } | |
| 514 | 683 | ||
| 515 | 684 | // --------------------------------------------------------------------------- | |
| @@ -517,39 +686,49 @@ func TestFullRedraw(t *testing.T) { | |||
| 517 | 686 | // --------------------------------------------------------------------------- | |
| 518 | 687 | ||
| 519 | 688 | func TestScrollOnlyDamage(t *testing.T) { | |
| 520 | - | r := New(Capabilities{}) | |
| 521 | - | frame := NewFrame(4, 3) | |
| 522 | - | markFrame(&frame) | |
| 523 | - | ||
| 524 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |
| 525 | - | if err != nil { | |
| 526 | - | t.Fatal(err) | |
| 689 | + | tests := []struct { | |
| 690 | + | name string | |
| 691 | + | }{ | |
| 692 | + | {name: "scroll damage plus new-row text damage from the VT model"}, | |
| 527 | 693 | } | |
| 528 | 694 | ||
| 529 | - | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | |
| 530 | - | scrolled := NewFrame(4, 3) | |
| 531 | - | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |
| 532 | - | for i := 8; i < 12; i++ { | |
| 533 | - | scrolled.Cells[i] = BlankCell() | |
| 534 | - | } | |
| 535 | - | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | |
| 695 | + | for _, tt := range tests { | |
| 696 | + | t.Run(tt.name, func(t *testing.T) { | |
| 697 | + | r := New(Capabilities{}) | |
| 698 | + | frame := NewFrame(4, 3) | |
| 699 | + | markFrame(&frame) | |
| 536 | 700 | ||
| 537 | - | damage := []Damage{ | |
| 538 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 539 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 540 | - | } | |
| 701 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |
| 702 | + | if err != nil { | |
| 703 | + | t.Fatal(err) | |
| 704 | + | } | |
| 541 | 705 | ||
| 542 | - | out, err := r.Draw(scrolled, damage) | |
| 543 | - | if err != nil { | |
| 544 | - | t.Fatal(err) | |
| 545 | - | } | |
| 546 | - | if len(out) == 0 { | |
| 547 | - | t.Fatal("expected output for scroll") | |
| 706 | + | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | |
| 707 | + | scrolled := NewFrame(4, 3) | |
| 708 | + | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |
| 709 | + | for i := 8; i < 12; i++ { | |
| 710 | + | scrolled.Cells[i] = BlankCell() | |
| 711 | + | } | |
| 712 | + | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | |
| 713 | + | ||
| 714 | + | damage := []Damage{ | |
| 715 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 716 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | out, err := r.Draw(scrolled, damage) | |
| 720 | + | if err != nil { | |
| 721 | + | t.Fatal(err) | |
| 722 | + | } | |
| 723 | + | if len(out) == 0 { | |
| 724 | + | t.Fatal("expected output for scroll") | |
| 725 | + | } | |
| 726 | + | // Should include scroll region | |
| 727 | + | outputContains(t, out, "\x1b[1;3r") | |
| 728 | + | outputContains(t, out, "\x1b[r") | |
| 729 | + | outputEndsWith(t, out, "\x1b[0m") | |
| 730 | + | }) | |
| 548 | 731 | } | |
| 549 | - | // Should include scroll region | |
| 550 | - | outputContains(t, out, "\x1b[1;3r") | |
| 551 | - | outputContains(t, out, "\x1b[r") | |
| 552 | - | outputEndsWith(t, out, "\x1b[0m") | |
| 553 | 732 | } | |
| 554 | 733 | ||
| 555 | 734 | // --------------------------------------------------------------------------- | |
| @@ -557,15 +736,23 @@ func TestScrollOnlyDamage(t *testing.T) { | |||
| 557 | 736 | // --------------------------------------------------------------------------- | |
| 558 | 737 | ||
| 559 | 738 | func TestWriteCursor(t *testing.T) { | |
| 560 | - | var buf bytes.Buffer | |
| 561 | - | writeCursor(&buf, 0, 0) | |
| 562 | - | if buf.String() != "\x1b[1;1H" { | |
| 563 | - | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |
| 739 | + | tests := []struct { | |
| 740 | + | name string | |
| 741 | + | y, x int | |
| 742 | + | want string | |
| 743 | + | }{ | |
| 744 | + | {name: "origin", y: 0, x: 0, want: "\x1b[1;1H"}, | |
| 745 | + | {name: "offset", y: 2, x: 5, want: "\x1b[3;6H"}, | |
| 564 | 746 | } | |
| 565 | - | buf.Reset() | |
| 566 | - | writeCursor(&buf, 2, 5) | |
| 567 | - | if buf.String() != "\x1b[3;6H" { | |
| 568 | - | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |
| 747 | + | ||
| 748 | + | for _, tt := range tests { | |
| 749 | + | t.Run(tt.name, func(t *testing.T) { | |
| 750 | + | var buf bytes.Buffer | |
| 751 | + | writeCursor(&buf, tt.y, tt.x) | |
| 752 | + | if buf.String() != tt.want { | |
| 753 | + | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |
| 754 | + | } | |
| 755 | + | }) | |
| 569 | 756 | } | |
| 570 | 757 | } | |
| 571 | 758 | ||
| @@ -605,21 +792,38 @@ func TestIsSafeScroll(t *testing.T) { | |||
| 605 | 792 | // --------------------------------------------------------------------------- | |
| 606 | 793 | ||
| 607 | 794 | func TestCellEqual(t *testing.T) { | |
| 608 | - | a := BlankCell() | |
| 609 | - | b := BlankCell() | |
| 610 | - | if !a.Equal(b) { | |
| 611 | - | t.Error("blank cells should be equal") | |
| 612 | - | } | |
| 613 | - | ||
| 614 | - | a.Rune = 'A' | |
| 615 | - | if a.Equal(b) { | |
| 616 | - | t.Error("different runes should not be equal") | |
| 795 | + | tests := []struct { | |
| 796 | + | name string | |
| 797 | + | a Cell | |
| 798 | + | b Cell | |
| 799 | + | want bool | |
| 800 | + | }{ | |
| 801 | + | { | |
| 802 | + | name: "blank cells should be equal", | |
| 803 | + | a: BlankCell(), | |
| 804 | + | b: BlankCell(), | |
| 805 | + | want: true, | |
| 806 | + | }, | |
| 807 | + | { | |
| 808 | + | name: "different runes should not be equal", | |
| 809 | + | a: Cell{Rune: 'A', Style: DefaultStyle()}, | |
| 810 | + | b: BlankCell(), | |
| 811 | + | want: false, | |
| 812 | + | }, | |
| 813 | + | { | |
| 814 | + | name: "different styles should not be equal", | |
| 815 | + | a: Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}}, | |
| 816 | + | b: BlankCell(), | |
| 817 | + | want: false, | |
| 818 | + | }, | |
| 617 | 819 | } | |
| 618 | 820 | ||
| 619 | - | a = BlankCell() | |
| 620 | - | a.Style.Bold = true | |
| 621 | - | if a.Equal(b) { | |
| 622 | - | t.Error("different styles should not be equal") | |
| 821 | + | for _, tt := range tests { | |
| 822 | + | t.Run(tt.name, func(t *testing.T) { | |
| 823 | + | if got := tt.a.Equal(tt.b); got != tt.want { | |
| 824 | + | t.Errorf("Equal() = %v, want %v", got, tt.want) | |
| 825 | + | } | |
| 826 | + | }) | |
| 623 | 827 | } | |
| 624 | 828 | } | |
| 625 | 829 | ||
| @@ -628,39 +832,53 @@ func TestCellEqual(t *testing.T) { | |||
| 628 | 832 | // --------------------------------------------------------------------------- | |
| 629 | 833 | ||
| 630 | 834 | func TestFrameValidate(t *testing.T) { | |
| 631 | - | // Valid frame. | |
| 632 | - | f := NewFrame(5, 3) | |
| 633 | - | if err := f.Validate(); err != nil { | |
| 634 | - | t.Fatalf("unexpected error: %v", err) | |
| 635 | - | } | |
| 636 | - | ||
| 637 | - | // Invalid dimensions. | |
| 638 | - | f2 := Frame{Width: 0, Height: 5} | |
| 639 | - | if err := f2.Validate(); err == nil { | |
| 640 | - | t.Fatal("expected error for zero width") | |
| 835 | + | tests := []struct { | |
| 836 | + | name string | |
| 837 | + | frame Frame | |
| 838 | + | wantErr bool | |
| 839 | + | }{ | |
| 840 | + | {name: "valid frame", frame: NewFrame(5, 3), wantErr: false}, | |
| 841 | + | {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true}, | |
| 842 | + | {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true}, | |
| 641 | 843 | } | |
| 642 | 844 | ||
| 643 | - | // Wrong cell count. | |
| 644 | - | f3 := Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)} | |
| 645 | - | if err := f3.Validate(); err == nil { | |
| 646 | - | t.Fatal("expected error for wrong cell count") | |
| 845 | + | for _, tt := range tests { | |
| 846 | + | t.Run(tt.name, func(t *testing.T) { | |
| 847 | + | err := tt.frame.Validate() | |
| 848 | + | if tt.wantErr && err == nil { | |
| 849 | + | t.Fatal("expected error, got nil") | |
| 850 | + | } | |
| 851 | + | if !tt.wantErr && err != nil { | |
| 852 | + | t.Fatalf("unexpected error: %v", err) | |
| 853 | + | } | |
| 854 | + | }) | |
| 647 | 855 | } | |
| 648 | 856 | } | |
| 649 | 857 | ||
| 650 | 858 | func TestFrameAtSet(t *testing.T) { | |
| 651 | - | f := NewFrame(3, 2) | |
| 652 | - | cell := Cell{Rune: 'X', Style: Style{Bold: true}} | |
| 653 | - | f.Set(1, 1, cell) | |
| 654 | - | ||
| 655 | - | got := f.At(1, 1) | |
| 656 | - | if got.Rune != 'X' || !got.Style.Bold { | |
| 657 | - | t.Errorf("At/Set round-trip failed: got %+v", got) | |
| 859 | + | tests := []struct { | |
| 860 | + | name string | |
| 861 | + | }{ | |
| 862 | + | {name: "At/Set round-trip and unaffected cells stay blank"}, | |
| 658 | 863 | } | |
| 659 | 864 | ||
| 660 | - | // Unaffected cells are blank. | |
| 661 | - | got = f.At(0, 0) | |
| 662 | - | if got != BlankCell() { | |
| 663 | - | t.Errorf("unexpected cell at (0,0): %+v", got) | |
| 865 | + | for _, tt := range tests { | |
| 866 | + | t.Run(tt.name, func(t *testing.T) { | |
| 867 | + | f := NewFrame(3, 2) | |
| 868 | + | cell := Cell{Rune: 'X', Style: Style{Bold: true}} | |
| 869 | + | f.Set(1, 1, cell) | |
| 870 | + | ||
| 871 | + | got := f.At(1, 1) | |
| 872 | + | if got.Rune != 'X' || !got.Style.Bold { | |
| 873 | + | t.Errorf("At/Set round-trip failed: got %+v", got) | |
| 874 | + | } | |
| 875 | + | ||
| 876 | + | // Unaffected cells are blank. | |
| 877 | + | got = f.At(0, 0) | |
| 878 | + | if got != BlankCell() { | |
| 879 | + | t.Errorf("unexpected cell at (0,0): %+v", got) | |
| 880 | + | } | |
| 881 | + | }) | |
| 664 | 882 | } | |
| 665 | 883 | } | |
| 666 | 884 | ||
| @@ -669,15 +887,32 @@ func TestFrameAtSet(t *testing.T) { | |||
| 669 | 887 | // --------------------------------------------------------------------------- | |
| 670 | 888 | ||
| 671 | 889 | func TestSameDamage(t *testing.T) { | |
| 672 | - | a := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5} | |
| 673 | - | b := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5} | |
| 674 | - | if !sameDamage(a, b) { | |
| 675 | - | t.Error("identical damages should be equal") | |
| 890 | + | tests := []struct { | |
| 891 | + | name string | |
| 892 | + | a Damage | |
| 893 | + | b Damage | |
| 894 | + | want bool | |
| 895 | + | }{ | |
| 896 | + | { | |
| 897 | + | name: "identical damages should be equal", | |
| 898 | + | a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |
| 899 | + | b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |
| 900 | + | want: true, | |
| 901 | + | }, | |
| 902 | + | { | |
| 903 | + | name: "different counts should not be equal", | |
| 904 | + | a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |
| 905 | + | b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0}, | |
| 906 | + | want: false, | |
| 907 | + | }, | |
| 676 | 908 | } | |
| 677 | 909 | ||
| 678 | - | c := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0} | |
| 679 | - | if sameDamage(a, c) { | |
| 680 | - | t.Error("different counts should not be equal") | |
| 910 | + | for _, tt := range tests { | |
| 911 | + | t.Run(tt.name, func(t *testing.T) { | |
| 912 | + | if got := sameDamage(tt.a, tt.b); got != tt.want { | |
| 913 | + | t.Errorf("sameDamage() = %v, want %v", got, tt.want) | |
| 914 | + | } | |
| 915 | + | }) | |
| 681 | 916 | } | |
| 682 | 917 | } | |
| 683 | 918 | ||
| @@ -686,20 +921,24 @@ func TestDamageCoversCell(t *testing.T) { | |||
| 686 | 921 | {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2}, | |
| 687 | 922 | } | |
| 688 | 923 | ||
| 689 | - | if !damageCoversCell(damage, 3, 3) { | |
| 690 | - | t.Error("should cover (3,3)") | |
| 691 | - | } | |
| 692 | - | if !damageCoversCell(damage, 6, 4) { | |
| 693 | - | t.Error("should cover (6,4)") | |
| 694 | - | } | |
| 695 | - | if damageCoversCell(damage, 1, 3) { | |
| 696 | - | t.Error("should not cover (1,3)") | |
| 697 | - | } | |
| 698 | - | if damageCoversCell(damage, 3, 5) { | |
| 699 | - | t.Error("should not cover (3,5)") | |
| 924 | + | tests := []struct { | |
| 925 | + | name string | |
| 926 | + | x, y int | |
| 927 | + | want bool | |
| 928 | + | }{ | |
| 929 | + | {name: "should cover (3,3)", x: 3, y: 3, want: true}, | |
| 930 | + | {name: "should cover (6,4)", x: 6, y: 4, want: true}, | |
| 931 | + | {name: "should not cover (1,3)", x: 1, y: 3, want: false}, | |
| 932 | + | {name: "should not cover (3,5)", x: 3, y: 5, want: false}, | |
| 933 | + | {name: "should not cover (7,3)", x: 7, y: 3, want: false}, | |
| 700 | 934 | } | |
| 701 | - | if damageCoversCell(damage, 7, 3) { | |
| 702 | - | t.Error("should not cover (7,3)") | |
| 935 | + | ||
| 936 | + | for _, tt := range tests { | |
| 937 | + | t.Run(tt.name, func(t *testing.T) { | |
| 938 | + | if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want { | |
| 939 | + | t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want) | |
| 940 | + | } | |
| 941 | + | }) | |
| 703 | 942 | } | |
| 704 | 943 | } | |
| 705 | 944 | ||
| @@ -708,63 +947,85 @@ func TestDamageCoversCell(t *testing.T) { | |||
| 708 | 947 | // --------------------------------------------------------------------------- | |
| 709 | 948 | ||
| 710 | 949 | func TestNeedsFull(t *testing.T) { | |
| 711 | - | if needsFull(nil) { | |
| 712 | - | t.Error("nil damage should not need full") | |
| 713 | - | } | |
| 714 | - | if needsFull([]Damage{}) { | |
| 715 | - | t.Error("empty damage should not need full") | |
| 716 | - | } | |
| 717 | - | if needsFull([]Damage{{Kind: DamageText}}) { | |
| 718 | - | t.Error("text damage should not need full") | |
| 719 | - | } | |
| 720 | - | if !needsFull([]Damage{FullRedraw()}) { | |
| 721 | - | t.Error("FullRedraw should need full") | |
| 950 | + | tests := []struct { | |
| 951 | + | name string | |
| 952 | + | damage []Damage | |
| 953 | + | want bool | |
| 954 | + | }{ | |
| 955 | + | {name: "nil damage should not need full", damage: nil, want: false}, | |
| 956 | + | {name: "empty damage should not need full", damage: []Damage{}, want: false}, | |
| 957 | + | {name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false}, | |
| 958 | + | {name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true}, | |
| 722 | 959 | } | |
| 723 | - | } | |
| 724 | 960 | ||
| 725 | - | func TestScrollDamageFallbackFullRedrawWhenFastPathUnsafe(t *testing.T) { | |
| 726 | - | r := New(Capabilities{}) | |
| 727 | - | frame := NewFrame(4, 3) | |
| 728 | - | markFrame(&frame) | |
| 729 | - | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil { | |
| 730 | - | t.Fatal(err) | |
| 961 | + | for _, tt := range tests { | |
| 962 | + | t.Run(tt.name, func(t *testing.T) { | |
| 963 | + | if got := needsFull(tt.damage); got != tt.want { | |
| 964 | + | t.Errorf("needsFull() = %v, want %v", got, tt.want) | |
| 965 | + | } | |
| 966 | + | }) | |
| 731 | 967 | } | |
| 968 | + | } | |
| 732 | 969 | ||
| 733 | - | changed := NewFrame(4, 3) | |
| 734 | - | markFrame(&changed) | |
| 735 | - | changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship | |
| 736 | - | damage := []Damage{ | |
| 737 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 738 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 739 | - | } | |
| 740 | - | out, err := r.Draw(changed, damage) | |
| 741 | - | if err != nil { | |
| 742 | - | t.Fatal(err) | |
| 743 | - | } | |
| 744 | - | if rows := strings.Count(string(out), "H"); rows < changed.Height { | |
| 745 | - | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | |
| 746 | - | } | |
| 747 | - | for i := range changed.Cells { | |
| 748 | - | if !r.shadow[i].Equal(changed.Cells[i]) { | |
| 749 | - | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | |
| 750 | - | } | |
| 970 | + | func TestDrawFallbackAndClampingEdgeCases(t *testing.T) { | |
| 971 | + | tests := []struct { | |
| 972 | + | name string | |
| 973 | + | run func(t *testing.T) | |
| 974 | + | }{ | |
| 975 | + | { | |
| 976 | + | name: "falls back to full redraw when scroll fast path is unsafe", | |
| 977 | + | run: func(t *testing.T) { | |
| 978 | + | r := New(Capabilities{}) | |
| 979 | + | frame := NewFrame(4, 3) | |
| 980 | + | markFrame(&frame) | |
| 981 | + | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil { | |
| 982 | + | t.Fatal(err) | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | changed := NewFrame(4, 3) | |
| 986 | + | markFrame(&changed) | |
| 987 | + | changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship | |
| 988 | + | damage := []Damage{ | |
| 989 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 990 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 991 | + | } | |
| 992 | + | out, err := r.Draw(changed, damage) | |
| 993 | + | if err != nil { | |
| 994 | + | t.Fatal(err) | |
| 995 | + | } | |
| 996 | + | if rows := strings.Count(string(out), "H"); rows < changed.Height { | |
| 997 | + | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | |
| 998 | + | } | |
| 999 | + | for i := range changed.Cells { | |
| 1000 | + | if !r.shadow[i].Equal(changed.Cells[i]) { | |
| 1001 | + | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | |
| 1002 | + | } | |
| 1003 | + | } | |
| 1004 | + | }, | |
| 1005 | + | }, | |
| 1006 | + | { | |
| 1007 | + | name: "out-of-bounds damage rectangles are clamped without panicking", | |
| 1008 | + | run: func(t *testing.T) { | |
| 1009 | + | r := New(Capabilities{}) | |
| 1010 | + | frame := NewFrame(3, 2) | |
| 1011 | + | markFrame(&frame) | |
| 1012 | + | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |
| 1013 | + | t.Fatal(err) | |
| 1014 | + | } | |
| 1015 | + | frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()}) | |
| 1016 | + | defer func() { | |
| 1017 | + | if r := recover(); r != nil { | |
| 1018 | + | t.Fatalf("Draw panicked for out-of-bounds damage: %v", r) | |
| 1019 | + | } | |
| 1020 | + | }() | |
| 1021 | + | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil { | |
| 1022 | + | t.Fatal(err) | |
| 1023 | + | } | |
| 1024 | + | }, | |
| 1025 | + | }, | |
| 751 | 1026 | } | |
| 752 | - | } | |
| 753 | 1027 | ||
| 754 | - | func TestDamageRectanglesAreClamped(t *testing.T) { | |
| 755 | - | r := New(Capabilities{}) | |
| 756 | - | frame := NewFrame(3, 2) | |
| 757 | - | markFrame(&frame) | |
| 758 | - | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |
| 759 | - | t.Fatal(err) | |
| 760 | - | } | |
| 761 | - | frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()}) | |
| 762 | - | defer func() { | |
| 763 | - | if r := recover(); r != nil { | |
| 764 | - | t.Fatalf("Draw panicked for out-of-bounds damage: %v", r) | |
| 765 | - | } | |
| 766 | - | }() | |
| 767 | - | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil { | |
| 768 | - | t.Fatal(err) | |
| 1028 | + | for _, tt := range tests { | |
| 1029 | + | t.Run(tt.name, tt.run) | |
| 769 | 1030 | } | |
| 770 | 1031 | } | |
| @@ -41,37 +41,44 @@ func outputEndsWith(t *testing.T, data []byte, suffix string) { | |||||
| 41 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- | ||
| 42 | 42 | ||||
| 43 | func TestFirstDraw(t *testing.T) { | 43 | func TestFirstDraw(t *testing.T) { | ||
| 44 | - | r := New(Capabilities{}) | 44 | + | tests := []struct { |
| 45 | - | frame := NewFrame(5, 3) | 45 | + | name string |
| 46 | - | markFrame(&frame) | 46 | + | damage []Damage |
| 47 | - | 47 | + | minCSICount int | |
| 48 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}) | 48 | + | }{ |
| 49 | - | if err != nil { | 49 | + | { |
| 50 | - | t.Fatal(err) | 50 | + | name: "explicit text damage", |
| 51 | - | } | 51 | + | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}, |
| 52 | - | if len(out) == 0 { | 52 | + | minCSICount: 3, |
| 53 | - | t.Fatal("expected non-empty output on first draw") | 53 | + | }, |
| 54 | - | } | 54 | + | { |
| 55 | - | outputContains(t, out, "\x1b[0m") | 55 | + | name: "full redraw damage", |
| 56 | - | // Should have cursor-position sequences for all three rows. | 56 | + | damage: []Damage{FullRedraw()}, |
| 57 | - | if c := strings.Count(string(out), "\x1b["); c < 3 { | 57 | + | }, |
| 58 | - | t.Fatalf("expected at least 3 CSI sequences, got %d", c) | |||
| 59 | } | 58 | } | ||
| 60 | - | } | |||
| 61 | 59 | ||||
| 62 | - | func TestFirstDrawFullRedraw(t *testing.T) { | 60 | + | for _, tt := range tests { |
| 63 | - | r := New(Capabilities{}) | 61 | + | t.Run(tt.name, func(t *testing.T) { |
| 64 | - | frame := NewFrame(5, 3) | 62 | + | r := New(Capabilities{}) |
| 65 | - | markFrame(&frame) | 63 | + | frame := NewFrame(5, 3) |
| 64 | + | markFrame(&frame) | |||
| 66 | 65 | ||||
| 67 | - | out, err := r.Draw(frame, []Damage{FullRedraw()}) | 66 | + | out, err := r.Draw(frame, tt.damage) |
| 68 | - | if err != nil { | 67 | + | if err != nil { |
| 69 | - | t.Fatal(err) | 68 | + | t.Fatal(err) |
| 70 | - | } | 69 | + | } |
| 71 | - | if len(out) == 0 { | 70 | + | if len(out) == 0 { |
| 72 | - | t.Fatal("expected non-empty output on full redraw") | 71 | + | t.Fatal("expected non-empty output on first draw") |
| 72 | + | } | |||
| 73 | + | outputContains(t, out, "\x1b[0m") | |||
| 74 | + | if tt.minCSICount > 0 { | |||
| 75 | + | // Should have cursor-position sequences for all three rows. | |||
| 76 | + | if c := strings.Count(string(out), "\x1b["); c < tt.minCSICount { | |||
| 77 | + | t.Fatalf("expected at least %d CSI sequences, got %d", tt.minCSICount, c) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 73 | } | 81 | } | ||
| 74 | - | outputContains(t, out, "\x1b[0m") | |||
| 75 | } | 82 | } | ||
| 76 | 83 | ||||
| 77 | // --------------------------------------------------------------------------- | 84 | // --------------------------------------------------------------------------- | ||
| @@ -79,47 +86,53 @@ func TestFirstDrawFullRedraw(t *testing.T) { | |||||
| 79 | // --------------------------------------------------------------------------- | 86 | // --------------------------------------------------------------------------- | ||
| 80 | 87 | ||||
| 81 | func TestNoOp(t *testing.T) { | 88 | func TestNoOp(t *testing.T) { | ||
| 82 | - | r := New(Capabilities{}) | 89 | + | tests := []struct { |
| 83 | - | frame := NewFrame(5, 3) | 90 | + | name string |
| 84 | - | markFrame(&frame) | 91 | + | width int |
| 85 | - | 92 | + | height int | |
| 86 | - | // First draw – populate shadow. | 93 | + | firstDamage []Damage |
| 87 | - | out1, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}) | 94 | + | secondDamage []Damage |
| 88 | - | if err != nil { | 95 | + | }{ |
| 89 | - | t.Fatal(err) | 96 | + | { |
| 90 | - | } | 97 | + | name: "nil damage on unchanged frame", |
| 91 | - | if len(out1) == 0 { | 98 | + | width: 5, |
| 92 | - | t.Fatal("first draw must produce output") | 99 | + | height: 3, |
| 93 | - | } | 100 | + | firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}, |
| 94 | - | 101 | + | secondDamage: nil, | |
| 95 | - | // Second draw with no changes. | 102 | + | }, |
| 96 | - | out2, err := r.Draw(frame, nil) | 103 | + | { |
| 97 | - | if err != nil { | 104 | + | name: "empty damage slice on unchanged frame", |
| 98 | - | t.Fatal(err) | 105 | + | width: 3, |
| 99 | - | } | 106 | + | height: 2, |
| 100 | - | if len(out2) != 0 { | 107 | + | firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}, |
| 101 | - | t.Fatalf("expected no-op (empty output), got %q", string(out2)) | 108 | + | secondDamage: []Damage{}, |
| 109 | + | }, | |||
| 102 | } | 110 | } | ||
| 103 | - | } | |||
| 104 | 111 | ||||
| 105 | - | func TestNoOpWithEmptyDamage(t *testing.T) { | 112 | + | for _, tt := range tests { |
| 106 | - | r := New(Capabilities{}) | 113 | + | t.Run(tt.name, func(t *testing.T) { |
| 107 | - | frame := NewFrame(3, 2) | 114 | + | r := New(Capabilities{}) |
| 108 | - | markFrame(&frame) | 115 | + | frame := NewFrame(tt.width, tt.height) |
| 109 | - | 116 | + | markFrame(&frame) | |
| 110 | - | // Populate shadow. | 117 | + | |
| 111 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | 118 | + | // First draw – populate shadow. |
| 112 | - | if err != nil { | 119 | + | out1, err := r.Draw(frame, tt.firstDamage) |
| 113 | - | t.Fatal(err) | 120 | + | if err != nil { |
| 114 | - | } | 121 | + | t.Fatal(err) |
| 122 | + | } | |||
| 123 | + | if len(out1) == 0 { | |||
| 124 | + | t.Fatal("first draw must produce output") | |||
| 125 | + | } | |||
| 115 | 126 | ||||
| 116 | - | // Same frame, empty damage slice. | 127 | + | // Second draw with no changes. |
| 117 | - | out, err := r.Draw(frame, []Damage{}) | 128 | + | out2, err := r.Draw(frame, tt.secondDamage) |
| 118 | - | if err != nil { | 129 | + | if err != nil { |
| 119 | - | t.Fatal(err) | 130 | + | t.Fatal(err) |
| 120 | - | } | 131 | + | } |
| 121 | - | if len(out) != 0 { | 132 | + | if len(out2) != 0 { |
| 122 | - | t.Fatalf("expected empty output for no changes, got %q", string(out)) | 133 | + | t.Fatalf("expected no-op (empty output), got %q", string(out2)) |
| 134 | + | } | |||
| 135 | + | }) | |||
| 123 | } | 136 | } | ||
| 124 | } | 137 | } | ||
| 125 | 138 | ||||
| @@ -127,103 +140,156 @@ func TestNoOpWithEmptyDamage(t *testing.T) { | |||||
| 127 | // Style reset discipline | 140 | // Style reset discipline | ||
| 128 | // --------------------------------------------------------------------------- | 141 | // --------------------------------------------------------------------------- | ||
| 129 | 142 | ||||
| 130 | - | func TestStyleReset(t *testing.T) { | 143 | + | func TestStyleResetDiscipline(t *testing.T) { |
| 131 | - | r := New(Capabilities{}) | 144 | + | tests := []struct { |
| 132 | - | frame := NewFrame(4, 2) | 145 | + | name string |
| 133 | - | 146 | + | run func(t *testing.T) | |
| 134 | - | // One styled cell, rest default. | 147 | + | }{ |
| 135 | - | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}}) | 148 | + | { |
| 136 | - | 149 | + | name: "single styled cell among default cells", | |
| 137 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | 150 | + | run: func(t *testing.T) { |
| 138 | - | if err != nil { | 151 | + | r := New(Capabilities{}) |
| 139 | - | t.Fatal(err) | 152 | + | frame := NewFrame(4, 2) |
| 140 | - | } | 153 | + | |
| 141 | - | // Must end with style reset. | 154 | + | // One styled cell, rest default. |
| 142 | - | outputEndsWith(t, out, "\x1b[0m") | 155 | + | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}}) |
| 143 | - | } | 156 | + | |
| 144 | - | 157 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |
| 145 | - | func TestStyleEqualUsesActiveColorMode(t *testing.T) { | 158 | + | if err != nil { |
| 146 | - | left := Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1} | 159 | + | t.Fatal(err) |
| 147 | - | right := Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1} | 160 | + | } |
| 148 | - | if !left.Equal(right) { | 161 | + | // Must end with style reset. |
| 149 | - | t.Fatal("RGB foreground equality should ignore inactive indexed foreground") | 162 | + | outputEndsWith(t, out, "\x1b[0m") |
| 163 | + | }, | |||
| 164 | + | }, | |||
| 165 | + | { | |||
| 166 | + | name: "after scroll damage", | |||
| 167 | + | run: func(t *testing.T) { | |||
| 168 | + | r := New(Capabilities{}) | |||
| 169 | + | frame := NewFrame(4, 3) | |||
| 170 | + | markFrame(&frame) | |||
| 171 | + | ||||
| 172 | + | // Populate shadow. | |||
| 173 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |||
| 174 | + | if err != nil { | |||
| 175 | + | t.Fatal(err) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | |||
| 179 | + | frame2 := NewFrame(4, 3) | |||
| 180 | + | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | |||
| 181 | + | for i := 8; i < 12; i++ { | |||
| 182 | + | frame2.Cells[i] = BlankCell() // bottom row blank | |||
| 183 | + | } | |||
| 184 | + | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | |||
| 185 | + | ||||
| 186 | + | damage := []Damage{ | |||
| 187 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 188 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | out, err := r.Draw(frame2, damage) | |||
| 192 | + | if err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | // The output must end with a style reset (either from emitScrollUp or writeDamage). | |||
| 196 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 197 | + | }, | |||
| 198 | + | }, | |||
| 150 | } | 199 | } | ||
| 151 | 200 | ||||
| 152 | - | right.ForegroundRGB.R = 13 | 201 | + | for _, tt := range tests { |
| 153 | - | if left.Equal(right) { | 202 | + | t.Run(tt.name, tt.run) |
| 154 | - | t.Fatal("RGB foreground equality should compare active RGB values") | |||
| 155 | } | 203 | } | ||
| 156 | } | 204 | } | ||
| 157 | 205 | ||||
| 158 | - | func TestRendererEmitsTruecolorSGR(t *testing.T) { | 206 | + | func TestStyleEqualUsesActiveColorMode(t *testing.T) { |
| 159 | - | r := New(Capabilities{}) | 207 | + | tests := []struct { |
| 160 | - | frame := NewFrame(1, 1) | 208 | + | name string |
| 161 | - | frame.Set(0, 0, Cell{ | 209 | + | left Style |
| 162 | - | Rune: 'X', | 210 | + | right Style |
| 163 | - | Style: Style{ | 211 | + | wantEqual bool |
| 164 | - | Foreground: -1, | 212 | + | }{ |
| 165 | - | Background: -1, | 213 | + | { |
| 166 | - | HasForegroundRGB: true, | 214 | + | name: "ignores inactive indexed foreground", |
| 167 | - | ForegroundRGB: RGB{R: 12, G: 34, B: 56}, | 215 | + | left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, |
| 168 | - | HasBackgroundRGB: true, | 216 | + | right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, |
| 169 | - | BackgroundRGB: RGB{R: 200, G: 100, B: 50}, | 217 | + | wantEqual: true, |
| 218 | + | }, | |||
| 219 | + | { | |||
| 220 | + | name: "compares active RGB values", | |||
| 221 | + | left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, | |||
| 222 | + | right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1}, | |||
| 223 | + | wantEqual: false, | |||
| 170 | }, | 224 | }, | ||
| 171 | - | }) | |||
| 172 | - | ||||
| 173 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}) | |||
| 174 | - | if err != nil { | |||
| 175 | - | t.Fatal(err) | |||
| 176 | } | 225 | } | ||
| 177 | 226 | ||||
| 178 | - | outputContains(t, out, "\x1b[0;38;2;12;34;56;48;2;200;100;50m") | 227 | + | for _, tt := range tests { |
| 179 | - | outputContains(t, out, "X") | 228 | + | t.Run(tt.name, func(t *testing.T) { |
| 180 | - | } | 229 | + | if got := tt.left.Equal(tt.right); got != tt.wantEqual { |
| 181 | - | 230 | + | t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual) | |
| 182 | - | func TestRendererEmitsStyleChangesForAdjacentColorModes(t *testing.T) { | 231 | + | } |
| 183 | - | r := New(Capabilities{}) | 232 | + | }) |
| 184 | - | frame := NewFrame(3, 1) | |||
| 185 | - | frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}}) | |||
| 186 | - | frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}}) | |||
| 187 | - | frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()}) | |||
| 188 | - | ||||
| 189 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}) | |||
| 190 | - | if err != nil { | |||
| 191 | - | t.Fatal(err) | |||
| 192 | } | 233 | } | ||
| 193 | - | ||||
| 194 | - | outputContains(t, out, "\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD") | |||
| 195 | } | 234 | } | ||
| 196 | 235 | ||||
| 197 | - | func TestStyleResetAfterScroll(t *testing.T) { | 236 | + | func TestRendererEmitsSGR(t *testing.T) { |
| 198 | - | r := New(Capabilities{}) | 237 | + | tests := []struct { |
| 199 | - | frame := NewFrame(4, 3) | 238 | + | name string |
| 200 | - | markFrame(&frame) | 239 | + | width int |
| 201 | - | 240 | + | height int | |
| 202 | - | // Populate shadow. | 241 | + | setup func(frame Frame) |
| 203 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | 242 | + | damage []Damage |
| 204 | - | if err != nil { | 243 | + | wantAll []string |
| 205 | - | t.Fatal(err) | 244 | + | }{ |
| 245 | + | { | |||
| 246 | + | name: "truecolor foreground and background", | |||
| 247 | + | width: 1, | |||
| 248 | + | height: 1, | |||
| 249 | + | setup: func(frame Frame) { | |||
| 250 | + | frame.Set(0, 0, Cell{ | |||
| 251 | + | Rune: 'X', | |||
| 252 | + | Style: Style{ | |||
| 253 | + | Foreground: -1, | |||
| 254 | + | Background: -1, | |||
| 255 | + | HasForegroundRGB: true, | |||
| 256 | + | ForegroundRGB: RGB{R: 12, G: 34, B: 56}, | |||
| 257 | + | HasBackgroundRGB: true, | |||
| 258 | + | BackgroundRGB: RGB{R: 200, G: 100, B: 50}, | |||
| 259 | + | }, | |||
| 260 | + | }) | |||
| 261 | + | }, | |||
| 262 | + | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |||
| 263 | + | wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"}, | |||
| 264 | + | }, | |||
| 265 | + | { | |||
| 266 | + | name: "style changes across adjacent color modes", | |||
| 267 | + | width: 3, | |||
| 268 | + | height: 1, | |||
| 269 | + | setup: func(frame Frame) { | |||
| 270 | + | frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}}) | |||
| 271 | + | frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}}) | |||
| 272 | + | frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()}) | |||
| 273 | + | }, | |||
| 274 | + | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}, | |||
| 275 | + | wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"}, | |||
| 276 | + | }, | |||
| 206 | } | 277 | } | ||
| 207 | 278 | ||||
| 208 | - | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | 279 | + | for _, tt := range tests { |
| 209 | - | frame2 := NewFrame(4, 3) | 280 | + | t.Run(tt.name, func(t *testing.T) { |
| 210 | - | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | 281 | + | r := New(Capabilities{}) |
| 211 | - | for i := 8; i < 12; i++ { | 282 | + | frame := NewFrame(tt.width, tt.height) |
| 212 | - | frame2.Cells[i] = BlankCell() // bottom row blank | 283 | + | tt.setup(frame) |
| 213 | - | } | |||
| 214 | - | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | |||
| 215 | 284 | ||||
| 216 | - | damage := []Damage{ | 285 | + | out, err := r.Draw(frame, tt.damage) |
| 217 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | 286 | + | if err != nil { |
| 218 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | 287 | + | t.Fatal(err) |
| 219 | - | } | 288 | + | } |
| 220 | 289 | ||||
| 221 | - | out, err := r.Draw(frame2, damage) | 290 | + | outputContains(t, out, tt.wantAll...) |
| 222 | - | if err != nil { | 291 | + | }) |
| 223 | - | t.Fatal(err) | |||
| 224 | } | 292 | } | ||
| 225 | - | // The output must end with a style reset (either from emitScrollUp or writeDamage). | |||
| 226 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 227 | } | 293 | } | ||
| 228 | 294 | ||||
| 229 | // --------------------------------------------------------------------------- | 295 | // --------------------------------------------------------------------------- | ||
| @@ -231,45 +297,55 @@ func TestStyleResetAfterScroll(t *testing.T) { | |||||
| 231 | // --------------------------------------------------------------------------- | 297 | // --------------------------------------------------------------------------- | ||
| 232 | 298 | ||||
| 233 | func TestScrollFastPath(t *testing.T) { | 299 | func TestScrollFastPath(t *testing.T) { | ||
| 234 | - | r := New(Capabilities{}) | 300 | + | tests := []struct { |
| 235 | - | frame := NewFrame(5, 4) | 301 | + | name string |
| 236 | - | markFrame(&frame) | 302 | + | }{ |
| 237 | - | 303 | + | {name: "scroll up by one row exposes new bottom row via scroll region"}, | |
| 238 | - | // Populate shadow. | |||
| 239 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}}) | |||
| 240 | - | if err != nil { | |||
| 241 | - | t.Fatal(err) | |||
| 242 | } | 304 | } | ||
| 243 | 305 | ||||
| 244 | - | // Build a new frame that has been scrolled up by 1 (like the VT model does). | 306 | + | for _, tt := range tests { |
| 245 | - | scrolled := NewFrame(5, 4) | 307 | + | t.Run(tt.name, func(t *testing.T) { |
| 246 | - | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 | 308 | + | r := New(Capabilities{}) |
| 247 | - | for i := 15; i < 20; i++ { | 309 | + | frame := NewFrame(5, 4) |
| 248 | - | scrolled.Cells[i] = BlankCell() // row 3 blanked | 310 | + | markFrame(&frame) |
| 249 | - | } | 311 | + | |
| 250 | - | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | 312 | + | // Populate shadow. |
| 251 | - | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | 313 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}}) |
| 252 | - | scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()}) | 314 | + | if err != nil { |
| 315 | + | t.Fatal(err) | |||
| 316 | + | } | |||
| 253 | 317 | ||||
| 254 | - | damage := []Damage{ | 318 | + | // Build a new frame that has been scrolled up by 1 (like the VT model does). |
| 255 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | 319 | + | scrolled := NewFrame(5, 4) |
| 256 | - | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | 320 | + | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 |
| 257 | - | } | 321 | + | for i := 15; i < 20; i++ { |
| 322 | + | scrolled.Cells[i] = BlankCell() // row 3 blanked | |||
| 323 | + | } | |||
| 324 | + | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | |||
| 325 | + | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | |||
| 326 | + | scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()}) | |||
| 258 | 327 | ||||
| 259 | - | out, err := r.Draw(scrolled, damage) | 328 | + | damage := []Damage{ |
| 260 | - | if err != nil { | 329 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, |
| 261 | - | t.Fatal(err) | 330 | + | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, |
| 262 | - | } | 331 | + | } |
| 263 | - | if len(out) == 0 { | 332 | + | |
| 264 | - | t.Fatal("expected non-empty output for scroll") | 333 | + | out, err := r.Draw(scrolled, damage) |
| 334 | + | if err != nil { | |||
| 335 | + | t.Fatal(err) | |||
| 336 | + | } | |||
| 337 | + | if len(out) == 0 { | |||
| 338 | + | t.Fatal("expected non-empty output for scroll") | |||
| 339 | + | } | |||
| 340 | + | // Should contain scroll-region sequences. | |||
| 341 | + | outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4 | |||
| 342 | + | outputContains(t, out, "\x1b[r") // restore scroll region | |||
| 343 | + | // Should contain the new text on the exposed row. | |||
| 344 | + | outputContains(t, out, "N") | |||
| 345 | + | outputContains(t, out, "e") | |||
| 346 | + | outputContains(t, out, "w") | |||
| 347 | + | }) | |||
| 265 | } | 348 | } | ||
| 266 | - | // Should contain scroll-region sequences. | |||
| 267 | - | outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4 | |||
| 268 | - | outputContains(t, out, "\x1b[r") // restore scroll region | |||
| 269 | - | // Should contain the new text on the exposed row. | |||
| 270 | - | outputContains(t, out, "N") | |||
| 271 | - | outputContains(t, out, "e") | |||
| 272 | - | outputContains(t, out, "w") | |||
| 273 | } | 349 | } | ||
| 274 | 350 | ||||
| 275 | // --------------------------------------------------------------------------- | 351 | // --------------------------------------------------------------------------- | ||
| @@ -277,40 +353,56 @@ func TestScrollFastPath(t *testing.T) { | |||||
| 277 | // --------------------------------------------------------------------------- | 353 | // --------------------------------------------------------------------------- | ||
| 278 | 354 | ||||
| 279 | func TestSynchronizedOutput(t *testing.T) { | 355 | func TestSynchronizedOutput(t *testing.T) { | ||
| 280 | - | r := New(Capabilities{SynchronizedOutput: true}) | 356 | + | tests := []struct { |
| 281 | - | frame := NewFrame(3, 2) | 357 | + | name string |
| 282 | - | markFrame(&frame) | 358 | + | run func(t *testing.T) |
| 283 | - | 359 | + | }{ | |
| 284 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | 360 | + | { |
| 285 | - | if err != nil { | 361 | + | name: "wraps output in sync CSI", |
| 286 | - | t.Fatal(err) | 362 | + | run: func(t *testing.T) { |
| 287 | - | } | 363 | + | r := New(Capabilities{SynchronizedOutput: true}) |
| 288 | - | if !strings.HasPrefix(string(out), SyncStartCSI) { | 364 | + | frame := NewFrame(3, 2) |
| 289 | - | t.Errorf("expected output to start with sync start CSI") | 365 | + | markFrame(&frame) |
| 290 | - | } | 366 | + | |
| 291 | - | if !strings.HasSuffix(string(out), SyncEndCSI) { | 367 | + | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) |
| 292 | - | t.Errorf("expected output to end with sync end CSI, got %q", string(out)) | 368 | + | if err != nil { |
| 293 | - | } | 369 | + | t.Fatal(err) |
| 294 | - | } | 370 | + | } |
| 295 | - | 371 | + | if !strings.HasPrefix(string(out), SyncStartCSI) { | |
| 296 | - | func TestSynchronizedOutputNoOp(t *testing.T) { | 372 | + | t.Errorf("expected output to start with sync start CSI") |
| 297 | - | r := New(Capabilities{SynchronizedOutput: true}) | 373 | + | } |
| 298 | - | frame := NewFrame(3, 2) | 374 | + | if !strings.HasSuffix(string(out), SyncEndCSI) { |
| 299 | - | markFrame(&frame) | 375 | + | t.Errorf("expected output to end with sync end CSI, got %q", string(out)) |
| 300 | - | 376 | + | } | |
| 301 | - | // First draw. | 377 | + | }, |
| 302 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | 378 | + | }, |
| 303 | - | if err != nil { | 379 | + | { |
| 304 | - | t.Fatal(err) | 380 | + | name: "no-op returns empty output, not wrapped empty output", |
| 381 | + | run: func(t *testing.T) { | |||
| 382 | + | r := New(Capabilities{SynchronizedOutput: true}) | |||
| 383 | + | frame := NewFrame(3, 2) | |||
| 384 | + | markFrame(&frame) | |||
| 385 | + | ||||
| 386 | + | // First draw. | |||
| 387 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 388 | + | if err != nil { | |||
| 389 | + | t.Fatal(err) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | // No-op – must return nil, not wrapped empty output. | |||
| 393 | + | out, err := r.Draw(frame, nil) | |||
| 394 | + | if err != nil { | |||
| 395 | + | t.Fatal(err) | |||
| 396 | + | } | |||
| 397 | + | if len(out) != 0 { | |||
| 398 | + | t.Fatalf("expected empty output for no-op, got %q", string(out)) | |||
| 399 | + | } | |||
| 400 | + | }, | |||
| 401 | + | }, | |||
| 305 | } | 402 | } | ||
| 306 | 403 | ||||
| 307 | - | // No-op – must return nil, not wrapped empty output. | 404 | + | for _, tt := range tests { |
| 308 | - | out, err := r.Draw(frame, nil) | 405 | + | t.Run(tt.name, tt.run) |
| 309 | - | if err != nil { | |||
| 310 | - | t.Fatal(err) | |||
| 311 | - | } | |||
| 312 | - | if len(out) != 0 { | |||
| 313 | - | t.Fatalf("expected empty output for no-op, got %q", string(out)) | |||
| 314 | } | 406 | } | ||
| 315 | } | 407 | } | ||
| 316 | 408 | ||||
| @@ -320,27 +412,56 @@ func TestSynchronizedOutputNoOp(t *testing.T) { | |||||
| 320 | 412 | ||||
| 321 | func TestWrapSynchronized(t *testing.T) { | 413 | func TestWrapSynchronized(t *testing.T) { | ||
| 322 | content := []byte("\x1b[0mhello") | 414 | content := []byte("\x1b[0mhello") | ||
| 323 | - | wrapped := WrapSynchronized(content, true) | |||
| 324 | - | if !strings.HasPrefix(string(wrapped), SyncStartCSI) { | |||
| 325 | - | t.Errorf("missing sync start") | |||
| 326 | - | } | |||
| 327 | - | if !strings.HasSuffix(string(wrapped), SyncEndCSI) { | |||
| 328 | - | t.Errorf("missing sync end") | |||
| 329 | - | } | |||
| 330 | - | if !strings.Contains(string(wrapped), "hello") { | |||
| 331 | - | t.Errorf("missing original content") | |||
| 332 | - | } | |||
| 333 | 415 | ||||
| 334 | - | // disabled | 416 | + | tests := []struct { |
| 335 | - | plain := WrapSynchronized(content, false) | 417 | + | name string |
| 336 | - | if string(plain) != string(content) { | 418 | + | content []byte |
| 337 | - | t.Errorf("disabled wrapping should return content unchanged") | 419 | + | enabled bool |
| 420 | + | check func(t *testing.T, got []byte) | |||
| 421 | + | }{ | |||
| 422 | + | { | |||
| 423 | + | name: "enabled wraps with sync start/end and preserves content", | |||
| 424 | + | content: content, | |||
| 425 | + | enabled: true, | |||
| 426 | + | check: func(t *testing.T, got []byte) { | |||
| 427 | + | if !strings.HasPrefix(string(got), SyncStartCSI) { | |||
| 428 | + | t.Errorf("missing sync start") | |||
| 429 | + | } | |||
| 430 | + | if !strings.HasSuffix(string(got), SyncEndCSI) { | |||
| 431 | + | t.Errorf("missing sync end") | |||
| 432 | + | } | |||
| 433 | + | if !strings.Contains(string(got), "hello") { | |||
| 434 | + | t.Errorf("missing original content") | |||
| 435 | + | } | |||
| 436 | + | }, | |||
| 437 | + | }, | |||
| 438 | + | { | |||
| 439 | + | name: "disabled returns content unchanged", | |||
| 440 | + | content: content, | |||
| 441 | + | enabled: false, | |||
| 442 | + | check: func(t *testing.T, got []byte) { | |||
| 443 | + | if string(got) != string(content) { | |||
| 444 | + | t.Errorf("disabled wrapping should return content unchanged") | |||
| 445 | + | } | |||
| 446 | + | }, | |||
| 447 | + | }, | |||
| 448 | + | { | |||
| 449 | + | name: "empty input returns empty", | |||
| 450 | + | content: nil, | |||
| 451 | + | enabled: true, | |||
| 452 | + | check: func(t *testing.T, got []byte) { | |||
| 453 | + | if len(got) != 0 { | |||
| 454 | + | t.Errorf("empty input should return empty") | |||
| 455 | + | } | |||
| 456 | + | }, | |||
| 457 | + | }, | |||
| 338 | } | 458 | } | ||
| 339 | 459 | ||||
| 340 | - | // empty input | 460 | + | for _, tt := range tests { |
| 341 | - | empty := WrapSynchronized(nil, true) | 461 | + | t.Run(tt.name, func(t *testing.T) { |
| 342 | - | if len(empty) != 0 { | 462 | + | got := WrapSynchronized(tt.content, tt.enabled) |
| 343 | - | t.Errorf("empty input should return empty") | 463 | + | tt.check(t, got) |
| 464 | + | }) | |||
| 344 | } | 465 | } | ||
| 345 | } | 466 | } | ||
| 346 | 467 | ||||
| @@ -349,110 +470,138 @@ func TestWrapSynchronized(t *testing.T) { | |||||
| 349 | // --------------------------------------------------------------------------- | 470 | // --------------------------------------------------------------------------- | ||
| 350 | 471 | ||||
| 351 | func TestPartialDamage(t *testing.T) { | 472 | func TestPartialDamage(t *testing.T) { | ||
| 352 | - | r := New(Capabilities{}) | 473 | + | tests := []struct { |
| 353 | - | frame := NewFrame(4, 2) | 474 | + | name string |
| 354 | - | markFrame(&frame) | 475 | + | }{ |
| 355 | - | 476 | + | {name: "single changed cell repositions cursor and resets style"}, | |
| 356 | - | // Populate shadow. | |||
| 357 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |||
| 358 | - | if err != nil { | |||
| 359 | - | t.Fatal(err) | |||
| 360 | } | 477 | } | ||
| 361 | 478 | ||||
| 362 | - | // Change one cell. | 479 | + | for _, tt := range tests { |
| 363 | - | frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()}) | 480 | + | t.Run(tt.name, func(t *testing.T) { |
| 364 | - | damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}} | 481 | + | r := New(Capabilities{}) |
| 482 | + | frame := NewFrame(4, 2) | |||
| 483 | + | markFrame(&frame) | |||
| 484 | + | ||||
| 485 | + | // Populate shadow. | |||
| 486 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |||
| 487 | + | if err != nil { | |||
| 488 | + | t.Fatal(err) | |||
| 489 | + | } | |||
| 365 | 490 | ||||
| 366 | - | out, err := r.Draw(frame, damage) | 491 | + | // Change one cell. |
| 367 | - | if err != nil { | 492 | + | frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()}) |
| 368 | - | t.Fatal(err) | 493 | + | damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}} |
| 369 | - | } | 494 | + | |
| 370 | - | if len(out) == 0 { | 495 | + | out, err := r.Draw(frame, damage) |
| 371 | - | t.Fatal("expected non-empty output for partial damage") | 496 | + | if err != nil { |
| 497 | + | t.Fatal(err) | |||
| 498 | + | } | |||
| 499 | + | if len(out) == 0 { | |||
| 500 | + | t.Fatal("expected non-empty output for partial damage") | |||
| 501 | + | } | |||
| 502 | + | // Should position cursor at (2,1) — 1-indexed (3;2). | |||
| 503 | + | outputContains(t, out, "\x1b[2;3H") | |||
| 504 | + | outputContains(t, out, "Z") | |||
| 505 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 506 | + | }) | |||
| 372 | } | 507 | } | ||
| 373 | - | // Should position cursor at (2,1) — 1-indexed (3;2). | |||
| 374 | - | outputContains(t, out, "\x1b[2;3H") | |||
| 375 | - | outputContains(t, out, "Z") | |||
| 376 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 377 | } | 508 | } | ||
| 378 | 509 | ||||
| 379 | // --------------------------------------------------------------------------- | 510 | // --------------------------------------------------------------------------- | ||
| 380 | // Scheduler coalescing | 511 | // Scheduler coalescing | ||
| 381 | // --------------------------------------------------------------------------- | 512 | // --------------------------------------------------------------------------- | ||
| 382 | 513 | ||||
| 383 | - | func TestSchedulerCoalescing(t *testing.T) { | 514 | + | func TestScheduler(t *testing.T) { |
| 384 | - | s := NewScheduler(30 * time.Millisecond) | 515 | + | tests := []struct { |
| 385 | - | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) | 516 | + | name string |
| 386 | - | defer cancel() | 517 | + | run func(t *testing.T) |
| 387 | - | 518 | + | }{ | |
| 388 | - | frames := s.Run(ctx) | 519 | + | { |
| 389 | - | 520 | + | name: "coalesces bursts of requests into a single frame", | |
| 390 | - | // Fire several requests before the timer fires. | 521 | + | run: func(t *testing.T) { |
| 391 | - | s.Request() | 522 | + | s := NewScheduler(30 * time.Millisecond) |
| 392 | - | s.Request() | 523 | + | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) |
| 393 | - | s.Request() | 524 | + | defer cancel() |
| 394 | - | 525 | + | ||
| 395 | - | // Expect exactly one frame. | 526 | + | frames := s.Run(ctx) |
| 396 | - | select { | 527 | + | |
| 397 | - | case _, ok := <-frames: | 528 | + | // Fire several requests before the timer fires. |
| 398 | - | if !ok { | 529 | + | s.Request() |
| 399 | - | t.Fatal("channel closed unexpectedly") | 530 | + | s.Request() |
| 400 | - | } | 531 | + | s.Request() |
| 401 | - | case <-ctx.Done(): | 532 | + | |
| 402 | - | t.Fatal("timeout waiting for frame") | 533 | + | // Expect exactly one frame. |
| 403 | - | } | 534 | + | select { |
| 404 | - | 535 | + | case _, ok := <-frames: | |
| 405 | - | // No second frame should arrive without another request. | 536 | + | if !ok { |
| 406 | - | select { | 537 | + | t.Fatal("channel closed unexpectedly") |
| 407 | - | case <-frames: | 538 | + | } |
| 408 | - | t.Fatal("unexpected second frame") | 539 | + | case <-ctx.Done(): |
| 409 | - | case <-time.After(80 * time.Millisecond): | 540 | + | t.Fatal("timeout waiting for frame") |
| 410 | - | // good – coalesced. | 541 | + | } |
| 411 | - | } | 542 | + | |
| 412 | - | } | 543 | + | // No second frame should arrive without another request. |
| 413 | - | 544 | + | select { | |
| 414 | - | func TestSchedulerMultipleFrames(t *testing.T) { | 545 | + | case <-frames: |
| 415 | - | s := NewScheduler(10 * time.Millisecond) | 546 | + | t.Fatal("unexpected second frame") |
| 416 | - | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) | 547 | + | case <-time.After(80 * time.Millisecond): |
| 417 | - | defer cancel() | 548 | + | // good – coalesced. |
| 418 | - | 549 | + | } | |
| 419 | - | frames := s.Run(ctx) | 550 | + | }, |
| 420 | - | 551 | + | }, | |
| 421 | - | s.Request() | 552 | + | { |
| 422 | - | <-frames // consume first | 553 | + | name: "delivers a frame per request without spurious extras", |
| 423 | - | 554 | + | run: func(t *testing.T) { | |
| 424 | - | s.Request() | 555 | + | s := NewScheduler(10 * time.Millisecond) |
| 425 | - | <-frames // consume second | 556 | + | ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) |
| 426 | - | 557 | + | defer cancel() | |
| 427 | - | // No pending request – should not deliver another frame. | 558 | + | |
| 428 | - | select { | 559 | + | frames := s.Run(ctx) |
| 429 | - | case <-frames: | 560 | + | |
| 430 | - | t.Fatal("unexpected frame without request") | 561 | + | s.Request() |
| 431 | - | case <-time.After(40 * time.Millisecond): | 562 | + | <-frames // consume first |
| 432 | - | // good | 563 | + | |
| 564 | + | s.Request() | |||
| 565 | + | <-frames // consume second | |||
| 566 | + | ||||
| 567 | + | // No pending request – should not deliver another frame. | |||
| 568 | + | select { | |||
| 569 | + | case <-frames: | |||
| 570 | + | t.Fatal("unexpected frame without request") | |||
| 571 | + | case <-time.After(40 * time.Millisecond): | |||
| 572 | + | // good | |||
| 573 | + | } | |||
| 574 | + | }, | |||
| 575 | + | }, | |||
| 576 | + | { | |||
| 577 | + | name: "closes frames channel on context cancellation", | |||
| 578 | + | run: func(t *testing.T) { | |||
| 579 | + | s := NewScheduler(50 * time.Millisecond) | |||
| 580 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 581 | + | ||||
| 582 | + | frames := s.Run(ctx) | |||
| 583 | + | s.Request() | |||
| 584 | + | ||||
| 585 | + | // Cancel before the timer fires. | |||
| 586 | + | cancel() | |||
| 587 | + | ||||
| 588 | + | // Channel should close without ever delivering a frame. | |||
| 589 | + | select { | |||
| 590 | + | case _, ok := <-frames: | |||
| 591 | + | if ok { | |||
| 592 | + | // A frame may have been emitted before cancellation took effect; | |||
| 593 | + | // that is acceptable. What matters is that the goroutine exits. | |||
| 594 | + | _ = ok | |||
| 595 | + | } | |||
| 596 | + | case <-time.After(200 * time.Millisecond): | |||
| 597 | + | // Channel didn't close – goroutine may be stuck. | |||
| 598 | + | } | |||
| 599 | + | }, | |||
| 600 | + | }, | |||
| 433 | } | 601 | } | ||
| 434 | - | } | |||
| 435 | - | ||||
| 436 | - | func TestSchedulerContextCancellation(t *testing.T) { | |||
| 437 | - | s := NewScheduler(50 * time.Millisecond) | |||
| 438 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 439 | 602 | ||||
| 440 | - | frames := s.Run(ctx) | 603 | + | for _, tt := range tests { |
| 441 | - | s.Request() | 604 | + | t.Run(tt.name, tt.run) |
| 442 | - | ||||
| 443 | - | // Cancel before the timer fires. | |||
| 444 | - | cancel() | |||
| 445 | - | ||||
| 446 | - | // Channel should close without ever delivering a frame. | |||
| 447 | - | select { | |||
| 448 | - | case _, ok := <-frames: | |||
| 449 | - | if ok { | |||
| 450 | - | // A frame may have been emitted before cancellation took effect; | |||
| 451 | - | // that is acceptable. What matters is that the goroutine exits. | |||
| 452 | - | _ = ok | |||
| 453 | - | } | |||
| 454 | - | case <-time.After(200 * time.Millisecond): | |||
| 455 | - | // Channel didn't close – goroutine may be stuck. | |||
| 456 | } | 605 | } | ||
| 457 | } | 606 | } | ||
| 458 | 607 | ||||
| @@ -461,28 +610,38 @@ func TestSchedulerContextCancellation(t *testing.T) { | |||||
| 461 | // --------------------------------------------------------------------------- | 610 | // --------------------------------------------------------------------------- | ||
| 462 | 611 | ||||
| 463 | func TestReset(t *testing.T) { | 612 | func TestReset(t *testing.T) { | ||
| 464 | - | r := New(Capabilities{}) | 613 | + | tests := []struct { |
| 465 | - | frame := NewFrame(3, 2) | 614 | + | name string |
| 466 | - | markFrame(&frame) | 615 | + | }{ |
| 467 | - | 616 | + | {name: "clears shadow state and forces a full draw next time"}, | |
| 468 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 469 | - | if err != nil { | |||
| 470 | - | t.Fatal(err) | |||
| 471 | } | 617 | } | ||
| 472 | 618 | ||||
| 473 | - | r.Reset() | 619 | + | for _, tt := range tests { |
| 620 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 621 | + | r := New(Capabilities{}) | |||
| 622 | + | frame := NewFrame(3, 2) | |||
| 623 | + | markFrame(&frame) | |||
| 474 | 624 | ||||
| 475 | - | if r.width != 0 || r.height != 0 || r.shadow != nil { | 625 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) |
| 476 | - | t.Fatal("Reset should clear width/height/shadow") | 626 | + | if err != nil { |
| 477 | - | } | 627 | + | t.Fatal(err) |
| 628 | + | } | |||
| 478 | 629 | ||||
| 479 | - | // After reset, the next draw should be a full draw. | 630 | + | r.Reset() |
| 480 | - | out, err := r.Draw(frame, nil) | 631 | + | |
| 481 | - | if err != nil { | 632 | + | if r.width != 0 || r.height != 0 || r.shadow != nil { |
| 482 | - | t.Fatal(err) | 633 | + | t.Fatal("Reset should clear width/height/shadow") |
| 483 | - | } | 634 | + | } |
| 484 | - | if len(out) == 0 { | 635 | + | |
| 485 | - | t.Fatal("expected non-empty output after reset") | 636 | + | // After reset, the next draw should be a full draw. |
| 637 | + | out, err := r.Draw(frame, nil) | |||
| 638 | + | if err != nil { | |||
| 639 | + | t.Fatal(err) | |||
| 640 | + | } | |||
| 641 | + | if len(out) == 0 { | |||
| 642 | + | t.Fatal("expected non-empty output after reset") | |||
| 643 | + | } | |||
| 644 | + | }) | |||
| 486 | } | 645 | } | ||
| 487 | } | 646 | } | ||
| 488 | 647 | ||||
| @@ -491,25 +650,35 @@ func TestReset(t *testing.T) { | |||||
| 491 | // --------------------------------------------------------------------------- | 650 | // --------------------------------------------------------------------------- | ||
| 492 | 651 | ||||
| 493 | func TestFullRedraw(t *testing.T) { | 652 | func TestFullRedraw(t *testing.T) { | ||
| 494 | - | r := New(Capabilities{}) | 653 | + | tests := []struct { |
| 495 | - | frame := NewFrame(3, 2) | 654 | + | name string |
| 496 | - | markFrame(&frame) | 655 | + | }{ |
| 497 | - | 656 | + | {name: "forces full output even with an unchanged frame"}, | |
| 498 | - | // Populate. | |||
| 499 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 500 | - | if err != nil { | |||
| 501 | - | t.Fatal(err) | |||
| 502 | } | 657 | } | ||
| 503 | 658 | ||||
| 504 | - | // FullRedraw forces full output even with same frame. | 659 | + | for _, tt := range tests { |
| 505 | - | out, err := r.Draw(frame, []Damage{FullRedraw()}) | 660 | + | t.Run(tt.name, func(t *testing.T) { |
| 506 | - | if err != nil { | 661 | + | r := New(Capabilities{}) |
| 507 | - | t.Fatal(err) | 662 | + | frame := NewFrame(3, 2) |
| 508 | - | } | 663 | + | markFrame(&frame) |
| 509 | - | if len(out) == 0 { | 664 | + | |
| 510 | - | t.Fatal("expected output for FullRedraw") | 665 | + | // Populate. |
| 666 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 667 | + | if err != nil { | |||
| 668 | + | t.Fatal(err) | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | // FullRedraw forces full output even with same frame. | |||
| 672 | + | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 673 | + | if err != nil { | |||
| 674 | + | t.Fatal(err) | |||
| 675 | + | } | |||
| 676 | + | if len(out) == 0 { | |||
| 677 | + | t.Fatal("expected output for FullRedraw") | |||
| 678 | + | } | |||
| 679 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 680 | + | }) | |||
| 511 | } | 681 | } | ||
| 512 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 513 | } | 682 | } | ||
| 514 | 683 | ||||
| 515 | // --------------------------------------------------------------------------- | 684 | // --------------------------------------------------------------------------- | ||
| @@ -517,39 +686,49 @@ func TestFullRedraw(t *testing.T) { | |||||
| 517 | // --------------------------------------------------------------------------- | 686 | // --------------------------------------------------------------------------- | ||
| 518 | 687 | ||||
| 519 | func TestScrollOnlyDamage(t *testing.T) { | 688 | func TestScrollOnlyDamage(t *testing.T) { | ||
| 520 | - | r := New(Capabilities{}) | 689 | + | tests := []struct { |
| 521 | - | frame := NewFrame(4, 3) | 690 | + | name string |
| 522 | - | markFrame(&frame) | 691 | + | }{ |
| 523 | - | 692 | + | {name: "scroll damage plus new-row text damage from the VT model"}, | |
| 524 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |||
| 525 | - | if err != nil { | |||
| 526 | - | t.Fatal(err) | |||
| 527 | } | 693 | } | ||
| 528 | 694 | ||||
| 529 | - | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | 695 | + | for _, tt := range tests { |
| 530 | - | scrolled := NewFrame(4, 3) | 696 | + | t.Run(tt.name, func(t *testing.T) { |
| 531 | - | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | 697 | + | r := New(Capabilities{}) |
| 532 | - | for i := 8; i < 12; i++ { | 698 | + | frame := NewFrame(4, 3) |
| 533 | - | scrolled.Cells[i] = BlankCell() | 699 | + | markFrame(&frame) |
| 534 | - | } | |||
| 535 | - | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 536 | 700 | ||||
| 537 | - | damage := []Damage{ | 701 | + | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) |
| 538 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | 702 | + | if err != nil { |
| 539 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | 703 | + | t.Fatal(err) |
| 540 | - | } | 704 | + | } |
| 541 | 705 | ||||
| 542 | - | out, err := r.Draw(scrolled, damage) | 706 | + | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. |
| 543 | - | if err != nil { | 707 | + | scrolled := NewFrame(4, 3) |
| 544 | - | t.Fatal(err) | 708 | + | copy(scrolled.Cells[0:8], frame.Cells[4:12]) |
| 545 | - | } | 709 | + | for i := 8; i < 12; i++ { |
| 546 | - | if len(out) == 0 { | 710 | + | scrolled.Cells[i] = BlankCell() |
| 547 | - | t.Fatal("expected output for scroll") | 711 | + | } |
| 712 | + | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 713 | + | ||||
| 714 | + | damage := []Damage{ | |||
| 715 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 716 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 717 | + | } | |||
| 718 | + | ||||
| 719 | + | out, err := r.Draw(scrolled, damage) | |||
| 720 | + | if err != nil { | |||
| 721 | + | t.Fatal(err) | |||
| 722 | + | } | |||
| 723 | + | if len(out) == 0 { | |||
| 724 | + | t.Fatal("expected output for scroll") | |||
| 725 | + | } | |||
| 726 | + | // Should include scroll region | |||
| 727 | + | outputContains(t, out, "\x1b[1;3r") | |||
| 728 | + | outputContains(t, out, "\x1b[r") | |||
| 729 | + | outputEndsWith(t, out, "\x1b[0m") | |||
| 730 | + | }) | |||
| 548 | } | 731 | } | ||
| 549 | - | // Should include scroll region | |||
| 550 | - | outputContains(t, out, "\x1b[1;3r") | |||
| 551 | - | outputContains(t, out, "\x1b[r") | |||
| 552 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 553 | } | 732 | } | ||
| 554 | 733 | ||||
| 555 | // --------------------------------------------------------------------------- | 734 | // --------------------------------------------------------------------------- | ||
| @@ -557,15 +736,23 @@ func TestScrollOnlyDamage(t *testing.T) { | |||||
| 557 | // --------------------------------------------------------------------------- | 736 | // --------------------------------------------------------------------------- | ||
| 558 | 737 | ||||
| 559 | func TestWriteCursor(t *testing.T) { | 738 | func TestWriteCursor(t *testing.T) { | ||
| 560 | - | var buf bytes.Buffer | 739 | + | tests := []struct { |
| 561 | - | writeCursor(&buf, 0, 0) | 740 | + | name string |
| 562 | - | if buf.String() != "\x1b[1;1H" { | 741 | + | y, x int |
| 563 | - | t.Errorf("unexpected cursor positioning: %q", buf.String()) | 742 | + | want string |
| 743 | + | }{ | |||
| 744 | + | {name: "origin", y: 0, x: 0, want: "\x1b[1;1H"}, | |||
| 745 | + | {name: "offset", y: 2, x: 5, want: "\x1b[3;6H"}, | |||
| 564 | } | 746 | } | ||
| 565 | - | buf.Reset() | 747 | + | |
| 566 | - | writeCursor(&buf, 2, 5) | 748 | + | for _, tt := range tests { |
| 567 | - | if buf.String() != "\x1b[3;6H" { | 749 | + | t.Run(tt.name, func(t *testing.T) { |
| 568 | - | t.Errorf("unexpected cursor positioning: %q", buf.String()) | 750 | + | var buf bytes.Buffer |
| 751 | + | writeCursor(&buf, tt.y, tt.x) | |||
| 752 | + | if buf.String() != tt.want { | |||
| 753 | + | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |||
| 754 | + | } | |||
| 755 | + | }) | |||
| 569 | } | 756 | } | ||
| 570 | } | 757 | } | ||
| 571 | 758 | ||||
| @@ -605,21 +792,38 @@ func TestIsSafeScroll(t *testing.T) { | |||||
| 605 | // --------------------------------------------------------------------------- | 792 | // --------------------------------------------------------------------------- | ||
| 606 | 793 | ||||
| 607 | func TestCellEqual(t *testing.T) { | 794 | func TestCellEqual(t *testing.T) { | ||
| 608 | - | a := BlankCell() | 795 | + | tests := []struct { |
| 609 | - | b := BlankCell() | 796 | + | name string |
| 610 | - | if !a.Equal(b) { | 797 | + | a Cell |
| 611 | - | t.Error("blank cells should be equal") | 798 | + | b Cell |
| 612 | - | } | 799 | + | want bool |
| 613 | - | 800 | + | }{ | |
| 614 | - | a.Rune = 'A' | 801 | + | { |
| 615 | - | if a.Equal(b) { | 802 | + | name: "blank cells should be equal", |
| 616 | - | t.Error("different runes should not be equal") | 803 | + | a: BlankCell(), |
| 804 | + | b: BlankCell(), | |||
| 805 | + | want: true, | |||
| 806 | + | }, | |||
| 807 | + | { | |||
| 808 | + | name: "different runes should not be equal", | |||
| 809 | + | a: Cell{Rune: 'A', Style: DefaultStyle()}, | |||
| 810 | + | b: BlankCell(), | |||
| 811 | + | want: false, | |||
| 812 | + | }, | |||
| 813 | + | { | |||
| 814 | + | name: "different styles should not be equal", | |||
| 815 | + | a: Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}}, | |||
| 816 | + | b: BlankCell(), | |||
| 817 | + | want: false, | |||
| 818 | + | }, | |||
| 617 | } | 819 | } | ||
| 618 | 820 | ||||
| 619 | - | a = BlankCell() | 821 | + | for _, tt := range tests { |
| 620 | - | a.Style.Bold = true | 822 | + | t.Run(tt.name, func(t *testing.T) { |
| 621 | - | if a.Equal(b) { | 823 | + | if got := tt.a.Equal(tt.b); got != tt.want { |
| 622 | - | t.Error("different styles should not be equal") | 824 | + | t.Errorf("Equal() = %v, want %v", got, tt.want) |
| 825 | + | } | |||
| 826 | + | }) | |||
| 623 | } | 827 | } | ||
| 624 | } | 828 | } | ||
| 625 | 829 | ||||
| @@ -628,39 +832,53 @@ func TestCellEqual(t *testing.T) { | |||||
| 628 | // --------------------------------------------------------------------------- | 832 | // --------------------------------------------------------------------------- | ||
| 629 | 833 | ||||
| 630 | func TestFrameValidate(t *testing.T) { | 834 | func TestFrameValidate(t *testing.T) { | ||
| 631 | - | // Valid frame. | 835 | + | tests := []struct { |
| 632 | - | f := NewFrame(5, 3) | 836 | + | name string |
| 633 | - | if err := f.Validate(); err != nil { | 837 | + | frame Frame |
| 634 | - | t.Fatalf("unexpected error: %v", err) | 838 | + | wantErr bool |
| 635 | - | } | 839 | + | }{ |
| 636 | - | 840 | + | {name: "valid frame", frame: NewFrame(5, 3), wantErr: false}, | |
| 637 | - | // Invalid dimensions. | 841 | + | {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true}, |
| 638 | - | f2 := Frame{Width: 0, Height: 5} | 842 | + | {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true}, |
| 639 | - | if err := f2.Validate(); err == nil { | |||
| 640 | - | t.Fatal("expected error for zero width") | |||
| 641 | } | 843 | } | ||
| 642 | 844 | ||||
| 643 | - | // Wrong cell count. | 845 | + | for _, tt := range tests { |
| 644 | - | f3 := Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)} | 846 | + | t.Run(tt.name, func(t *testing.T) { |
| 645 | - | if err := f3.Validate(); err == nil { | 847 | + | err := tt.frame.Validate() |
| 646 | - | t.Fatal("expected error for wrong cell count") | 848 | + | if tt.wantErr && err == nil { |
| 849 | + | t.Fatal("expected error, got nil") | |||
| 850 | + | } | |||
| 851 | + | if !tt.wantErr && err != nil { | |||
| 852 | + | t.Fatalf("unexpected error: %v", err) | |||
| 853 | + | } | |||
| 854 | + | }) | |||
| 647 | } | 855 | } | ||
| 648 | } | 856 | } | ||
| 649 | 857 | ||||
| 650 | func TestFrameAtSet(t *testing.T) { | 858 | func TestFrameAtSet(t *testing.T) { | ||
| 651 | - | f := NewFrame(3, 2) | 859 | + | tests := []struct { |
| 652 | - | cell := Cell{Rune: 'X', Style: Style{Bold: true}} | 860 | + | name string |
| 653 | - | f.Set(1, 1, cell) | 861 | + | }{ |
| 654 | - | 862 | + | {name: "At/Set round-trip and unaffected cells stay blank"}, | |
| 655 | - | got := f.At(1, 1) | |||
| 656 | - | if got.Rune != 'X' || !got.Style.Bold { | |||
| 657 | - | t.Errorf("At/Set round-trip failed: got %+v", got) | |||
| 658 | } | 863 | } | ||
| 659 | 864 | ||||
| 660 | - | // Unaffected cells are blank. | 865 | + | for _, tt := range tests { |
| 661 | - | got = f.At(0, 0) | 866 | + | t.Run(tt.name, func(t *testing.T) { |
| 662 | - | if got != BlankCell() { | 867 | + | f := NewFrame(3, 2) |
| 663 | - | t.Errorf("unexpected cell at (0,0): %+v", got) | 868 | + | cell := Cell{Rune: 'X', Style: Style{Bold: true}} |
| 869 | + | f.Set(1, 1, cell) | |||
| 870 | + | ||||
| 871 | + | got := f.At(1, 1) | |||
| 872 | + | if got.Rune != 'X' || !got.Style.Bold { | |||
| 873 | + | t.Errorf("At/Set round-trip failed: got %+v", got) | |||
| 874 | + | } | |||
| 875 | + | ||||
| 876 | + | // Unaffected cells are blank. | |||
| 877 | + | got = f.At(0, 0) | |||
| 878 | + | if got != BlankCell() { | |||
| 879 | + | t.Errorf("unexpected cell at (0,0): %+v", got) | |||
| 880 | + | } | |||
| 881 | + | }) | |||
| 664 | } | 882 | } | ||
| 665 | } | 883 | } | ||
| 666 | 884 | ||||
| @@ -669,15 +887,32 @@ func TestFrameAtSet(t *testing.T) { | |||||
| 669 | // --------------------------------------------------------------------------- | 887 | // --------------------------------------------------------------------------- | ||
| 670 | 888 | ||||
| 671 | func TestSameDamage(t *testing.T) { | 889 | func TestSameDamage(t *testing.T) { | ||
| 672 | - | a := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5} | 890 | + | tests := []struct { |
| 673 | - | b := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5} | 891 | + | name string |
| 674 | - | if !sameDamage(a, b) { | 892 | + | a Damage |
| 675 | - | t.Error("identical damages should be equal") | 893 | + | b Damage |
| 894 | + | want bool | |||
| 895 | + | }{ | |||
| 896 | + | { | |||
| 897 | + | name: "identical damages should be equal", | |||
| 898 | + | a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |||
| 899 | + | b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |||
| 900 | + | want: true, | |||
| 901 | + | }, | |||
| 902 | + | { | |||
| 903 | + | name: "different counts should not be equal", | |||
| 904 | + | a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |||
| 905 | + | b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0}, | |||
| 906 | + | want: false, | |||
| 907 | + | }, | |||
| 676 | } | 908 | } | ||
| 677 | 909 | ||||
| 678 | - | c := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0} | 910 | + | for _, tt := range tests { |
| 679 | - | if sameDamage(a, c) { | 911 | + | t.Run(tt.name, func(t *testing.T) { |
| 680 | - | t.Error("different counts should not be equal") | 912 | + | if got := sameDamage(tt.a, tt.b); got != tt.want { |
| 913 | + | t.Errorf("sameDamage() = %v, want %v", got, tt.want) | |||
| 914 | + | } | |||
| 915 | + | }) | |||
| 681 | } | 916 | } | ||
| 682 | } | 917 | } | ||
| 683 | 918 | ||||
| @@ -686,20 +921,24 @@ func TestDamageCoversCell(t *testing.T) { | |||||
| 686 | {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2}, | 921 | {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2}, | ||
| 687 | } | 922 | } | ||
| 688 | 923 | ||||
| 689 | - | if !damageCoversCell(damage, 3, 3) { | 924 | + | tests := []struct { |
| 690 | - | t.Error("should cover (3,3)") | 925 | + | name string |
| 691 | - | } | 926 | + | x, y int |
| 692 | - | if !damageCoversCell(damage, 6, 4) { | 927 | + | want bool |
| 693 | - | t.Error("should cover (6,4)") | 928 | + | }{ |
| 694 | - | } | 929 | + | {name: "should cover (3,3)", x: 3, y: 3, want: true}, |
| 695 | - | if damageCoversCell(damage, 1, 3) { | 930 | + | {name: "should cover (6,4)", x: 6, y: 4, want: true}, |
| 696 | - | t.Error("should not cover (1,3)") | 931 | + | {name: "should not cover (1,3)", x: 1, y: 3, want: false}, |
| 697 | - | } | 932 | + | {name: "should not cover (3,5)", x: 3, y: 5, want: false}, |
| 698 | - | if damageCoversCell(damage, 3, 5) { | 933 | + | {name: "should not cover (7,3)", x: 7, y: 3, want: false}, |
| 699 | - | t.Error("should not cover (3,5)") | |||
| 700 | } | 934 | } | ||
| 701 | - | if damageCoversCell(damage, 7, 3) { | 935 | + | |
| 702 | - | t.Error("should not cover (7,3)") | 936 | + | for _, tt := range tests { |
| 937 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 938 | + | if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want { | |||
| 939 | + | t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want) | |||
| 940 | + | } | |||
| 941 | + | }) | |||
| 703 | } | 942 | } | ||
| 704 | } | 943 | } | ||
| 705 | 944 | ||||
| @@ -708,63 +947,85 @@ func TestDamageCoversCell(t *testing.T) { | |||||
| 708 | // --------------------------------------------------------------------------- | 947 | // --------------------------------------------------------------------------- | ||
| 709 | 948 | ||||
| 710 | func TestNeedsFull(t *testing.T) { | 949 | func TestNeedsFull(t *testing.T) { | ||
| 711 | - | if needsFull(nil) { | 950 | + | tests := []struct { |
| 712 | - | t.Error("nil damage should not need full") | 951 | + | name string |
| 713 | - | } | 952 | + | damage []Damage |
| 714 | - | if needsFull([]Damage{}) { | 953 | + | want bool |
| 715 | - | t.Error("empty damage should not need full") | 954 | + | }{ |
| 716 | - | } | 955 | + | {name: "nil damage should not need full", damage: nil, want: false}, |
| 717 | - | if needsFull([]Damage{{Kind: DamageText}}) { | 956 | + | {name: "empty damage should not need full", damage: []Damage{}, want: false}, |
| 718 | - | t.Error("text damage should not need full") | 957 | + | {name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false}, |
| 719 | - | } | 958 | + | {name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true}, |
| 720 | - | if !needsFull([]Damage{FullRedraw()}) { | |||
| 721 | - | t.Error("FullRedraw should need full") | |||
| 722 | } | 959 | } | ||
| 723 | - | } | |||
| 724 | 960 | ||||
| 725 | - | func TestScrollDamageFallbackFullRedrawWhenFastPathUnsafe(t *testing.T) { | 961 | + | for _, tt := range tests { |
| 726 | - | r := New(Capabilities{}) | 962 | + | t.Run(tt.name, func(t *testing.T) { |
| 727 | - | frame := NewFrame(4, 3) | 963 | + | if got := needsFull(tt.damage); got != tt.want { |
| 728 | - | markFrame(&frame) | 964 | + | t.Errorf("needsFull() = %v, want %v", got, tt.want) |
| 729 | - | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil { | 965 | + | } |
| 730 | - | t.Fatal(err) | 966 | + | }) |
| 731 | } | 967 | } | ||
| 968 | + | } | |||
| 732 | 969 | ||||
| 733 | - | changed := NewFrame(4, 3) | 970 | + | func TestDrawFallbackAndClampingEdgeCases(t *testing.T) { |
| 734 | - | markFrame(&changed) | 971 | + | tests := []struct { |
| 735 | - | changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship | 972 | + | name string |
| 736 | - | damage := []Damage{ | 973 | + | run func(t *testing.T) |
| 737 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | 974 | + | }{ |
| 738 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | 975 | + | { |
| 739 | - | } | 976 | + | name: "falls back to full redraw when scroll fast path is unsafe", |
| 740 | - | out, err := r.Draw(changed, damage) | 977 | + | run: func(t *testing.T) { |
| 741 | - | if err != nil { | 978 | + | r := New(Capabilities{}) |
| 742 | - | t.Fatal(err) | 979 | + | frame := NewFrame(4, 3) |
| 743 | - | } | 980 | + | markFrame(&frame) |
| 744 | - | if rows := strings.Count(string(out), "H"); rows < changed.Height { | 981 | + | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil { |
| 745 | - | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | 982 | + | t.Fatal(err) |
| 746 | - | } | 983 | + | } |
| 747 | - | for i := range changed.Cells { | 984 | + | |
| 748 | - | if !r.shadow[i].Equal(changed.Cells[i]) { | 985 | + | changed := NewFrame(4, 3) |
| 749 | - | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | 986 | + | markFrame(&changed) |
| 750 | - | } | 987 | + | changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship |
| 988 | + | damage := []Damage{ | |||
| 989 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 990 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 991 | + | } | |||
| 992 | + | out, err := r.Draw(changed, damage) | |||
| 993 | + | if err != nil { | |||
| 994 | + | t.Fatal(err) | |||
| 995 | + | } | |||
| 996 | + | if rows := strings.Count(string(out), "H"); rows < changed.Height { | |||
| 997 | + | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | |||
| 998 | + | } | |||
| 999 | + | for i := range changed.Cells { | |||
| 1000 | + | if !r.shadow[i].Equal(changed.Cells[i]) { | |||
| 1001 | + | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | |||
| 1002 | + | } | |||
| 1003 | + | } | |||
| 1004 | + | }, | |||
| 1005 | + | }, | |||
| 1006 | + | { | |||
| 1007 | + | name: "out-of-bounds damage rectangles are clamped without panicking", | |||
| 1008 | + | run: func(t *testing.T) { | |||
| 1009 | + | r := New(Capabilities{}) | |||
| 1010 | + | frame := NewFrame(3, 2) | |||
| 1011 | + | markFrame(&frame) | |||
| 1012 | + | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 1013 | + | t.Fatal(err) | |||
| 1014 | + | } | |||
| 1015 | + | frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()}) | |||
| 1016 | + | defer func() { | |||
| 1017 | + | if r := recover(); r != nil { | |||
| 1018 | + | t.Fatalf("Draw panicked for out-of-bounds damage: %v", r) | |||
| 1019 | + | } | |||
| 1020 | + | }() | |||
| 1021 | + | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil { | |||
| 1022 | + | t.Fatal(err) | |||
| 1023 | + | } | |||
| 1024 | + | }, | |||
| 1025 | + | }, | |||
| 751 | } | 1026 | } | ||
| 752 | - | } | |||
| 753 | 1027 | ||||
| 754 | - | func TestDamageRectanglesAreClamped(t *testing.T) { | 1028 | + | for _, tt := range tests { |
| 755 | - | r := New(Capabilities{}) | 1029 | + | t.Run(tt.name, tt.run) |
| 756 | - | frame := NewFrame(3, 2) | |||
| 757 | - | markFrame(&frame) | |||
| 758 | - | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 759 | - | t.Fatal(err) | |||
| 760 | - | } | |||
| 761 | - | frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()}) | |||
| 762 | - | defer func() { | |||
| 763 | - | if r := recover(); r != nil { | |||
| 764 | - | t.Fatalf("Draw panicked for out-of-bounds damage: %v", r) | |||
| 765 | - | } | |||
| 766 | - | }() | |||
| 767 | - | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil { | |||
| 768 | - | t.Fatal(err) | |||
| 769 | } | 1030 | } | ||
| 770 | } | 1031 | } | ||