composer_test.go

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