chat_draw_cache_test.go

  1package model
  2
  3import (
  4	"strconv"
  5	"testing"
  6
  7	"github.com/charmbracelet/crush/internal/ui/chat"
  8	uv "github.com/charmbracelet/ultraviolet"
  9	"github.com/charmbracelet/x/ansi"
 10	"github.com/stretchr/testify/require"
 11)
 12
 13// drawTestArea is a fixed area used by the cache tests; it must be at least
 14// as large as the test items so we can compare every cell.
 15func drawTestArea(width, height int) uv.Rectangle {
 16	return uv.Rect(0, 0, width, height)
 17}
 18
 19// renderToBuffer mirrors what Chat.Draw does at the screen layer so tests
 20// can assert byte-equivalence with a fresh uv.NewStyledString render.
 21func renderToBuffer(t *testing.T, c *Chat, w, h int) string {
 22	t.Helper()
 23	scr := uv.NewScreenBuffer(w, h)
 24	c.Draw(scr, drawTestArea(w, h))
 25	return scr.Render()
 26}
 27
 28// TestChatDrawCache_HitOnIdenticalRender asserts that two consecutive Draws
 29// against the same list output reuse the same chatDrawCache rather than
 30// allocating a fresh one. Both the wrapper pointer and the embedded
 31// RenderBuffer pointer must be stable across the second Draw — that's what
 32// proves no re-decoding happened.
 33func TestChatDrawCache_HitOnIdenticalRender(t *testing.T) {
 34	t.Parallel()
 35
 36	u := newTestUI()
 37	u.chat.SetMessages(
 38		testMessageItem{id: "a", text: "alpha"},
 39		testMessageItem{id: "b", text: "beta"},
 40	)
 41	u.updateLayoutAndSize()
 42
 43	w, h := 80, 20
 44	_ = renderToBuffer(t, u.chat, w, h)
 45	require.NotNil(t, u.chat.drawCache, "first draw should populate cache")
 46	firstCache := u.chat.drawCache
 47	firstBuf := u.chat.drawCache.buf.RenderBuffer
 48
 49	_ = renderToBuffer(t, u.chat, w, h)
 50	require.Same(t, firstCache, u.chat.drawCache,
 51		"identical rendered string must reuse the same cache pointer")
 52	require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
 53		"identical rendered string must reuse the same RenderBuffer pointer")
 54}
 55
 56// TestChatDrawCache_MissOnDifferentRender asserts that when the list output
 57// changes, the cache is rebuilt and the new draw output matches a fresh
 58// uv.NewStyledString render byte-for-byte.
 59func TestChatDrawCache_MissOnDifferentRender(t *testing.T) {
 60	t.Parallel()
 61
 62	u := newTestUI()
 63	u.chat.SetMessages(
 64		testMessageItem{id: "a", text: "alpha"},
 65	)
 66	u.updateLayoutAndSize()
 67
 68	w, h := 80, 20
 69	_ = renderToBuffer(t, u.chat, w, h)
 70	require.NotNil(t, u.chat.drawCache)
 71	firstCache := u.chat.drawCache
 72
 73	// Replace the items so the rendered string differs.
 74	u.chat.SetMessages(
 75		testMessageItem{id: "c", text: "gamma delta"},
 76	)
 77	u.updateLayoutAndSize()
 78
 79	got := renderToBuffer(t, u.chat, w, h)
 80	require.NotSame(t, firstCache, u.chat.drawCache,
 81		"changed rendered string must replace the cache entry")
 82
 83	// Output must match a fresh uv.NewStyledString render of the current
 84	// list output for the same area. This is the byte-equivalence guard
 85	// that protects against blit drift from StyledString.Draw.
 86	want := freshStyledRender(u.chat.list.Render(), w, h)
 87	require.Equal(t, want, got)
 88}
 89
 90// TestChatDrawCache_ReusedAcrossDifferentArea asserts that the cached
 91// decoded lines do not depend on the screen / area: the same string drawn
 92// into a smaller area must hit the cache, and the output must match a fresh
 93// uv.NewStyledString render against the new area.
 94func TestChatDrawCache_ReusedAcrossDifferentArea(t *testing.T) {
 95	t.Parallel()
 96
 97	u := newTestUI()
 98	u.chat.SetMessages(
 99		testMessageItem{id: "a", text: "alpha"},
100		testMessageItem{id: "b", text: "beta"},
101	)
102	u.updateLayoutAndSize()
103
104	w, h := 80, 20
105	_ = renderToBuffer(t, u.chat, w, h)
106	require.NotNil(t, u.chat.drawCache)
107	firstCache := u.chat.drawCache
108	firstBuf := u.chat.drawCache.buf.RenderBuffer
109
110	// Draw the same content into a smaller buffer. The list output is
111	// width-sensitive in production (list.Render depends on width), so we
112	// hold the list width fixed via updateLayoutAndSize and only shrink
113	// the destination area. The cache key is the rendered string + width
114	// method, both unchanged here.
115	smallScr := uv.NewScreenBuffer(40, 10)
116	u.chat.Draw(smallScr, uv.Rect(0, 0, 40, 10))
117
118	require.Same(t, firstCache, u.chat.drawCache,
119		"smaller area must still hit the cache")
120	require.Same(t, firstBuf, u.chat.drawCache.buf.RenderBuffer,
121		"cached RenderBuffer must be reused across area changes")
122
123	want := freshStyledRender(u.chat.list.Render(), 40, 10)
124	require.Equal(t, want, smallScr.Render())
125}
126
127// TestChatDrawCache_BoundedSize asserts the draw cache holds at most one
128// entry. This is structural — any future change that introduces an LRU or
129// ring would have to update this test.
130func TestChatDrawCache_BoundedSize(t *testing.T) {
131	t.Parallel()
132
133	u := newTestUI()
134	w, h := 80, 20
135
136	// Cycle through several distinct list outputs and confirm that
137	// drawCache is still a single *chatDrawCache pointing at the most
138	// recent rendered string each time.
139	for i := range 5 {
140		u.chat.SetMessages(
141			testMessageItem{id: "x", text: "tick " + strconv.Itoa(i)},
142		)
143		u.updateLayoutAndSize()
144		_ = renderToBuffer(t, u.chat, w, h)
145		require.NotNil(t, u.chat.drawCache)
146		require.Equal(t, u.chat.list.Render(), u.chat.drawCache.rendered,
147			"cache.rendered must always match the most recent list output")
148	}
149
150	// Sanity: the type of drawCache is a single pointer, not a slice/map.
151	// This is enforced at compile time but checking it explicitly here
152	// keeps the bounded-size invariant visible to future readers.
153	require.IsType(t, (*chatDrawCache)(nil), u.chat.drawCache)
154}
155
156// freshStyledRender renders the same string through uv.NewStyledString into
157// a fresh ScreenBuffer of the given size. It's the reference implementation
158// used by the cache tests to detect blit drift.
159func freshStyledRender(s string, w, h int) string {
160	scr := uv.NewScreenBuffer(w, h)
161	uv.NewStyledString(s).Draw(scr, uv.Rect(0, 0, w, h))
162	return scr.Render()
163}
164
165// TestChatDrawCache_InvalidatedByWidthMethodSwap asserts the cache rebuilds
166// when the destination screen's width method changes between frames.
167// GraphemeWidth and WcWidth disagree on emoji ZWJ sequences and other
168// modern unicode, so the decoded buffer is only valid for the method it
169// was decoded with. Reusing across methods would corrupt cell widths.
170func TestChatDrawCache_InvalidatedByWidthMethodSwap(t *testing.T) {
171	t.Parallel()
172
173	u := newTestUI()
174	// Use content where the two methods disagree so any accidental
175	// reuse would surface as a different rendered cell layout. The
176	// woman-technologist ZWJ sequence is one cell under GraphemeWidth
177	// but two under WcWidth (the components combine).
178	u.chat.SetMessages(
179		testMessageItem{id: "a", text: "hi 👩\u200d💻 there"},
180	)
181	u.updateLayoutAndSize()
182
183	w, h := 80, 5
184
185	// First draw under GraphemeWidth.
186	scrA := uv.ScreenBuffer{
187		RenderBuffer: uv.NewRenderBuffer(w, h),
188		Method:       ansi.GraphemeWidth,
189	}
190	u.chat.Draw(scrA, uv.Rect(0, 0, w, h))
191	require.NotNil(t, u.chat.drawCache)
192	require.Equal(t, ansi.GraphemeWidth, u.chat.drawCache.method)
193	firstCache := u.chat.drawCache
194
195	// Same string, swap the method. The cache key includes method, so
196	// this must rebuild even though `rendered` is byte-identical.
197	scrB := uv.ScreenBuffer{
198		RenderBuffer: uv.NewRenderBuffer(w, h),
199		Method:       ansi.WcWidth,
200	}
201	u.chat.Draw(scrB, uv.Rect(0, 0, w, h))
202	require.NotSame(t, firstCache, u.chat.drawCache,
203		"width method change must invalidate the cache")
204	require.Equal(t, ansi.WcWidth, u.chat.drawCache.method)
205}
206
207// fixedMethodScreen wraps a uv.ScreenBuffer but reports a custom (non
208// ansi.Method) WidthMethod implementation. Chat.Draw is expected to
209// detect the type-assertion miss and fall through to the uncached path.
210type fixedMethodScreen struct {
211	uv.ScreenBuffer
212	method uv.WidthMethod
213}
214
215func (s fixedMethodScreen) WidthMethod() uv.WidthMethod { return s.method }
216
217// customWidth is a WidthMethod whose concrete type is intentionally NOT
218// ansi.Method, so the type assertion in Chat.Draw fails. The actual
219// width math just delegates to ansi.GraphemeWidth so the rendered
220// output is well-defined and comparable.
221type customWidth struct{}
222
223func (customWidth) StringWidth(s string) int {
224	return ansi.GraphemeWidth.StringWidth(s)
225}
226
227// TestChatDrawCache_FallbackOnNonAnsiMethod asserts that when the screen
228// reports a WidthMethod whose concrete type is not ansi.Method, Chat.Draw
229// skips the cache (it has no comparable key for an arbitrary interface)
230// and falls through to a direct uv.NewStyledString.Draw, producing the
231// same output as the upstream uncached path.
232func TestChatDrawCache_FallbackOnNonAnsiMethod(t *testing.T) {
233	t.Parallel()
234
235	u := newTestUI()
236	u.chat.SetMessages(
237		testMessageItem{id: "a", text: "alpha"},
238		testMessageItem{id: "b", text: "beta"},
239	)
240	u.updateLayoutAndSize()
241
242	w, h := 40, 10
243	scr := fixedMethodScreen{
244		ScreenBuffer: uv.ScreenBuffer{
245			RenderBuffer: uv.NewRenderBuffer(w, h),
246			Method:       ansi.GraphemeWidth,
247		},
248		method: customWidth{},
249	}
250	// Sanity: the wrapper actually returns a non-ansi.Method type so
251	// the fallback path is exercised end-to-end.
252	_, isAnsiMethod := scr.WidthMethod().(ansi.Method)
253	require.False(t, isAnsiMethod,
254		"test setup must hand Draw a non-ansi.Method WidthMethod")
255
256	u.chat.Draw(scr, uv.Rect(0, 0, w, h))
257
258	// The fallback path uses uv.NewStyledString(rendered).Draw — same
259	// thing freshStyledRender does — so output must match the
260	// reference render byte-for-byte.
261	want := freshStyledRender(u.chat.list.Render(), w, h)
262	require.Equal(t, want, scr.Render())
263
264	// And no cache was populated (or if it was, it wasn't used for
265	// this draw). Either way: drawing through the fallback must never
266	// leave a stale cache that future draws would reuse incorrectly.
267	require.Nil(t, u.chat.drawCache,
268		"fallback path must not populate the cache")
269}
270
271// TestRenderedBounds_MatchesPrintStringTallyForZWJ pins the invariant
272// that renderedBounds (used to size the cache buffer) returns the same
273// width as the cell tally StyledString.Draw will write into a buffer
274// using the same WidthMethod. We compare the computed width to a
275// reference render: lay the string into an oversized buffer, then count
276// non-empty cells per row. A divergent-width sample (woman-technologist
277// ZWJ sequence) is the canonical case that would expose a Bounds()
278// vs printString mismatch — under GraphemeWidth the sequence is one
279// cell, under WcWidth it's two — so the same string must produce two
280// different widths under the two methods, both matching the tally.
281func TestRenderedBounds_MatchesPrintStringTallyForZWJ(t *testing.T) {
282	t.Parallel()
283
284	const sample = "x 👩\u200d💻 y"
285
286	for _, m := range []ansi.Method{ansi.GraphemeWidth, ansi.WcWidth} {
287		w, h := renderedBounds(sample, m)
288		// Tally what printString would actually write.
289		// Use an oversized buffer so nothing gets clipped.
290		scr := uv.ScreenBuffer{
291			RenderBuffer: uv.NewRenderBuffer(64, 4),
292			Method:       m,
293		}
294		uv.NewStyledString(sample).Draw(scr, uv.Rect(0, 0, 64, 4))
295
296		// Tally width: rightmost non-empty cell + its width, per row.
297		gotW := 0
298		gotH := 0
299		for y := 0; y < 4; y++ {
300			rowW := 0
301			rowHasContent := false
302			for x := 0; x < 64; x++ {
303				cell := scr.CellAt(x, y)
304				if cell == nil || cell.IsZero() ||
305					cell.Content == " " && cell.Width == 1 &&
306						cell.Style == (uv.Style{}) {
307					continue
308				}
309				rowHasContent = true
310				if x+cell.Width > rowW {
311					rowW = x + cell.Width
312				}
313			}
314			if rowHasContent {
315				gotH = y + 1
316				if rowW > gotW {
317					gotW = rowW
318				}
319			}
320		}
321
322		require.Equal(t, gotW, w,
323			"renderedBounds width must match printString cell tally for method %v", m)
324		// Height is just '\n' count + 1 — no special-casing needed
325		// here, but pin it so future regressions on multi-line
326		// inputs don't sneak by.
327		require.Equal(t, gotH, h,
328			"renderedBounds height must match printString row tally for method %v", m)
329	}
330}
331
332// Compile-time guard: testMessageItem is reused from layout_test.go and
333// satisfies chat.MessageItem there. We rely on the same shape here.
334var _ chat.MessageItem = testMessageItem{}