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