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