Download internal/ui/model/chat_draw_cache_test.go from SaylorTwift/crush: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/SaylorTwift/crush/resolve/main/internal/ui/model/chat_draw_cache_test.go
- Command line
-
hf download hf://SaylorTwift/crush/internal/ui/model/chat_draw_cache_test.go
-
curl -L -o chat_draw_cache_test.go https://huggingface.co/SaylorTwift/crush/resolve/main/internal/ui/model/chat_draw_cache_test.go
12 kB
| 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{} | |