layout_test.go

  1package model
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"testing"
  7
  8	"charm.land/bubbles/v2/textarea"
  9	"github.com/charmbracelet/crush/internal/session"
 10	"github.com/charmbracelet/crush/internal/ui/chat"
 11	"github.com/charmbracelet/crush/internal/ui/common"
 12)
 13
 14// testMessageItem is a minimal chat item used to populate the chat list
 15// without pulling in full message rendering machinery.
 16type testMessageItem struct {
 17	id   string
 18	text string
 19}
 20
 21func (m testMessageItem) ID() string           { return m.id }
 22func (m testMessageItem) Render(int) string    { return m.text }
 23func (m testMessageItem) RawRender(int) string { return m.text }
 24func (m testMessageItem) Version() uint64      { return 0 }
 25func (m testMessageItem) Finished() bool       { return true }
 26
 27var _ chat.MessageItem = testMessageItem{}
 28
 29// newTestUI builds a focused uiChat model with dynamic textarea sizing enabled.
 30// It intentionally keeps dependencies minimal so layout behavior can be tested
 31// in isolation.
 32func newTestUI() *UI {
 33	com := common.DefaultCommon(nil)
 34
 35	ta := textarea.New()
 36	ta.SetStyles(com.Styles.Editor.Textarea)
 37	ta.ShowLineNumbers = false
 38	ta.CharLimit = -1
 39	ta.SetVirtualCursor(false)
 40	ta.DynamicHeight = true
 41	ta.MinHeight = TextareaMinHeight
 42	ta.MaxHeight = TextareaMaxHeight
 43	ta.Focus()
 44
 45	u := &UI{
 46		com:      com,
 47		status:   NewStatus(com, nil),
 48		chat:     NewChat(com),
 49		textarea: ta,
 50		state:    uiChat,
 51		focus:    uiFocusEditor,
 52		width:    140,
 53		height:   45,
 54	}
 55
 56	return u
 57}
 58
 59func TestUpdateLayoutAndSize_EditorGrowthShrinksChat(t *testing.T) {
 60	t.Parallel()
 61
 62	// Baseline layout at min textarea height.
 63	u := newTestUI()
 64	u.updateLayoutAndSize()
 65
 66	initialEditorHeight := u.layout.editor.Dy()
 67	initialChatHeight := u.layout.main.Dy()
 68
 69	// Increase textarea content enough to trigger growth, then run the
 70	// same resize hook used in the real update path.
 71	prevHeight := u.textarea.Height()
 72	u.textarea.SetValue(strings.Repeat("line\n", 8))
 73	u.textarea.MoveToEnd()
 74	_ = u.handleTextareaHeightChange(prevHeight)
 75
 76	if got := u.layout.editor.Dy(); got <= initialEditorHeight {
 77		t.Fatalf("expected editor to grow: got %d, want > %d", got, initialEditorHeight)
 78	}
 79
 80	if got := u.layout.main.Dy(); got >= initialChatHeight {
 81		t.Fatalf("expected chat to shrink: got %d, want < %d", got, initialChatHeight)
 82	}
 83}
 84
 85func TestHandleTextareaHeightChange_FollowModeStaysAtBottom(t *testing.T) {
 86	t.Parallel()
 87
 88	// Use enough messages to make the chat scrollable so AtBottom/Follow
 89	// assertions are meaningful.
 90	u := newTestUI()
 91
 92	msgs := make([]chat.MessageItem, 0, 60)
 93	for i := range 60 {
 94		msgs = append(msgs, testMessageItem{
 95			id:   "m-" + strconv.Itoa(i),
 96			text: "message " + strconv.Itoa(i),
 97		})
 98	}
 99	u.chat.SetMessages(msgs...)
100	u.updateLayoutAndSize()
101
102	// Enter follow mode and verify we're anchored at the bottom first.
103	u.chat.ScrollToBottom()
104	if !u.chat.AtBottom() {
105		t.Fatal("expected chat to start at bottom")
106	}
107
108	// Grow the editor; follow mode should keep the chat pinned to the end
109	// even as the chat viewport shrinks.
110	prevHeight := u.textarea.Height()
111	u.textarea.SetValue(strings.Repeat("line\n", 10))
112	u.textarea.MoveToEnd()
113	_ = u.handleTextareaHeightChange(prevHeight)
114
115	if !u.chat.Follow() {
116		t.Fatal("expected follow mode to remain enabled")
117	}
118	if !u.chat.AtBottom() {
119		t.Fatal("expected chat to remain at bottom after editor resize in follow mode")
120	}
121}
122
123func TestAutoExpandPillsIfReasonable(t *testing.T) {
124	t.Parallel()
125
126	t.Run("expands when terminal is tall enough and todos exist", func(t *testing.T) {
127		t.Parallel()
128
129		u := newTestUI()
130		u.height = 50
131		u.session = &session.Session{ID: "s1", Todos: []session.Todo{
132			{Status: session.TodoStatusInProgress, Content: "do work"},
133			{Status: session.TodoStatusPending, Content: "do more"},
134		}}
135
136		u.autoExpandPillsIfReasonable()
137
138		if !u.pillsExpanded {
139			t.Fatal("expected pillsExpanded to be true")
140		}
141		if u.focusedPillSection != pillSectionTodos {
142			t.Fatalf("expected focusedPillSection to be pillSectionTodos, got %d", u.focusedPillSection)
143		}
144	})
145
146	t.Run("does not expand when terminal is too short", func(t *testing.T) {
147		t.Parallel()
148
149		u := newTestUI()
150		u.height = 30
151		u.session = &session.Session{ID: "s1", Todos: []session.Todo{
152			{Status: session.TodoStatusInProgress, Content: "do work"},
153		}}
154
155		u.autoExpandPillsIfReasonable()
156
157		if u.pillsExpanded {
158			t.Fatal("expected pillsExpanded to be false when terminal height is below threshold")
159		}
160	})
161
162	t.Run("does not expand when all todos are completed", func(t *testing.T) {
163		t.Parallel()
164
165		u := newTestUI()
166		u.height = 50
167		u.session = &session.Session{ID: "s1", Todos: []session.Todo{
168			{Status: session.TodoStatusCompleted, Content: "done"},
169		}}
170
171		u.autoExpandPillsIfReasonable()
172
173		if u.pillsExpanded {
174			t.Fatal("expected pillsExpanded to be false when all todos are completed")
175		}
176	})
177
178	t.Run("does not expand when already expanded", func(t *testing.T) {
179		t.Parallel()
180
181		u := newTestUI()
182		u.height = 50
183		u.pillsExpanded = true
184		u.session = &session.Session{ID: "s1", Todos: []session.Todo{
185			{Status: session.TodoStatusInProgress, Content: "do work"},
186		}}
187		u.updateLayoutAndSize()
188
189		u.autoExpandPillsIfReasonable()
190
191		if !u.pillsExpanded {
192			t.Fatal("expected pillsExpanded to stay true")
193		}
194	})
195
196	t.Run("expands for prompt queue when no todos", func(t *testing.T) {
197		t.Parallel()
198
199		u := newTestUI()
200		u.height = 50
201		u.session = &session.Session{ID: "s1", Todos: []session.Todo{}}
202		u.promptQueue = 2
203
204		u.autoExpandPillsIfReasonable()
205
206		if !u.pillsExpanded {
207			t.Fatal("expected pillsExpanded to be true for prompt queue")
208		}
209		if u.focusedPillSection != pillSectionQueue {
210			t.Fatalf("expected focusedPillSection to be pillSectionQueue, got %d", u.focusedPillSection)
211		}
212	})
213
214	t.Run("does not expand when no session", func(t *testing.T) {
215		t.Parallel()
216
217		u := newTestUI()
218		u.height = 50
219		u.session = nil
220
221		u.autoExpandPillsIfReasonable()
222
223		if u.pillsExpanded {
224			t.Fatal("expected pillsExpanded to be false when there is no session")
225		}
226	})
227}