composer_test.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"testing"
  8
  9	tea "charm.land/bubbletea/v2"
 10	"github.com/floatpane/matcha/config"
 11)
 12
 13func TestMailingListSuggestionTruncates(t *testing.T) {
 14	composer := NewComposer("", "", "", "", false)
 15	composer.width = 60
 16
 17	addresses := make([]string, 20)
 18	for i := range addresses {
 19		addresses[i] = fmt.Sprintf("very.long.recipient.%02d@example.com", i)
 20	}
 21
 22	display := suggestionDisplay(config.Contact{
 23		Name:      "Team",
 24		Addresses: addresses,
 25	}, suggestionDisplayWidth(composer.width))
 26
 27	if got, want := len([]rune(display)), suggestionDisplayWidth(composer.width); got > want {
 28		t.Fatalf("Expected mailing-list suggestion to be at most %d runes, got %d: %q", want, got, display)
 29	}
 30
 31	singleAddress := config.Contact{
 32		Name:  "Very Long Contact Name That Should Stay Fully Visible",
 33		Email: "very.long.single.address.that.exceeds.width@example.com",
 34	}
 35	singleDisplay := suggestionDisplay(singleAddress, suggestionDisplayWidth(composer.width))
 36	expected := fmt.Sprintf("%s <%s>", singleAddress.Name, singleAddress.Email)
 37	if singleDisplay != expected {
 38		t.Fatalf("Expected single-address suggestion to stay untruncated, got %q", singleDisplay)
 39	}
 40}
 41
 42// TestComposerUpdate verifies the state transitions in the email composer.
 43func TestComposerUpdate(t *testing.T) {
 44	// Initialize a new composer with accounts.
 45	accounts := []config.Account{
 46		{ID: "account-1", Email: "test@example.com", Name: "Test User"},
 47	}
 48	composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
 49
 50	t.Run("Focus cycling", func(t *testing.T) {
 51		// Initial focus is on the 'To' input (index 1, since From is 0).
 52		// But NewComposer starts focus at focusTo which is 1.
 53		if composer.focusIndex != focusTo {
 54			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex)
 55		}
 56
 57		// Simulate pressing Tab to move to the 'Cc' field.
 58		model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 59		composer = model.(*Composer)
 60		if composer.focusIndex != focusCc {
 61			t.Errorf("After one Tab, focusIndex should be %d (focusCc), got %d", focusCc, composer.focusIndex)
 62		}
 63
 64		// Simulate pressing Tab to move to the 'Bcc' field.
 65		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 66		composer = model.(*Composer)
 67		if composer.focusIndex != focusBcc {
 68			t.Errorf("After two Tabs, focusIndex should be %d (focusBcc), got %d", focusBcc, composer.focusIndex)
 69		}
 70
 71		// Simulate pressing Tab to move to the 'Subject' field.
 72		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 73		composer = model.(*Composer)
 74		if composer.focusIndex != focusSubject {
 75			t.Errorf("After three Tabs, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
 76		}
 77
 78		// Simulate pressing Tab again to move to the 'Body' field.
 79		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 80		composer = model.(*Composer)
 81		if composer.focusIndex != focusBody {
 82			t.Errorf("After four Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
 83		}
 84
 85		// Simulate pressing Tab again to move to the 'Signature' field.
 86		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 87		composer = model.(*Composer)
 88		if composer.focusIndex != focusSignature {
 89			t.Errorf("After five Tabs, focusIndex should be %d (focusSignature), got %d", focusSignature, composer.focusIndex)
 90		}
 91
 92		// Simulate pressing Tab again to move to the 'Attachment' field.
 93		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 94		composer = model.(*Composer)
 95		if composer.focusIndex != focusAttachment {
 96			t.Errorf("After six Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
 97		}
 98
 99		// Simulate pressing Tab again to move to the 'EncryptSMIME' toggle.
100		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
101		composer = model.(*Composer)
102		if composer.focusIndex != focusEncryptSMIME {
103			t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, composer.focusIndex)
104		}
105
106		// Simulate pressing Tab again to move to the 'Send' button.
107		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
108		composer = model.(*Composer)
109		if composer.focusIndex != focusSend {
110			t.Errorf("After eight Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
111		}
112
113		// Simulate one more Tab to wrap around.
114		// With single account, From field is skipped, so it wraps to focusTo.
115		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
116		composer = model.(*Composer)
117		if composer.focusIndex != focusTo {
118			t.Errorf("After nine Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
119		}
120	})
121
122	t.Run("Send email message", func(t *testing.T) {
123		// Re-initialize composer for this test
124		composer = NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
125
126		// Set values for the email fields.
127		composer.toInput.SetValue("recipient@example.com")
128		composer.subjectInput.SetValue("Test Subject")
129		composer.bodyInput.SetValue("This is the body.")
130		// Set focus to the Send button.
131		composer.focusIndex = focusSend
132
133		// Simulate pressing Enter to send the email.
134		_, cmd := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
135		if cmd == nil {
136			t.Fatal("Expected a command to be returned, but got nil.")
137		}
138
139		// Execute the command and check the resulting message.
140		msg := cmd()
141		sendMsg, ok := msg.(SendEmailMsg)
142		if !ok {
143			t.Fatalf("Expected a SendEmailMsg, but got %T", msg)
144		}
145
146		// Verify the content of the message.
147		if sendMsg.To != "recipient@example.com" {
148			t.Errorf("Expected To 'recipient@example.com', got %q", sendMsg.To)
149		}
150		if sendMsg.Subject != "Test Subject" {
151			t.Errorf("Expected Subject 'Test Subject', got %q", sendMsg.Subject)
152		}
153		if sendMsg.Body != "This is the body." {
154			t.Errorf("Expected Body 'This is the body.', got %q", sendMsg.Body)
155		}
156		if sendMsg.AccountID != "account-1" {
157			t.Errorf("Expected AccountID 'account-1', got %q", sendMsg.AccountID)
158		}
159	})
160
161	t.Run("Account picker with multiple accounts", func(t *testing.T) {
162		multiAccounts := []config.Account{
163			{ID: "account-1", Email: "test1@example.com", Name: "User 1"},
164			{ID: "account-2", Email: "test2@example.com", Name: "User 2"},
165		}
166		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "", false)
167
168		// Move focus to From field
169		multiComposer.focusIndex = focusFrom
170
171		// Press Enter to open account picker
172		model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
173		multiComposer = model.(*Composer)
174
175		if !multiComposer.showAccountPicker {
176			t.Error("Expected account picker to be shown")
177		}
178
179		// Navigate down to select second account
180		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
181		multiComposer = model.(*Composer)
182
183		if multiComposer.selectedAccountIdx != 1 {
184			t.Errorf("Expected selectedAccountIdx to be 1, got %d", multiComposer.selectedAccountIdx)
185		}
186
187		// Press Enter to confirm selection
188		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
189		multiComposer = model.(*Composer)
190
191		if multiComposer.showAccountPicker {
192			t.Error("Expected account picker to be closed")
193		}
194
195		// Verify the selected account
196		if multiComposer.GetSelectedAccountID() != "account-2" {
197			t.Errorf("Expected selected account ID 'account-2', got %q", multiComposer.GetSelectedAccountID())
198		}
199	})
200
201	t.Run("Single account no picker", func(t *testing.T) {
202		singleAccounts := []config.Account{
203			{ID: "account-1", Email: "test@example.com"},
204		}
205		singleComposer := NewComposerWithAccounts(singleAccounts, "account-1", "", "", "", false)
206
207		// Move focus to From field
208		singleComposer.focusIndex = focusFrom
209
210		// Press Enter - should not open picker with single account
211		model, _ := singleComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
212		singleComposer = model.(*Composer)
213
214		if singleComposer.showAccountPicker {
215			t.Error("Account picker should not open with single account")
216		}
217	})
218
219	t.Run("Multi-account focus cycling includes From", func(t *testing.T) {
220		multiAccounts := []config.Account{
221			{ID: "account-1", Email: "test1@example.com"},
222			{ID: "account-2", Email: "test2@example.com"},
223		}
224		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "", false)
225
226		// Initial focus is on 'To' field
227		if multiComposer.focusIndex != focusTo {
228			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
229		}
230
231		// Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap)
232		model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc
233		multiComposer = model.(*Composer)
234		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc
235		multiComposer = model.(*Composer)
236		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Bcc -> Subject
237		multiComposer = model.(*Composer)
238		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Subject -> Body
239		multiComposer = model.(*Composer)
240		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Body -> Signature
241		multiComposer = model.(*Composer)
242		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Signature -> Attachment
243		multiComposer = model.(*Composer)
244		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME
245		multiComposer = model.(*Composer)
246		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> Send
247		multiComposer = model.(*Composer)
248		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap)
249		multiComposer = model.(*Composer)
250		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // From -> To (wrap)
251		multiComposer = model.(*Composer)
252
253		// With multiple accounts, From field should be included in tab order
254		if multiComposer.focusIndex != focusTo {
255			t.Errorf("After ten Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
256		}
257	})
258}
259
260func TestFormatAttachmentNameIncludesSize(t *testing.T) {
261	dir := t.TempDir()
262	path := filepath.Join(dir, "image.jpg")
263	if err := os.WriteFile(path, make([]byte, 1258291), 0600); err != nil {
264		t.Fatal(err)
265	}
266
267	got := formatAttachmentName(path)
268	want := "image.jpg (1.2 MB)"
269	if got != want {
270		t.Fatalf("formatAttachmentName() = %q, want %q", got, want)
271	}
272}
273
274func TestFormatAttachmentNameMissingFile(t *testing.T) {
275	got := formatAttachmentName("/missing/image.jpg")
276	want := "image.jpg"
277	if got != want {
278		t.Fatalf("formatAttachmentName() = %q, want %q", got, want)
279	}
280}
281
282func TestComposerAttachmentSelectionAndRemoval(t *testing.T) {
283	composer := NewComposer("", "", "", "", false)
284	composer.focusIndex = focusAttachment
285	composer.attachmentPaths = []string{"/tmp/a.txt", "/tmp/b.txt", "/tmp/c.txt"}
286	composer.attachmentNames = map[string]string{
287		"/tmp/a.txt": "a.txt",
288		"/tmp/b.txt": "b.txt",
289		"/tmp/c.txt": "c.txt",
290	}
291
292	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
293	composer = model.(*Composer)
294	if composer.attachmentCursor != 1 {
295		t.Fatalf("Expected attachmentCursor 1 after Down, got %d", composer.attachmentCursor)
296	}
297
298	model, _ = composer.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
299	composer = model.(*Composer)
300
301	want := []string{"/tmp/a.txt", "/tmp/c.txt"}
302	if len(composer.attachmentPaths) != len(want) {
303		t.Fatalf("Expected %d attachments after removal, got %d", len(want), len(composer.attachmentPaths))
304	}
305	for i, path := range want {
306		if composer.attachmentPaths[i] != path {
307			t.Fatalf("attachmentPaths[%d] = %q, want %q", i, composer.attachmentPaths[i], path)
308		}
309	}
310	if _, ok := composer.attachmentNames["/tmp/b.txt"]; ok {
311		t.Fatal("Expected removed attachment display name to be deleted")
312	}
313	if composer.attachmentCursor != 1 {
314		t.Fatalf("Expected cursor to stay on the next attachment, got %d", composer.attachmentCursor)
315	}
316
317	model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
318	composer = model.(*Composer)
319	if composer.attachmentCursor != 0 {
320		t.Fatalf("Expected attachmentCursor to wrap to 0 after Down, got %d", composer.attachmentCursor)
321	}
322}
323
324// TestComposerGetFromAddress verifies the from address formatting.
325func TestComposerGetFromAddress(t *testing.T) {
326	t.Run("With name", func(t *testing.T) {
327		accounts := []config.Account{
328			{ID: "account-1", FetchEmail: "test@example.com", Name: "Test User"},
329		}
330		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
331
332		fromAddr := composer.getFromAddress()
333		expected := "Test User <test@example.com>"
334		if fromAddr != expected {
335			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
336		}
337	})
338
339	t.Run("Without name", func(t *testing.T) {
340		accounts := []config.Account{
341			{ID: "account-1", FetchEmail: "test@example.com"},
342		}
343		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
344
345		fromAddr := composer.getFromAddress()
346		expected := "test@example.com"
347		if fromAddr != expected {
348			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
349		}
350	})
351
352	t.Run("Send as overrides fetch email", func(t *testing.T) {
353		accounts := []config.Account{
354			{ID: "account-1", FetchEmail: "gmail@gmail.com", SendAsEmail: "alias@example.com", Name: "Test User"},
355		}
356		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
357
358		fromAddr := composer.getFromAddress()
359		expected := "Test User <alias@example.com>"
360		if fromAddr != expected {
361			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
362		}
363	})
364
365	t.Run("No accounts", func(t *testing.T) {
366		composer := NewComposer("", "", "", "", false)
367
368		fromAddr := composer.getFromAddress()
369		if fromAddr != "" {
370			t.Errorf("Expected empty from address, got %q", fromAddr)
371		}
372	})
373}
374
375// TestComposerSetSelectedAccount verifies account selection.
376func TestComposerSetSelectedAccount(t *testing.T) {
377	accounts := []config.Account{
378		{ID: "account-1", FetchEmail: "test1@example.com"},
379		{ID: "account-2", FetchEmail: "test2@example.com"},
380		{ID: "account-3", FetchEmail: "test3@example.com"},
381	}
382	composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
383
384	composer.SetSelectedAccount("account-3")
385	if composer.selectedAccountIdx != 2 {
386		t.Errorf("Expected selectedAccountIdx 2, got %d", composer.selectedAccountIdx)
387	}
388	if composer.GetSelectedAccountID() != "account-3" {
389		t.Errorf("Expected selected account ID 'account-3', got %q", composer.GetSelectedAccountID())
390	}
391
392	// Test non-existent account (should not change)
393	composer.SetSelectedAccount("non-existent")
394	if composer.selectedAccountIdx != 2 {
395		t.Errorf("Expected selectedAccountIdx to remain 2, got %d", composer.selectedAccountIdx)
396	}
397}
398
399// TestComposerDynamicHeight verifies that window resize updates textarea heights.
400func TestComposerDynamicHeight(t *testing.T) {
401	composer := NewComposer("", "", "", "", false)
402
403	model, _ := composer.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
404	composer = model.(*Composer)
405
406	if composer.height != 40 {
407		t.Errorf("Expected height 40, got %d", composer.height)
408	}
409
410	bodyH := composer.bodyInput.Height()
411	sigH := composer.signatureInput.Height()
412
413	if bodyH <= 3 {
414		t.Errorf("Expected bodyInput height > 3, got %d", bodyH)
415	}
416	if sigH <= 1 {
417		t.Errorf("Expected signatureInput height > 1, got %d", sigH)
418	}
419	if bodyH <= sigH {
420		t.Errorf("Expected bodyInput height (%d) > signatureInput height (%d)", bodyH, sigH)
421	}
422
423	// Small window: heights should not go below minimums
424	model, _ = composer.Update(tea.WindowSizeMsg{Width: 80, Height: 10})
425	composer = model.(*Composer)
426	if composer.bodyInput.Height() < 3 {
427		t.Errorf("bodyInput height should be at least 3, got %d", composer.bodyInput.Height())
428	}
429	if composer.signatureInput.Height() < 2 {
430		t.Errorf("signatureInput height should be at least 2, got %d", composer.signatureInput.Height())
431	}
432}