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