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 'EncryptPGP' toggle.
 75		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 76		composer = model.(*Composer)
 77		if composer.focusIndex != focusEncryptPGP {
 78			t.Errorf("After eight Tabs, focusIndex should be %d (focusEncryptPGP), got %d", focusEncryptPGP, composer.focusIndex)
 79		}
 80
 81		// Simulate pressing Tab again to move to the 'Send' button.
 82		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 83		composer = model.(*Composer)
 84		if composer.focusIndex != focusSend {
 85			t.Errorf("After nine Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
 86		}
 87
 88		// Simulate one more Tab to wrap around.
 89		// With single account, From field is skipped, so it wraps to focusTo.
 90		model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
 91		composer = model.(*Composer)
 92		if composer.focusIndex != focusTo {
 93			t.Errorf("After ten Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
 94		}
 95	})
 96
 97	t.Run("Send email message", func(t *testing.T) {
 98		// Re-initialize composer for this test
 99		composer = NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
100
101		// Set values for the email fields.
102		composer.toInput.SetValue("recipient@example.com")
103		composer.subjectInput.SetValue("Test Subject")
104		composer.bodyInput.SetValue("This is the body.")
105		// Set focus to the Send button.
106		composer.focusIndex = focusSend
107
108		// Simulate pressing Enter to send the email.
109		_, cmd := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
110		if cmd == nil {
111			t.Fatal("Expected a command to be returned, but got nil.")
112		}
113
114		// Execute the command and check the resulting message.
115		msg := cmd()
116		sendMsg, ok := msg.(SendEmailMsg)
117		if !ok {
118			t.Fatalf("Expected a SendEmailMsg, but got %T", msg)
119		}
120
121		// Verify the content of the message.
122		if sendMsg.To != "recipient@example.com" {
123			t.Errorf("Expected To 'recipient@example.com', got %q", sendMsg.To)
124		}
125		if sendMsg.Subject != "Test Subject" {
126			t.Errorf("Expected Subject 'Test Subject', got %q", sendMsg.Subject)
127		}
128		if sendMsg.Body != "This is the body." {
129			t.Errorf("Expected Body 'This is the body.', got %q", sendMsg.Body)
130		}
131		if sendMsg.AccountID != "account-1" {
132			t.Errorf("Expected AccountID 'account-1', got %q", sendMsg.AccountID)
133		}
134	})
135
136	t.Run("Account picker with multiple accounts", func(t *testing.T) {
137		multiAccounts := []config.Account{
138			{ID: "account-1", Email: "test1@example.com", Name: "User 1"},
139			{ID: "account-2", Email: "test2@example.com", Name: "User 2"},
140		}
141		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "", false)
142
143		// Move focus to From field
144		multiComposer.focusIndex = focusFrom
145
146		// Press Enter to open account picker
147		model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
148		multiComposer = model.(*Composer)
149
150		if !multiComposer.showAccountPicker {
151			t.Error("Expected account picker to be shown")
152		}
153
154		// Navigate down to select second account
155		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
156		multiComposer = model.(*Composer)
157
158		if multiComposer.selectedAccountIdx != 1 {
159			t.Errorf("Expected selectedAccountIdx to be 1, got %d", multiComposer.selectedAccountIdx)
160		}
161
162		// Press Enter to confirm selection
163		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
164		multiComposer = model.(*Composer)
165
166		if multiComposer.showAccountPicker {
167			t.Error("Expected account picker to be closed")
168		}
169
170		// Verify the selected account
171		if multiComposer.GetSelectedAccountID() != "account-2" {
172			t.Errorf("Expected selected account ID 'account-2', got %q", multiComposer.GetSelectedAccountID())
173		}
174	})
175
176	t.Run("Single account no picker", func(t *testing.T) {
177		singleAccounts := []config.Account{
178			{ID: "account-1", Email: "test@example.com"},
179		}
180		singleComposer := NewComposerWithAccounts(singleAccounts, "account-1", "", "", "", false)
181
182		// Move focus to From field
183		singleComposer.focusIndex = focusFrom
184
185		// Press Enter - should not open picker with single account
186		model, _ := singleComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
187		singleComposer = model.(*Composer)
188
189		if singleComposer.showAccountPicker {
190			t.Error("Account picker should not open with single account")
191		}
192	})
193
194	t.Run("Multi-account focus cycling includes From", func(t *testing.T) {
195		multiAccounts := []config.Account{
196			{ID: "account-1", Email: "test1@example.com"},
197			{ID: "account-2", Email: "test2@example.com"},
198		}
199		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "", false)
200
201		// Initial focus is on 'To' field
202		if multiComposer.focusIndex != focusTo {
203			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
204		}
205
206		// Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap)
207		model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc
208		multiComposer = model.(*Composer)
209		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc
210		multiComposer = model.(*Composer)
211		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Bcc -> Subject
212		multiComposer = model.(*Composer)
213		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Subject -> Body
214		multiComposer = model.(*Composer)
215		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Body -> Signature
216		multiComposer = model.(*Composer)
217		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Signature -> Attachment
218		multiComposer = model.(*Composer)
219		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME
220		multiComposer = model.(*Composer)
221		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> EncryptPGP
222		multiComposer = model.(*Composer)
223		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptPGP -> Send
224		multiComposer = model.(*Composer)
225		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap)
226		multiComposer = model.(*Composer)
227		model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // From -> To (wrap)
228		multiComposer = model.(*Composer)
229
230		// With multiple accounts, From field should be included in tab order
231		if multiComposer.focusIndex != focusTo {
232			t.Errorf("After ten Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
233		}
234	})
235}
236
237// TestComposerGetFromAddress verifies the from address formatting.
238func TestComposerGetFromAddress(t *testing.T) {
239	t.Run("With name", func(t *testing.T) {
240		accounts := []config.Account{
241			{ID: "account-1", FetchEmail: "test@example.com", Name: "Test User"},
242		}
243		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
244
245		fromAddr := composer.getFromAddress()
246		expected := "Test User <test@example.com>"
247		if fromAddr != expected {
248			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
249		}
250	})
251
252	t.Run("Without name", func(t *testing.T) {
253		accounts := []config.Account{
254			{ID: "account-1", FetchEmail: "test@example.com"},
255		}
256		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
257
258		fromAddr := composer.getFromAddress()
259		expected := "test@example.com"
260		if fromAddr != expected {
261			t.Errorf("Expected from address %q, got %q", expected, fromAddr)
262		}
263	})
264
265	t.Run("No accounts", func(t *testing.T) {
266		composer := NewComposer("", "", "", "", false)
267
268		fromAddr := composer.getFromAddress()
269		if fromAddr != "" {
270			t.Errorf("Expected empty from address, got %q", fromAddr)
271		}
272	})
273}
274
275// TestComposerSetSelectedAccount verifies account selection.
276func TestComposerSetSelectedAccount(t *testing.T) {
277	accounts := []config.Account{
278		{ID: "account-1", FetchEmail: "test1@example.com"},
279		{ID: "account-2", FetchEmail: "test2@example.com"},
280		{ID: "account-3", FetchEmail: "test3@example.com"},
281	}
282	composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
283
284	composer.SetSelectedAccount("account-3")
285	if composer.selectedAccountIdx != 2 {
286		t.Errorf("Expected selectedAccountIdx 2, got %d", composer.selectedAccountIdx)
287	}
288	if composer.GetSelectedAccountID() != "account-3" {
289		t.Errorf("Expected selected account ID 'account-3', got %q", composer.GetSelectedAccountID())
290	}
291
292	// Test non-existent account (should not change)
293	composer.SetSelectedAccount("non-existent")
294	if composer.selectedAccountIdx != 2 {
295		t.Errorf("Expected selectedAccountIdx to remain 2, got %d", composer.selectedAccountIdx)
296	}
297}
298
299// TestComposerDynamicHeight verifies that window resize updates textarea heights.
300func TestComposerDynamicHeight(t *testing.T) {
301	composer := NewComposer("", "", "", "", false)
302
303	model, _ := composer.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
304	composer = model.(*Composer)
305
306	if composer.height != 40 {
307		t.Errorf("Expected height 40, got %d", composer.height)
308	}
309
310	bodyH := composer.bodyInput.Height()
311	sigH := composer.signatureInput.Height()
312
313	if bodyH <= 3 {
314		t.Errorf("Expected bodyInput height > 3, got %d", bodyH)
315	}
316	if sigH <= 1 {
317		t.Errorf("Expected signatureInput height > 1, got %d", sigH)
318	}
319	if bodyH <= sigH {
320		t.Errorf("Expected bodyInput height (%d) > signatureInput height (%d)", bodyH, sigH)
321	}
322
323	// Small window: heights should not go below minimums
324	model, _ = composer.Update(tea.WindowSizeMsg{Width: 80, Height: 10})
325	composer = model.(*Composer)
326	if composer.bodyInput.Height() < 3 {
327		t.Errorf("bodyInput height should be at least 3, got %d", composer.bodyInput.Height())
328	}
329	if composer.signatureInput.Height() < 2 {
330		t.Errorf("signatureInput height should be at least 2, got %d", composer.signatureInput.Height())
331	}
332}