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