applyhighlight_callback_test.go

  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}