test(renderer): cover canonical damage regressions
- commit
- 3328c176d02242b119c70f6374a8ad505886021c
- author
- brice · · 2026-07-15 07:03 UTC
- parent
- f8d0bc7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +124 -0
M pkg/renderer/output_min_test.go +85 -0
| @@ -71,6 +71,91 @@ func setRow(f Frame, y int, s string, st Style) { | |||
| 71 | 71 | } | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | func hasCUB(out string) bool { | |
| 75 | + | for i := 0; i+2 < len(out); i++ { | |
| 76 | + | if out[i] != '\x1b' || out[i+1] != '[' { | |
| 77 | + | continue | |
| 78 | + | } | |
| 79 | + | j := i + 2 | |
| 80 | + | for j < len(out) && out[j] >= '0' && out[j] <= '9' { | |
| 81 | + | j++ | |
| 82 | + | } | |
| 83 | + | if j < len(out) && out[j] == 'D' { | |
| 84 | + | return true | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) { | |
| 91 | + | r := New(Capabilities{}) | |
| 92 | + | base := NewFrame(10, 1) | |
| 93 | + | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | frame := NewFrame(10, 1) | |
| 98 | + | setRow(frame, 0, "abcdefghi", DefaultStyle()) | |
| 99 | + | got := drawGolden(t, r, frame, []Damage{ | |
| 100 | + | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1}, | |
| 101 | + | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |
| 102 | + | }) | |
| 103 | + | want := "\x1b[1;1Habcdefghi\x1b[0m" | |
| 104 | + | if got != want { | |
| 105 | + | t.Fatalf("output = %q, want %q", got, want) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) { | |
| 110 | + | tests := []struct { | |
| 111 | + | name string | |
| 112 | + | spans []Damage | |
| 113 | + | }{ | |
| 114 | + | { | |
| 115 | + | name: "overlapping spans", | |
| 116 | + | spans: []Damage{ | |
| 117 | + | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |
| 118 | + | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1}, | |
| 119 | + | }, | |
| 120 | + | }, | |
| 121 | + | { | |
| 122 | + | name: "adjacent spans", | |
| 123 | + | spans: []Damage{ | |
| 124 | + | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |
| 125 | + | {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1}, | |
| 126 | + | }, | |
| 127 | + | }, | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | for _, tt := range tests { | |
| 131 | + | t.Run(tt.name, func(t *testing.T) { | |
| 132 | + | render := func(spans []Damage) string { | |
| 133 | + | t.Helper() | |
| 134 | + | r := New(Capabilities{}) | |
| 135 | + | if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | frame := NewFrame(10, 1) | |
| 139 | + | setRow(frame, 0, "abcdefghi", DefaultStyle()) | |
| 140 | + | return drawGolden(t, r, frame, spans) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | forward := render(tt.spans) | |
| 144 | + | reverse := render([]Damage{tt.spans[1], tt.spans[0]}) | |
| 145 | + | if forward != reverse { | |
| 146 | + | t.Fatalf("forward output = %q, reverse output = %q", forward, reverse) | |
| 147 | + | } | |
| 148 | + | if hasCUB(forward) { | |
| 149 | + | t.Fatalf("canonical output contains CUB: %q", forward) | |
| 150 | + | } | |
| 151 | + | want := "\x1b[1;1Habcdefghi\x1b[0m" | |
| 152 | + | if forward != want { | |
| 153 | + | t.Fatalf("output = %q, want canonical output %q", forward, want) | |
| 154 | + | } | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 74 | 159 | func TestNativeClearGolden(t *testing.T) { | |
| 75 | 160 | tests := []struct { | |
| 76 | 161 | name string | |
| @@ -71,6 +71,91 @@ func setRow(f Frame, y int, s string, st Style) { | |||||
| 71 | } | 71 | } | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | + | func hasCUB(out string) bool { | |||
| 75 | + | for i := 0; i+2 < len(out); i++ { | |||
| 76 | + | if out[i] != '\x1b' || out[i+1] != '[' { | |||
| 77 | + | continue | |||
| 78 | + | } | |||
| 79 | + | j := i + 2 | |||
| 80 | + | for j < len(out) && out[j] >= '0' && out[j] <= '9' { | |||
| 81 | + | j++ | |||
| 82 | + | } | |||
| 83 | + | if j < len(out) && out[j] == 'D' { | |||
| 84 | + | return true | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | return false | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) { | |||
| 91 | + | r := New(Capabilities{}) | |||
| 92 | + | base := NewFrame(10, 1) | |||
| 93 | + | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | frame := NewFrame(10, 1) | |||
| 98 | + | setRow(frame, 0, "abcdefghi", DefaultStyle()) | |||
| 99 | + | got := drawGolden(t, r, frame, []Damage{ | |||
| 100 | + | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 101 | + | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 102 | + | }) | |||
| 103 | + | want := "\x1b[1;1Habcdefghi\x1b[0m" | |||
| 104 | + | if got != want { | |||
| 105 | + | t.Fatalf("output = %q, want %q", got, want) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) { | |||
| 110 | + | tests := []struct { | |||
| 111 | + | name string | |||
| 112 | + | spans []Damage | |||
| 113 | + | }{ | |||
| 114 | + | { | |||
| 115 | + | name: "overlapping spans", | |||
| 116 | + | spans: []Damage{ | |||
| 117 | + | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 118 | + | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 119 | + | }, | |||
| 120 | + | }, | |||
| 121 | + | { | |||
| 122 | + | name: "adjacent spans", | |||
| 123 | + | spans: []Damage{ | |||
| 124 | + | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 125 | + | {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1}, | |||
| 126 | + | }, | |||
| 127 | + | }, | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | for _, tt := range tests { | |||
| 131 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 132 | + | render := func(spans []Damage) string { | |||
| 133 | + | t.Helper() | |||
| 134 | + | r := New(Capabilities{}) | |||
| 135 | + | if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | frame := NewFrame(10, 1) | |||
| 139 | + | setRow(frame, 0, "abcdefghi", DefaultStyle()) | |||
| 140 | + | return drawGolden(t, r, frame, spans) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | forward := render(tt.spans) | |||
| 144 | + | reverse := render([]Damage{tt.spans[1], tt.spans[0]}) | |||
| 145 | + | if forward != reverse { | |||
| 146 | + | t.Fatalf("forward output = %q, reverse output = %q", forward, reverse) | |||
| 147 | + | } | |||
| 148 | + | if hasCUB(forward) { | |||
| 149 | + | t.Fatalf("canonical output contains CUB: %q", forward) | |||
| 150 | + | } | |||
| 151 | + | want := "\x1b[1;1Habcdefghi\x1b[0m" | |||
| 152 | + | if forward != want { | |||
| 153 | + | t.Fatalf("output = %q, want canonical output %q", forward, want) | |||
| 154 | + | } | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 74 | func TestNativeClearGolden(t *testing.T) { | 159 | func TestNativeClearGolden(t *testing.T) { | ||
| 75 | tests := []struct { | 160 | tests := []struct { | ||
| 76 | name string | 161 | name string | ||
M pkg/vt/screen_integration_test.go +39 -0
| @@ -6,6 +6,45 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | + | func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) { | |
| 10 | + | s := NewScreen(20, 2) | |
| 11 | + | r := renderer.New(renderer.Capabilities{}) | |
| 12 | + | ||
| 13 | + | s.Write([]byte("❯ ")) | |
| 14 | + | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | s.ClearDamage() | |
| 18 | + | ||
| 19 | + | s.Write([]byte("h")) | |
| 20 | + | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | s.ClearDamage() | |
| 24 | + | ||
| 25 | + | s.Write([]byte("\x1b[31mh\x1b[39m")) | |
| 26 | + | s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m")) | |
| 27 | + | ||
| 28 | + | for y := range s.Frame.Height { | |
| 29 | + | for x := range s.Frame.Width { | |
| 30 | + | r := s.Frame.At(x, y).Rune | |
| 31 | + | if r == '\r' || r == '\n' { | |
| 32 | + | t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | out, err := r.Draw(s.Frame, s.Damage()) | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | for _, b := range out { | |
| 42 | + | if b == '\r' || b == '\n' { | |
| 43 | + | t.Fatalf("incremental output contains raw control byte %q: %q", b, string(out)) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 9 | 48 | func TestIntegrationScenarios(t *testing.T) { | |
| 10 | 49 | tests := []struct { | |
| 11 | 50 | name string | |
| @@ -6,6 +6,45 @@ import ( | |||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | + | func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) { | |||
| 10 | + | s := NewScreen(20, 2) | |||
| 11 | + | r := renderer.New(renderer.Capabilities{}) | |||
| 12 | + | ||||
| 13 | + | s.Write([]byte("❯ ")) | |||
| 14 | + | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | s.ClearDamage() | |||
| 18 | + | ||||
| 19 | + | s.Write([]byte("h")) | |||
| 20 | + | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | s.ClearDamage() | |||
| 24 | + | ||||
| 25 | + | s.Write([]byte("\x1b[31mh\x1b[39m")) | |||
| 26 | + | s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m")) | |||
| 27 | + | ||||
| 28 | + | for y := range s.Frame.Height { | |||
| 29 | + | for x := range s.Frame.Width { | |||
| 30 | + | r := s.Frame.At(x, y).Rune | |||
| 31 | + | if r == '\r' || r == '\n' { | |||
| 32 | + | t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | out, err := r.Draw(s.Frame, s.Damage()) | |||
| 38 | + | if err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | for _, b := range out { | |||
| 42 | + | if b == '\r' || b == '\n' { | |||
| 43 | + | t.Fatalf("incremental output contains raw control byte %q: %q", b, string(out)) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 9 | func TestIntegrationScenarios(t *testing.T) { | 48 | func TestIntegrationScenarios(t *testing.T) { | ||
| 10 | tests := []struct { | 49 | tests := []struct { | ||
| 11 | name string | 50 | name string | ||