1package chat
2
3import (
4 "testing"
5
6 "github.com/charmbracelet/crush/internal/message"
7 "github.com/charmbracelet/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}