1package html
2
3import (
4 "errors"
5 "math"
6 "math/rand"
7 "os"
8 "testing"
9 "unicode/utf8"
10
11 "github.com/bnema/vev-vt/core"
12 "github.com/stretchr/testify/require"
13)
14
15func TestRendererSnapshotCommitsTransactionally(t *testing.T) {
16 frame := core.NewFrame(3, 1)
17 frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
18 frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
19 frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
20
21 renderer, err := New(Options{})
22 require.NoError(t, err)
23
24 prepared, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true})
25 require.NoError(t, err)
26 require.True(t, prepared.Update().Snapshot)
27 require.Len(t, prepared.Update().Rows, 1)
28 require.Equal(t, []CellUpdate{
29 {Column: 0, Width: 1, Text: "A", Style: 0},
30 {Column: 1, Width: 2, Text: "界", Style: 0},
31 }, prepared.Update().Rows[0].Cells)
32 require.NotContains(t, string(prepared.JSON()), `"kind":0,"rgb"`)
33
34 _, err = renderer.Prepare(frame, nil, false, Cursor{})
35 require.ErrorIs(t, err, ErrPendingDraw)
36 require.NoError(t, prepared.Commit())
37 require.ErrorIs(t, prepared.Commit(), ErrFinalizedDraw)
38
39 unchanged, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true})
40 require.NoError(t, err)
41 require.False(t, unchanged.Update().Snapshot)
42 require.Empty(t, unchanged.Update().Rows)
43 require.NoError(t, unchanged.Abort())
44 require.ErrorIs(t, unchanged.Abort(), ErrFinalizedDraw)
45
46 var nilDraw *PreparedDraw
47 require.True(t, errors.Is(nilDraw.Commit(), ErrStaleDraw))
48}
49
50func TestPreparedJSONMatchesBrowserFixture(t *testing.T) {
51 frame := core.NewFrame(3, 1)
52 frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
53 frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
54 frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
55 renderer, err := New(Options{})
56 require.NoError(t, err)
57 prepared, err := renderer.Prepare(frame, nil, false, Cursor{Column: 1, Visible: true, Style: 3, StyleSet: true})
58 require.NoError(t, err)
59 fixture, err := os.ReadFile("../internal/htmlharness/testdata/snapshot.json")
60 require.NoError(t, err)
61 require.JSONEq(t, string(fixture), string(prepared.JSON()))
62}
63
64func TestRendererAcceptsCellSourceWithoutFrameCopy(t *testing.T) {
65 frame := core.NewFrame(2, 1)
66 frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
67 snapshot := frame.Clone()
68
69 renderer, err := New(Options{})
70 require.NoError(t, err)
71 prepared, err := renderer.Prepare(cellSourceFunc{
72 columns: snapshot.Columns(),
73 rows: snapshot.Rows(),
74 cell: snapshot.Cell,
75 }, nil, false, Cursor{})
76 require.NoError(t, err)
77 require.True(t, prepared.Update().Snapshot)
78 require.Equal(t, []CellUpdate{{Column: 0, Width: 2, Text: "A ", Style: 0}}, prepared.Update().Rows[0].Cells)
79 require.NoError(t, prepared.Commit())
80
81 _, err = renderer.Prepare(nil, nil, false, Cursor{})
82 require.ErrorContains(t, err, "nil cell source")
83}
84
85type cellSourceFunc struct {
86 columns int
87 rows int
88 cell func(x, y int) core.Cell
89}
90
91func (s cellSourceFunc) Columns() int { return s.columns }
92func (s cellSourceFunc) Rows() int { return s.rows }
93func (s cellSourceFunc) Cell(x, y int) core.Cell { return s.cell(x, y) }
94func (s cellSourceFunc) At(x, y int) core.Cell { return s.cell(x, y) }
95func (s cellSourceFunc) Row(y int) []core.Cell { return nil }
96func (s cellSourceFunc) WriteRow(y, x int, c []core.Cell) int { return 0 }
97
98func TestRendererNormalizesWrapPendingCursor(t *testing.T) {
99 frame := core.NewFrame(3, 2)
100 renderer, err := New(Options{})
101 require.NoError(t, err)
102 prepared, err := renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 3, Visible: true})
103 require.NoError(t, err)
104 require.Equal(t, 2, prepared.Update().Cursor.Column)
105 require.NoError(t, prepared.Commit())
106
107 _, err = renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 4})
108 require.ErrorContains(t, err, "outside")
109}
110
111func TestRendererEnforcesExactGeneratedByteLimit(t *testing.T) {
112 frame := core.NewFrame(1, 1)
113 renderer, err := New(Options{})
114 require.NoError(t, err)
115 prepared, err := renderer.Prepare(frame, nil, true, Cursor{})
116 require.NoError(t, err)
117 size := len(prepared.JSON())
118 require.NoError(t, prepared.Commit())
119
120 tight, err := New(Options{Limits: Limits{MaxGeneratedBytes: size}})
121 require.NoError(t, err)
122 accepted, err := tight.Prepare(frame, nil, true, Cursor{})
123 require.NoError(t, err)
124 require.NoError(t, accepted.Commit())
125
126 strict, err := New(Options{Limits: Limits{MaxGeneratedBytes: size - 1}})
127 require.NoError(t, err)
128 _, err = strict.Prepare(frame, nil, true, Cursor{})
129 require.ErrorIs(t, err, ErrLimitExceeded)
130 require.ErrorContains(t, err, "generated update is")
131}
132
133func TestRendererMergesASCIICellsIntoStyledTextRuns(t *testing.T) {
134 plain := core.DefaultStyle()
135 bold := core.DefaultStyle()
136 bold.Bold = true
137 frame := core.NewFrame(12, 1)
138 for x, r := range "ab" {
139 frame.Set(x, 0, core.Cell{Rune: r, Style: plain})
140 }
141 // x=2 stays blank (Rune 0) and joins the plain run as a space.
142 frame.Set(3, 0, core.Cell{Rune: '界', Style: plain})
143 frame.Set(4, 0, core.Cell{Continuation: true, Style: plain})
144 frame.Set(5, 0, core.Cell{Rune: 'é', Style: plain})
145 frame.Set(6, 0, core.Cell{Rune: 'c', Style: bold})
146 frame.Set(7, 0, core.Cell{Rune: 'd', Style: bold})
147 frame.Set(8, 0, core.Cell{Rune: '~', Style: plain})
148
149 renderer, err := New(Options{})
150 require.NoError(t, err)
151 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
152 require.NoError(t, err)
153 require.Equal(t, []CellUpdate{
154 {Column: 0, Width: 3, Text: "ab ", Style: 0},
155 {Column: 3, Width: 2, Text: "界", Style: 0},
156 {Column: 5, Width: 1, Text: "é", Style: 0},
157 {Column: 6, Width: 2, Text: "cd", Style: 1},
158 {Column: 8, Width: 4, Text: "~ ", Style: 0},
159 }, prepared.Update().Rows[0].Cells)
160 require.NoError(t, prepared.Commit())
161}
162
163func TestRendererIncrementalRunsSliceChangedRowsIndependently(t *testing.T) {
164 plain := core.DefaultStyle()
165 frame := core.NewFrame(4, 3)
166 renderer, err := New(Options{})
167 require.NoError(t, err)
168 first, err := renderer.Prepare(frame, nil, false, Cursor{})
169 require.NoError(t, err)
170 require.NoError(t, first.Commit())
171
172 frame.Set(0, 0, core.Cell{Rune: 'a', Style: plain})
173 frame.Set(1, 2, core.Cell{Rune: '界', Style: plain})
174 frame.Set(2, 2, core.Cell{Continuation: true, Style: plain})
175 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
176 require.NoError(t, err)
177 rows := prepared.Update().Rows
178 require.Len(t, rows, 2)
179 require.Equal(t, RowUpdate{Row: 0, Cells: []CellUpdate{{Column: 0, Width: 4, Text: "a ", Style: 0}}}, rows[0])
180 require.Equal(t, RowUpdate{Row: 2, Cells: []CellUpdate{
181 {Column: 0, Width: 1, Text: " ", Style: 0},
182 {Column: 1, Width: 2, Text: "界", Style: 0},
183 {Column: 3, Width: 1, Text: " ", Style: 0},
184 }}, rows[1])
185 require.NoError(t, prepared.Commit())
186}
187
188// TestRendererRunsCoverEveryRowExactly checks the run invariants on random
189// frames: entries are contiguous, widths sum to the frame width, and every
190// entry wider than one cell is printable ASCII with one byte per column.
191func TestRendererRunsCoverEveryRowExactly(t *testing.T) {
192 rng := rand.New(rand.NewSource(1))
193 runes := []rune{0, 'a', 'b', ' ', '~', 'é', '✓', '界'}
194 styles := []core.Style{core.DefaultStyle(), func() core.Style { s := core.DefaultStyle(); s.Bold = true; return s }()}
195 for iteration := range 200 {
196 width, height := 1+rng.Intn(20), 1+rng.Intn(4)
197 frame := core.NewFrame(width, height)
198 for y := range height {
199 for x := 0; x < width; x++ {
200 r := runes[rng.Intn(len(runes))]
201 style := styles[rng.Intn(len(styles))]
202 if core.RuneWidth(r) == 2 {
203 if x+1 >= width {
204 r = 'w'
205 } else {
206 frame.Set(x, y, core.Cell{Rune: r, Style: style})
207 frame.Set(x+1, y, core.Cell{Continuation: true, Style: style})
208 x++
209 continue
210 }
211 }
212 frame.Set(x, y, core.Cell{Rune: r, Style: style})
213 }
214 }
215 renderer, err := New(Options{})
216 require.NoError(t, err)
217 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
218 require.NoError(t, err, "iteration %d", iteration)
219 requireRunsMatchFrame(t, prepared.Update(), frame, iteration)
220
221 // An incremental update after one changed cell decodes the same way,
222 // through the renderer's reused frame page.
223 require.NoError(t, prepared.Commit())
224 y := rng.Intn(height)
225 frame.Set(0, y, core.Cell{Rune: 'Z', Style: styles[1]})
226 if width > 1 && frame.Cell(1, y).Continuation {
227 frame.Set(1, y, core.Cell{Rune: ' '})
228 }
229 prepared, err = renderer.Prepare(frame, nil, false, Cursor{})
230 require.NoError(t, err, "iteration %d", iteration)
231 update := prepared.Update()
232 require.False(t, update.Snapshot)
233 require.Len(t, update.Rows, 1)
234 requireRunsMatchFrame(t, update, frame, iteration)
235 }
236}
237
238// requireRunsMatchFrame expands every entry back into cells and compares
239// each column's text, style and width with the source frame.
240func requireRunsMatchFrame(t *testing.T, update Update, frame core.Frame, iteration int) {
241 t.Helper()
242 for _, row := range update.Rows {
243 column := 0
244 for _, cell := range row.Cells {
245 require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)
246 source := frame.Cell(column, row.Row)
247 require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column, row.Row)
248 if r, _ := utf8.DecodeRuneInString(cell.Text); r >= 0x80 {
249 require.Equal(t, string(source.Rune), cell.Text, "iteration %d (%d,%d)", iteration, column, row.Row)
250 require.Equal(t, core.RuneWidth(source.Rune), cell.Width, "iteration %d (%d,%d)", iteration, column, row.Row)
251 column += cell.Width
252 continue
253 }
254 require.Len(t, cell.Text, cell.Width, "iteration %d text %q", iteration, cell.Text)
255 for i := range len(cell.Text) {
256 source := frame.Cell(column+i, row.Row)
257 want := source.Rune
258 if want == 0 {
259 want = ' '
260 }
261 require.Equal(t, string(want), cell.Text[i:i+1], "iteration %d (%d,%d)", iteration, column+i, row.Row)
262 require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column+i, row.Row)
263 }
264 column += cell.Width
265 }
266 require.Equal(t, frame.Width, column, "iteration %d row %d", iteration, row.Row)
267 }
268}
269
270func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) {
271 renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}})
272 require.NoError(t, err)
273 prepared, err := renderer.Prepare(core.NewFrame(2, 1), nil, true, Cursor{})
274 require.NoError(t, err)
275 require.NoError(t, prepared.Commit())
276}
277
278func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) {
279 frame := core.NewFrame(2, 2)
280 renderer, err := New(Options{})
281 require.NoError(t, err)
282 first, err := renderer.Prepare(frame, nil, false, Cursor{})
283 require.NoError(t, err)
284 require.NoError(t, first.Commit())
285
286 frame.Set(0, 0, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
287 prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, false, Cursor{})
288 require.NoError(t, err)
289 require.False(t, prepared.Update().Snapshot)
290 require.NoError(t, prepared.Commit())
291}
292
293func TestRendererFindsChangesOutsideDamage(t *testing.T) {
294 frame := core.NewFrame(2, 2)
295 renderer, err := New(Options{})
296 require.NoError(t, err)
297 first, err := renderer.Prepare(frame, nil, false, Cursor{})
298 require.NoError(t, err)
299 require.NoError(t, first.Commit())
300
301 frame.Set(1, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
302 prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false, Cursor{})
303 require.NoError(t, err)
304 require.False(t, prepared.Update().Snapshot)
305 require.Equal(t, 1, prepared.Update().Rows[0].Row)
306 require.NoError(t, prepared.Commit())
307}
308
309func TestRendererSnapshotsScrollAndEmitsCursorOnlyChanges(t *testing.T) {
310 frame := core.NewFrame(2, 2)
311 renderer, err := New(Options{})
312 require.NoError(t, err)
313 first, err := renderer.Prepare(frame, nil, false, Cursor{})
314 require.NoError(t, err)
315 require.NoError(t, first.Commit())
316
317 cursorOnly, err := renderer.Prepare(frame, nil, false, Cursor{Row: 1, Column: 1, Visible: true, Style: 4, StyleSet: true})
318 require.NoError(t, err)
319 require.Empty(t, cursorOnly.Update().Rows)
320 require.NotNil(t, cursorOnly.Update().Rows)
321 require.NotNil(t, cursorOnly.Update().Styles)
322 require.Contains(t, string(cursorOnly.JSON()), `"rows":[]`)
323 require.Contains(t, string(cursorOnly.JSON()), `"styles":[]`)
324 require.Equal(t, 1, cursorOnly.Update().Cursor.Row)
325 require.NoError(t, cursorOnly.Commit())
326
327 scroll, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageScrollUp, X: 0, Y: 0, Width: 2, Height: 2, Count: 1}}, false, Cursor{})
328 require.NoError(t, err)
329 require.True(t, scroll.Update().Snapshot)
330 require.Len(t, scroll.Update().Rows, 2)
331 require.NoError(t, scroll.Abort())
332}
333
334func TestPreparedDrawReturnsOwnedUpdateAndJSON(t *testing.T) {
335 frame := core.NewFrame(1, 1)
336 renderer, err := New(Options{})
337 require.NoError(t, err)
338 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
339 require.NoError(t, err)
340
341 update := prepared.Update()
342 encoded := prepared.JSON()
343 wantJSON := string(encoded)
344 update.Rows[0].Cells[0].Text = "mutated"
345 encoded[0] = 'X'
346 require.Equal(t, " ", prepared.Update().Rows[0].Cells[0].Text)
347 require.JSONEq(t, wantJSON, string(prepared.JSON()))
348 require.Equal(t, byte('{'), prepared.JSON()[0])
349}
350
351func TestCopiedPreparedDrawSharesFinalization(t *testing.T) {
352 frame := core.NewFrame(1, 1)
353 renderer, err := New(Options{})
354 require.NoError(t, err)
355 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
356 require.NoError(t, err)
357 copied := *prepared
358 require.NoError(t, copied.Commit())
359 require.ErrorIs(t, prepared.Abort(), ErrFinalizedDraw)
360}
361
362func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) {
363 frame := core.NewFrame(1, 1)
364 renderer, err := New(Options{})
365 require.NoError(t, err)
366 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
367 require.NoError(t, err)
368 copied := *prepared
369
370 renderer.Reset()
371 require.ErrorIs(t, prepared.Commit(), ErrStaleDraw)
372 require.ErrorIs(t, copied.Abort(), ErrStaleDraw)
373
374 next, err := renderer.Prepare(frame, nil, false, Cursor{})
375 require.NoError(t, err)
376 require.True(t, next.Update().Snapshot)
377 require.NoError(t, next.Commit())
378}
379
380func TestPreparedDrawIsIndependentOfLaterPrepares(t *testing.T) {
381 frame := core.NewFrame(4, 3)
382 frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
383 renderer, err := New(Options{})
384 require.NoError(t, err)
385
386 first, err := renderer.Prepare(frame, nil, false, Cursor{})
387 require.NoError(t, err)
388 firstUpdate, firstJSON := first.Update(), first.JSON()
389 require.NoError(t, first.Commit())
390
391 // The caller mutates its frame in place; neither the earlier draw nor the
392 // committed shadow may observe it.
393 frame.Set(1, 1, core.Cell{Rune: 'B', Style: core.DefaultStyle()})
394 second, err := renderer.Prepare(frame, nil, false, Cursor{})
395 require.NoError(t, err)
396 require.Equal(t, []int{1}, rowIndexes(second.Update()))
397 secondUpdate, secondJSON := second.Update(), second.JSON()
398 require.NoError(t, second.Commit())
399
400 frame.Set(2, 2, core.Cell{Rune: 'C', Style: core.DefaultStyle()})
401 third, err := renderer.Prepare(frame, nil, false, Cursor{})
402 require.NoError(t, err)
403 require.Equal(t, []int{2}, rowIndexes(third.Update()))
404 require.NoError(t, third.Commit())
405
406 require.Equal(t, firstUpdate, first.Update())
407 require.Equal(t, firstJSON, first.JSON())
408 require.Equal(t, secondUpdate, second.Update())
409 require.Equal(t, secondJSON, second.JSON())
410}
411
412func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) {
413 frame := core.NewFrame(3, 2)
414 renderer, err := New(Options{})
415 require.NoError(t, err)
416 first, err := renderer.Prepare(frame, nil, false, Cursor{})
417 require.NoError(t, err)
418 require.NoError(t, first.Commit())
419
420 frame.Set(0, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
421 aborted, err := renderer.Prepare(frame, nil, false, Cursor{})
422 require.NoError(t, err)
423 abortedJSON := aborted.JSON()
424 require.NoError(t, aborted.Abort())
425
426 retry, err := renderer.Prepare(frame, nil, false, Cursor{})
427 require.NoError(t, err)
428 require.False(t, retry.Update().Snapshot)
429 require.Equal(t, []int{1}, rowIndexes(retry.Update()))
430 require.Equal(t, abortedJSON, retry.JSON())
431 require.NoError(t, retry.Commit())
432
433 steady, err := renderer.Prepare(frame, nil, false, Cursor{})
434 require.NoError(t, err)
435 require.Empty(t, steady.Update().Rows)
436}
437
438func TestRendererKeepsEncoderScratchAt240x80(t *testing.T) {
439 frames := [2]core.Frame{core.NewFrame(240, 80), core.NewFrame(240, 80)}
440 for y := range 80 {
441 for x := range 240 {
442 frames[0].Set(x, y, core.Cell{Rune: 'a'})
443 frames[1].Set(x, y, core.Cell{Rune: 'b'})
444 }
445 }
446 renderer, err := New(Options{})
447 require.NoError(t, err)
448 for step := range 2 {
449 prepared, err := renderer.Prepare(frames[step], nil, false, Cursor{})
450 require.NoError(t, err)
451 require.NoError(t, prepared.Commit())
452 }
453 require.GreaterOrEqual(t, cap(renderer.rowStarts), 80, "row-start scratch was not retained")
454 require.GreaterOrEqual(t, cap(renderer.ends), 240, "run-end scratch was not retained")
455}
456
457// TestRendererMatchesFreshRendererAcrossTransactions drives one renderer
458// (reused spare pages and scratch) through random edits with payloads and
459// equivalent styles, aborts, resets, stale commits and failed prepares. Most
460// steps commit and keep editing, so the shadow and spare pages alternate.
461// Every update must list exactly the rows that differ from a cloned committed
462// shadow (compared cell by cell with Cell.Equal) and encode the source frame.
463func TestRendererMatchesFreshRendererAcrossTransactions(t *testing.T) {
464 rng := rand.New(rand.NewSource(7))
465 link := func(i int) core.CellPayload {
466 payload, err := core.NewCellPayload("", "https://example.test/"+string(rune('a'+i)))
467 require.NoError(t, err)
468 return payload
469 }
470 bold := core.DefaultStyle()
471 bold.Bold = true
472 equivalent := bold
473 equivalent.ForegroundRGB = core.RGB{R: 1} // inactive RGB: same style
474 for seed := range 30 {
475 width, height := 2+rng.Intn(10), 1+rng.Intn(5)
476 frame := core.NewFrame(width, height)
477 renderer, err := New(Options{})
478 require.NoError(t, err)
479 var committed core.Frame
480 var stale *PreparedDraw
481 for step := range 60 {
482 for range rng.Intn(4) {
483 cell := core.Cell{Rune: rune('a' + rng.Intn(4))}
484 switch rng.Intn(4) {
485 case 0:
486 cell.Style = bold
487 case 1:
488 cell.Style = equivalent
489 case 2:
490 cell.Payload = link(rng.Intn(3))
491 }
492 frame.Set(rng.Intn(width), rng.Intn(height), cell)
493 }
494 if stale != nil && rng.Intn(3) == 0 {
495 require.ErrorIs(t, stale.Commit(), ErrStaleDraw)
496 stale = nil
497 }
498 if rng.Intn(10) == 0 {
499 // A failed prepare must not lose or corrupt the spare page.
500 _, err := renderer.Prepare(frame, nil, false, Cursor{Row: height})
501 require.Error(t, err)
502 }
503
504 want := []int{}
505 for y := range height {
506 for x := range width {
507 if committed.Width == 0 || !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
508 want = append(want, y)
509 break
510 }
511 }
512 }
513 got, err := renderer.Prepare(frame, nil, false, Cursor{})
514 require.NoError(t, err, "seed %d step %d", seed, step)
515 update := got.Update()
516 require.Equal(t, committed.Width == 0, update.Snapshot, "seed %d step %d", seed, step)
517 require.Equal(t, want, rowIndexes(update), "seed %d step %d", seed, step)
518 requireRunsMatchFrame(t, update, frame, step)
519
520 switch k := rng.Intn(20); {
521 case k == 0:
522 renderer.Reset()
523 committed = core.Frame{}
524 stale = got
525 case k < 4:
526 require.NoError(t, got.Abort())
527 default:
528 require.NoError(t, got.Commit())
529 committed = frame.Clone()
530 }
531 }
532 }
533}
534
535func rowIndexes(update Update) []int {
536 rows := make([]int, 0, len(update.Rows))
537 for _, row := range update.Rows {
538 rows = append(rows, row.Row)
539 }
540 return rows
541}