1package model
2
3import (
4 "strconv"
5 "testing"
6
7 "git.secluded.site/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{}