Files
crush/internal/ui/model/chat_draw_cache_test.go
Christian RochaandCharm Crush 65f30a1343 perf(chat): skip re-parsing the rendered chat when nothing has changed
Each frame the chat list is turned into a string with ANSI color codes
that we then have to parse back into cells before drawing. When the
chat list output is the same as the previous frame, we now reuse the
cells we already produced and skip the parsing step.

Co-Authored-By: Charm Crush <crush@charm.land>
2026-05-12 16:41:14 -04:00

335 lines
12 KiB
Go

package model
import (
"strconv"
"testing"
"github.com/charmbracelet/crush/internal/ui/chat"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/stretchr/testify/require"
)
// drawTestArea is a fixed area used by the cache tests; it must be at least
// as large as the test items so we can compare every cell.
func drawTestArea(width, height int) uv.Rectangle {
return uv.Rect(0, 0, width, height)
}
// renderToBuffer mirrors what Chat.Draw does at the screen layer so tests
// can assert byte-equivalence with a fresh uv.NewStyledString render.
func renderToBuffer(t *testing.T, c *Chat, w, h int) string {
t.Helper()
scr := uv.NewScreenBuffer(w, h)
c.Draw(scr, drawTestArea(w, h))
return scr.Render()
}
// TestChatDrawCache_HitOnIdenticalRender asserts that two consecutive Draws
// against the same list output reuse the same chatDrawCache rather than
// allocating a fresh one. Both the wrapper pointer and the embedded
// RenderBuffer pointer must be stable across the second Draw — that's what
// proves no re-decoding happened.
func TestChatDrawCache_HitOnIdenticalRender(t *testing.T) {
t.Parallel()
u := newTestUI()
u.chat.SetMessages(
testMessageItem{id: "a", text: "alpha"},
testMessageItem{id: "b", text: "beta"},
)
u.updateLayoutAndSize()
w, h := 80, 20
_ = renderToBuffer(t, u.chat, w, h)
require.NotNil(t, u.chat.drawCache, "first draw should populate cache")
firstCache := u.chat.drawCache
firstBuf := u.chat.drawCache.buf.RenderBuffer
_ = renderToBuffer(t, u.chat, w, h)
require.Same(t, firstCache, u.chat.drawCache,
"identical rendered string must reuse the same cache pointer")
require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
"identical rendered string must reuse the same RenderBuffer pointer")
}
// TestChatDrawCache_MissOnDifferentRender asserts that when the list output
// changes, the cache is rebuilt and the new draw output matches a fresh
// uv.NewStyledString render byte-for-byte.
func TestChatDrawCache_MissOnDifferentRender(t *testing.T) {
t.Parallel()
u := newTestUI()
u.chat.SetMessages(
testMessageItem{id: "a", text: "alpha"},
)
u.updateLayoutAndSize()
w, h := 80, 20
_ = renderToBuffer(t, u.chat, w, h)
require.NotNil(t, u.chat.drawCache)
firstCache := u.chat.drawCache
// Replace the items so the rendered string differs.
u.chat.SetMessages(
testMessageItem{id: "c", text: "gamma delta"},
)
u.updateLayoutAndSize()
got := renderToBuffer(t, u.chat, w, h)
require.NotSame(t, firstCache, u.chat.drawCache,
"changed rendered string must replace the cache entry")
// Output must match a fresh uv.NewStyledString render of the current
// list output for the same area. This is the byte-equivalence guard
// that protects against blit drift from StyledString.Draw.
want := freshStyledRender(u.chat.list.Render(), w, h)
require.Equal(t, want, got)
}
// TestChatDrawCache_ReusedAcrossDifferentArea asserts that the cached
// decoded lines do not depend on the screen / area: the same string drawn
// into a smaller area must hit the cache, and the output must match a fresh
// uv.NewStyledString render against the new area.
func TestChatDrawCache_ReusedAcrossDifferentArea(t *testing.T) {
t.Parallel()
u := newTestUI()
u.chat.SetMessages(
testMessageItem{id: "a", text: "alpha"},
testMessageItem{id: "b", text: "beta"},
)
u.updateLayoutAndSize()
w, h := 80, 20
_ = renderToBuffer(t, u.chat, w, h)
require.NotNil(t, u.chat.drawCache)
firstCache := u.chat.drawCache
firstBuf := u.chat.drawCache.buf.RenderBuffer
// Draw the same content into a smaller buffer. The list output is
// width-sensitive in production (list.Render depends on width), so we
// hold the list width fixed via updateLayoutAndSize and only shrink
// the destination area. The cache key is the rendered string + width
// method, both unchanged here.
smallScr := uv.NewScreenBuffer(40, 10)
u.chat.Draw(smallScr, uv.Rect(0, 0, 40, 10))
require.Same(t, firstCache, u.chat.drawCache,
"smaller area must still hit the cache")
require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
"cached RenderBuffer must be reused across area changes")
want := freshStyledRender(u.chat.list.Render(), 40, 10)
require.Equal(t, want, smallScr.Render())
}
// TestChatDrawCache_BoundedSize asserts the draw cache holds at most one
// entry. This is structural — any future change that introduces an LRU or
// ring would have to update this test.
func TestChatDrawCache_BoundedSize(t *testing.T) {
t.Parallel()
u := newTestUI()
w, h := 80, 20
// Cycle through several distinct list outputs and confirm that
// drawCache is still a single *chatDrawCache pointing at the most
// recent rendered string each time.
for i := range 5 {
u.chat.SetMessages(
testMessageItem{id: "x", text: "tick " + strconv.Itoa(i)},
)
u.updateLayoutAndSize()
_ = renderToBuffer(t, u.chat, w, h)
require.NotNil(t, u.chat.drawCache)
require.Equal(t, u.chat.list.Render(), u.chat.drawCache.rendered,
"cache.rendered must always match the most recent list output")
}
// Sanity: the type of drawCache is a single pointer, not a slice/map.
// This is enforced at compile time but checking it explicitly here
// keeps the bounded-size invariant visible to future readers.
require.IsType(t, (*chatDrawCache)(nil), u.chat.drawCache)
}
// freshStyledRender renders the same string through uv.NewStyledString into
// a fresh ScreenBuffer of the given size. It's the reference implementation
// used by the cache tests to detect blit drift.
func freshStyledRender(s string, w, h int) string {
scr := uv.NewScreenBuffer(w, h)
uv.NewStyledString(s).Draw(scr, uv.Rect(0, 0, w, h))
return scr.Render()
}
// TestChatDrawCache_InvalidatedByWidthMethodSwap asserts the cache rebuilds
// when the destination screen's width method changes between frames.
// GraphemeWidth and WcWidth disagree on emoji ZWJ sequences and other
// modern unicode, so the decoded buffer is only valid for the method it
// was decoded with. Reusing across methods would corrupt cell widths.
func TestChatDrawCache_InvalidatedByWidthMethodSwap(t *testing.T) {
t.Parallel()
u := newTestUI()
// Use content where the two methods disagree so any accidental
// reuse would surface as a different rendered cell layout. The
// woman-technologist ZWJ sequence is one cell under GraphemeWidth
// but two under WcWidth (the components combine).
u.chat.SetMessages(
testMessageItem{id: "a", text: "hi 👩\u200d💻 there"},
)
u.updateLayoutAndSize()
w, h := 80, 5
// First draw under GraphemeWidth.
scrA := uv.ScreenBuffer{
RenderBuffer: uv.NewRenderBuffer(w, h),
Method: ansi.GraphemeWidth,
}
u.chat.Draw(scrA, uv.Rect(0, 0, w, h))
require.NotNil(t, u.chat.drawCache)
require.Equal(t, ansi.GraphemeWidth, u.chat.drawCache.method)
firstCache := u.chat.drawCache
// Same string, swap the method. The cache key includes method, so
// this must rebuild even though `rendered` is byte-identical.
scrB := uv.ScreenBuffer{
RenderBuffer: uv.NewRenderBuffer(w, h),
Method: ansi.WcWidth,
}
u.chat.Draw(scrB, uv.Rect(0, 0, w, h))
require.NotSame(t, firstCache, u.chat.drawCache,
"width method change must invalidate the cache")
require.Equal(t, ansi.WcWidth, u.chat.drawCache.method)
}
// fixedMethodScreen wraps a uv.ScreenBuffer but reports a custom (non
// ansi.Method) WidthMethod implementation. Chat.Draw is expected to
// detect the type-assertion miss and fall through to the uncached path.
type fixedMethodScreen struct {
uv.ScreenBuffer
method uv.WidthMethod
}
func (s fixedMethodScreen) WidthMethod() uv.WidthMethod { return s.method }
// customWidth is a WidthMethod whose concrete type is intentionally NOT
// ansi.Method, so the type assertion in Chat.Draw fails. The actual
// width math just delegates to ansi.GraphemeWidth so the rendered
// output is well-defined and comparable.
type customWidth struct{}
func (customWidth) StringWidth(s string) int {
return ansi.GraphemeWidth.StringWidth(s)
}
// TestChatDrawCache_FallbackOnNonAnsiMethod asserts that when the screen
// reports a WidthMethod whose concrete type is not ansi.Method, Chat.Draw
// skips the cache (it has no comparable key for an arbitrary interface)
// and falls through to a direct uv.NewStyledString.Draw, producing the
// same output as the upstream uncached path.
func TestChatDrawCache_FallbackOnNonAnsiMethod(t *testing.T) {
t.Parallel()
u := newTestUI()
u.chat.SetMessages(
testMessageItem{id: "a", text: "alpha"},
testMessageItem{id: "b", text: "beta"},
)
u.updateLayoutAndSize()
w, h := 40, 10
scr := fixedMethodScreen{
ScreenBuffer: uv.ScreenBuffer{
RenderBuffer: uv.NewRenderBuffer(w, h),
Method: ansi.GraphemeWidth,
},
method: customWidth{},
}
// Sanity: the wrapper actually returns a non-ansi.Method type so
// the fallback path is exercised end-to-end.
_, isAnsiMethod := scr.WidthMethod().(ansi.Method)
require.False(t, isAnsiMethod,
"test setup must hand Draw a non-ansi.Method WidthMethod")
u.chat.Draw(scr, uv.Rect(0, 0, w, h))
// The fallback path uses uv.NewStyledString(rendered).Draw — same
// thing freshStyledRender does — so output must match the
// reference render byte-for-byte.
want := freshStyledRender(u.chat.list.Render(), w, h)
require.Equal(t, want, scr.Render())
// And no cache was populated (or if it was, it wasn't used for
// this draw). Either way: drawing through the fallback must never
// leave a stale cache that future draws would reuse incorrectly.
require.Nil(t, u.chat.drawCache,
"fallback path must not populate the cache")
}
// TestRenderedBounds_MatchesPrintStringTallyForZWJ pins the invariant
// that renderedBounds (used to size the cache buffer) returns the same
// width as the cell tally StyledString.Draw will write into a buffer
// using the same WidthMethod. We compare the computed width to a
// reference render: lay the string into an oversized buffer, then count
// non-empty cells per row. A divergent-width sample (woman-technologist
// ZWJ sequence) is the canonical case that would expose a Bounds()
// vs printString mismatch — under GraphemeWidth the sequence is one
// cell, under WcWidth it's two — so the same string must produce two
// different widths under the two methods, both matching the tally.
func TestRenderedBounds_MatchesPrintStringTallyForZWJ(t *testing.T) {
t.Parallel()
const sample = "x 👩\u200d💻 y"
for _, m := range []ansi.Method{ansi.GraphemeWidth, ansi.WcWidth} {
w, h := renderedBounds(sample, m)
// Tally what printString would actually write.
// Use an oversized buffer so nothing gets clipped.
scr := uv.ScreenBuffer{
RenderBuffer: uv.NewRenderBuffer(64, 4),
Method: m,
}
uv.NewStyledString(sample).Draw(scr, uv.Rect(0, 0, 64, 4))
// Tally width: rightmost non-empty cell + its width, per row.
gotW := 0
gotH := 0
for y := 0; y < 4; y++ {
rowW := 0
rowHasContent := false
for x := 0; x < 64; x++ {
cell := scr.CellAt(x, y)
if cell == nil || cell.IsZero() ||
cell.Content == " " && cell.Width == 1 &&
cell.Style == (uv.Style{}) {
continue
}
rowHasContent = true
if x+cell.Width > rowW {
rowW = x + cell.Width
}
}
if rowHasContent {
gotH = y + 1
if rowW > gotW {
gotW = rowW
}
}
}
require.Equal(t, gotW, w,
"renderedBounds width must match printString cell tally for method %v", m)
// Height is just '\n' count + 1 — no special-casing needed
// here, but pin it so future regressions on multi-line
// inputs don't sneak by.
require.Equal(t, gotH, h,
"renderedBounds height must match printString row tally for method %v", m)
}
}
// Compile-time guard: testMessageItem is reused from layout_test.go and
// satisfies chat.MessageItem there. We rely on the same shape here.
var _ chat.MessageItem = testMessageItem{}