1package chat
2
3import (
4 "testing"
5
6 "git.secluded.site/crush/internal/message"
7 "git.secluded.site/crush/internal/ui/list"
8 "git.secluded.site/crush/internal/ui/styles"
9 "github.com/stretchr/testify/require"
10)
11
12// renderCountingItem wraps a real chat item and counts Render calls
13// to expose the list-level cache behaviour to tests. The wrapper
14// forwards the list.Item methods exercised by this test โ Render,
15// Version, Finished โ plus the list.Highlightable surface
16// (SetHighlight / Highlight) used by the callback-driven scenario.
17// Focus is not exercised here, so list.Focusable is not forwarded;
18// add SetFocused/IsFocused if a future test needs to drive focus
19// through the wrapper.
20type renderCountingItem struct {
21 inner MessageItem
22 renderHits int
23 highlightCb func(start [4]int)
24}
25
26func newRenderCountingItem(inner MessageItem) *renderCountingItem {
27 return &renderCountingItem{inner: inner}
28}
29
30func (r *renderCountingItem) Render(width int) string {
31 r.renderHits++
32 return r.inner.Render(width)
33}
34
35func (r *renderCountingItem) Version() uint64 {
36 return r.inner.(versionedItem).Version()
37}
38
39func (r *renderCountingItem) Finished() bool {
40 return r.inner.Finished()
41}
42
43// SetHighlight forwards to the embedded item; the underlying
44// highlightableMessageItem dedupes equivalent ranges and bumps the
45// shared version on observable change.
46func (r *renderCountingItem) SetHighlight(startLine, startCol, endLine, endCol int) {
47 if h, ok := r.inner.(list.Highlightable); ok {
48 h.SetHighlight(startLine, startCol, endLine, endCol)
49 if r.highlightCb != nil {
50 r.highlightCb([4]int{startLine, startCol, endLine, endCol})
51 }
52 }
53}
54
55func (r *renderCountingItem) Highlight() (int, int, int, int) {
56 if h, ok := r.inner.(list.Highlightable); ok {
57 return h.Highlight()
58 }
59 return -1, -1, -1, -1
60}
61
62// TestList_CallbackDrivenHighlightUnfreezeAndReFreeze covers F6
63// ยง4.5.1 along the live applyHighlightRange path. Instead of
64// driving BeginSelectionDrag directly, the test registers a render
65// callback that mutates the chat items' highlight ranges (just like
66// Chat.applyHighlightRange does in production) and verifies the
67// resulting cache behaviour:
68//
69// - Items inside the active range pick up a SetHighlight call,
70// their version bumps, the F6 cache invalidates, and the list
71// re-renders them on the next draw. The post-render entry is
72// frozen again because the items are Finished() โ but their
73// stored output now reflects the highlight.
74// - Subsequent draws while the range is unchanged are cache hits:
75// the callback's SetHighlight call dedupes (same range), the
76// version is stable, and the list serves the previous output
77// verbatim without calling Render.
78// - When the range moves OFF an item, the callback clears the
79// highlight, the version bumps, and the item re-renders. After
80// that single re-render the entry re-freezes; further draws are
81// cache hits.
82func TestList_CallbackDrivenHighlightUnfreezeAndReFreeze(t *testing.T) {
83 t.Parallel()
84
85 sty := styles.CharmtonePantera()
86
87 // Build three finished assistant messages so all three are
88 // candidates for freezing. Real items (per Round 2 spec) โ the
89 // surrounding renderCountingItem wrapper just lets the test see
90 // per-item Render calls.
91 mk := func(id, body string) *renderCountingItem {
92 msg := &message.Message{
93 ID: id,
94 Role: message.Assistant,
95 Parts: []message.ContentPart{
96 message.ReasoningContent{
97 Thinking: "thinking",
98 StartedAt: testStartedAt,
99 FinishedAt: testFinishedAt,
100 },
101 message.TextContent{Text: body},
102 message.Finish{Reason: message.FinishReasonEndTurn, Time: testFinishTime},
103 },
104 }
105 inner := NewAssistantMessageItem(&sty, msg)
106 require.True(t, inner.Finished(), "test fixture must be Finished()")
107 return newRenderCountingItem(inner)
108 }
109
110 a := mk("a", "alpha")
111 b := mk("b", "bravo")
112 c := mk("c", "charlie")
113
114 l := list.NewList(a, b, c)
115 l.SetSize(80, 30)
116
117 // activeRange holds the inclusive [start, end] item indexes the
118 // callback should highlight. -1 means no active selection.
119 activeRange := [2]int{-1, -1}
120
121 cb := func(idx, _ int, item list.Item) list.Item {
122 hi, ok := item.(list.Highlightable)
123 if !ok {
124 return item
125 }
126 if activeRange[0] >= 0 && idx >= activeRange[0] && idx <= activeRange[1] {
127 // Inside the range: highlight the entire item.
128 hi.SetHighlight(0, 0, -1, -1)
129 } else {
130 // Outside the range: clear highlight.
131 hi.SetHighlight(-1, -1, -1, -1)
132 }
133 return item
134 }
135 l.RegisterRenderCallback(cb)
136
137 // First render populates the cache. Each item renders exactly
138 // once even though the callback runs for all three.
139 _ = l.Render()
140 require.Equal(t, 1, a.renderHits, "first render: a renders once")
141 require.Equal(t, 1, b.renderHits, "first render: b renders once")
142 require.Equal(t, 1, c.renderHits, "first render: c renders once")
143
144 // Subsequent renders without an active range are cache hits.
145 // The callback's SetHighlight call dedupes (already cleared),
146 // no version bump, frozen entries served verbatim.
147 for range 3 {
148 _ = l.Render()
149 }
150 require.Equal(t, 1, a.renderHits, "frozen item must not re-render across stable draws")
151 require.Equal(t, 1, b.renderHits, "frozen item must not re-render across stable draws")
152 require.Equal(t, 1, c.renderHits, "frozen item must not re-render across stable draws")
153
154 // Activate a selection range over items a and b. The callback
155 // will SetHighlight on both during the next render, bumping
156 // their versions. The cache hit fails (version mismatch) and
157 // each in-range item re-renders exactly once.
158 activeRange = [2]int{0, 1}
159 _ = l.Render()
160 require.Equal(t, 2, a.renderHits, "in-range item must re-render after SetHighlight")
161 require.Equal(t, 2, b.renderHits, "in-range item must re-render after SetHighlight")
162 require.Equal(t, 1, c.renderHits, "out-of-range item stays frozen")
163
164 // Verify the highlight actually landed on the in-range items.
165 sLine, _, eLine, _ := a.Highlight()
166 require.Equal(t, 0, sLine)
167 require.Equal(t, -1, eLine)
168 sLine, _, eLine, _ = c.Highlight()
169 require.Equal(t, -1, sLine, "out-of-range item must not be highlighted")
170 require.Equal(t, -1, eLine)
171
172 // While the range stays the same, subsequent renders are cache
173 // hits. The callback dedupes (same range), no version bump,
174 // the post-render entry served verbatim. Note: items are
175 // re-frozen because they're still Finished() and not in the
176 // list's freezeSuppressed set.
177 for range 3 {
178 _ = l.Render()
179 }
180 require.Equal(t, 2, a.renderHits, "in-range item re-freezes after the highlight render")
181 require.Equal(t, 2, b.renderHits, "in-range item re-freezes after the highlight render")
182 require.Equal(t, 1, c.renderHits, "out-of-range item stays frozen")
183
184 // Move the range off the items entirely. The callback clears
185 // each in-range item's highlight back to (-1,-1,-1,-1), which
186 // bumps their versions and triggers exactly one re-render
187 // each. After that, the entries re-freeze.
188 activeRange = [2]int{-1, -1}
189 _ = l.Render()
190 require.Equal(t, 3, a.renderHits, "exiting-range item must re-render once when highlight clears")
191 require.Equal(t, 3, b.renderHits, "exiting-range item must re-render once when highlight clears")
192 require.Equal(t, 1, c.renderHits, "never-highlighted item stays frozen")
193
194 // Confirm the highlight has been fully cleared.
195 sLine, _, eLine, _ = a.Highlight()
196 require.Equal(t, -1, sLine)
197 require.Equal(t, -1, eLine)
198
199 // And subsequent renders are cache hits again โ the items
200 // re-froze.
201 for range 3 {
202 _ = l.Render()
203 }
204 require.Equal(t, 3, a.renderHits, "re-frozen item must not re-render across stable draws")
205 require.Equal(t, 3, b.renderHits, "re-frozen item must not re-render across stable draws")
206 require.Equal(t, 1, c.renderHits, "never-highlighted item stays frozen")
207}