composer_test.go

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