detached f8f97be

fix(ansi): widen damage spans that split a wide rune

Text damage recorded before a scroll could start on the right half of a wide rune moved under it; the renderer then wrote over that half and erased the rune on the client. Planned spans now cover whole wide pairs, which also keeps cursor tracking exact. The oracle replays ANSI-256 output too, and a seeded scroll workload test replays real VT damage for every CellSource shape.
commit
f8f97be4f2c62bf11d30f9acf99c36dfd39ee3ae
author
brice · · 2026-10-02 12:26 UTC
parent
86aa468
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +188 -29
M ansi/damage_plan.go +17 -2
@@ -38,7 +38,7 @@ func buildDamagePlan(frame CellSource, damage []Damage, skip *Damage) ([]Span, b
3838 if len(spans) == maxPlannedDamageSpans {
3939 return nil, true
4040 }
41- spans = append(spans, Span{Y: row, X: x, Width: width})
41+ spans = append(spans, wideAlignedSpan(frame, row, x, width))
4242 }
4343 }
4444
@@ -62,11 +62,26 @@ func buildSingleDamageSpans(frame CellSource, d Damage) ([]Span, bool) {
6262 }
6363 spans := make([]Span, height)
6464 for row := range height {
65- spans[row] = Span{Y: y + row, X: x, Width: width}
65+ spans[row] = wideAlignedSpan(frame, y+row, x, width)
6666 }
6767 return spans, false
6868 }
6969
70+// wideAlignedSpan widens a clamped span so it never splits a wide rune.
71+// Damage can name only one half of a pair, for example when text damage was
72+// recorded before a scroll moved a wide rune under it. Writing from the right
73+// half would erase the rune on the client, and the shadow must hold whole pairs.
74+func wideAlignedSpan(frame CellSource, y, x, width int) Span {
75+ if x > 0 && frame.Cell(x, y).Continuation {
76+ x--
77+ width++
78+ }
79+ if end := x + width; end < frame.Columns() && frame.Cell(end, y).Continuation {
80+ width++
81+ }
82+ return Span{Y: y, X: x, Width: width}
83+}
84+
7085 func mergeDamageSpans(spans []Span) []Span {
7186 if len(spans) < 2 {
7287 return spans
@@ -38,7 +38,7 @@ func buildDamagePlan(frame CellSource, damage []Damage, skip *Damage) ([]Span, b
38 if len(spans) == maxPlannedDamageSpans {38 if len(spans) == maxPlannedDamageSpans {
39 return nil, true39 return nil, true
40 }40 }
41- spans = append(spans, Span{Y: row, X: x, Width: width})41+ spans = append(spans, wideAlignedSpan(frame, row, x, width))
42 }42 }
43 }43 }
44 44
@@ -62,11 +62,26 @@ func buildSingleDamageSpans(frame CellSource, d Damage) ([]Span, bool) {
62 }62 }
63 spans := make([]Span, height)63 spans := make([]Span, height)
64 for row := range height {64 for row := range height {
65- spans[row] = Span{Y: y + row, X: x, Width: width}65+ spans[row] = wideAlignedSpan(frame, y+row, x, width)
66 }66 }
67 return spans, false67 return spans, false
68 }68 }
69 69
70+// wideAlignedSpan widens a clamped span so it never splits a wide rune.
71+// Damage can name only one half of a pair, for example when text damage was
72+// recorded before a scroll moved a wide rune under it. Writing from the right
73+// half would erase the rune on the client, and the shadow must hold whole pairs.
74+func wideAlignedSpan(frame CellSource, y, x, width int) Span {
75+ if x > 0 && frame.Cell(x, y).Continuation {
76+ x--
77+ width++
78+ }
79+ if end := x + width; end < frame.Columns() && frame.Cell(end, y).Continuation {
80+ width++
81+ }
82+ return Span{Y: y, X: x, Width: width}
83+}
84+
70 func mergeDamageSpans(spans []Span) []Span {85 func mergeDamageSpans(spans []Span) []Span {
71 if len(spans) < 2 {86 if len(spans) < 2 {
72 return spans87 return spans
M ansi/output_min_test.go +6 -8
@@ -293,11 +293,10 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
293293 }
294294 })
295295
296- t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
296+ t.Run("rect splitting a wide pair is widened to the whole pair", func(t *testing.T) {
297297 // A damage rect that ends on a wide head whose continuation lies
298- // outside the rect makes the terminal advance one column further than
299- // the rect width accounts for. Tracking must be dropped: the next
300- // rect must be positioned with absolute CUP, not a relative move.
298+ // outside the rect is widened to cover the pair, so the terminal
299+ // advance matches the span and tracking stays exact.
301300 r := New(Capabilities{})
302301 base := NewFrame(8, 1)
303302 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -314,9 +313,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
314313 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
315314 }
316315 got := drawGolden(t, r, frame, damage)
317- // After A你 the real cursor is at col 3, not col 2: a relative CUF
318- // would be off by one, so the second rect must use absolute CUP.
319- want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
316+ // After A你 the cursor is known at col 3, so one CUF reaches col 4.
317+ want := "\x1b[1;1HA你\x1b[CB\x1b[0m"
320318 if got != want {
321319 t.Fatalf("output = %q, want %q", got, want)
322320 }
@@ -425,7 +423,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
425423 if err != nil {
426424 t.Fatal(err)
427425 }
428- if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
426+ if got, want := string(out), "\x1b[1;1HA你\x1b[CB\x1b[0m"; got != want {
429427 t.Fatalf("output = %q, want %q", got, want)
430428 }
431429 }
@@ -293,11 +293,10 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
293 }293 }
294 })294 })
295 295
296- t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {296+ t.Run("rect splitting a wide pair is widened to the whole pair", func(t *testing.T) {
297 // A damage rect that ends on a wide head whose continuation lies297 // A damage rect that ends on a wide head whose continuation lies
298- // outside the rect makes the terminal advance one column further than298+ // outside the rect is widened to cover the pair, so the terminal
299- // the rect width accounts for. Tracking must be dropped: the next299+ // advance matches the span and tracking stays exact.
300- // rect must be positioned with absolute CUP, not a relative move.
301 r := New(Capabilities{})300 r := New(Capabilities{})
302 base := NewFrame(8, 1)301 base := NewFrame(8, 1)
303 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {302 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -314,9 +313,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
314 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},313 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
315 }314 }
316 got := drawGolden(t, r, frame, damage)315 got := drawGolden(t, r, frame, damage)
317- // After A你 the real cursor is at col 3, not col 2: a relative CUF316+ // After A你 the cursor is known at col 3, so one CUF reaches col 4.
318- // would be off by one, so the second rect must use absolute CUP.317+ want := "\x1b[1;1HA你\x1b[CB\x1b[0m"
319- want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
320 if got != want {318 if got != want {
321 t.Fatalf("output = %q, want %q", got, want)319 t.Fatalf("output = %q, want %q", got, want)
322 }320 }
@@ -425,7 +423,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
425 if err != nil {423 if err != nil {
426 t.Fatal(err)424 t.Fatal(err)
427 }425 }
428- if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {426+ if got, want := string(out), "\x1b[1;1HA你\x1b[CB\x1b[0m"; got != want {
429 t.Fatalf("output = %q, want %q", got, want)427 t.Fatalf("output = %q, want %q", got, want)
430 }428 }
431 }429 }
A ansi/replay_property_test.go +115 -0
@@ -0,0 +1,115 @@
1+package ansi_test
2+
3+import (
4+ "fmt"
5+ "math/rand/v2"
6+ "strings"
7+ "testing"
8+
9+ vt "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev-vt/ansi"
11+ core "github.com/bnema/vev-vt/core"
12+)
13+
14+type hiddenCellSource struct{ core.CellSource }
15+
16+// Random VT scroll workloads rendered with real screen damage must replay to
17+// the exact source grid for every CellSource shape the renderer dispatches on.
18+func TestRandomScrollWorkloadsReplayExactly(t *testing.T) {
19+ seeds := 400
20+ if testing.Short() {
21+ seeds = 60
22+ }
23+ for _, kind := range []string{"screen", "frame", "pointer", "hidden"} {
24+ t.Run(kind, func(t *testing.T) {
25+ for seed := range seeds {
26+ runReplayWorkload(t, kind, uint64(seed))
27+ }
28+ })
29+ }
30+}
31+
32+func runReplayWorkload(t *testing.T, kind string, seed uint64) {
33+ t.Helper()
34+ r := rand.New(rand.NewPCG(seed, 0x5c4011))
35+ cols, rows := 4+r.IntN(14), 3+r.IntN(10)
36+ s := vt.NewScreen(cols, rows)
37+ renderer := ansi.New(ansi.Capabilities{})
38+ replay := vt.NewScreen(cols, rows)
39+ mirror := core.NewFrame(cols, rows)
40+ for step := range 30 {
41+ input := randomScrollInput(r, cols, rows)
42+ s.Write(input)
43+ var source core.CellSource = s
44+ if kind != "screen" {
45+ for y := range rows {
46+ for x := range cols {
47+ mirror.Set(x, y, s.Cell(x, y))
48+ }
49+ }
50+ switch kind {
51+ case "frame":
52+ source = mirror
53+ case "pointer":
54+ source = &mirror
55+ default:
56+ source = hiddenCellSource{mirror}
57+ }
58+ }
59+ prepared, err := renderer.Prepare(source, s.Damage(), false)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ prepared.Commit()
64+ replay.Write(prepared.Bytes())
65+ s.ClearDamage()
66+ for y := range rows {
67+ for x := range cols {
68+ want, got := s.Cell(x, y), replay.Cell(x, y)
69+ if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) {
70+ t.Fatalf("seed %d step %d input %q: cell (%d,%d) = %+v, want %+v", seed, step, input, x, y, got, want)
71+ }
72+ }
73+ }
74+ }
75+}
76+
77+func randomScrollInput(r *rand.Rand, cols, rows int) []byte {
78+ var b strings.Builder
79+ for range 1 + r.IntN(12) {
80+ switch r.IntN(14) {
81+ case 0:
82+ top := 1 + r.IntN(rows)
83+ fmt.Fprintf(&b, "\x1b[%d;%dr", top, top+r.IntN(rows-top+1))
84+ case 1:
85+ b.WriteString("\x1b[r")
86+ case 2:
87+ b.WriteString("\x1bD")
88+ case 3:
89+ b.WriteString("\x1bM")
90+ case 4:
91+ fmt.Fprintf(&b, "\x1b[%dS", 1+r.IntN(3))
92+ case 5:
93+ fmt.Fprintf(&b, "\x1b[%dT", 1+r.IntN(3))
94+ case 6:
95+ fmt.Fprintf(&b, "\x1b[%dL", 1+r.IntN(3))
96+ case 7:
97+ fmt.Fprintf(&b, "\x1b[%dM", 1+r.IntN(3))
98+ case 8:
99+ b.WriteString("\r\n")
100+ case 9:
101+ b.WriteString([]string{"\x1b[K", "\x1b[1K", "\x1b[2K", "\x1b[J"}[r.IntN(4)])
102+ case 10:
103+ fmt.Fprintf(&b, "\x1b[%dm", []int{0, 1, 7, 31, 42}[r.IntN(5)])
104+ case 11:
105+ fmt.Fprintf(&b, "\x1b[%d;%dH", 1+r.IntN(rows), 1+r.IntN(cols))
106+ case 12:
107+ b.WriteString("界")
108+ default:
109+ for range 1 + r.IntN(cols) {
110+ b.WriteByte(byte('a' + r.IntN(26)))
111+ }
112+ }
113+ }
114+ return []byte(b.String())
115+}
@@ -0,0 +1,115 @@
1+package ansi_test
2+
3+import (
4+ "fmt"
5+ "math/rand/v2"
6+ "strings"
7+ "testing"
8+
9+ vt "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev-vt/ansi"
11+ core "github.com/bnema/vev-vt/core"
12+)
13+
14+type hiddenCellSource struct{ core.CellSource }
15+
16+// Random VT scroll workloads rendered with real screen damage must replay to
17+// the exact source grid for every CellSource shape the renderer dispatches on.
18+func TestRandomScrollWorkloadsReplayExactly(t *testing.T) {
19+ seeds := 400
20+ if testing.Short() {
21+ seeds = 60
22+ }
23+ for _, kind := range []string{"screen", "frame", "pointer", "hidden"} {
24+ t.Run(kind, func(t *testing.T) {
25+ for seed := range seeds {
26+ runReplayWorkload(t, kind, uint64(seed))
27+ }
28+ })
29+ }
30+}
31+
32+func runReplayWorkload(t *testing.T, kind string, seed uint64) {
33+ t.Helper()
34+ r := rand.New(rand.NewPCG(seed, 0x5c4011))
35+ cols, rows := 4+r.IntN(14), 3+r.IntN(10)
36+ s := vt.NewScreen(cols, rows)
37+ renderer := ansi.New(ansi.Capabilities{})
38+ replay := vt.NewScreen(cols, rows)
39+ mirror := core.NewFrame(cols, rows)
40+ for step := range 30 {
41+ input := randomScrollInput(r, cols, rows)
42+ s.Write(input)
43+ var source core.CellSource = s
44+ if kind != "screen" {
45+ for y := range rows {
46+ for x := range cols {
47+ mirror.Set(x, y, s.Cell(x, y))
48+ }
49+ }
50+ switch kind {
51+ case "frame":
52+ source = mirror
53+ case "pointer":
54+ source = &mirror
55+ default:
56+ source = hiddenCellSource{mirror}
57+ }
58+ }
59+ prepared, err := renderer.Prepare(source, s.Damage(), false)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ prepared.Commit()
64+ replay.Write(prepared.Bytes())
65+ s.ClearDamage()
66+ for y := range rows {
67+ for x := range cols {
68+ want, got := s.Cell(x, y), replay.Cell(x, y)
69+ if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) {
70+ t.Fatalf("seed %d step %d input %q: cell (%d,%d) = %+v, want %+v", seed, step, input, x, y, got, want)
71+ }
72+ }
73+ }
74+ }
75+}
76+
77+func randomScrollInput(r *rand.Rand, cols, rows int) []byte {
78+ var b strings.Builder
79+ for range 1 + r.IntN(12) {
80+ switch r.IntN(14) {
81+ case 0:
82+ top := 1 + r.IntN(rows)
83+ fmt.Fprintf(&b, "\x1b[%d;%dr", top, top+r.IntN(rows-top+1))
84+ case 1:
85+ b.WriteString("\x1b[r")
86+ case 2:
87+ b.WriteString("\x1bD")
88+ case 3:
89+ b.WriteString("\x1bM")
90+ case 4:
91+ fmt.Fprintf(&b, "\x1b[%dS", 1+r.IntN(3))
92+ case 5:
93+ fmt.Fprintf(&b, "\x1b[%dT", 1+r.IntN(3))
94+ case 6:
95+ fmt.Fprintf(&b, "\x1b[%dL", 1+r.IntN(3))
96+ case 7:
97+ fmt.Fprintf(&b, "\x1b[%dM", 1+r.IntN(3))
98+ case 8:
99+ b.WriteString("\r\n")
100+ case 9:
101+ b.WriteString([]string{"\x1b[K", "\x1b[1K", "\x1b[2K", "\x1b[J"}[r.IntN(4)])
102+ case 10:
103+ fmt.Fprintf(&b, "\x1b[%dm", []int{0, 1, 7, 31, 42}[r.IntN(5)])
104+ case 11:
105+ fmt.Fprintf(&b, "\x1b[%d;%dH", 1+r.IntN(rows), 1+r.IntN(cols))
106+ case 12:
107+ b.WriteString("界")
108+ default:
109+ for range 1 + r.IntN(cols) {
110+ b.WriteByte(byte('a' + r.IntN(26)))
111+ }
112+ }
113+ }
114+ return []byte(b.String())
115+}
M ansi/scroll.go +1 -4
@@ -121,10 +121,7 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com
121121 }
122122
123123 func outsideScrollMatches(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool {
124- dense, isDense := frame.(Frame)
125- if pointer, ok := frame.(*Frame); ok {
126- dense, isDense = *pointer, true
127- }
124+ dense, isDense := asFrame(frame)
128125 columns := frame.Columns()
129126 for y := range frame.Rows() {
130127 if y >= scroll.Y && y < scroll.Y+scroll.Height {
@@ -121,10 +121,7 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com
121 }121 }
122 122
123 func outsideScrollMatches(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool {123 func outsideScrollMatches(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool {
124- dense, isDense := frame.(Frame)124+ dense, isDense := asFrame(frame)
125- if pointer, ok := frame.(*Frame); ok {
126- dense, isDense = *pointer, true
127- }
128 columns := frame.Columns()125 columns := frame.Columns()
129 for y := range frame.Rows() {126 for y := range frame.Rows() {
130 if y >= scroll.Y && y < scroll.Y+scroll.Height {127 if y >= scroll.Y && y < scroll.Y+scroll.Height {
M ansi/scroll_multi_region_test.go +36 -7
@@ -42,13 +42,42 @@ func TestTwoScrollRegionsInOneBatchReplayExactly(t *testing.T) {
4242 draw()
4343 s.Write([]byte("\x1b[1;4r\x1b[4;1H\nX\x1b[5;8r\x1b[8;1H\n\nY\x1b[r"))
4444 draw()
45- for y := range 8 {
46- for x := range 10 {
47- if want, got := s.Cell(x, y), replay.Cell(x, y); want.Rune != got.Rune {
48- t.Fatalf("cell (%d,%d) = %q, want %q", x, y, got.Rune, want.Rune)
49- }
50- }
51- }
45+ requireReplayMatches(t, s, replay)
5246 })
5347 }
5448 }
49+
50+// Text damage recorded before a scroll can start on the right half of a wide
51+// rune that the scroll moved there; the renderer must repaint the rune.
52+func TestSpanStartingOnWideContinuationRepaintsHead(t *testing.T) {
53+ s := vt.NewScreen(6, 3)
54+ r := ansi.New(ansi.Capabilities{})
55+ replay := vt.NewScreen(6, 3)
56+ draw := func() {
57+ prepared, err := r.Prepare(s, s.Damage(), false)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ prepared.Commit()
62+ replay.Write(prepared.Bytes())
63+ s.ClearDamage()
64+ }
65+ draw()
66+ s.Write([]byte("\x1b[2;1H界"))
67+ draw()
68+ s.Write([]byte("\x1b[1;2Hab\x1b[1;2r\x1b[2;1H\n\x1b[r"))
69+ draw()
70+ requireReplayMatches(t, s, replay)
71+}
72+
73+func requireReplayMatches(t *testing.T, source, replay *vt.Screen) {
74+ t.Helper()
75+ for y := range source.Rows() {
76+ for x := range source.Columns() {
77+ want, got := source.Cell(x, y), replay.Cell(x, y)
78+ if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) {
79+ t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
80+ }
81+ }
82+ }
83+}
@@ -42,13 +42,42 @@ func TestTwoScrollRegionsInOneBatchReplayExactly(t *testing.T) {
42 draw()42 draw()
43 s.Write([]byte("\x1b[1;4r\x1b[4;1H\nX\x1b[5;8r\x1b[8;1H\n\nY\x1b[r"))43 s.Write([]byte("\x1b[1;4r\x1b[4;1H\nX\x1b[5;8r\x1b[8;1H\n\nY\x1b[r"))
44 draw()44 draw()
45- for y := range 8 {45+ requireReplayMatches(t, s, replay)
46- for x := range 10 {
47- if want, got := s.Cell(x, y), replay.Cell(x, y); want.Rune != got.Rune {
48- t.Fatalf("cell (%d,%d) = %q, want %q", x, y, got.Rune, want.Rune)
49- }
50- }
51- }
52 })46 })
53 }47 }
54 }48 }
49+
50+// Text damage recorded before a scroll can start on the right half of a wide
51+// rune that the scroll moved there; the renderer must repaint the rune.
52+func TestSpanStartingOnWideContinuationRepaintsHead(t *testing.T) {
53+ s := vt.NewScreen(6, 3)
54+ r := ansi.New(ansi.Capabilities{})
55+ replay := vt.NewScreen(6, 3)
56+ draw := func() {
57+ prepared, err := r.Prepare(s, s.Damage(), false)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ prepared.Commit()
62+ replay.Write(prepared.Bytes())
63+ s.ClearDamage()
64+ }
65+ draw()
66+ s.Write([]byte("\x1b[2;1H界"))
67+ draw()
68+ s.Write([]byte("\x1b[1;2Hab\x1b[1;2r\x1b[2;1H\n\x1b[r"))
69+ draw()
70+ requireReplayMatches(t, s, replay)
71+}
72+
73+func requireReplayMatches(t *testing.T, source, replay *vt.Screen) {
74+ t.Helper()
75+ for y := range source.Rows() {
76+ for x := range source.Columns() {
77+ want, got := source.Cell(x, y), replay.Cell(x, y)
78+ if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) {
79+ t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
80+ }
81+ }
82+ }
83+}
M oracle_test.go +13 -8
@@ -72,6 +72,9 @@ type oracleRun struct {
7272 ansi256 *ansi.Renderer
7373 htmlR *html.Renderer
7474 replay *vt.Screen
75+ // replay256 receives the ANSI-256 renderer output. Colors are projected,
76+ // so only text and wide-cell structure are compared.
77+ replay256 *vt.Screen
7578 // mirror reproduces the vev daemon consumer: one core.Frame reused and
7679 // mutated in place between draws, so renderer shadows must not alias it.
7780 mirror core.Frame
@@ -432,7 +435,7 @@ func (o *oracleRun) render(step int) {
432435 o.emit("ansi discard")
433436 } else {
434437 prepared.Commit()
435- o.applyReplay(out, step)
438+ o.replay = o.applyReplay(o.replay, out, step, true)
436439 if !s.AcknowledgeDamage(capture.Generation) {
437440 o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed)
438441 }
@@ -442,6 +445,7 @@ func (o *oracleRun) render(step int) {
442445 o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err)
443446 }
444447 o.emit("ansi256 %q", other)
448+ o.replay256 = o.applyReplay(o.replay256, other, step, false)
445449
446450 snap := s.Snapshot()
447451 cur := snap.Cursor()
@@ -468,20 +472,21 @@ func (o *oracleRun) render(step int) {
468472 // applyReplay feeds committed ANSI output to an independent VT and requires
469473 // the visible grid to match the source. This semantic check does not depend on
470474 // the golden file.
471-func (o *oracleRun) applyReplay(out []byte, step int) {
475+func (o *oracleRun) applyReplay(replay *vt.Screen, out []byte, step int, styles bool) *vt.Screen {
472476 s := o.screen
473- if o.replay == nil || o.replay.Columns() != s.Columns() || o.replay.Rows() != s.Rows() {
474- o.replay = vt.NewScreen(s.Columns(), s.Rows())
477+ if replay == nil || replay.Columns() != s.Columns() || replay.Rows() != s.Rows() {
478+ replay = vt.NewScreen(s.Columns(), s.Rows())
475479 }
476- o.replay.Write(out)
480+ replay.Write(out)
477481 for y := range s.Rows() {
478482 for x := range s.Columns() {
479- want, got := s.Cell(x, y), o.replay.Cell(x, y)
480- if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) {
481- o.t.Fatalf("seed %d step %d: ANSI replay mismatch at (%d,%d): got %s want %s", o.sc.seed, step, x, y, formatCell(got), formatCell(want))
483+ want, got := s.Cell(x, y), replay.Cell(x, y)
484+ if want.Rune != got.Rune || want.Continuation != got.Continuation || (styles && !want.Style.Equal(got.Style)) {
485+ o.t.Fatalf("seed %d step %d: ANSI replay (styles=%t) mismatch at (%d,%d): got %s want %s", o.sc.seed, step, styles, x, y, formatCell(got), formatCell(want))
482486 }
483487 }
484488 }
489+ return replay
485490 }
486491
487492 func runOracleScenario(t *testing.T, idx int) string {
@@ -72,6 +72,9 @@ type oracleRun struct {
72 ansi256 *ansi.Renderer72 ansi256 *ansi.Renderer
73 htmlR *html.Renderer73 htmlR *html.Renderer
74 replay *vt.Screen74 replay *vt.Screen
75+ // replay256 receives the ANSI-256 renderer output. Colors are projected,
76+ // so only text and wide-cell structure are compared.
77+ replay256 *vt.Screen
75 // mirror reproduces the vev daemon consumer: one core.Frame reused and78 // mirror reproduces the vev daemon consumer: one core.Frame reused and
76 // mutated in place between draws, so renderer shadows must not alias it.79 // mutated in place between draws, so renderer shadows must not alias it.
77 mirror core.Frame80 mirror core.Frame
@@ -432,7 +435,7 @@ func (o *oracleRun) render(step int) {
432 o.emit("ansi discard")435 o.emit("ansi discard")
433 } else {436 } else {
434 prepared.Commit()437 prepared.Commit()
435- o.applyReplay(out, step)438+ o.replay = o.applyReplay(o.replay, out, step, true)
436 if !s.AcknowledgeDamage(capture.Generation) {439 if !s.AcknowledgeDamage(capture.Generation) {
437 o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed)440 o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed)
438 }441 }
@@ -442,6 +445,7 @@ func (o *oracleRun) render(step int) {
442 o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err)445 o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err)
443 }446 }
444 o.emit("ansi256 %q", other)447 o.emit("ansi256 %q", other)
448+ o.replay256 = o.applyReplay(o.replay256, other, step, false)
445 449
446 snap := s.Snapshot()450 snap := s.Snapshot()
447 cur := snap.Cursor()451 cur := snap.Cursor()
@@ -468,20 +472,21 @@ func (o *oracleRun) render(step int) {
468 // applyReplay feeds committed ANSI output to an independent VT and requires472 // applyReplay feeds committed ANSI output to an independent VT and requires
469 // the visible grid to match the source. This semantic check does not depend on473 // the visible grid to match the source. This semantic check does not depend on
470 // the golden file.474 // the golden file.
471-func (o *oracleRun) applyReplay(out []byte, step int) {475+func (o *oracleRun) applyReplay(replay *vt.Screen, out []byte, step int, styles bool) *vt.Screen {
472 s := o.screen476 s := o.screen
473- if o.replay == nil || o.replay.Columns() != s.Columns() || o.replay.Rows() != s.Rows() {477+ if replay == nil || replay.Columns() != s.Columns() || replay.Rows() != s.Rows() {
474- o.replay = vt.NewScreen(s.Columns(), s.Rows())478+ replay = vt.NewScreen(s.Columns(), s.Rows())
475 }479 }
476- o.replay.Write(out)480+ replay.Write(out)
477 for y := range s.Rows() {481 for y := range s.Rows() {
478 for x := range s.Columns() {482 for x := range s.Columns() {
479- want, got := s.Cell(x, y), o.replay.Cell(x, y)483+ want, got := s.Cell(x, y), replay.Cell(x, y)
480- if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) {484+ if want.Rune != got.Rune || want.Continuation != got.Continuation || (styles && !want.Style.Equal(got.Style)) {
481- o.t.Fatalf("seed %d step %d: ANSI replay mismatch at (%d,%d): got %s want %s", o.sc.seed, step, x, y, formatCell(got), formatCell(want))485+ o.t.Fatalf("seed %d step %d: ANSI replay (styles=%t) mismatch at (%d,%d): got %s want %s", o.sc.seed, step, styles, x, y, formatCell(got), formatCell(want))
482 }486 }
483 }487 }
484 }488 }
489+ return replay
485 }490 }
486 491
487 func runOracleScenario(t *testing.T, idx int) string {492 func runOracleScenario(t *testing.T, idx int) string {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close