package html import ( "errors" "math" "math/rand" "os" "testing" "unicode/utf8" "github.com/bnema/vev-vt/core" "github.com/stretchr/testify/require" ) func TestRendererSnapshotCommitsTransactionally(t *testing.T) { frame := core.NewFrame(3, 1) frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true}) require.NoError(t, err) require.True(t, prepared.Update().Snapshot) require.Len(t, prepared.Update().Rows, 1) require.Equal(t, []CellUpdate{ {Column: 0, Width: 1, Text: "A", Style: 0}, {Column: 1, Width: 2, Text: "界", Style: 0}, }, prepared.Update().Rows[0].Cells) require.NotContains(t, string(prepared.JSON()), `"kind":0,"rgb"`) _, err = renderer.Prepare(frame, nil, false, Cursor{}) require.ErrorIs(t, err, ErrPendingDraw) require.NoError(t, prepared.Commit()) require.ErrorIs(t, prepared.Commit(), ErrFinalizedDraw) unchanged, err := renderer.Prepare(frame, nil, false, Cursor{Visible: true, StyleSet: true}) require.NoError(t, err) require.False(t, unchanged.Update().Snapshot) require.Empty(t, unchanged.Update().Rows) require.NoError(t, unchanged.Abort()) require.ErrorIs(t, unchanged.Abort(), ErrFinalizedDraw) var nilDraw *PreparedDraw require.True(t, errors.Is(nilDraw.Commit(), ErrStaleDraw)) } func TestPreparedJSONMatchesBrowserFixture(t *testing.T) { frame := core.NewFrame(3, 1) frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, false, Cursor{Column: 1, Visible: true, Style: 3, StyleSet: true}) require.NoError(t, err) fixture, err := os.ReadFile("../internal/htmlharness/testdata/snapshot.json") require.NoError(t, err) require.JSONEq(t, string(fixture), string(prepared.JSON())) } func TestRendererAcceptsCellSourceWithoutFrameCopy(t *testing.T) { frame := core.NewFrame(2, 1) frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) snapshot := frame.Clone() renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(cellSourceFunc{ columns: snapshot.Columns(), rows: snapshot.Rows(), cell: snapshot.Cell, }, nil, false, Cursor{}) require.NoError(t, err) require.True(t, prepared.Update().Snapshot) require.Equal(t, []CellUpdate{{Column: 0, Width: 2, Text: "A ", Style: 0}}, prepared.Update().Rows[0].Cells) require.NoError(t, prepared.Commit()) _, err = renderer.Prepare(nil, nil, false, Cursor{}) require.ErrorContains(t, err, "nil cell source") } type cellSourceFunc struct { columns int rows int cell func(x, y int) core.Cell } func (s cellSourceFunc) Columns() int { return s.columns } func (s cellSourceFunc) Rows() int { return s.rows } func (s cellSourceFunc) Cell(x, y int) core.Cell { return s.cell(x, y) } func (s cellSourceFunc) At(x, y int) core.Cell { return s.cell(x, y) } func (s cellSourceFunc) Row(y int) []core.Cell { return nil } func (s cellSourceFunc) WriteRow(y, x int, c []core.Cell) int { return 0 } func TestRendererNormalizesWrapPendingCursor(t *testing.T) { frame := core.NewFrame(3, 2) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 3, Visible: true}) require.NoError(t, err) require.Equal(t, 2, prepared.Update().Cursor.Column) require.NoError(t, prepared.Commit()) _, err = renderer.Prepare(frame, nil, false, Cursor{Row: 0, Column: 4}) require.ErrorContains(t, err, "outside") } func TestRendererEnforcesExactGeneratedByteLimit(t *testing.T) { frame := core.NewFrame(1, 1) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, true, Cursor{}) require.NoError(t, err) size := len(prepared.JSON()) require.NoError(t, prepared.Commit()) tight, err := New(Options{Limits: Limits{MaxGeneratedBytes: size}}) require.NoError(t, err) accepted, err := tight.Prepare(frame, nil, true, Cursor{}) require.NoError(t, err) require.NoError(t, accepted.Commit()) strict, err := New(Options{Limits: Limits{MaxGeneratedBytes: size - 1}}) require.NoError(t, err) _, err = strict.Prepare(frame, nil, true, Cursor{}) require.ErrorIs(t, err, ErrLimitExceeded) require.ErrorContains(t, err, "generated update is") } func TestRendererMergesASCIICellsIntoStyledTextRuns(t *testing.T) { plain := core.DefaultStyle() bold := core.DefaultStyle() bold.Bold = true frame := core.NewFrame(12, 1) for x, r := range "ab" { frame.Set(x, 0, core.Cell{Rune: r, Style: plain}) } // x=2 stays blank (Rune 0) and joins the plain run as a space. frame.Set(3, 0, core.Cell{Rune: '界', Style: plain}) frame.Set(4, 0, core.Cell{Continuation: true, Style: plain}) frame.Set(5, 0, core.Cell{Rune: 'é', Style: plain}) frame.Set(6, 0, core.Cell{Rune: 'c', Style: bold}) frame.Set(7, 0, core.Cell{Rune: 'd', Style: bold}) frame.Set(8, 0, core.Cell{Rune: '~', Style: plain}) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.Equal(t, []CellUpdate{ {Column: 0, Width: 3, Text: "ab ", Style: 0}, {Column: 3, Width: 2, Text: "界", Style: 0}, {Column: 5, Width: 1, Text: "é", Style: 0}, {Column: 6, Width: 2, Text: "cd", Style: 1}, {Column: 8, Width: 4, Text: "~ ", Style: 0}, }, prepared.Update().Rows[0].Cells) require.NoError(t, prepared.Commit()) } func TestRendererIncrementalRunsSliceChangedRowsIndependently(t *testing.T) { plain := core.DefaultStyle() frame := core.NewFrame(4, 3) renderer, err := New(Options{}) require.NoError(t, err) first, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.NoError(t, first.Commit()) frame.Set(0, 0, core.Cell{Rune: 'a', Style: plain}) frame.Set(1, 2, core.Cell{Rune: '界', Style: plain}) frame.Set(2, 2, core.Cell{Continuation: true, Style: plain}) prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) rows := prepared.Update().Rows require.Len(t, rows, 2) require.Equal(t, RowUpdate{Row: 0, Cells: []CellUpdate{{Column: 0, Width: 4, Text: "a ", Style: 0}}}, rows[0]) require.Equal(t, RowUpdate{Row: 2, Cells: []CellUpdate{ {Column: 0, Width: 1, Text: " ", Style: 0}, {Column: 1, Width: 2, Text: "界", Style: 0}, {Column: 3, Width: 1, Text: " ", Style: 0}, }}, rows[1]) require.NoError(t, prepared.Commit()) } // TestRendererRunsCoverEveryRowExactly checks the run invariants on random // frames: entries are contiguous, widths sum to the frame width, and every // entry wider than one cell is printable ASCII with one byte per column. func TestRendererRunsCoverEveryRowExactly(t *testing.T) { rng := rand.New(rand.NewSource(1)) runes := []rune{0, 'a', 'b', ' ', '~', 'é', '✓', '界'} styles := []core.Style{core.DefaultStyle(), func() core.Style { s := core.DefaultStyle(); s.Bold = true; return s }()} for iteration := range 200 { width, height := 1+rng.Intn(20), 1+rng.Intn(4) frame := core.NewFrame(width, height) for y := range height { for x := 0; x < width; x++ { r := runes[rng.Intn(len(runes))] style := styles[rng.Intn(len(styles))] if core.RuneWidth(r) == 2 { if x+1 >= width { r = 'w' } else { frame.Set(x, y, core.Cell{Rune: r, Style: style}) frame.Set(x+1, y, core.Cell{Continuation: true, Style: style}) x++ continue } } frame.Set(x, y, core.Cell{Rune: r, Style: style}) } } renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err, "iteration %d", iteration) requireRunsMatchFrame(t, prepared.Update(), frame, iteration) // An incremental update after one changed cell decodes the same way, // through the renderer's reused frame page. require.NoError(t, prepared.Commit()) y := rng.Intn(height) frame.Set(0, y, core.Cell{Rune: 'Z', Style: styles[1]}) if width > 1 && frame.Cell(1, y).Continuation { frame.Set(1, y, core.Cell{Rune: ' '}) } prepared, err = renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err, "iteration %d", iteration) update := prepared.Update() require.False(t, update.Snapshot) require.Len(t, update.Rows, 1) requireRunsMatchFrame(t, update, frame, iteration) } } // requireRunsMatchFrame expands every entry back into cells and compares // each column's text, style and width with the source frame. func requireRunsMatchFrame(t *testing.T, update Update, frame core.Frame, iteration int) { t.Helper() for _, row := range update.Rows { column := 0 for _, cell := range row.Cells { require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row) source := frame.Cell(column, row.Row) require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column, row.Row) if r, _ := utf8.DecodeRuneInString(cell.Text); r >= 0x80 { require.Equal(t, string(source.Rune), cell.Text, "iteration %d (%d,%d)", iteration, column, row.Row) require.Equal(t, core.RuneWidth(source.Rune), cell.Width, "iteration %d (%d,%d)", iteration, column, row.Row) column += cell.Width continue } require.Len(t, cell.Text, cell.Width, "iteration %d text %q", iteration, cell.Text) for i := range len(cell.Text) { source := frame.Cell(column+i, row.Row) want := source.Rune if want == 0 { want = ' ' } require.Equal(t, string(want), cell.Text[i:i+1], "iteration %d (%d,%d)", iteration, column+i, row.Row) require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column+i, row.Row) } column += cell.Width } require.Equal(t, frame.Width, column, "iteration %d row %d", iteration, row.Row) } } func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) { renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}}) require.NoError(t, err) prepared, err := renderer.Prepare(core.NewFrame(2, 1), nil, true, Cursor{}) require.NoError(t, err) require.NoError(t, prepared.Commit()) } func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) { frame := core.NewFrame(2, 2) renderer, err := New(Options{}) require.NoError(t, err) first, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.NoError(t, first.Commit()) frame.Set(0, 0, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, false, Cursor{}) require.NoError(t, err) require.False(t, prepared.Update().Snapshot) require.NoError(t, prepared.Commit()) } func TestRendererFindsChangesOutsideDamage(t *testing.T) { frame := core.NewFrame(2, 2) renderer, err := New(Options{}) require.NoError(t, err) first, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.NoError(t, first.Commit()) frame.Set(1, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) prepared, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false, Cursor{}) require.NoError(t, err) require.False(t, prepared.Update().Snapshot) require.Equal(t, 1, prepared.Update().Rows[0].Row) require.NoError(t, prepared.Commit()) } func TestRendererSnapshotsScrollAndEmitsCursorOnlyChanges(t *testing.T) { frame := core.NewFrame(2, 2) renderer, err := New(Options{}) require.NoError(t, err) first, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.NoError(t, first.Commit()) cursorOnly, err := renderer.Prepare(frame, nil, false, Cursor{Row: 1, Column: 1, Visible: true, Style: 4, StyleSet: true}) require.NoError(t, err) require.Empty(t, cursorOnly.Update().Rows) require.NotNil(t, cursorOnly.Update().Rows) require.NotNil(t, cursorOnly.Update().Styles) require.Contains(t, string(cursorOnly.JSON()), `"rows":[]`) require.Contains(t, string(cursorOnly.JSON()), `"styles":[]`) require.Equal(t, 1, cursorOnly.Update().Cursor.Row) require.NoError(t, cursorOnly.Commit()) scroll, err := renderer.Prepare(frame, []core.Damage{{Kind: core.DamageScrollUp, X: 0, Y: 0, Width: 2, Height: 2, Count: 1}}, false, Cursor{}) require.NoError(t, err) require.True(t, scroll.Update().Snapshot) require.Len(t, scroll.Update().Rows, 2) require.NoError(t, scroll.Abort()) } func TestPreparedDrawReturnsOwnedUpdateAndJSON(t *testing.T) { frame := core.NewFrame(1, 1) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) update := prepared.Update() encoded := prepared.JSON() wantJSON := string(encoded) update.Rows[0].Cells[0].Text = "mutated" encoded[0] = 'X' require.Equal(t, " ", prepared.Update().Rows[0].Cells[0].Text) require.JSONEq(t, wantJSON, string(prepared.JSON())) require.Equal(t, byte('{'), prepared.JSON()[0]) } func TestCopiedPreparedDrawSharesFinalization(t *testing.T) { frame := core.NewFrame(1, 1) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) copied := *prepared require.NoError(t, copied.Commit()) require.ErrorIs(t, prepared.Abort(), ErrFinalizedDraw) } func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) { frame := core.NewFrame(1, 1) renderer, err := New(Options{}) require.NoError(t, err) prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) copied := *prepared renderer.Reset() require.ErrorIs(t, prepared.Commit(), ErrStaleDraw) require.ErrorIs(t, copied.Abort(), ErrStaleDraw) next, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.True(t, next.Update().Snapshot) require.NoError(t, next.Commit()) } func TestPreparedDrawIsIndependentOfLaterPrepares(t *testing.T) { frame := core.NewFrame(4, 3) frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) renderer, err := New(Options{}) require.NoError(t, err) first, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) firstUpdate, firstJSON := first.Update(), first.JSON() require.NoError(t, first.Commit()) // The caller mutates its frame in place; neither the earlier draw nor the // committed shadow may observe it. frame.Set(1, 1, core.Cell{Rune: 'B', Style: core.DefaultStyle()}) second, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.Equal(t, []int{1}, rowIndexes(second.Update())) secondUpdate, secondJSON := second.Update(), second.JSON() require.NoError(t, second.Commit()) frame.Set(2, 2, core.Cell{Rune: 'C', Style: core.DefaultStyle()}) third, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.Equal(t, []int{2}, rowIndexes(third.Update())) require.NoError(t, third.Commit()) require.Equal(t, firstUpdate, first.Update()) require.Equal(t, firstJSON, first.JSON()) require.Equal(t, secondUpdate, second.Update()) require.Equal(t, secondJSON, second.JSON()) } func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) { frame := core.NewFrame(3, 2) renderer, err := New(Options{}) require.NoError(t, err) first, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.NoError(t, first.Commit()) frame.Set(0, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) aborted, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) abortedJSON := aborted.JSON() require.NoError(t, aborted.Abort()) retry, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.False(t, retry.Update().Snapshot) require.Equal(t, []int{1}, rowIndexes(retry.Update())) require.Equal(t, abortedJSON, retry.JSON()) require.NoError(t, retry.Commit()) steady, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err) require.Empty(t, steady.Update().Rows) } func TestRendererKeepsEncoderScratchAt240x80(t *testing.T) { frames := [2]core.Frame{core.NewFrame(240, 80), core.NewFrame(240, 80)} for y := range 80 { for x := range 240 { frames[0].Set(x, y, core.Cell{Rune: 'a'}) frames[1].Set(x, y, core.Cell{Rune: 'b'}) } } renderer, err := New(Options{}) require.NoError(t, err) for step := range 2 { prepared, err := renderer.Prepare(frames[step], nil, false, Cursor{}) require.NoError(t, err) require.NoError(t, prepared.Commit()) } require.GreaterOrEqual(t, cap(renderer.rowStarts), 80, "row-start scratch was not retained") require.GreaterOrEqual(t, cap(renderer.ends), 240, "run-end scratch was not retained") } // TestRendererMatchesFreshRendererAcrossTransactions drives one renderer // (reused spare pages and scratch) through random edits with payloads and // equivalent styles, aborts, resets, stale commits and failed prepares. Most // steps commit and keep editing, so the shadow and spare pages alternate. // Every update must list exactly the rows that differ from a cloned committed // shadow (compared cell by cell with Cell.Equal) and encode the source frame. func TestRendererMatchesFreshRendererAcrossTransactions(t *testing.T) { rng := rand.New(rand.NewSource(7)) link := func(i int) core.CellPayload { payload, err := core.NewCellPayload("", "https://example.test/"+string(rune('a'+i))) require.NoError(t, err) return payload } bold := core.DefaultStyle() bold.Bold = true equivalent := bold equivalent.ForegroundRGB = core.RGB{R: 1} // inactive RGB: same style for seed := range 30 { width, height := 2+rng.Intn(10), 1+rng.Intn(5) frame := core.NewFrame(width, height) renderer, err := New(Options{}) require.NoError(t, err) var committed core.Frame var stale *PreparedDraw for step := range 60 { for range rng.Intn(4) { cell := core.Cell{Rune: rune('a' + rng.Intn(4))} switch rng.Intn(4) { case 0: cell.Style = bold case 1: cell.Style = equivalent case 2: cell.Payload = link(rng.Intn(3)) } frame.Set(rng.Intn(width), rng.Intn(height), cell) } if stale != nil && rng.Intn(3) == 0 { require.ErrorIs(t, stale.Commit(), ErrStaleDraw) stale = nil } if rng.Intn(10) == 0 { // A failed prepare must not lose or corrupt the spare page. _, err := renderer.Prepare(frame, nil, false, Cursor{Row: height}) require.Error(t, err) } want := []int{} for y := range height { for x := range width { if committed.Width == 0 || !frame.Cell(x, y).Equal(committed.Cell(x, y)) { want = append(want, y) break } } } got, err := renderer.Prepare(frame, nil, false, Cursor{}) require.NoError(t, err, "seed %d step %d", seed, step) update := got.Update() require.Equal(t, committed.Width == 0, update.Snapshot, "seed %d step %d", seed, step) require.Equal(t, want, rowIndexes(update), "seed %d step %d", seed, step) requireRunsMatchFrame(t, update, frame, step) switch k := rng.Intn(20); { case k == 0: renderer.Reset() committed = core.Frame{} stale = got case k < 4: require.NoError(t, got.Abort()) default: require.NoError(t, got.Commit()) committed = frame.Clone() } } } } func rowIndexes(update Update) []int { rows := make([]int, 0, len(update.Rows)) for _, row := range update.Rows { rows = append(rows, row.Row) } return rows }