composer_test.go

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