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}