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}