@@ -0,0 +1,334 @@
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"
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.
15+func drawTestArea(width, height int) uv.Rectangle {
16+ return uv.Rect(0, 0, width, height)
19+// renderToBuffer mirrors what Chat.Draw does at the screen layer so tests
20+// can assert byte-equivalence with a fresh uv.NewStyledString render.
21+func renderToBuffer(t *testing.T, c *Chat, w, h int) string {
23+ scr := uv.NewScreenBuffer(w, h)
24+ c.Draw(scr, drawTestArea(w, h))
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.
33+func TestChatDrawCache_HitOnIdenticalRender(t *testing.T) {
38+ testMessageItem{id: "a", text: "alpha"},
39+ testMessageItem{id: "b", text: "beta"},
41+ u.updateLayoutAndSize()
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
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")
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.
59+func TestChatDrawCache_MissOnDifferentRender(t *testing.T) {
64+ testMessageItem{id: "a", text: "alpha"},
66+ u.updateLayoutAndSize()
69+ _ = renderToBuffer(t, u.chat, w, h)
70+ require.NotNil(t, u.chat.drawCache)
71+ firstCache := u.chat.drawCache
73+ // Replace the items so the rendered string differs.
75+ testMessageItem{id: "c", text: "gamma delta"},
77+ u.updateLayoutAndSize()
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")
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)
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.
94+func TestChatDrawCache_ReusedAcrossDifferentArea(t *testing.T) {
99+ testMessageItem{id: "a", text: "alpha"},
100+ testMessageItem{id: "b", text: "beta"},
102+ u.updateLayoutAndSize()
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
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))
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")
123+ want := freshStyledRender(u.chat.list.Render(), 40, 10)
124+ require.Equal(t, want, smallScr.Render())
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.
130+func TestChatDrawCache_BoundedSize(t *testing.T) {
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.
141+ testMessageItem{id: "x", text: "tick " + strconv.Itoa(i)},
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")
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)
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.
159+func 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))
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.
170+func TestChatDrawCache_InvalidatedByWidthMethodSwap(t *testing.T) {
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).
179+ testMessageItem{id: "a", text: "hi 👩\u200d💻 there"},
181+ u.updateLayoutAndSize()
185+ // First draw under GraphemeWidth.
186+ scrA := uv.ScreenBuffer{
187+ RenderBuffer: uv.NewRenderBuffer(w, h),
188+ Method: ansi.GraphemeWidth,
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
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,
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)
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.
210+type fixedMethodScreen struct {
212+ method uv.WidthMethod
215+func (s fixedMethodScreen) WidthMethod() uv.WidthMethod { return s.method }
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.
221+type customWidth struct{}
223+func (customWidth) StringWidth(s string) int {
224+ return ansi.GraphemeWidth.StringWidth(s)
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.
232+func TestChatDrawCache_FallbackOnNonAnsiMethod(t *testing.T) {
237+ testMessageItem{id: "a", text: "alpha"},
238+ testMessageItem{id: "b", text: "beta"},
240+ u.updateLayoutAndSize()
243+ scr := fixedMethodScreen{
244+ ScreenBuffer: uv.ScreenBuffer{
245+ RenderBuffer: uv.NewRenderBuffer(w, h),
246+ Method: ansi.GraphemeWidth,
248+ method: customWidth{},
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")
256+ u.chat.Draw(scr, uv.Rect(0, 0, w, h))
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())
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")
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.
281+func TestRenderedBounds_MatchesPrintStringTallyForZWJ(t *testing.T) {
284+ const sample = "x 👩\u200d💻 y"
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),
294+ uv.NewStyledString(sample).Draw(scr, uv.Rect(0, 0, 64, 4))
296+ // Tally width: rightmost non-empty cell + its width, per row.
299+ for y := 0; y < 4; y++ {
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{}) {
309+ rowHasContent = true
310+ if x+cell.Width > rowW {
311+ rowW = x + cell.Width
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)
332+// Compile-time guard: testMessageItem is reused from layout_test.go and
333+// satisfies chat.MessageItem there. We rely on the same shape here.
334+var _ chat.MessageItem = testMessageItem{}