1package tui
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 tea "charm.land/bubbletea/v2"
9 "github.com/floatpane/matcha/config"
10)
11
12// TestComposerUpdate verifies the state transitions in the email composer.
13func TestComposerUpdate(t *testing.T) {
14 // Initialize a new composer with accounts.
15 accounts := []config.Account{
16 {ID: "account-1", Email: "test@example.com", Name: "Test User"},
17 }
18 composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
19
20 t.Run("Focus cycling", func(t *testing.T) {
21 // Initial focus is on the 'To' input (index 1, since From is 0).
22 // But NewComposer starts focus at focusTo which is 1.
23 if composer.focusIndex != focusTo {
24 t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex)
25 }
26
27 // Simulate pressing Tab to move to the 'Cc' field.
28 model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
29 composer = model.(*Composer)
30 if composer.focusIndex != focusCc {
31 t.Errorf("After one Tab, focusIndex should be %d (focusCc), got %d", focusCc, composer.focusIndex)
32 }
33
34 // Simulate pressing Tab to move to the 'Bcc' field.
35 model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
36 composer = model.(*Composer)
37 if composer.focusIndex != focusBcc {
38 t.Errorf("After two Tabs, focusIndex should be %d (focusBcc), got %d", focusBcc, composer.focusIndex)
39 }
40
41 // Simulate pressing Tab to move to the 'Subject' field.
42 model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
43 composer = model.(*Composer)
44 if composer.focusIndex != focusSubject {
45 t.Errorf("After three Tabs, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
46 }
47
48 // Simulate pressing Tab again to move to the 'Body' field.
49 model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
50 composer = model.(*Composer)
51 if composer.focusIndex != focusBody {
52 t.Errorf("After four Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
53 }
54
55 // Simulate pressing Tab again to move to the 'Signature' field.
56 model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
57 composer = model.(*Composer)
58 if composer.focusIndex != focusSignature {
59 t.Errorf("After five Tabs, focusIndex should be %d (focusSignature), got %d", focusSignature, composer.focusIndex)
60 }
61
62 // Simulate pressing Tab again to move to the 'Attachment' field.
63 model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
64 composer = model.(*Composer)
65 if composer.focusIndex != focusAttachment {
66 t.Errorf("After six Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
67 }
68
69 // Simulate pressing Tab again to move to the 'EncryptSMIME' toggle.
70 model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
71 composer = model.(*Composer)
72 if composer.focusIndex != focusEncryptSMIME {
73 t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, composer.focusIndex)
74 }
75
76 // Simulate pressing Tab again to move to the 'Send' button.
77 model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
78 composer = model.(*Composer)
79 if composer.focusIndex != focusSend {
80 t.Errorf("After eight Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
81 }
82
83 // Simulate one more Tab to wrap around.
84 // With single account, From field is skipped, so it wraps to focusTo.
85 model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab})
86 composer = model.(*Composer)
87 if composer.focusIndex != focusTo {
88 t.Errorf("After nine Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
89 }
90 })
91
92 t.Run("Send email message", func(t *testing.T) {
93 // Re-initialize composer for this test
94 composer = NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
95
96 // Set values for the email fields.
97 composer.toInput.SetValue("recipient@example.com")
98 composer.subjectInput.SetValue("Test Subject")
99 composer.bodyInput.SetValue("This is the body.")
100 // Set focus to the Send button.
101 composer.focusIndex = focusSend
102
103 // Simulate pressing Enter to send the email.
104 _, cmd := composer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
105 if cmd == nil {
106 t.Fatal("Expected a command to be returned, but got nil.")
107 }
108
109 // Execute the command and check the resulting message.
110 msg := cmd()
111 sendMsg, ok := msg.(SendEmailMsg)
112 if !ok {
113 t.Fatalf("Expected a SendEmailMsg, but got %T", msg)
114 }
115
116 // Verify the content of the message.
117 if sendMsg.To != "recipient@example.com" {
118 t.Errorf("Expected To 'recipient@example.com', got %q", sendMsg.To)
119 }
120 if sendMsg.Subject != "Test Subject" {
121 t.Errorf("Expected Subject 'Test Subject', got %q", sendMsg.Subject)
122 }
123 if sendMsg.Body != "This is the body." {
124 t.Errorf("Expected Body 'This is the body.', got %q", sendMsg.Body)
125 }
126 if sendMsg.AccountID != "account-1" {
127 t.Errorf("Expected AccountID 'account-1', got %q", sendMsg.AccountID)
128 }
129 })
130
131 t.Run("Account picker with multiple accounts", func(t *testing.T) {
132 multiAccounts := []config.Account{
133 {ID: "account-1", Email: "test1@example.com", Name: "User 1"},
134 {ID: "account-2", Email: "test2@example.com", Name: "User 2"},
135 }
136 multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "", false)
137
138 // Move focus to From field
139 multiComposer.focusIndex = focusFrom
140
141 // Press Enter to open account picker
142 model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
143 multiComposer = model.(*Composer)
144
145 if !multiComposer.showAccountPicker {
146 t.Error("Expected account picker to be shown")
147 }
148
149 // Navigate down to select second account
150 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
151 multiComposer = model.(*Composer)
152
153 if multiComposer.selectedAccountIdx != 1 {
154 t.Errorf("Expected selectedAccountIdx to be 1, got %d", multiComposer.selectedAccountIdx)
155 }
156
157 // Press Enter to confirm selection
158 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
159 multiComposer = model.(*Composer)
160
161 if multiComposer.showAccountPicker {
162 t.Error("Expected account picker to be closed")
163 }
164
165 // Verify the selected account
166 if multiComposer.GetSelectedAccountID() != "account-2" {
167 t.Errorf("Expected selected account ID 'account-2', got %q", multiComposer.GetSelectedAccountID())
168 }
169 })
170
171 t.Run("Single account no picker", func(t *testing.T) {
172 singleAccounts := []config.Account{
173 {ID: "account-1", Email: "test@example.com"},
174 }
175 singleComposer := NewComposerWithAccounts(singleAccounts, "account-1", "", "", "", false)
176
177 // Move focus to From field
178 singleComposer.focusIndex = focusFrom
179
180 // Press Enter - should not open picker with single account
181 model, _ := singleComposer.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
182 singleComposer = model.(*Composer)
183
184 if singleComposer.showAccountPicker {
185 t.Error("Account picker should not open with single account")
186 }
187 })
188
189 t.Run("Multi-account focus cycling includes From", func(t *testing.T) {
190 multiAccounts := []config.Account{
191 {ID: "account-1", Email: "test1@example.com"},
192 {ID: "account-2", Email: "test2@example.com"},
193 }
194 multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "", false)
195
196 // Initial focus is on 'To' field
197 if multiComposer.focusIndex != focusTo {
198 t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
199 }
200
201 // Tab through all fields: To -> Cc -> Bcc -> Subject -> Body -> Signature -> Attachment -> EncryptSMIME -> Send -> From (wrap)
202 model, _ := multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // To -> Cc
203 multiComposer = model.(*Composer)
204 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Cc -> Bcc
205 multiComposer = model.(*Composer)
206 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Bcc -> Subject
207 multiComposer = model.(*Composer)
208 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Subject -> Body
209 multiComposer = model.(*Composer)
210 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Body -> Signature
211 multiComposer = model.(*Composer)
212 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Signature -> Attachment
213 multiComposer = model.(*Composer)
214 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME
215 multiComposer = model.(*Composer)
216 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> Send
217 multiComposer = model.(*Composer)
218 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap)
219 multiComposer = model.(*Composer)
220 model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // From -> To (wrap)
221 multiComposer = model.(*Composer)
222
223 // With multiple accounts, From field should be included in tab order
224 if multiComposer.focusIndex != focusTo {
225 t.Errorf("After ten Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
226 }
227 })
228}
229
230func TestFormatAttachmentNameIncludesSize(t *testing.T) {
231 dir := t.TempDir()
232 path := filepath.Join(dir, "image.jpg")
233 if err := os.WriteFile(path, make([]byte, 1258291), 0600); err != nil {
234 t.Fatal(err)
235 }
236
237 got := formatAttachmentName(path)
238 want := "image.jpg (1.2 MB)"
239 if got != want {
240 t.Fatalf("formatAttachmentName() = %q, want %q", got, want)
241 }
242}
243
244func TestFormatAttachmentNameMissingFile(t *testing.T) {
245 got := formatAttachmentName("/missing/image.jpg")
246 want := "image.jpg"
247 if got != want {
248 t.Fatalf("formatAttachmentName() = %q, want %q", got, want)
249 }
250}
251
252// TestComposerGetFromAddress verifies the from address formatting.
253func TestComposerGetFromAddress(t *testing.T) {
254 t.Run("With name", func(t *testing.T) {
255 accounts := []config.Account{
256 {ID: "account-1", FetchEmail: "test@example.com", Name: "Test User"},
257 }
258 composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
259
260 fromAddr := composer.getFromAddress()
261 expected := "Test User <test@example.com>"
262 if fromAddr != expected {
263 t.Errorf("Expected from address %q, got %q", expected, fromAddr)
264 }
265 })
266
267 t.Run("Without name", func(t *testing.T) {
268 accounts := []config.Account{
269 {ID: "account-1", FetchEmail: "test@example.com"},
270 }
271 composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
272
273 fromAddr := composer.getFromAddress()
274 expected := "test@example.com"
275 if fromAddr != expected {
276 t.Errorf("Expected from address %q, got %q", expected, fromAddr)
277 }
278 })
279
280 t.Run("Send as overrides fetch email", func(t *testing.T) {
281 accounts := []config.Account{
282 {ID: "account-1", FetchEmail: "gmail@gmail.com", SendAsEmail: "alias@example.com", Name: "Test User"},
283 }
284 composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
285
286 fromAddr := composer.getFromAddress()
287 expected := "Test User <alias@example.com>"
288 if fromAddr != expected {
289 t.Errorf("Expected from address %q, got %q", expected, fromAddr)
290 }
291 })
292
293 t.Run("No accounts", func(t *testing.T) {
294 composer := NewComposer("", "", "", "", false)
295
296 fromAddr := composer.getFromAddress()
297 if fromAddr != "" {
298 t.Errorf("Expected empty from address, got %q", fromAddr)
299 }
300 })
301}
302
303// TestComposerSetSelectedAccount verifies account selection.
304func TestComposerSetSelectedAccount(t *testing.T) {
305 accounts := []config.Account{
306 {ID: "account-1", FetchEmail: "test1@example.com"},
307 {ID: "account-2", FetchEmail: "test2@example.com"},
308 {ID: "account-3", FetchEmail: "test3@example.com"},
309 }
310 composer := NewComposerWithAccounts(accounts, "account-1", "", "", "", false)
311
312 composer.SetSelectedAccount("account-3")
313 if composer.selectedAccountIdx != 2 {
314 t.Errorf("Expected selectedAccountIdx 2, got %d", composer.selectedAccountIdx)
315 }
316 if composer.GetSelectedAccountID() != "account-3" {
317 t.Errorf("Expected selected account ID 'account-3', got %q", composer.GetSelectedAccountID())
318 }
319
320 // Test non-existent account (should not change)
321 composer.SetSelectedAccount("non-existent")
322 if composer.selectedAccountIdx != 2 {
323 t.Errorf("Expected selectedAccountIdx to remain 2, got %d", composer.selectedAccountIdx)
324 }
325}
326
327// TestComposerDynamicHeight verifies that window resize updates textarea heights.
328func TestComposerDynamicHeight(t *testing.T) {
329 composer := NewComposer("", "", "", "", false)
330
331 model, _ := composer.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
332 composer = model.(*Composer)
333
334 if composer.height != 40 {
335 t.Errorf("Expected height 40, got %d", composer.height)
336 }
337
338 bodyH := composer.bodyInput.Height()
339 sigH := composer.signatureInput.Height()
340
341 if bodyH <= 3 {
342 t.Errorf("Expected bodyInput height > 3, got %d", bodyH)
343 }
344 if sigH <= 1 {
345 t.Errorf("Expected signatureInput height > 1, got %d", sigH)
346 }
347 if bodyH <= sigH {
348 t.Errorf("Expected bodyInput height (%d) > signatureInput height (%d)", bodyH, sigH)
349 }
350
351 // Small window: heights should not go below minimums
352 model, _ = composer.Update(tea.WindowSizeMsg{Width: 80, Height: 10})
353 composer = model.(*Composer)
354 if composer.bodyInput.Height() < 3 {
355 t.Errorf("bodyInput height should be at least 3, got %d", composer.bodyInput.Height())
356 }
357 if composer.signatureInput.Height() < 2 {
358 t.Errorf("signatureInput height should be at least 2, got %d", composer.signatureInput.Height())
359 }
360}