1package ansi
2
3import (
4 "fmt"
5 "math/rand/v2"
6 "reflect"
7 "testing"
8
9 "github.com/bnema/vev-vt/core"
10)
11
12// genericSource hides the concrete core.Frame type so the renderer must use
13// the generic CellSource paths.
14type genericSource struct{ f Frame }
15
16func (g genericSource) Columns() int { return g.f.Columns() }
17func (g genericSource) Rows() int { return g.f.Rows() }
18func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) }
19
20var propertyStyles = []Style{
21 DefaultStyle(),
22 {Bold: true, Foreground: 1, Background: -1},
23 {Italic: true, Foreground: -1, Background: 4},
24 {Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: core.RGB{R: 9, G: 8, B: 7}},
25 {Inverse: true, Foreground: 2, Background: 3},
26}
27
28func propertyCell(r *rand.Rand) Cell {
29 c := Cell{Rune: rune('a' + r.IntN(6)), Style: propertyStyles[r.IntN(len(propertyStyles))]}
30 if r.IntN(12) == 0 {
31 if p, err := core.NewCellPayload("e\u0301", "https://example.test/"+string(rune('a'+r.IntN(3)))); err == nil {
32 c.Payload = p
33 }
34 }
35 return c
36}
37
38// propertyWrite writes one random cell at (x, y). Some writes are a wide
39// pair, so span widening runs against the reused shadow buffers. A write that
40// lands on half of an existing pair blanks the other half to keep the frame
41// valid.
42func propertyWrite(r *rand.Rand, frame *Frame, w, h int) {
43 x, y := r.IntN(w), r.IntN(h)
44 clearPair := func(x int) {
45 if x < 0 || x >= w {
46 return
47 }
48 cell := frame.Cell(x, y)
49 if cell.Continuation && x > 0 {
50 frame.Set(x-1, y, Cell{Rune: ' '})
51 frame.Set(x, y, Cell{Rune: ' '})
52 } else if core.RuneWidth(cell.Rune) == 2 && x+1 < w {
53 frame.Set(x, y, Cell{Rune: ' '})
54 frame.Set(x+1, y, Cell{Rune: ' '})
55 }
56 }
57 if r.IntN(4) == 0 && x+1 < w {
58 clearPair(x)
59 clearPair(x + 1)
60 style := propertyStyles[r.IntN(len(propertyStyles))]
61 frame.Set(x, y, Cell{Rune: '界', Style: style})
62 frame.Set(x+1, y, Cell{Continuation: true, Style: style})
63 return
64 }
65 clearPair(x)
66 frame.Set(x, y, propertyCell(r))
67}
68
69func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
70 d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
71 d.Width = 1 + r.IntN(w-d.X)
72 d.Height = 1 + r.IntN(h-d.Y)
73 if kind == DamageScrollUp || kind == DamageScrollDown {
74 d.X, d.Width = 0, w
75 d.Count = 1 + r.IntN(max(1, d.Height))
76 }
77 return d
78}
79
80func propertyDamage(r *rand.Rand, w, h int) []Damage {
81 switch r.IntN(6) {
82 case 0:
83 return nil
84 case 1:
85 return []Damage{FullRedraw()}
86 case 2:
87 return []Damage{propertyRect(r, w, h, DamageText)}
88 case 3:
89 return []Damage{propertyRect(r, w, h, DamageClear)}
90 case 4:
91 return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)}
92 default:
93 out := []Damage{propertyRect(r, w, h, DamageScrollDown)}
94 for range r.IntN(4) {
95 out = append(out, propertyRect(r, w, h, DamageText))
96 }
97 return out
98 }
99}
100
101// TestShadowMatchesCloneReference drives one renderer (reused buffers) and a
102// reference built from the public clone-based PlanDelta/Commit pair through
103// random edits, under-reported damage, scrolls, resets, resizes, discarded
104// draws and stale commits. The committed shadows must always agree.
105func TestShadowMatchesCloneReference(t *testing.T) {
106 for seed := range uint64(40) {
107 t.Run(fmt.Sprint(seed), func(t *testing.T) {
108 r := rand.New(rand.NewPCG(seed, 0xa11ce))
109 rend := New(Capabilities{})
110 var ref Frame
111 refHas := false
112 w, h := 3+r.IntN(10), 2+r.IntN(8)
113 frame := NewFrame(w, h)
114 var outstanding *PreparedDraw // last prepared draw, for stale commits
115
116 for step := range 150 {
117 // Mutate the caller's single frame in place.
118 switch r.IntN(8) {
119 case 0:
120 w, h = 3+r.IntN(10), 2+r.IntN(8)
121 frame = NewFrame(w, h)
122 case 1:
123 top := r.IntN(h)
124 bottom := top + r.IntN(h-top)
125 n := 1 + r.IntN(bottom-top+1)
126 if r.IntN(2) == 0 {
127 frame.ScrollUp(top, bottom, n)
128 } else {
129 frame.ScrollDown(top, bottom, n)
130 }
131 }
132 for range r.IntN(12) {
133 propertyWrite(r, &frame, w, h)
134 }
135 damage := propertyDamage(r, w, h)
136 reset := r.IntN(15) == 0
137
138 var source CellSource = frame
139 switch r.IntN(4) {
140 case 0:
141 source = &frame
142 case 1:
143 source = genericSource{frame}
144 }
145
146 // Reference plan against a clone-based committed shadow.
147 refReset := reset || !refHas
148 want, err := PlanDelta(source, damage, ref, refReset)
149 if err != nil {
150 t.Fatal(err)
151 }
152 prepared, err := rend.Prepare(source, damage, reset)
153 if err != nil {
154 t.Fatal(err)
155 }
156 if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) {
157 t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan)
158 }
159 if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) {
160 t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step)
161 }
162
163 // Caller keeps mutating after Prepare, before Commit.
164 snapshotMutation := r.IntN(3) == 0
165 if snapshotMutation {
166 propertyWrite(r, &frame, w, h)
167 }
168
169 switch k := r.IntN(10); {
170 case k < 6: // commit
171 prepared.Commit()
172 want.Commit(&ref)
173 refHas = refHas || want.frame.Width != 0
174 if want.frame.Width == 0 && !refHas {
175 // A no-op candidate with no shadow cannot occur: reset snapshots.
176 t.Fatalf("step %d: empty plan without committed state", step)
177 }
178 outstanding = nil
179 case k < 8: // discard
180 outstanding = &prepared
181 default: // stale commit of the previous discarded draw
182 if outstanding != nil {
183 if hasWork(outstanding.candidate.Plan) {
184 outstanding.Commit()
185 refHas = false
186 } else {
187 outstanding.Commit()
188 }
189 outstanding = nil
190 }
191 }
192 if r.IntN(25) == 0 {
193 rend.Reset()
194 refHas = false
195 ref = Frame{}
196 }
197
198 if rend.hasCommitted != refHas {
199 t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas)
200 }
201 if refHas {
202 if !framesEqual(rend.committed, ref) {
203 t.Fatalf("step %d: committed shadow differs from reference", step)
204 }
205 if err := rend.committed.CheckInvariants(); err != nil {
206 t.Fatalf("step %d: %v", step, err)
207 }
208 if rend.width != ref.Width || rend.height != ref.Height {
209 t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height)
210 }
211 }
212 }
213 })
214 }
215}
216
217func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 }
218
219func framesEqual(a, b Frame) bool {
220 if a.Width != b.Width || a.Height != b.Height {
221 return false
222 }
223 for y := range a.Height {
224 for x := range a.Width {
225 if !a.Cell(x, y).Equal(b.Cell(x, y)) {
226 return false
227 }
228 }
229 }
230 return true
231}