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}