prefix_cache_test.go

  1package chat
  2
  3import (
  4	"strings"
  5	"testing"
  6	"time"
  7
  8	"github.com/charmbracelet/crush/internal/message"
  9	"github.com/charmbracelet/crush/internal/ui/attachments"
 10	"github.com/charmbracelet/crush/internal/ui/styles"
 11	"github.com/stretchr/testify/require"
 12)
 13
 14// finishedAssistantMessage builds an assistant message with text content and a
 15// finish part so AssistantMessageItem.isSpinning returns false and the
 16// prefix cache is exercised.
 17func finishedAssistantMessage(id, text string) *message.Message {
 18	return &message.Message{
 19		ID:   id,
 20		Role: message.Assistant,
 21		Parts: []message.ContentPart{
 22			message.TextContent{Text: text},
 23			message.Finish{Reason: message.FinishReasonEndTurn, Time: time.Now().Unix()},
 24		},
 25	}
 26}
 27
 28// TestAssistantMessageItemRender_PrefixCacheFocusBlur covers the F3 invariant
 29// that focus → blur → focus produces the correct prefix every time and never
 30// leaks the previous focus state out of the cache.
 31func TestAssistantMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
 32	t.Parallel()
 33
 34	sty := styles.CharmtonePantera()
 35	msg := finishedAssistantMessage("m1", "Hello world from the cache test.")
 36	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
 37
 38	const width = 60
 39
 40	item.SetFocused(true)
 41	focused1 := item.Render(width)
 42	focused2 := item.Render(width)
 43	require.Equal(t, focused1, focused2, "second render must hit the cache and match the first")
 44
 45	item.SetFocused(false)
 46	blurred1 := item.Render(width)
 47	require.NotEqual(t, focused1, blurred1, "blur must produce a different prefixed render than focus")
 48
 49	item.SetFocused(true)
 50	focused3 := item.Render(width)
 51	require.Equal(t, focused1, focused3, "re-focus must produce identical output to the original focused render")
 52}
 53
 54// TestAssistantMessageItemRender_PrefixCacheWidthInvalidates asserts that a
 55// width change does not return the previous width's cached output.
 56func TestAssistantMessageItemRender_PrefixCacheWidthInvalidates(t *testing.T) {
 57	t.Parallel()
 58
 59	sty := styles.CharmtonePantera()
 60	msg := finishedAssistantMessage("m2", "Some content that wraps differently at different widths so the rendered output diverges.")
 61	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
 62	item.SetFocused(true)
 63
 64	narrow := item.Render(40)
 65	wide := item.Render(100)
 66	require.NotEqual(t, narrow, wide, "different widths must produce different rendered output")
 67
 68	narrowAgain := item.Render(40)
 69	require.Equal(t, narrow, narrowAgain, "returning to the original width must hit (or repopulate) the cache with the same output")
 70}
 71
 72// TestAssistantMessageItemRender_PrefixCacheHighlightOnTop guarantees that
 73// activating a highlight range bypasses the prefix cache so selection drags
 74// reflect immediately, and that clearing the highlight returns to the cached
 75// prefixed output unchanged.
 76func TestAssistantMessageItemRender_PrefixCacheHighlightOnTop(t *testing.T) {
 77	t.Parallel()
 78
 79	sty := styles.CharmtonePantera()
 80	msg := finishedAssistantMessage("m3", "Hello world from the highlight test.")
 81	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
 82	item.SetFocused(true)
 83
 84	const width = 60
 85	plain := item.Render(width)
 86
 87	// Activating a highlight must change the rendered output (selection
 88	// painted on top) without poisoning the cache for the un-highlighted
 89	// state that follows.
 90	item.SetHighlight(0, 0, 0, 5)
 91	highlighted := item.Render(width)
 92	require.NotEqual(t, plain, highlighted, "active highlight must change Render output")
 93
 94	// Clear the highlight; the cached un-highlighted prefix render must
 95	// be returned unchanged.
 96	item.SetHighlight(-1, -1, -1, -1)
 97	plainAfter := item.Render(width)
 98	require.Equal(t, plain, plainAfter, "clearing the highlight must restore the cached prefixed output exactly")
 99}
