composer_test.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8	"testing"
  9
 10	tea "charm.land/bubbletea/v2"
 11	"github.com/floatpane/matcha/config"
 12)
 13
 14func TestMailingListSuggestionTruncates(t *testing.T) {
 15	composer := NewComposer("", "", "", "", false)
 16	composer.width = 60
 17
 18	addresses := make([]string, 20)
 19	for i := range addresses {
 20		addresses[i] = fmt.Sprintf("very.long.recipient.%02d@example.com", i)
 21	}
 22
 23	display := suggestionDisplay(config.Contact{
 24		Name:      "Team",
 25		Addresses: addresses,
 26	}, suggestionDisplayWidth(composer.width))
 27
 28	if got, want := len([]rune(display)), suggestionDisplayWidth(composer.width); got > want {
 29		t.Fatalf("Expected mailing-list suggestion to be at most %d runes, got %d: %q", want, got, display)
 30	}
 31
 32	singleAddress := config.Contact{
 33		Name:  "Very Long Contact Name That Should Stay Fully Visible",
 34		Email: "very.long.single.address.that.exceeds.width@example.com",
 35	}
 36	singleDisplay := suggestionDisplay(singleAddress, suggestionDisplayWidth(composer.width))
 37	expected := fmt.Sprintf("%s <%s>", singleAddress.Name, singleAddress.Email)
 38	if singleDisplay != expected {
 39		t.Fatalf("Expected single-address suggestion to stay untruncated, got %q", singleDisplay)
 40	}
 41}
 42
 43func TestNormalizeEmailList(t *testing.T) {
 44	got, ok := normalizeEmailList("Alice Example <alice@example.com>, bob@example.com")
 45	if !ok {
 46		t.Fatal("Expected valid email list")
 47	}
 48	if want := "alice@example.com, bob@example.com"; got != want {
 49		t.Fatalf("normalizeEmailList() = %q, want %q", got, want)
 50	}
 51
 52	if _, ok := normalizeEmailList("not-an-email"); ok {
 53		t.Fatal("Expected invalid email list")
 54	}
 55}
 56
 57func TestComposerEmailValidationOnFieldBlur(t *testing.T) {
 58	composer := NewComposer("", "", "", "", false)
 59	composer.toInput.SetValue("not-an-email")
 60
 61	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 62	composer = model.(*Composer)
 63
 64	if composer.toError == "" {
 65		t.Fatal("Expected To validation error after leaving invalid field")
 66	}
 67	if !strings.Contains(fmt.Sprint(composer.View()), composer.toError) {
 68		t.Fatal("Expected validation error to be rendered below To field")
 69	}
 70}
 71
 72func TestComposerFromValidationOnFieldBlur(t *testing.T) {
 73	tests := []struct {
 74		name      string
 75		from      string
 76		wantError bool
 77	}{
 78		{
 79			name:      "invalid from",
 80			from:      "not-an-email",
 81			wantError: true,
 82		},
 83		{
 84			name: "bare address",
 85			from: "user@example.org",
 86		},
 87	}
 88
 89	for _, tt := range tests {
 90		t.Run(tt.name, func(t *testing.T) {
 91			accounts := []config.Account{
 92				{ID: "account-1", Email: "user@example.org", CatchAll: true},
 93			}
 94			composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
 95			composer.focusIndex = focusFrom
 96			composer.fromInput.Focus()
 97			composer.fromInput.SetValue(tt.from)
 98
 99			model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
100			composer = model.(*Composer)
101
102			if tt.wantError {
103				if composer.fromError == "" {
104					t.Fatal("Expected From validation error after leaving invalid catch-all From field")
105				}
106				if !strings.Contains(fmt.Sprint(composer.View()), composer.fromError) {
107					t.Fatal("Expected From validation error to be rendered below From field")
108				}
109				return
110			}
111			if composer.fromError != "" {
112				t.Fatalf("Expected From address to be valid, got %q", composer.fromError)
113			}
114		})
115	}
116}
117
118func TestComposerEmailValidationClearsWhenTyping(t *testing.T) {
119	composer := NewComposer("", "", "", "", false)
120	composer.toInput.SetValue("not-an-email")
121
122	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
123	composer = model.(*Composer)
124	if composer.toError == "" {
125		t.Fatal("Expected To validation error after leaving invalid field")
126	}
127
128	composer.focusIndex = focusTo
129	composer.toInput.Focus()
130	model, _ = composer.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})
131	composer = model.(*Composer)
132
133	if composer.toError != "" {
134		t.Fatalf("Expected To validation error to clear when typing, got %q", composer.toError)
135	}
136}
137
138func TestComposerSendValidatesEmailFields(t *testing.T) {
139	tests := []struct {
140		name          string
141		to            string
142		cc            string
143		catchAllFrom  string
144		wantCcError   bool
145		wantFromError bool
146	}{
147		{
148			name:        "invalid cc",
149			to:          "recipient@example.com",
150			cc:          "not-an-email",
151			wantCcError: true,
152		},
153		{
154			name:          "invalid catch-all from",
155			to:            "recipient@example.com",
156			catchAllFrom:  "not-an-email",
157			wantFromError: true,
158		},
159		{
160			name: "no recipients",
161		},
162	}
163
164	for _, tt := range tests {
165		t.Run(tt.name, func(t *testing.T) {
166			var composer *Composer
167			if tt.catchAllFrom != "" {
168				accounts := []config.Account{
169					{ID: "account-1", Email: "user@example.org", CatchAll: true},
170				}
171				composer = NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
172				composer.fromInput.SetValue(tt.catchAllFrom)
173			} else {
174				composer = NewComposer("", "", "", "", false)
175			}
176			composer.toInput.SetValue(tt.to)
177			composer.ccInput.SetValue(tt.cc)
178			composer.subjectInput.SetValue("Test Subject")
179			composer.bodyInput.SetValue("This is the body.")
180			composer.focusIndex = focusSend
181
182			model, cmd := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
183			composer = model.(*Composer)
184
185			if cmd == nil {
186				t.Fatal("Expected auto-close command for composer notice")
187			}
188			if !composer.showNotice {
189				t.Fatal("Expected composer notice to be shown after send attempt")
190			}
191			if tt.wantCcError && composer.ccError == "" {
192				t.Fatal("Expected Cc validation error after send attempt")
193			}
194			if tt.wantFromError && composer.fromError == "" {
195				t.Fatal("Expected From validation error after send attempt")
196			}
197
198			model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
199			composer = model.(*Composer)
200
201			if composer.showNotice {
202				t.Fatal("Expected composer notice to close on Enter")
203			}
204			if tt.wantCcError && !strings.Contains(fmt.Sprint(composer.View()), composer.ccError) {
205				t.Fatal("Expected Cc validation error to be rendered after closing notice")
206			}
207			if tt.wantFromError && !strings.Contains(fmt.Sprint(composer.View()), composer.fromError) {
208				t.Fatal("Expected From validation error to be rendered after closing notice")
209			}
210		})
211	}
212}
213
214func TestComposerContactSuggestionUsesDisplayName(t *testing.T) {
215	composer := NewComposer("", "", "", "", false)
216	composer.showSuggestions = true
217	composer.suggestions = []config.Contact{{
218		Name:  "Alice Example",
219		Email: "alice@example.com",
220	}}
221
222	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
223	composer = model.(*Composer)
224
225	if got, want := composer.toInput.Value(), "Alice Example <alice@example.com>, "; got != want {
226		t.Fatalf("Expected suggestion to insert display-name address, got %q, want %q", got, want)
227	}
228}
229
230// TestComposerUpdate verifies the state transitions in the email composer.
231func TestComposerUpdate(t *testing.T) {
232	// Initialize a new composer with accounts.
233	accounts := []config.Account{
234		{ID: "account-1", Email: "test@example.com", Name: "Test User"},
235	}
236	composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
237
238	t.Run("Focus cycling", func(t *testing.T) {
239		// Initial focus is on the 'To' input (index 1, since From is 0).
240		// But NewComposer starts focus at focusTo which is 1.
241		if composer.focusIndex != focusTo {
242			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex)
243		}
244
245		// Simulate pressing Tab to move to the 'Cc' field.
246		model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
247		composer = model.(*Composer)
248		if composer.focusIndex != focusCc {
249			t.Errorf("After one Tab, focusIndex should be %d (focusCc), got %d", focusCc, composer.focusIndex)
250		}
251
252		// Simulate pressing Tab to move to the 'Bcc' field.
253		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
254		composer = model.(*Composer)
255		if composer.focusIndex != focusBcc {
256			t.Errorf("After two Tabs, focusIndex should be %d (focusBcc), got %d", focusBcc, composer.focusIndex)
257		}
258
259		// Simulate pressing Tab to move to the 'Subject' field.
260		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
261		composer = model.(*Composer)
262		if composer.focusIndex != focusSubject {
263			t.Errorf("After three Tabs, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
264		}
265
266		// Simulate pressing Tab again to move to the 'Body' field.
267		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
268		composer = model.(*Composer)
269		if composer.focusIndex != focusBody {
270			t.Errorf("After four Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
271		}
272
273		// Simulate pressing Tab again to move to the 'Signature' field.
274		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
275		composer = model.(*Composer)
276		if composer.focusIndex != focusSignature {
277			t.Errorf("After five Tabs, focusIndex should be %d (focusSignature), got %d", focusSignature, composer.focusIndex)
278		}
279
280		// Simulate pressing Tab again to move to the 'Attachment' field.
281		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
282		composer = model.(*Composer)
283		if composer.focusIndex != focusAttachment {
284			t.Errorf("After six Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
285		}
286
287		// Simulate pressing Tab again to move to the 'EncryptSMIME' toggle.
288		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
289		composer = model.(*Composer)
290		if composer.focusIndex != focusEncryptSMIME {
291			t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, composer.focusIndex)
292		}
293
294		// Simulate pressing Tab again to move to the 'Send' button.
295		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
296		composer = model.(*Composer)
297		if composer.focusIndex != focusSend {
298			t.Errorf("After eight Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
299		}
300
301		// Simulate one more Tab to wrap around.
302		// With single account, From field is skipped, so it wraps to focusTo.
303		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
304		composer = model.(*Composer)
305		if composer.focusIndex != focusTo {
306			t.Errorf("After nine Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
307		}
308	})
309
310	t.Run("Send email message", func(t *testing.T) {
311		// Re-initialize composer for this test
312		composer = NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
313
314		// Set values for the email fields.
315		composer.toInput.SetValue("recipient@example.com")
316		composer.subjectInput.SetValue("Test Subject")
317		composer.bodyInput.SetValue("This is the body.")
318		// Set focus to the Send button.
319		composer.focusIndex = focusSend
320
321		// Simulate pressing Enter to send the email.
322		_, cmd := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
323		if cmd == nil {
324			t.Fatal("Expected a command to be returned, but got nil.")
325		}
326
327		// Execute the command and check the resulting message.
328		msg := cmd()
329		sendMsg, ok := msg.(SendEmailMsg)
330		if !ok {
331			t.Fatalf("Expected a SendEmailMsg, but got %T", msg)
332		}
333
334		// Verify the content of the message.
335		if sendMsg.To != "recipient@example.com" {
336			t.Errorf("Expected To 'recipient@example.com', got %q", sendMsg.To)
337		}
338		if sendMsg.Subject != "Test Subject" {
339			t.Errorf("Expected Subject 'Test Subject', got %q", sendMsg.Subject)
340		}
341		if sendMsg.Body != "This is the body." {
342			t.Errorf("Expected Body 'This is the body.', got %q", sendMsg.Body)
343		}
344		if sendMsg.AccountID != "account-1" {
345			t.Errorf("Expected AccountID 'account-1', got %q", sendMsg.AccountID)
346		}
347	})
348
349	t.Run("Account picker with multiple accounts", func(t *testing.T) {
350		multiAccounts := []config.Account{
351			{ID: "account-1", Email: "test1@example.com", Name: "User 1"},
352			{ID: "account-2", Email: "test2@example.com", Name: "User 2"},
353		}
354		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "", false)
355
356		// Move focus to From field
357		multiComposer.focusIndex = focusFrom
358
359		// Press Enter to open account picker
360		model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
361		multiComposer = model.(*Composer)
362
363		if !multiComposer.showAccountPicker {
364			t.Error("Expected account picker to be shown")
365		}
366
367		// Navigate down to select second account
368		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
369		multiComposer = model.(*Composer)
370
371		if multiComposer.selectedAccountIdx != 1 {
372			t.Errorf("Expected selectedAccountIdx to be 1, got %d", multiComposer.selectedAccountIdx)
373		}
374
375		// Press Enter to confirm selection
376		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
377		multiComposer = model.(*Composer)
378
379		if multiComposer.showAccountPicker {
380			t.Error("Expected account picker to be closed")
381		}
382
383		// Verify the selected account
384		if multiComposer.GetSelectedAccountID() != "account-2" {
385			t.Errorf("Expected selected account ID 'account-2', got %q", multiComposer.GetSelectedAccountID())
386		}
387	})
388
389	t.Run("Single account no picker", func(t *testing.T) {
390		singleAccounts := []config.Account{
391			{ID: "account-1", Email: "test@example.com"},
392		}
393		singleComposer := NewComposerWithAccounts(singleAccounts, "account-1", "", "", "", false)
394
395		// Move focus to From field
396		singleComposer.focusIndex = focusFrom
397
398		// Press Enter - should not open picker with single account
399		model, _ := singleComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
400		singleComposer = model.(*Composer)
401
402		if singleComposer.showAccountPicker {
403			t.Error("Account picker should not open with single account")
404		}
405	})
406
407	t.Run("Multi-account focus cycling includes From", func(t *testing.T) {
408		multiAccounts := []config.Account{
409			{ID: "account-1", Email: "test1@example.com"},
410			{ID: "account-2", Email: "test2@example.com"},
411		}
412		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "", false)
413
414		// Initial focus is on 'To' field
415		if multiComposer.focusIndex != focusTo {
416			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
417		}
418
419		// Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap)
420		model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc
421		multiComposer = model.(*Composer)
422		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc
423		multiComposer = model.(*Composer)
424		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Bcc -> Subject
425		multiComposer = model.(*Composer)
426		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Subject -> Body
427		multiComposer = model.(*Composer)
428		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Body -> Signature
429		multiComposer = model.(*Composer)
430		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Signature -> Attachment
431		multiComposer = model.(*Composer)
432		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME
433		multiComposer = model.(*Composer)
434		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> Send
435		multiComposer = model.(*Composer)
436		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap)
437		multiComposer = model.(*Composer)
438		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // From -> To (wrap)
439		multiComposer = model.(*Composer)
440
441		// With multiple accounts, From field should be included in tab order
442		if multiComposer.focusIndex != focusTo {
443			t.Errorf("After ten Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
444		}
445	})
446}
447
448func TestFormatAttachmentNameIncludesSize(t *testing.T) {
449	dir := t.TempDir()
450	path := filepath.Join(dir, "image.jpg")
451	if err := os.WriteFile(path, make([]byte, 1258291), 0600); err != nil {
452		t.Fatal(err)
453	}
454
455	got := formatAttachmentName(path)
456	want := "image.jpg (1.2 MB)"
457	if got != want {
458		t.Fatalf("formatAttachmentName() = %q, want %q", got, want)
459	}
460}
461
462func TestFormatAttachmentNameMissingFile(t *testing.T) {
463	got := formatAttachmentName("/missing/image.jpg")
464	want := "image.jpg"
465	if got != want {
466		t.Fatalf("formatAttachmentName() = %q, want %q", got, want)
467	}
468}
469
470func TestComposerAttachmentSelectionAndRemoval(t *testing.T) {
471	composer := NewComposer("", "", "", "", false)
472	composer.focusIndex = focusAttachment
473	composer.attachmentPaths = []string{"/tmp/a.txt", "/tmp/b.txt", "/tmp/c.txt"}
474	composer.attachmentNames = map[string]string{
475		"/tmp/a.txt": "a.txt",
476		"/tmp/b.txt": "b.txt",
477		"/tmp/c.txt": "c.txt",
478	}
479
480	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
481	composer = model.(*Composer)
482	if composer.attachmentCursor != 1 {
483		t.Fatalf("Expected attachmentCursor 1 after Down, got %d", composer.attachmentCursor)
484	}
485
486	model, _ = composer.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
487	composer = model.(*Composer)
488
489	want := []string{"/tmp/a.txt", "/tmp/c.txt"}
490	if len(composer.attachmentPaths) != len(want) {
491		t.Fatalf("Expected %d attachments after removal, got %d", len(want), len(composer.attachmentPaths))
492	}
493	for i, path := range want {
494		if composer.attachmentPaths[i] != path {
495			t.Fatalf("attachmentPaths[%d] = %q, want %q", i, composer.attachmentPaths[i], path)
496		}
497	}
498	if _, ok := composer.attachmentNames["/tmp/b.txt"]; ok {
499		t.Fatal("Expected removed attachment display name to be deleted")
500	}
501	if composer.attachmentCursor != 1 {
502		t.Fatalf("Expected cursor to stay on the next attachment, got %d", composer.attachmentCursor)
503	}
504
505	model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
506	composer = model.(*Composer)
507	if composer.attachmentCursor != 0 {
508		t.Fatalf("Expected attachmentCursor to wrap to 0 after Down, got %d", composer.attachmentCursor)
509	}
510}
511
512// TestComposerGetFromAddress verifies the from address formatting.
513func TestComposerGetFromAddress(t *testing.T) {
514	t.Run("With name", func(t *testing.T) {
515		accounts := []config.Account{
516			{ID: "account-1", FetchEmail: "test@example.com", Name: "Test User"},
517		}
518		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
519
520		fromAddr := composer.getFromAddress()
521		expected := "Test User <test@example.com>"
522		if fromAddr != expected {
523			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
524		}
525	})
526
527	t.Run("Without name", func(t *testing.T) {
528		accounts := []config.Account{
529			{ID: "account-1", FetchEmail: "test@example.com"},
530		}
531		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
532
533		fromAddr := composer.getFromAddress()
534		expected := "test@example.com"
535		if fromAddr != expected {
536			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
537		}
538	})
539
540	t.Run("Send as overrides fetch email", func(t *testing.T) {
541		accounts := []config.Account{
542			{ID: "account-1", FetchEmail: "gmail@gmail.com", SendAsEmail: "alias@example.com", Name: "Test User"},
543		}
544		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
545
546		fromAddr := composer.getFromAddress()
547		expected := "Test User <alias@example.com>"
548		if fromAddr != expected {
549			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
550		}
551	})
552
553	t.Run("No accounts", func(t *testing.T) {
554		composer := NewComposer("", "", "", "", false)
555
556		fromAddr := composer.getFromAddress()
557		if fromAddr != "" {
558			t.Errorf("Expected empty from address, got %q", fromAddr)
559		}
560	})
561}
562
563// TestComposerSetSelectedAccount verifies account selection.
564func TestComposerSetSelectedAccount(t *testing.T) {
565	accounts := []config.Account{
566		{ID: "account-1", FetchEmail: "test1@example.com"},
567		{ID: "account-2", FetchEmail: "test2@example.com"},
568		{ID: "account-3", FetchEmail: "test3@example.com"},
569	}
570	composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
571
572	composer.SetSelectedAccount("account-3")
573	if composer.selectedAccountIdx != 2 {
574		t.Errorf("Expected selectedAccountIdx 2, got %d", composer.selectedAccountIdx)
575	}
576	if composer.GetSelectedAccountID() != "account-3" {
577		t.Errorf("Expected selected account ID 'account-3', got %q", composer.GetSelectedAccountID())
578	}
579
580	// Test non-existent account (should not change)
581	composer.SetSelectedAccount("non-existent")
582	if composer.selectedAccountIdx != 2 {
583		t.Errorf("Expected selectedAccountIdx to remain 2, got %d", composer.selectedAccountIdx)
584	}
585}
586
587// TestComposerDynamicHeight verifies that window resize updates textarea heights.
588func TestComposerDynamicHeight(t *testing.T) {
589	composer := NewComposer("", "", "", "", false)
590
591	model, _ := composer.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
592	composer = model.(*Composer)
593
594	if composer.height != 40 {
595		t.Errorf("Expected height 40, got %d", composer.height)
596	}
597
598	bodyH := composer.bodyInput.Height()
599	sigH := composer.signatureInput.Height()
600
601	if bodyH <= 3 {
602		t.Errorf("Expected bodyInput height > 3, got %d", bodyH)
603	}
604	if sigH <= 1 {
605		t.Errorf("Expected signatureInput height > 1, got %d", sigH)
606	}
607	if bodyH <= sigH {
608		t.Errorf("Expected bodyInput height (%d) > signatureInput height (%d)", bodyH, sigH)
609	}
610
611	// Small window: heights should not go below minimums
612	model, _ = composer.Update(tea.WindowSizeMsg{Width: 80, Height: 10})
613	composer = model.(*Composer)
614	if composer.bodyInput.Height() < 3 {
615		t.Errorf("bodyInput height should be at least 3, got %d", composer.bodyInput.Height())
616	}
617	if composer.signatureInput.Height() < 2 {
618		t.Errorf("signatureInput height should be at least 2, got %d", composer.signatureInput.Height())
619	}
620}