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}