100
101// TestUserMessageItemRender_PrefixCacheFocusBlur is the user-message
102// counterpart of the assistant focus/blur cache test.
103func TestUserMessageItemRender_PrefixCacheFocusBlur(t *testing.T) {
104	t.Parallel()
105
106	sty := styles.CharmtonePantera()
107	msg := &message.Message{
108		ID:   "u1",
109		Role: message.User,
110		Parts: []message.ContentPart{
111			message.TextContent{Text: "Hello from the user."},
112		},
113	}
114	r := attachments.NewRenderer(
115		sty.Attachments.Normal,
116		sty.Attachments.Deleting,
117		sty.Attachments.Image,
118		sty.Attachments.Text,
119		sty.Attachments.Skill,
120	)
121	item := NewUserMessageItem(&sty, msg, r).(*UserMessageItem)
122
123	const width = 60
124
125	item.SetFocused(true)
126	focused1 := item.Render(width)
127	focused2 := item.Render(width)
128	require.Equal(t, focused1, focused2)
129
130	item.SetFocused(false)
131	blurred := item.Render(width)
132	require.NotEqual(t, focused1, blurred)
133
134	item.SetFocused(true)
135	focused3 := item.Render(width)
136	require.Equal(t, focused1, focused3)
137}
138
139// TestCachedMessageItem_PrefixCacheSemantics covers the constant-prefix
140// path used by AssistantInfoItem and the (width, key) keying used by every
141// item, against the underlying cachedMessageItem helper directly. This
142// avoids constructing a full *config.Config with an initialized provider
143// map just to exercise cache plumbing that is identical for all callers.
144func TestCachedMessageItem_PrefixCacheSemantics(t *testing.T) {
145	t.Parallel()
146
147	c := &cachedMessageItem{}
148
149	// Empty cache: miss.
150	_, ok := c.getCachedPrefixedRender(80, 0)
151	require.False(t, ok)
152
153	// Set then hit at the same (width, key).
154	c.setCachedPrefixedRender("hello", 80, 0)
155	got, ok := c.getCachedPrefixedRender(80, 0)
156	require.True(t, ok)
157	require.Equal(t, "hello", got)
158
159	// Different width: miss.
160	_, ok = c.getCachedPrefixedRender(120, 0)
161	require.False(t, ok)
162
163	// Different key (focused vs blurred): miss.
164	_, ok = c.getCachedPrefixedRender(80, 1)
165	require.False(t, ok)
166
167	// clearCache drops the prefixed cache too.
168	c.setCachedRender("raw", 80, 1)
169	c.setCachedPrefixedRender("hello", 80, 0)
170	c.clearCache()
171	_, ok = c.getCachedPrefixedRender(80, 0)
172	require.False(t, ok, "clearCache must drop the prefixed render cache")
173	_, _, ok = c.getCachedRender(80)
174	require.False(t, ok, "clearCache must also drop the raw render cache")
175}
176
177// TestAssistantMessageItemRender_PrefixCacheNoCacheLeak guards against a
178// regression where the cache returned the prefixed output of the previous
179// width. We verify that the cached output for width=W contains the W-sized
180// prefix and not a stale wider one by checking that line lengths are
181// consistent on cache hit.
182func TestAssistantMessageItemRender_PrefixCacheNoCacheLeak(t *testing.T) {
183	t.Parallel()
184
185	sty := styles.CharmtonePantera()
186	msg := finishedAssistantMessage("m4", strings.Repeat("word ", 40))
187	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
188	item.SetFocused(true)
189
190	out80 := item.Render(80)
191	out120 := item.Render(120)
192	require.NotEqual(t, out80, out120)
193
194	// Hit each cached entry again and confirm stability.
195	require.Equal(t, out80, item.Render(80))
196	require.Equal(t, out120, item.Render(120))
197}