1package vt_test
2
3import (
4 "bufio"
5 "bytes"
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "flag"
10 "fmt"
11 "hash"
12 "math/rand/v2"
13 "os"
14 "path/filepath"
15 "strings"
16 "testing"
17
18 vt "github.com/bnema/vev-vt"
19 "github.com/bnema/vev-vt/ansi"
20 core "github.com/bnema/vev-vt/core"
21 "github.com/bnema/vev-vt/graphics"
22 "github.com/bnema/vev-vt/html"
23)
24
25// The differential oracle drives seeded terminal workloads through the public
26// pipeline (VT parsing, damage, history, codecs, ANSI and HTML renderers) and
27// hashes every observable result at checkpoints. Performance work must keep
28// the golden file byte-identical; behavior changes must regenerate it with
29// -oracle.update and justify the difference.
30var (
31 oracleUpdate = flag.Bool("oracle.update", false, "rewrite testdata/oracle/golden.txt")
32 oracleDump = flag.String("oracle.dump", "", "write full plain-text transcripts to this directory")
33)
34
35const (
36 oracleScenarios = 36
37 oracleSteps = 240
38 oracleCheckEvery = 20
39 oracleGolden = "testdata/oracle/golden.txt"
40)
41
42type oracleScenario struct {
43 seed uint64
44 cols int
45 rows int
46 history *vt.HistoryConfig
47 byteSplit bool
48 evictHook bool
49}
50
51func oracleScenarioFor(i int) oracleScenario {
52 r := rand.New(rand.NewPCG(uint64(i), 0x5eed))
53 sc := oracleScenario{seed: uint64(i), cols: 8 + r.IntN(110), rows: 3 + r.IntN(40)}
54 switch i % 4 {
55 case 1:
56 sc.history = &vt.HistoryConfig{MaxRows: 50 + r.IntN(400), ChunkRows: 1 + r.IntN(16)}
57 case 2:
58 sc.history = &vt.HistoryConfig{MaxBytes: uint64(64<<10 + r.IntN(512<<10)), ChunkRows: 1 + r.IntN(64)}
59 case 3:
60 sc.history = &vt.HistoryConfig{MaxRows: 2000, MaxBytes: 4 << 20}
61 }
62 sc.byteSplit = i%5 == 0
63 sc.evictHook = i%3 == 0
64 return sc
65}
66
67type oracleRun struct {
68 t *testing.T
69 sc oracleScenario
70 r *rand.Rand
71 screen *vt.Screen
72 ansiR *ansi.Renderer
73 ansi256 *ansi.Renderer
74 htmlR *html.Renderer
75 replay *vt.Screen
76 // replay256 receives the ANSI-256 renderer output. Colors are projected,
77 // so only text and wide-cell structure are compared.
78 replay256 *vt.Screen
79 // mirror reproduces the vev daemon consumer: one core.Frame reused and
80 // mutated in place between draws, so renderer shadows must not alias it.
81 mirror core.Frame
82 // hashes holds one digest per observable concern, so an intentional
83 // change to one layer (for example ANSI bytes) leaves the others'
84 // golden columns untouched and auditable.
85 hashes map[string]hash.Hash
86 dump *bufio.Writer
87 events []string
88}
89
90// oracleConcerns are the golden columns, in output order. "input" covers the
91// generated workload and VT callbacks; the others cover one layer each.
92var oracleConcerns = []string{"input", "vt", "graphics", "history", "ansi", "ansi256", "html"}
93
94func (o *oracleRun) emit(concern, format string, args ...any) {
95 line := fmt.Sprintf(format, args...)
96 h := o.hashes[concern]
97 if h == nil {
98 o.t.Fatalf("unknown oracle concern %q", concern)
99 }
100 h.Write([]byte(line))
101 h.Write([]byte{'\n'})
102 if o.dump != nil {
103 // bufio.Writer keeps the first error; the deferred Flush reports it.
104 _, _ = fmt.Fprintf(o.dump, "%s: %s\n", concern, line)
105 }
106}
107
108func (o *oracleRun) digest() string {
109 var b strings.Builder
110 for i, concern := range oracleConcerns {
111 if i > 0 {
112 b.WriteByte(' ')
113 }
114 b.WriteString(concern)
115 b.WriteByte('=')
116 b.WriteString(hex.EncodeToString(o.hashes[concern].Sum(nil))[:12])
117 }
118 return b.String()
119}
120
121func formatCell(c core.Cell) string {
122 return fmt.Sprintf("%q|%+v|%q|%q|%t", c.Rune, c.Style, c.Payload.Grapheme(), c.Payload.Hyperlink(), c.Continuation)
123}
124
125// formatRow is a compact exact digest of a row: every Cell field contributes.
126// Full text is only produced for dumps, where it is needed for diagnosis.
127func formatRow(row []core.Cell) string {
128 h := sha256.New()
129 var buf []byte
130 for _, c := range row {
131 buf = fmt.Appendf(buf[:0], "%d|%+v|%s|%s|%t;", c.Rune, c.Style, c.Payload.Grapheme(), c.Payload.Hyperlink(), c.Continuation)
132 h.Write(buf)
133 }
134 return hex.EncodeToString(h.Sum(nil)[:12])
135}
136
137func rowText(row []core.Cell) string {
138 var b strings.Builder
139 for _, c := range row {
140 if c.Continuation {
141 continue
142 }
143 if g := c.Payload.Grapheme(); g != "" {
144 b.WriteString(g)
145 } else if c.Rune != 0 {
146 b.WriteRune(c.Rune)
147 }
148 }
149 return b.String()
150}
151
152func (o *oracleRun) genChunk() []byte {
153 r := o.r
154 var b bytes.Buffer
155 for range 1 + r.IntN(6) {
156 switch op := r.IntN(100); {
157 case op < 22:
158 words := []string{"hello", "world", "vev", "ls -la", "❯", "foo/bar", "λx", "0123456789"}
159 for range 1 + r.IntN(8) {
160 b.WriteString(words[r.IntN(len(words))])
161 b.WriteByte(' ')
162 }
163 if r.IntN(2) == 0 {
164 b.WriteString("\r\n")
165 }
166 case op < 30:
167 uni := []string{"界", "🙂", "é", "e\u0301", "한국어", "👍🏽", "a\u200db", "ff"}
168 for range 1 + r.IntN(10) {
169 b.WriteString(uni[r.IntN(len(uni))])
170 }
171 case op < 42:
172 b.WriteString(randomSGR(r))
173 case op < 50:
174 fmt.Fprintf(&b, "\x1b[%d;%dH", 1+r.IntN(o.sc.rows+2), 1+r.IntN(o.sc.cols+2))
175 case op < 56:
176 seqs := []string{"\x1b[K", "\x1b[1K", "\x1b[2K", "\x1b[J", "\x1b[2J", "\x1b[3@", "\x1b[2P", "\x1b[L", "\x1b[2M", "\x1b[4X", "\x1b[S", "\x1b[2T", "\x1bM", "\x1bD", "\x1bE", "\t", "\b"}
177 b.WriteString(seqs[r.IntN(len(seqs))])
178 case op < 64:
179 for range 1 + r.IntN(3*o.sc.rows) {
180 fmt.Fprintf(&b, "line %d %s\r\n", r.IntN(1000), strings.Repeat("=", r.IntN(o.sc.cols+5)))
181 }
182 case op < 68:
183 top := 1 + r.IntN(o.sc.rows)
184 bottom := top + r.IntN(o.sc.rows-top+1)
185 fmt.Fprintf(&b, "\x1b[%d;%dr\x1b[%d;1H", top, bottom, bottom)
186 for range 1 + r.IntN(6) {
187 b.WriteString("region\n")
188 }
189 if r.IntN(2) == 0 {
190 b.WriteString("\x1b[r")
191 }
192 case op < 71:
193 if r.IntN(2) == 0 {
194 b.WriteString("\x1b[?1049h")
195 } else {
196 b.WriteString("\x1b[?1049l")
197 }
198 case op < 74:
199 fmt.Fprintf(&b, "\x1b]8;;https://example.invalid/%d\x1b\\link\x1b]8;;\x1b\\", r.IntN(9))
200 case op < 76:
201 fmt.Fprintf(&b, "\x1b]0;title %d\x07", r.IntN(99))
202 case op < 81:
203 b.WriteString(randomKitty(r))
204 case op < 84:
205 queries := []string{"\x1b[6n", "\x1b[c", "\x1b[?2004$p", "\x1b[5n"}
206 b.WriteString(queries[r.IntN(len(queries))])
207 case op < 86:
208 if r.IntN(2) == 0 {
209 b.WriteString("\x1b[?7l")
210 } else {
211 b.WriteString("\x1b[?7h")
212 }
213 case op < 91:
214 // High-cardinality truecolor burst.
215 for range 1 + r.IntN(40) {
216 fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm#", r.IntN(256), r.IntN(256), r.IntN(256))
217 }
218 b.WriteString("\x1b[0m")
219 case op < 94:
220 // Styled blanks and erases inherit the current background.
221 fmt.Fprintf(&b, "\x1b[48;5;%dm\x1b[K \x1b[0m", r.IntN(256))
222 default:
223 fmt.Fprintf(&b, "\r\x1b[K❯ abc\x1b[90m suggestion\x1b[39m\r\x1b[%dC", r.IntN(8))
224 }
225 }
226 return b.Bytes()
227}
228
229func randomSGR(r *rand.Rand) string {
230 parts := []string{"0", "1", "2", "3", "4", "4:3", "5", "7", "9", "22", "23", "24", "27", "39", "49", "59"}
231 switch r.IntN(6) {
232 case 0:
233 return fmt.Sprintf("\x1b[%dm", 30+r.IntN(8))
234 case 1:
235 return fmt.Sprintf("\x1b[38;5;%d;48;5;%dm", r.IntN(256), r.IntN(256))
236 case 2:
237 return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r.IntN(256), r.IntN(256), r.IntN(256))
238 case 3:
239 return fmt.Sprintf("\x1b[58;2;%d;%d;%d;4:%dm", r.IntN(256), r.IntN(256), r.IntN(256), r.IntN(6))
240 default:
241 return "\x1b[" + parts[r.IntN(len(parts))] + "m"
242 }
243}
244
245func randomKitty(r *rand.Rand) string {
246 switch r.IntN(4) {
247 case 0:
248 w, h := 1+r.IntN(4), 1+r.IntN(4)
249 pixels := make([]byte, w*h*4)
250 for i := range pixels {
251 pixels[i] = byte(r.IntN(256))
252 }
253 enc := encodeBase64(pixels, r.IntN(2) == 0)
254 return fmt.Sprintf("\x1b_Ga=T,i=%d,f=32,s=%d,v=%d,c=%d,r=%d,q=%d;%s\x1b\\", 1+r.IntN(4), w, h, 1+r.IntN(3), 1+r.IntN(2), r.IntN(3), enc)
255 case 1:
256 // Chunked transmission.
257 pixels := make([]byte, 4*4*4)
258 for i := range pixels {
259 pixels[i] = byte(r.IntN(256))
260 }
261 enc := encodeBase64(pixels, true)
262 mid := (len(enc) / 8) * 4
263 return fmt.Sprintf("\x1b_Ga=t,i=%d,f=32,s=4,v=4,m=1;%s\x1b\\\x1b_Gm=0;%s\x1b\\\x1b_Ga=p,i=%d\x1b\\", 5+r.IntN(3), enc[:mid], enc[mid:], 5+r.IntN(3))
264 case 2:
265 return fmt.Sprintf("\x1b_Ga=d,d=%s\x1b\\", []string{"a", "A", "i", "I"}[r.IntN(4)])
266 default:
267 // Invalid base64 must be rejected identically.
268 return "\x1b_Ga=T,f=32,s=1,v=1;!!!\x1b\\"
269 }
270}
271
272func encodeBase64(data []byte, padded bool) string {
273 const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
274 var b strings.Builder
275 for i := 0; i < len(data); i += 3 {
276 var chunk [3]byte
277 n := copy(chunk[:], data[i:])
278 v := uint(chunk[0])<<16 | uint(chunk[1])<<8 | uint(chunk[2])
279 for j := range 4 {
280 if j <= n {
281 b.WriteByte(alphabet[(v>>(18-6*j))&63])
282 } else if padded {
283 b.WriteByte('=')
284 }
285 }
286 }
287 return b.String()
288}
289
290func (o *oracleRun) write(data []byte) {
291 if o.sc.byteSplit {
292 for i := range data {
293 o.screen.Write(data[i : i+1 : i+1])
294 }
295 return
296 }
297 for len(data) > 0 {
298 n := 1 + o.r.IntN(len(data))
299 if o.r.IntN(3) != 0 {
300 n = len(data)
301 }
302 o.screen.Write(data[:n:n])
303 data = data[n:]
304 }
305}
306
307func (o *oracleRun) maybeResize(step int) {
308 if o.r.IntN(40) != 0 {
309 return
310 }
311 cols, rows := 8+o.r.IntN(110), 3+o.r.IntN(40)
312 o.emit("input", "step %d resize %dx%d", step, cols, rows)
313 o.screen.Resize(cols, rows)
314}
315
316func (o *oracleRun) checkScreen() {
317 s := o.screen
318 o.emit("vt", "screen %dx%d cursor=%d,%d vis=%t title=%q alt=%t", s.Columns(), s.Rows(), s.CursorRow(), s.CursorCol(), s.CursorVisible(), s.TerminalTitle(), s.AltScreenActive())
319 snap := s.Snapshot()
320 o.emit("vt", "modes %+v cursor %+v next=%d", snap.Modes(), snap.Cursor(), snap.NextRowID())
321 for y := range s.Rows() {
322 o.emit("vt", "row %d id=%d bound=%+v %s %q", y, s.RowID(y), snap.Bound(y), formatRow(s.RowCells(y)), rowText(s.RowCells(y)))
323 snapRow := snap.Row(y)
324 if formatRow(snapRow) != formatRow(s.RowCells(y)) {
325 o.t.Fatalf("seed %d: snapshot row %d differs from live row", o.sc.seed, y)
326 }
327 }
328 o.emit("vt", "bounds %+v", s.LineBounds())
329}
330
331func (o *oracleRun) checkGraphics() {
332 g := o.screen.GraphicsSnapshot()
333 if g == nil {
334 o.emit("graphics", "graphics nil")
335 return
336 }
337 o.emit("graphics", "graphics gen=%d usage=%+v", g.Generation(), g.Usage())
338 for _, a := range g.Assets() {
339 blob := a.Blob()
340 sum := sha256.Sum256(blob.Encoded)
341 o.emit("graphics", "asset fmt=%v %dx%d px=%d sha=%x", blob.Format, blob.Width, blob.Height, blob.DecodedPixels, sum[:8])
342 }
343 for _, p := range g.Placements() {
344 o.emit("graphics", "placement %+v", p.Spec())
345 }
346 o.emit("graphics", "fragments %+v", g.VisibleCellFragments(graphics.CellRect{Width: int64(o.screen.Columns()), Height: int64(o.screen.Rows())}))
347}
348
349func (o *oracleRun) checkHistory(deep bool) {
350 h := o.screen.History()
351 if h == nil {
352 return
353 }
354 o.emit("history", "history len=%d cells=%d bytes=%d next=%d stats=%+v", h.Len(), h.Cells(), h.LogicalBytes(), h.NextRowID(), h.CompressionStats())
355 if !deep {
356 return
357 }
358 snapView := h.SnapshotView()
359 tail, err := vt.MarshalHistoryTail(snapView)
360 if err != nil {
361 o.t.Fatalf("seed %d: MarshalHistoryTail: %v", o.sc.seed, err)
362 }
363 sum := sha256.Sum256(tail)
364 o.emit("history", "snapview len=%d chunks=%d tail=%x", snapView.Len(), snapView.ChunkCount(), sum)
365 var sealed [][]byte
366 for i := range snapView.ChunkCount() {
367 blob, err := vt.MarshalHistoryChunk(snapView.Chunk(i))
368 if err != nil {
369 o.t.Fatalf("seed %d: MarshalHistoryChunk: %v", o.sc.seed, err)
370 }
371 sum := sha256.Sum256(blob)
372 o.emit("history", "chunk %d %x", i, sum)
373 sealed = append(sealed, blob)
374 }
375 view := h.View()
376 var rows []string
377 var texts []string
378 if err := view.Range(func(row []core.Cell) bool {
379 rows = append(rows, formatRow(row))
380 texts = append(texts, rowText(row))
381 return true
382 }); err != nil {
383 o.t.Fatalf("seed %d: history Range: %v", o.sc.seed, err)
384 }
385 for i, row := range rows {
386 o.emit("history", "hrow %d id=%d bound=%+v %s %q", i, view.RowID(i), view.Bound(i), row, texts[i])
387 if got := formatRow(view.Row(i)); got != row {
388 o.t.Fatalf("seed %d: history Row(%d) differs from Range", o.sc.seed, i)
389 }
390 dst := make([]core.Cell, view.RowWidth(i))
391 view.CopyRow(i, dst)
392 if formatRow(dst) != row {
393 o.t.Fatalf("seed %d: history CopyRow(%d) differs from Range", o.sc.seed, i)
394 }
395 }
396 restored, err := vt.HistoryFromBlobs(h.Limits(), sealed, tail)
397 if err != nil {
398 o.t.Fatalf("seed %d: HistoryFromBlobs: %v", o.sc.seed, err)
399 }
400 rview := restored.View()
401 if rview.Len() != view.Len() || restored.NextRowID() != h.NextRowID() {
402 o.t.Fatalf("seed %d: restored history len=%d next=%d want len=%d next=%d", o.sc.seed, rview.Len(), restored.NextRowID(), view.Len(), h.NextRowID())
403 }
404 for i, row := range rows {
405 if formatRow(rview.Row(i)) != row || rview.RowID(i) != view.RowID(i) || rview.Bound(i) != view.Bound(i) {
406 o.t.Fatalf("seed %d: restored history row %d differs", o.sc.seed, i)
407 }
408 }
409 transcript, err := o.screen.RecoveryTranscriptSnapshot().Marshal()
410 if err != nil {
411 o.t.Fatalf("seed %d: recovery transcript: %v", o.sc.seed, err)
412 }
413 tsum := sha256.Sum256(transcript)
414 o.emit("history", "transcript %x", tsum)
415 recovered, err := vt.NewScreenWithRecoveryTranscript(o.screen.Columns(), o.screen.Rows(), h.Limits(), sealed, tail, transcript)
416 if err != nil {
417 o.t.Fatalf("seed %d: NewScreenWithRecoveryTranscript: %v", o.sc.seed, err)
418 }
419 rh := recovered.History()
420 o.emit("history", "recovered len=%d next=%d", rh.Len(), rh.NextRowID())
421 if rh.Len() > 0 {
422 rv := rh.View()
423 o.emit("history", "recovered last %s", formatRow(rv.Row(rv.Len()-1)))
424 }
425}
426
427func (o *oracleRun) render(step int) {
428 s := o.screen
429 damage := append([]core.Damage(nil), s.Damage()...)
430 capture := s.CaptureDamage()
431 if len(capture.Damage) != len(damage) {
432 o.t.Fatalf("seed %d: CaptureDamage length %d want %d", o.sc.seed, len(capture.Damage), len(damage))
433 }
434 o.emit("vt", "damage %+v", damage)
435
436 if o.mirror.Width != s.Columns() || o.mirror.Height != s.Rows() {
437 o.mirror = core.NewFrame(s.Columns(), s.Rows())
438 }
439 for y := range s.Rows() {
440 for x := range s.Columns() {
441 o.mirror.Set(x, y, s.Cell(x, y))
442 }
443 }
444 var source core.CellSource = o.mirror
445 switch o.sc.seed % 3 {
446 case 1:
447 source = &o.mirror
448 case 2:
449 source = s // generic CellSource path
450 }
451 prepared, err := o.ansiR.Prepare(source, damage, false)
452 if err != nil {
453 o.t.Fatalf("seed %d step %d: ansi Prepare: %v", o.sc.seed, step, err)
454 }
455 out := prepared.Bytes()
456 o.emit("ansi", "ansi %q", out)
457 if o.r.IntN(7) == 0 {
458 // Discarded draw: renderer must keep its committed shadow and damage
459 // must remain pending for the next draw.
460 o.emit("ansi", "ansi discard")
461 } else {
462 prepared.Commit()
463 o.replay = o.applyReplay(o.replay, out, step, true)
464 if !s.AcknowledgeDamage(capture.Generation) {
465 o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed)
466 }
467 }
468 other, err := o.ansi256.Draw(source, damage)
469 if err != nil {
470 o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err)
471 }
472 o.emit("ansi256", "ansi256 %q", other)
473 o.replay256 = o.applyReplay(o.replay256, other, step, false)
474
475 snap := s.Snapshot()
476 cur := snap.Cursor()
477 var htmlSource core.CellSource = o.mirror
478 if o.sc.seed%3 == 1 {
479 htmlSource = snap
480 }
481 hp, err := o.htmlR.Prepare(htmlSource, damage, false, html.Cursor{Row: cur.Row, Column: cur.Col, Visible: cur.Visible, Style: html.CursorStyle(cur.Style), StyleSet: cur.StyleSet})
482 if err != nil {
483 o.emit("html", "html err %v", err)
484 return
485 }
486 o.emit("html", "html %s", hp.JSON())
487 if o.r.IntN(6) == 0 {
488 if err := hp.Abort(); err != nil {
489 o.t.Fatalf("seed %d: html abort: %v", o.sc.seed, err)
490 }
491 o.emit("html", "html abort")
492 } else if err := hp.Commit(); err != nil {
493 o.t.Fatalf("seed %d: html commit: %v", o.sc.seed, err)
494 }
495}
496
497// applyReplay feeds committed ANSI output to an independent VT and requires
498// the visible grid to match the source. This semantic check does not depend on
499// the golden file.
500func (o *oracleRun) applyReplay(replay *vt.Screen, out []byte, step int, styles bool) *vt.Screen {
501 s := o.screen
502 if replay == nil || replay.Columns() != s.Columns() || replay.Rows() != s.Rows() {
503 replay = vt.NewScreen(s.Columns(), s.Rows())
504 }
505 replay.Write(out)
506 for y := range s.Rows() {
507 for x := range s.Columns() {
508 want, got := s.Cell(x, y), replay.Cell(x, y)
509 if want.Rune != got.Rune || want.Continuation != got.Continuation || (styles && !want.Style.Equal(got.Style)) {
510 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))
511 }
512 }
513 }
514 return replay
515}
516
517func runOracleScenario(t *testing.T, idx int) string {
518 sc := oracleScenarioFor(idx)
519 o := &oracleRun{t: t, sc: sc, r: rand.New(rand.NewPCG(sc.seed, 0xdecaf)), hashes: map[string]hash.Hash{}}
520 for _, concern := range oracleConcerns {
521 o.hashes[concern] = sha256.New()
522 }
523 if *oracleDump != "" {
524 if err := os.MkdirAll(*oracleDump, 0o755); err != nil {
525 t.Fatal(err)
526 }
527 f, err := os.Create(filepath.Join(*oracleDump, fmt.Sprintf("scenario-%02d.txt", idx)))
528 if err != nil {
529 t.Fatal(err)
530 }
531 o.dump = bufio.NewWriter(f)
532 defer func() {
533 if err := errors.Join(o.dump.Flush(), f.Close()); err != nil {
534 t.Errorf("write oracle dump: %v", err)
535 }
536 }()
537 }
538 if sc.history != nil {
539 o.screen = vt.NewScreenWithHistory(sc.cols, sc.rows, *sc.history)
540 } else {
541 o.screen = vt.NewScreen(sc.cols, sc.rows)
542 }
543 o.screen.OnResponse = func(b []byte) { o.events = append(o.events, fmt.Sprintf("response %q", b)) }
544 o.screen.OnBell = func() { o.events = append(o.events, "bell") }
545 if sc.evictHook {
546 // The callback owns each row: retain it, verify it is never rewritten by
547 // later evictions, then scribble on it to prove VT kept its own copy.
548 var retained []core.Cell
549 var retainedDigest string
550 o.screen.OnLineEvicted = func(row []core.Cell) {
551 if retained != nil && formatRow(retained) != retainedDigest {
552 t.Fatalf("seed %d: evicted row storage was reused after the callback", sc.seed)
553 }
554 digest := formatRow(row)
555 o.events = append(o.events, "evicted "+digest+" "+rowText(row))
556 if prev := retained; prev != nil {
557 for i := range prev {
558 prev[i] = core.Cell{Rune: 'X'}
559 }
560 }
561 retained, retainedDigest = row, digest
562 }
563 }
564 o.ansiR = ansi.New(ansi.Capabilities{SynchronizedOutput: idx%2 == 0})
565 o.ansi256 = ansi.NewWithColorProfile(ansi.Capabilities{}, ansi.ColorProfileANSI256)
566 var err error
567 o.htmlR, err = html.New(html.Options{})
568 if err != nil {
569 t.Fatal(err)
570 }
571 var historyConfig vt.HistoryConfig
572 if sc.history != nil {
573 historyConfig = *sc.history
574 }
575 o.emit("input", "scenario %d %dx%d history=%+v byteSplit=%t evictHook=%t", idx, sc.cols, sc.rows, historyConfig, sc.byteSplit, sc.evictHook)
576
577 var digests []string
578 for step := range oracleSteps {
579 o.maybeResize(step)
580 chunk := o.genChunk()
581 o.emit("input", "step %d write %q", step, chunk)
582 o.write(chunk)
583 for _, e := range o.events {
584 o.emit("input", "%s", e)
585 }
586 o.events = o.events[:0]
587 o.render(step)
588 if step%oracleCheckEvery == oracleCheckEvery-1 {
589 o.checkScreen()
590 o.checkGraphics()
591 o.checkHistory(step%(2*oracleCheckEvery) == 2*oracleCheckEvery-1)
592 digests = append(digests, fmt.Sprintf("%02d/%03d %s", idx, step, o.digest()))
593 }
594 }
595 return strings.Join(digests, "\n")
596}
597
598// TestDifferentialOracle compares every checkpoint against
599// testdata/oracle/golden.txt. Each golden line holds one digest per concern
600// (see oracleConcerns), so an intentional output change shows which layers
601// moved. Regenerate with -oracle.update only for a deliberate behavior change,
602// on a full run, and justify the changed columns; diagnose a divergence by
603// running -oracle.dump=DIR on both trees and diffing the scenario transcript.
604// Under -race only every sixth scenario runs: scenarios are single-goroutine,
605// so the detector adds cost without coverage.
606func TestDifferentialOracle(t *testing.T) {
607 if testing.Short() {
608 t.Skip("differential oracle skipped in -short mode")
609 }
610 got := make([]string, oracleScenarios)
611 t.Run("scenarios", func(t *testing.T) {
612 for i := range oracleScenarios {
613 t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
614 if raceEnabled && i%6 != 0 {
615 t.Skip("reduced oracle under -race")
616 }
617 t.Parallel()
618 got[i] = runOracleScenario(t, i)
619 })
620 }
621 })
622 if t.Failed() {
623 return
624 }
625 ran := 0
626 for _, digests := range got {
627 if digests != "" {
628 ran++
629 }
630 }
631 if *oracleUpdate {
632 if ran != oracleScenarios {
633 t.Fatalf("-oracle.update needs every scenario; only %d of %d ran (remove -run filters and -race)", ran, oracleScenarios)
634 }
635 if err := os.MkdirAll(filepath.Dir(oracleGolden), 0o755); err != nil {
636 t.Fatal(err)
637 }
638 if err := os.WriteFile(oracleGolden, []byte(strings.Join(got, "\n")+"\n"), 0o644); err != nil {
639 t.Fatal(err)
640 }
641 return
642 }
643 golden, err := os.ReadFile(oracleGolden)
644 if err != nil {
645 t.Fatalf("read golden (run with -oracle.update on a trusted baseline): %v", err)
646 }
647 want := map[string][]string{}
648 for _, line := range strings.Split(strings.TrimSpace(string(golden)), "\n") {
649 if len(line) < 3 {
650 t.Fatalf("malformed golden line %q (regenerate with -oracle.update on a trusted baseline)", line)
651 }
652 want[line[:2]] = append(want[line[:2]], line)
653 }
654 for i, digests := range got {
655 if digests == "" {
656 continue
657 }
658 compareOracleScenario(t, fmt.Sprintf("%02d", i), strings.Split(digests, "\n"), want[fmt.Sprintf("%02d", i)])
659 }
660}
661
662func compareOracleScenario(t *testing.T, id string, got, want []string) {
663 t.Helper()
664 for i := range min(len(got), len(want)) {
665 if got[i] == want[i] {
666 continue
667 }
668 gotFields, wantFields := strings.Fields(got[i]), strings.Fields(want[i])
669 var moved []string
670 for j := 1; j < min(len(gotFields), len(wantFields)); j++ {
671 if gotFields[j] != wantFields[j] {
672 moved = append(moved, strings.SplitN(gotFields[j], "=", 2)[0])
673 }
674 }
675 t.Fatalf("scenario %s diverged at checkpoint %s in %v\n got %s\n want %s\nrerun with -oracle.dump=DIR on both trees and diff the transcript", id, gotFields[0], moved, got[i], want[i])
676 }
677 if len(got) != len(want) {
678 t.Fatalf("scenario %s checkpoint count differs: got %d want %d", id, len(got), len(want))
679 }
680}