assistant_test.go

  1package chat
  2
  3import (
  4	"testing"
  5
  6	"git.secluded.site/crush/internal/message"
  7	"git.secluded.site/crush/internal/ui/styles"
  8	"github.com/charmbracelet/x/ansi"
  9	"github.com/stretchr/testify/require"
 10)
 11
 12// TestAssistantMessageItemExpandable guards the Expandable contract on
 13// AssistantMessageItem along the keyboard-driven expand path. The earlier
 14// implementation returned no value, which meant the type silently did
 15// not satisfy chat.Expandable and the keyboard-driven expand path in
 16// model/chat.go skipped thinking blocks.
 17//
 18// We exercise the contract through the bare Expandable interface (the
 19// same dispatch site model.Chat.ToggleExpandedSelectedItem uses), which
 20// proves both that AssistantMessageItem still satisfies the interface
 21// and that the bool return reports the right semantic state at every
 22// point in the cycle.
 23func TestAssistantMessageItemExpandable(t *testing.T) {
 24	t.Parallel()
 25
 26	sty := styles.CharmtonePantera()
 27	// Short thinking: under the tail-window cap, so the cycle is
 28	// collapsed -> full -> collapsed (tail-window is skipped).
 29	msg := thinkingMessage("m1", "step one\nstep two\nstep three", "")
 30	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
 31
 32	exp, ok := any(item).(Expandable)
 33	require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
 34
 35	require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
 36		"new items must start in the collapsed view-mode")
 37	require.True(t, exp.ToggleExpanded(),
 38		"first toggle of a non-empty thinking block must report expanded")
 39	require.Equal(t, thinkingFullExpanded, item.thinkingViewMode,
 40		"short blocks must skip tail-window and land in full expansion")
 41	require.False(t, exp.ToggleExpanded(),
 42		"second toggle must report collapsed (cycle closed)")
 43	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
 44}
 45
 46// TestAssistantMessageItemExpandableEmptyThinkingNoOp guards the B2
 47// fix: a message with no thinking text must treat ToggleExpanded as a
 48// no-op. Mutating the view mode in that case would thrash the
 49// thinking-section cache key for no visible benefit and would surprise
 50// the caller (model.Chat.ToggleExpandedSelectedItem would treat a
 51// "now collapsed" return as a real state change and re-scroll on it).
 52func TestAssistantMessageItemExpandableEmptyThinkingNoOp(t *testing.T) {
 53	t.Parallel()
 54
 55	sty := styles.CharmtonePantera()
 56	msg := &message.Message{ID: "m1-empty", Role: message.Assistant}
 57	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
 58
 59	exp, ok := any(item).(Expandable)
 60	require.True(t, ok, "AssistantMessageItem must satisfy Expandable")
 61
 62	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
 63	require.False(t, exp.ToggleExpanded(),
 64		"empty thinking must report current (collapsed) state without flipping")
 65	require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
 66		"empty-thinking toggle must not mutate thinkingViewMode")
 67
 68	// Whitespace-only thinking is still effectively empty.
 69	item.message.Parts = []message.ContentPart{
 70		message.ReasoningContent{Thinking: "  \n\n\t  ", StartedAt: testStartedAt},
 71	}
 72	require.False(t, exp.ToggleExpanded())
 73	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
 74}
 75
 76// TestAssistantMessageItemTailWindowBoundary guards the B1 fix: the
 77// tail-window heuristic must compare logical line counts (1 +
 78// newline count) against the cap, not raw newline counts. A source
 79// whose logical line count exactly equals the cap must NOT trip the
 80// tail-window step (full render still fits cleanly under the cap),
 81// while one logical line over the cap must trip it.
 82func TestAssistantMessageItemTailWindowBoundary(t *testing.T) {
 83	t.Parallel()
 84
 85	sty := styles.CharmtonePantera()
 86
 87	atCap := buildLines(maxExpandedThinkingTailLines)
 88	overCap := buildLines(maxExpandedThinkingTailLines + 1)
 89
 90	atItem := NewAssistantMessageItem(&sty, thinkingMessage("at-cap", atCap, "")).(*AssistantMessageItem)
 91	require.False(t, atItem.tailWindowWouldTruncate(),
 92		"a source with exactly N logical lines must not trip the tail-window step")
 93
 94	overItem := NewAssistantMessageItem(&sty, thinkingMessage("over-cap", overCap, "")).(*AssistantMessageItem)
 95	require.True(t, overItem.tailWindowWouldTruncate(),
 96		"a source with N+1 logical lines must trip the tail-window step")
 97}
 98
 99// buildLines returns a string of n logical lines (n-1 newlines). Each
100// line is a unique short token so callers can distinguish head from
101// tail in rendered output if they need to.
102func buildLines(n int) string {
103	if n <= 0 {
104		return ""
105	}
106	var b []byte
107	for i := 1; i <= n; i++ {
108		if i > 1 {
109			b = append(b, '\n')
110		}
111		b = append(b, 'l', 'n')
112		b = append(b, []byte(itoa(i))...)
113	}
114	return string(b)
115}
116
117// TestAssistantMessageItemHandleMouseClick ensures HandleMouseClick does not
118// toggle expansion on its own. The generic Expandable path in
119// model/chat.go does the toggle; doing it here too would double-toggle and
120// net to no change.
121func TestAssistantMessageItemHandleMouseClick(t *testing.T) {
122	t.Parallel()
123
124	sty := styles.CharmtonePantera()
125	msg := &message.Message{ID: "m2", Role: message.Assistant}
126	item := NewAssistantMessageItem(&sty, msg).(*AssistantMessageItem)
127	item.thinkingBoxHeight = 5
128
129	// Click inside the thinking box signals handled but must not mutate
130	// the view-mode state.
131	require.True(t, item.HandleMouseClick(ansi.MouseLeft, 0, 2))
132	require.Equal(t, thinkingCollapsed, item.thinkingViewMode,
133		"HandleMouseClick must not toggle expansion on its own")
134
135	// Click outside the thinking box is ignored entirely.
136	require.False(t, item.HandleMouseClick(ansi.MouseLeft, 0, 10))
137	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
138
139	// Non-left button is ignored.
140	require.False(t, item.HandleMouseClick(ansi.MouseRight, 0, 2))
141	require.Equal(t, thinkingCollapsed, item.thinkingViewMode)
142}