Line data Source code
1 : /**
2 : * @file test_pty_views.c
3 : * @brief PTY-based tests for all email-cli views and modes.
4 : *
5 : * Usage: test-pty-views <email-cli> <mock-imap-server> <email-cli-ro> <email-sync>
6 : */
7 :
8 : #define _DEFAULT_SOURCE
9 : #define _XOPEN_SOURCE 600
10 :
11 : #include "ptytest.h"
12 : #include "pty_assert.h"
13 : #include <stdarg.h>
14 : #include <stdio.h>
15 : #include <stdlib.h>
16 : #include <string.h>
17 : #include <signal.h>
18 : #include <unistd.h>
19 : #include <fcntl.h>
20 : #include <sys/stat.h>
21 : #include <sys/wait.h>
22 : #include <sys/socket.h>
23 : #include <netinet/in.h>
24 :
25 : int g_tests_run = 0;
26 : int g_tests_failed = 0;
27 :
28 : /* ── Test infrastructure ─────────────────────────────────────────────── */
29 :
30 : static pid_t g_mock_pid = -1;
31 : static char g_test_home[256];
32 : static char g_cli_bin[512];
33 : static char g_cli_ro_bin[512];
34 : static char g_sync_bin[512];
35 : static char g_tui_bin[512];
36 : static char g_mock_bin[512];
37 : static char g_old_home[512];
38 : static char g_batch_cli_bin[512]; /* email-cli (batch), never changes */
39 :
40 : #define WAIT_MS 6000
41 : #define SETTLE_MS 600
42 : #define ROWS 24
43 : #define COLS 100
44 :
45 1453 : static void write_config(void) {
46 : char d1[300], d2[300], d3[350], d4[400], path[450];
47 1453 : snprintf(d1, sizeof(d1), "%s/.config", g_test_home);
48 1453 : snprintf(d2, sizeof(d2), "%s/.config/email-cli", g_test_home);
49 1453 : snprintf(d3, sizeof(d3), "%s/.config/email-cli/accounts", g_test_home);
50 1453 : snprintf(d4, sizeof(d4), "%s/.config/email-cli/accounts/testuser", g_test_home);
51 1453 : mkdir(g_test_home, 0700);
52 1453 : mkdir(d1, 0700);
53 1453 : mkdir(d2, 0700);
54 1453 : mkdir(d3, 0700);
55 1453 : mkdir(d4, 0700);
56 1453 : snprintf(path, sizeof(path), "%s/config.ini", d4);
57 1453 : FILE *fp = fopen(path, "w");
58 1453 : if (!fp) return;
59 1453 : fprintf(fp,
60 : "EMAIL_HOST=imaps://localhost:9993\n"
61 : "EMAIL_USER=testuser\n"
62 : "EMAIL_PASS=testpass\n"
63 : "EMAIL_FOLDER=INBOX\n"
64 : "SSL_NO_VERIFY=1\n" /* TLS with self-signed cert */
65 : "SMTP_HOST=smtps://localhost:9465\n"); /* dummy SMTP — avoids blocking prompt */
66 1453 : fclose(fp);
67 1453 : chmod(path, 0600);
68 : }
69 :
70 1128 : static void write_config_with_interval(int interval) {
71 : char d1[300], d2[300], d3[350], d4[400], path[450];
72 1128 : snprintf(d1, sizeof(d1), "%s/.config", g_test_home);
73 1128 : snprintf(d2, sizeof(d2), "%s/.config/email-cli", g_test_home);
74 1128 : snprintf(d3, sizeof(d3), "%s/.config/email-cli/accounts", g_test_home);
75 1128 : snprintf(d4, sizeof(d4), "%s/.config/email-cli/accounts/testuser", g_test_home);
76 1128 : mkdir(g_test_home, 0700);
77 1128 : mkdir(d1, 0700);
78 1128 : mkdir(d2, 0700);
79 1128 : mkdir(d3, 0700);
80 1128 : mkdir(d4, 0700);
81 1128 : snprintf(path, sizeof(path), "%s/config.ini", d4);
82 1128 : FILE *fp = fopen(path, "w");
83 1128 : if (!fp) return;
84 1128 : fprintf(fp,
85 : "EMAIL_HOST=imaps://localhost:9993\n"
86 : "EMAIL_USER=testuser\n"
87 : "EMAIL_PASS=testpass\n"
88 : "EMAIL_FOLDER=INBOX\n"
89 : "SSL_NO_VERIFY=1\n" /* TLS with self-signed cert */
90 : "SYNC_INTERVAL=%d\n", interval);
91 1128 : fclose(fp);
92 1128 : chmod(path, 0600);
93 : }
94 :
95 13215 : static int start_mock_server(void) {
96 13215 : g_mock_pid = fork();
97 13366 : if (g_mock_pid < 0) return -1;
98 13366 : if (g_mock_pid == 0) {
99 151 : int devnull = open("/dev/null", O_WRONLY);
100 151 : if (devnull >= 0) { dup2(devnull, 1); dup2(devnull, 2); close(devnull); }
101 151 : execl(g_mock_bin, "mock_imap_server", (char *)NULL);
102 151 : _exit(127);
103 : }
104 13215 : usleep(800000);
105 13215 : return 0;
106 : }
107 :
108 13017 : static int probe_server(void) {
109 13017 : int fd = socket(AF_INET, SOCK_STREAM, 0);
110 13017 : if (fd < 0) return -1;
111 13017 : struct sockaddr_in addr = { .sin_family = AF_INET,
112 13017 : .sin_port = htons(9993), .sin_addr.s_addr = htonl(INADDR_LOOPBACK) };
113 13017 : int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
114 13017 : close(fd);
115 13017 : return ret;
116 : }
117 :
118 13017 : static void restart_mock(void) {
119 13017 : if (g_mock_pid > 0) {
120 12078 : kill(g_mock_pid, SIGKILL);
121 12078 : waitpid(g_mock_pid, NULL, 0);
122 12078 : g_mock_pid = -1;
123 : }
124 13017 : usleep(200000);
125 13017 : start_mock_server();
126 13017 : for (int i = 0; i < 30 && probe_server() != 0; i++)
127 0 : usleep(100000);
128 13017 : }
129 :
130 957 : static void stop_mock_server(void) {
131 957 : if (g_mock_pid > 0) {
132 957 : kill(g_mock_pid, SIGKILL);
133 957 : waitpid(g_mock_pid, NULL, 0);
134 957 : g_mock_pid = -1;
135 : }
136 957 : }
137 :
138 9965 : static PtySession *cli_open_size(int cols, int rows, const char **extra_args) {
139 : const char *args[32];
140 9965 : int n = 0;
141 9965 : args[n++] = g_cli_bin;
142 9965 : if (extra_args)
143 26720 : for (int i = 0; extra_args[i] && n < 31; i++)
144 18379 : args[n++] = extra_args[i];
145 9965 : args[n] = NULL;
146 :
147 9965 : PtySession *s = pty_open(cols, rows);
148 9965 : if (!s) return NULL;
149 9965 : if (pty_run(s, args) != 0) { pty_close(s); return NULL; }
150 9872 : return s;
151 : }
152 :
153 7530 : static PtySession *cli_run(const char **extra_args) {
154 7530 : return cli_open_size(COLS, ROWS, extra_args);
155 : }
156 :
157 : /** @brief Find the row containing text; returns -1 if not found. */
158 817 : static int find_row(PtySession *s, const char *text) {
159 19452 : for (int r = 0; r < ROWS; r++)
160 19292 : if (pty_row_contains(s, r, text)) return r;
161 160 : return -1;
162 : }
163 :
164 : /* ══════════════════════════════════════════════════════════════════════
165 : * HELP PAGE TESTS
166 : * ══════════════════════════════════════════════════════════════════════ */
167 :
168 204 : static void test_help_general(void) {
169 204 : const char *a[] = {"--help", NULL};
170 : /* The general help now also lists the search, filters and JSON options;
171 : * it no longer fits in 50 rows. */
172 204 : PtySession *s = cli_open_size(120, 80, a);
173 203 : ASSERT(s != NULL, "help: opens");
174 203 : ASSERT_WAIT_FOR(s, "Reading:", WAIT_MS);
175 203 : pty_settle(s, 300);
176 203 : ASSERT_SCREEN_CONTAINS(s, "list");
177 203 : ASSERT_SCREEN_CONTAINS(s, "show");
178 203 : ASSERT_SCREEN_CONTAINS(s, "list-folders");
179 203 : ASSERT_SCREEN_CONTAINS(s, "sync");
180 203 : ASSERT_SCREEN_CONTAINS(s, "help");
181 203 : pty_close(s);
182 : }
183 :
184 203 : static void test_help_list(void) {
185 203 : const char *a[] = {"list", "--help", NULL};
186 203 : PtySession *s = cli_open_size(120, 80, a) /* list --help is ~60 lines */;
187 202 : ASSERT(s != NULL, "help list: opens");
188 202 : ASSERT_WAIT_FOR(s, "Usage: email-cli", WAIT_MS); /* common prefix for -ro too */
189 202 : pty_settle(s, 300);
190 202 : ASSERT_SCREEN_CONTAINS(s, "--all");
191 202 : ASSERT_SCREEN_CONTAINS(s, "--folder");
192 202 : pty_close(s);
193 : }
194 :
195 334 : static void test_help_show(void) {
196 334 : const char *a[] = {"show", "--help", NULL};
197 334 : PtySession *s = cli_open_size(120, 50, a);
198 332 : ASSERT(s != NULL, "help show: opens");
199 332 : ASSERT_WAIT_FOR(s, "show <uid>", WAIT_MS);
200 332 : pty_close(s);
201 : }
202 :
203 332 : static void test_help_folders(void) {
204 332 : const char *a[] = {"list-folders", "--help", NULL};
205 332 : PtySession *s = cli_open_size(120, 50, a);
206 330 : ASSERT(s != NULL, "help list-folders: opens");
207 330 : ASSERT_WAIT_FOR(s, "Usage: email-cli", WAIT_MS);
208 330 : pty_settle(s, 300);
209 330 : ASSERT_SCREEN_CONTAINS(s, "--tree");
210 330 : pty_close(s);
211 : }
212 :
213 200 : static void test_help_sync(void) {
214 200 : const char *a[] = {g_sync_bin, "--help", NULL};
215 200 : PtySession *s = pty_open(120, 50);
216 200 : ASSERT(s != NULL, "help sync: opens");
217 200 : ASSERT(pty_run(s, a) == 0, "help sync: pty_run");
218 199 : ASSERT_WAIT_FOR(s, "Usage: email-sync", WAIT_MS);
219 199 : pty_settle(s, 300);
220 199 : ASSERT_SCREEN_CONTAINS(s, "sync");
221 199 : pty_close(s);
222 : }
223 :
224 199 : static void test_help_cron(void) {
225 199 : const char *a[] = {g_sync_bin, "cron", "--help", NULL};
226 199 : PtySession *s = pty_open(120, 50);
227 199 : ASSERT(s != NULL, "help cron: opens");
228 199 : ASSERT(pty_run(s, a) == 0, "help cron: pty_run");
229 198 : ASSERT_WAIT_FOR(s, "Usage: email-sync", WAIT_MS);
230 198 : pty_settle(s, 300);
231 198 : ASSERT_SCREEN_CONTAINS(s, "cron");
232 198 : pty_close(s);
233 : }
234 :
235 : /* ══════════════════════════════════════════════════════════════════════
236 : * BATCH MODE TESTS
237 : * ══════════════════════════════════════════════════════════════════════ */
238 :
239 420 : static void test_batch_list(void) {
240 420 : const char *a[] = {"list", "--batch", NULL};
241 420 : PtySession *s = cli_run(a);
242 417 : ASSERT(s != NULL, "batch list: opens");
243 417 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
244 417 : pty_settle(s, SETTLE_MS);
245 417 : ASSERT_SCREEN_CONTAINS(s, "UID");
246 417 : ASSERT_SCREEN_CONTAINS(s, "From");
247 417 : ASSERT_SCREEN_CONTAINS(s, "Subject");
248 417 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
249 417 : pty_close(s);
250 : }
251 :
252 290 : static void test_batch_list_all(void) {
253 290 : const char *a[] = {"list", "--all", "--batch", NULL};
254 290 : PtySession *s = cli_run(a);
255 288 : ASSERT(s != NULL, "batch list --all: opens");
256 288 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
257 288 : ASSERT_SCREEN_CONTAINS(s, "message(s) in");
258 288 : pty_close(s);
259 : }
260 :
261 323 : static void test_batch_list_empty(void) {
262 323 : const char *a[] = {"list", "--folder", "INBOX.Empty", "--batch", NULL};
263 323 : PtySession *s = cli_run(a);
264 321 : ASSERT(s != NULL, "batch list empty: opens");
265 321 : ASSERT_WAIT_FOR(s, "No messages", WAIT_MS);
266 321 : pty_close(s);
267 : }
268 :
269 410 : static void test_batch_show(void) {
270 410 : const char *a[] = {"show", "1", "--batch", NULL};
271 : /* The mock message renders to ~30 lines; on a 24-row terminal the header
272 : * block would scroll off before the assertions run. */
273 410 : PtySession *s = cli_open_size(COLS, 60, a);
274 407 : ASSERT(s != NULL, "batch show: opens");
275 407 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
276 407 : pty_settle(s, SETTLE_MS);
277 407 : ASSERT_SCREEN_CONTAINS(s, "Subject:");
278 407 : ASSERT_SCREEN_CONTAINS(s, "Date:");
279 407 : ASSERT_SCREEN_CONTAINS(s, "Hello from Mock Server");
280 407 : pty_close(s);
281 : }
282 :
283 407 : static void test_batch_folders_flat(void) {
284 407 : const char *a[] = {"list-folders", "--batch", NULL};
285 407 : PtySession *s = cli_run(a);
286 404 : ASSERT(s != NULL, "batch list-folders: opens");
287 404 : ASSERT_WAIT_FOR(s, "INBOX", WAIT_MS);
288 404 : pty_settle(s, SETTLE_MS);
289 404 : ASSERT_SCREEN_CONTAINS(s, "INBOX.Sent");
290 404 : ASSERT_SCREEN_CONTAINS(s, "INBOX.Trash");
291 404 : pty_close(s);
292 : }
293 :
294 314 : static void test_batch_folders_tree(void) {
295 314 : const char *a[] = {"list-folders", "--tree", "--batch", NULL};
296 314 : PtySession *s = cli_run(a);
297 312 : ASSERT(s != NULL, "batch list-folders --tree: opens");
298 312 : ASSERT_WAIT_FOR(s, "INBOX", WAIT_MS);
299 312 : pty_settle(s, SETTLE_MS);
300 312 : ASSERT_SCREEN_CONTAINS(s, "Sent");
301 312 : ASSERT_SCREEN_CONTAINS(s, "Trash");
302 312 : pty_close(s);
303 : }
304 :
305 195 : static void test_batch_list_folder(void) {
306 195 : const char *a[] = {"list", "--folder", "INBOX.Sent", "--batch", NULL};
307 195 : PtySession *s = cli_run(a);
308 194 : ASSERT(s != NULL, "batch list --folder: opens");
309 194 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
310 194 : pty_settle(s, SETTLE_MS);
311 194 : ASSERT_SCREEN_CONTAINS(s, "INBOX.Sent");
312 194 : pty_close(s);
313 : }
314 :
315 194 : static void test_batch_list_limit(void) {
316 194 : const char *a[] = {"list", "--limit", "1", "--batch", NULL};
317 194 : PtySession *s = cli_run(a);
318 193 : ASSERT(s != NULL, "batch list --limit: opens");
319 193 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
320 193 : pty_settle(s, SETTLE_MS);
321 193 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
322 193 : pty_close(s);
323 : }
324 :
325 193 : static void test_batch_list_offset(void) {
326 193 : const char *a[] = {"list", "--offset", "1", "--batch", NULL};
327 193 : PtySession *s = cli_run(a);
328 192 : ASSERT(s != NULL, "batch list --offset: opens");
329 192 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
330 192 : pty_close(s);
331 : }
332 :
333 189 : static void test_batch_sync(void) {
334 189 : restart_mock();
335 189 : PtySession *s = pty_open(COLS, ROWS);
336 189 : ASSERT(s != NULL, "batch sync: opens");
337 189 : const char *a[] = {g_sync_bin, NULL};
338 189 : ASSERT(pty_run(s, a) == 0, "batch sync: pty_run");
339 188 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
340 188 : pty_close(s);
341 : }
342 :
343 : static void write_rules_ini(void);
344 : static void remove_rules_ini(void);
345 :
346 188 : static void test_batch_rules_apply(void) {
347 : /* "AlwaysTag" has no conditions → when_from_flat returns NULL → when=NULL
348 : * → mail_rule_matches takes the legacy flat-field path → glob_match covered. */
349 188 : write_rules_ini();
350 188 : const char *a[] = {"rules", "apply", NULL};
351 188 : PtySession *s = cli_run(a);
352 187 : ASSERT(s != NULL, "rules apply: opens");
353 187 : ASSERT_WAIT_FOR(s, "Rules applied:", WAIT_MS);
354 187 : pty_close(s);
355 187 : remove_rules_ini();
356 : }
357 :
358 187 : static void test_batch_cron_status(void) {
359 187 : const char *a[] = {g_sync_bin, "cron", "status", NULL};
360 187 : PtySession *s = pty_open(COLS, ROWS);
361 187 : ASSERT(s != NULL, "batch cron status: opens");
362 187 : ASSERT(pty_run(s, a) == 0, "batch cron status: pty_run");
363 : /* Matches "No email-sync cron entry found." or "Cron entry found:" */
364 186 : ASSERT_WAIT_FOR(s, "ron", WAIT_MS);
365 186 : pty_close(s);
366 : }
367 :
368 : /* ══════════════════════════════════════════════════════════════════════
369 : * COMMAND SEPARATION: labels (Gmail) vs folders (IMAP)
370 : * ══════════════════════════════════════════════════════════════════════ */
371 :
372 186 : static void test_list_labels_blocked_on_imap(void) {
373 : /* 'list-labels' is Gmail-only; on IMAP it must print an error and exit non-0 */
374 186 : restart_mock();
375 186 : const char *a[] = {"list-labels", NULL};
376 186 : PtySession *s = cli_run(a);
377 185 : ASSERT(s != NULL, "list-labels on IMAP: opens");
378 185 : ASSERT_WAIT_FOR(s, "list-labels", WAIT_MS);
379 185 : ASSERT_SCREEN_CONTAINS(s, "Gmail");
380 185 : pty_close(s);
381 : }
382 :
383 185 : static void test_list_folders_works_on_imap(void) {
384 : /* 'list-folders' must succeed on IMAP and list folder names */
385 185 : restart_mock();
386 185 : const char *a[] = {"list-folders", "--batch", NULL};
387 185 : PtySession *s = cli_run(a);
388 184 : ASSERT(s != NULL, "list-folders on IMAP: opens");
389 184 : ASSERT_WAIT_FOR(s, "INBOX", WAIT_MS);
390 184 : pty_close(s);
391 : }
392 :
393 184 : static void test_create_label_blocked_on_imap(void) {
394 : /* 'create-label' is Gmail-only; on IMAP it must print an error */
395 184 : restart_mock();
396 184 : const char *a[] = {"create-label", "TestLabel", NULL};
397 184 : PtySession *s = cli_run(a);
398 183 : ASSERT(s != NULL, "create-label on IMAP: opens");
399 183 : ASSERT_WAIT_FOR(s, "create-label", WAIT_MS);
400 183 : ASSERT_SCREEN_CONTAINS(s, "Gmail");
401 183 : pty_close(s);
402 : }
403 :
404 183 : static void test_delete_label_blocked_on_imap(void) {
405 : /* 'delete-label' is Gmail-only; on IMAP it must print an error */
406 183 : restart_mock();
407 183 : const char *a[] = {"delete-label", "SomeLabel", NULL};
408 183 : PtySession *s = cli_run(a);
409 182 : ASSERT(s != NULL, "delete-label on IMAP: opens");
410 182 : ASSERT_WAIT_FOR(s, "delete-label", WAIT_MS);
411 182 : ASSERT_SCREEN_CONTAINS(s, "Gmail");
412 182 : pty_close(s);
413 : }
414 :
415 182 : static void test_create_folder_help(void) {
416 : /* 'create-folder --help' must show usage */
417 182 : const char *a[] = {"create-folder", "--help", NULL};
418 182 : PtySession *s = cli_run(a);
419 181 : ASSERT(s != NULL, "create-folder --help: opens");
420 181 : ASSERT_WAIT_FOR(s, "create-folder", WAIT_MS);
421 181 : pty_settle(s, SETTLE_MS);
422 181 : ASSERT_SCREEN_CONTAINS(s, "IMAP");
423 181 : pty_close(s);
424 : }
425 :
426 181 : static void test_delete_folder_help(void) {
427 : /* 'delete-folder --help' must show usage */
428 181 : const char *a[] = {"delete-folder", "--help", NULL};
429 181 : PtySession *s = cli_run(a);
430 180 : ASSERT(s != NULL, "delete-folder --help: opens");
431 180 : ASSERT_WAIT_FOR(s, "delete-folder", WAIT_MS);
432 180 : pty_settle(s, SETTLE_MS);
433 180 : ASSERT_SCREEN_CONTAINS(s, "IMAP");
434 180 : pty_close(s);
435 : }
436 :
437 180 : static void test_create_folder_missing_arg(void) {
438 : /* 'create-folder' with no argument must print error and show usage */
439 180 : restart_mock();
440 180 : const char *a[] = {"create-folder", NULL};
441 180 : PtySession *s = cli_run(a);
442 179 : ASSERT(s != NULL, "create-folder no arg: opens");
443 179 : ASSERT_WAIT_FOR(s, "create-folder", WAIT_MS);
444 179 : pty_close(s);
445 : }
446 :
447 179 : static void test_delete_folder_missing_arg(void) {
448 : /* 'delete-folder' with no argument must print error and show usage */
449 179 : restart_mock();
450 179 : const char *a[] = {"delete-folder", NULL};
451 179 : PtySession *s = cli_run(a);
452 178 : ASSERT(s != NULL, "delete-folder no arg: opens");
453 178 : ASSERT_WAIT_FOR(s, "delete-folder", WAIT_MS);
454 178 : pty_close(s);
455 : }
456 :
457 : /* Forward declaration — defined later after the email-tui helper section */
458 : static PtySession *tui_open_to_list(void);
459 : static PtySession *tui_open_to_list_size(int cols, int rows);
460 : static void folders_goto_inbox(PtySession *s);
461 :
462 : /* ══════════════════════════════════════════════════════════════════════
463 : * INTERACTIVE LIST VIEW
464 : * ══════════════════════════════════════════════════════════════════════ */
465 :
466 178 : static void test_interactive_list_content(void) {
467 178 : restart_mock();
468 178 : PtySession *s = tui_open_to_list();
469 177 : ASSERT(s != NULL, "interactive list: opens");
470 177 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
471 177 : pty_settle(s, SETTLE_MS);
472 177 : ASSERT_SCREEN_CONTAINS(s, "From");
473 177 : ASSERT_SCREEN_CONTAINS(s, "Subject");
474 177 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
475 177 : pty_send_key(s, PTY_KEY_ESC);
476 177 : pty_close(s);
477 : }
478 :
479 177 : static void test_interactive_list_separator(void) {
480 177 : restart_mock();
481 177 : PtySession *s = tui_open_to_list();
482 176 : ASSERT(s != NULL, "list separator: opens");
483 176 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
484 176 : pty_settle(s, SETTLE_MS);
485 176 : ASSERT_SCREEN_CONTAINS(s, "══");
486 176 : pty_send_key(s, PTY_KEY_ESC);
487 176 : pty_close(s);
488 : }
489 :
490 176 : static void test_interactive_list_statusbar(void) {
491 176 : restart_mock();
492 176 : PtySession *s = tui_open_to_list();
493 175 : ASSERT(s != NULL, "list statusbar: opens");
494 175 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
495 175 : pty_settle(s, SETTLE_MS);
496 :
497 : /* Find statusbar dynamically — contains "step" */
498 175 : int sb = find_row(s, "step");
499 175 : ASSERT(sb >= 0, "list sb: found row with 'step'");
500 175 : if (sb >= 0) {
501 175 : ASSERT(pty_row_contains(s, sb, "Enter=open"), "list sb: Enter=open");
502 175 : ASSERT(pty_row_contains(s, sb, "Backspace=folders"), "list sb: Backspace");
503 175 : ASSERT(pty_row_contains(s, sb, "ESC=quit"), "list sb: ESC=quit");
504 175 : ASSERT_CELL_ATTR(s, sb, 2, PTY_ATTR_REVERSE);
505 : }
506 175 : pty_send_key(s, PTY_KEY_ESC);
507 175 : pty_close(s);
508 : }
509 :
510 175 : static void test_interactive_list_esc_quit(void) {
511 175 : restart_mock();
512 175 : PtySession *s = tui_open_to_list();
513 174 : ASSERT(s != NULL, "list ESC: opens");
514 174 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
515 174 : pty_settle(s, SETTLE_MS);
516 174 : pty_send_key(s, PTY_KEY_ESC);
517 174 : pty_settle(s, SETTLE_MS); /* program exits quietly — no output to wait for */
518 174 : pty_close(s);
519 : }
520 :
521 174 : static void test_interactive_list_nav(void) {
522 174 : restart_mock();
523 174 : PtySession *s = tui_open_to_list();
524 173 : ASSERT(s != NULL, "list nav: opens");
525 173 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
526 173 : pty_settle(s, SETTLE_MS);
527 : /* With one message, UP/DOWN keep cursor in place — no crash expected */
528 173 : pty_send_key(s, PTY_KEY_DOWN);
529 173 : pty_settle(s, SETTLE_MS);
530 173 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
531 173 : pty_send_key(s, PTY_KEY_UP);
532 173 : pty_settle(s, SETTLE_MS);
533 173 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
534 173 : pty_send_key(s, PTY_KEY_PGDN);
535 173 : pty_settle(s, SETTLE_MS);
536 173 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
537 173 : pty_send_key(s, PTY_KEY_PGUP);
538 173 : pty_settle(s, SETTLE_MS);
539 173 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
540 173 : pty_send_key(s, PTY_KEY_ESC);
541 173 : pty_close(s);
542 : }
543 :
544 173 : static void test_interactive_list_flags(void) {
545 173 : restart_mock();
546 173 : PtySession *s = tui_open_to_list();
547 172 : ASSERT(s != NULL, "list flags: opens");
548 172 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
549 172 : pty_settle(s, SETTLE_MS);
550 : /* Flag keys: n=new, f=flagged, d=done — server accepts silently */
551 172 : pty_send_str(s, "n");
552 172 : pty_settle(s, SETTLE_MS);
553 172 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
554 172 : pty_send_str(s, "f");
555 172 : pty_settle(s, SETTLE_MS);
556 172 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
557 172 : pty_send_str(s, "d");
558 172 : pty_settle(s, SETTLE_MS);
559 172 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
560 172 : pty_send_key(s, PTY_KEY_ESC);
561 172 : pty_close(s);
562 : }
563 :
564 : /** '/' in the message list activates the inline subject/body filter. */
565 172 : static void test_tui_list_search_filter(void) {
566 : /* Exercises list_filter_rebuild and filter input handling in email_service.c */
567 172 : restart_mock();
568 172 : PtySession *s = tui_open_to_list();
569 171 : ASSERT(s != NULL, "list search: opens");
570 171 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
571 171 : pty_settle(s, SETTLE_MS);
572 :
573 : /* Activate filter mode */
574 171 : pty_send_str(s, "/");
575 171 : ASSERT_WAIT_FOR(s, "Filter", WAIT_MS); /* filter bar appears */
576 171 : pty_settle(s, SETTLE_MS);
577 :
578 : /* Type search term — filter bar rebuilds on each character */
579 171 : pty_send_str(s, "Test");
580 171 : pty_settle(s, SETTLE_MS);
581 :
582 : /* TAB: cycle scope (Subject → From → …) */
583 171 : pty_send_key(s, PTY_KEY_TAB);
584 171 : pty_settle(s, SETTLE_MS / 2);
585 :
586 : /* BACK: remove last typed character */
587 171 : pty_send_key(s, PTY_KEY_BACK);
588 171 : pty_settle(s, SETTLE_MS / 2);
589 :
590 : /* Enter: commit filter (stay in filter mode but stop typing) */
591 171 : pty_send_key(s, PTY_KEY_ENTER);
592 171 : pty_settle(s, SETTLE_MS / 2);
593 :
594 : /* First ESC: clear filter, remain in list */
595 171 : pty_send_key(s, PTY_KEY_ESC);
596 171 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
597 :
598 : /* Second ESC: exit list view */
599 171 : pty_send_key(s, PTY_KEY_ESC);
600 171 : pty_close(s);
601 : }
602 :
603 : /* ══════════════════════════════════════════════════════════════════════
604 : * INTERACTIVE SHOW VIEW
605 : * ══════════════════════════════════════════════════════════════════════ */
606 :
607 171 : static void test_interactive_show_content(void) {
608 171 : restart_mock();
609 171 : PtySession *s = tui_open_to_list();
610 170 : ASSERT(s != NULL, "show content: opens");
611 170 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
612 170 : pty_settle(s, SETTLE_MS);
613 170 : pty_send_key(s, PTY_KEY_ENTER);
614 170 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
615 170 : pty_settle(s, SETTLE_MS);
616 170 : ASSERT_SCREEN_CONTAINS(s, "Subject:");
617 170 : ASSERT_SCREEN_CONTAINS(s, "Date:");
618 170 : ASSERT_SCREEN_CONTAINS(s, "Hello from Mock Server");
619 170 : pty_send_key(s, PTY_KEY_ESC);
620 170 : pty_close(s);
621 : }
622 :
623 170 : static void test_interactive_show_separator(void) {
624 170 : restart_mock();
625 170 : PtySession *s = tui_open_to_list();
626 169 : ASSERT(s != NULL, "show separator: opens");
627 169 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
628 169 : pty_settle(s, SETTLE_MS);
629 169 : pty_send_key(s, PTY_KEY_ENTER);
630 169 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
631 169 : pty_settle(s, SETTLE_MS);
632 169 : ASSERT_SCREEN_CONTAINS(s, "────");
633 169 : pty_send_key(s, PTY_KEY_ESC);
634 169 : pty_close(s);
635 : }
636 :
637 169 : static void test_interactive_show_statusbar(void) {
638 169 : restart_mock();
639 : /* The reader status bar lists every shortcut, including the two attachment
640 : * entries the mock message provides (a=save, A=save-all). That line is
641 : * longer than 100 columns and would be truncated before "/=search", so use
642 : * a wider terminal here. */
643 169 : PtySession *s = tui_open_to_list_size(160, ROWS);
644 168 : ASSERT(s != NULL, "show statusbar: opens");
645 168 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
646 168 : pty_settle(s, SETTLE_MS);
647 168 : pty_send_key(s, PTY_KEY_ENTER);
648 168 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
649 168 : pty_settle(s, SETTLE_MS);
650 :
651 168 : int sb = find_row(s, "/=search");
652 168 : ASSERT(sb >= 0, "show sb: found /=search");
653 168 : if (sb >= 0) {
654 168 : ASSERT(pty_row_contains(s, sb, "/=search"), "show sb: /=search");
655 168 : ASSERT(pty_row_contains(s, sb, "v=source"), "show sb: v=source");
656 168 : ASSERT_CELL_ATTR(s, sb, 2, PTY_ATTR_REVERSE);
657 : }
658 168 : pty_send_key(s, PTY_KEY_BACK);
659 168 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
660 168 : pty_send_key(s, PTY_KEY_ESC);
661 168 : pty_close(s);
662 : }
663 :
664 168 : static void test_interactive_show_backspace(void) {
665 168 : restart_mock();
666 168 : PtySession *s = tui_open_to_list();
667 167 : ASSERT(s != NULL, "show backspace: opens");
668 167 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
669 167 : pty_settle(s, SETTLE_MS);
670 167 : pty_send_key(s, PTY_KEY_ENTER);
671 167 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
672 167 : pty_settle(s, SETTLE_MS);
673 : /* Backspace returns to list */
674 167 : pty_send_key(s, PTY_KEY_BACK);
675 167 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
676 167 : pty_send_key(s, PTY_KEY_ESC);
677 167 : pty_close(s);
678 : }
679 :
680 : /*
681 : * ══════════════════════════════════════════════════════════════════════
682 : * US-RD-05: As a user reading a message, pressing ESC exits the program
683 : * immediately; Backspace returns to the message list.
684 : * Acceptance criteria:
685 : * - ESC in reader: program terminates, list NOT shown.
686 : * - Backspace in reader: list view shown again.
687 : * - 'q' in reader: list view shown again.
688 : * ══════════════════════════════════════════════════════════════════════
689 : */
690 167 : static void test_interactive_show_esc_exits(void) {
691 : /* US-RD-05 */
692 167 : restart_mock();
693 167 : PtySession *s = tui_open_to_list();
694 166 : ASSERT(s != NULL, "show ESC→exit: opens");
695 166 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
696 166 : pty_settle(s, SETTLE_MS);
697 166 : pty_send_key(s, PTY_KEY_ENTER);
698 166 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
699 166 : pty_settle(s, SETTLE_MS);
700 : /* ESC from reader exits the program (does NOT return to list) */
701 166 : pty_send_key(s, PTY_KEY_ESC);
702 166 : pty_settle(s, SETTLE_MS * 2);
703 166 : int r = pty_wait_for(s, "message(s) in", 1500);
704 166 : ASSERT(r != 0, "show ESC: does NOT return to list");
705 166 : pty_close(s);
706 : }
707 :
708 : /*
709 : * ══════════════════════════════════════════════════════════════════════
710 : * US-RD-01: As a user, I want to see the message UID in the reader header
711 : * so that I can identify the message for debugging or scripting.
712 : * Acceptance criteria:
713 : * - A "UID:" line is visible in the reader header area.
714 : * - The UID value is a non-empty string.
715 : * ══════════════════════════════════════════════════════════════════════
716 : */
717 163 : static void test_interactive_show_uid_in_header(void) {
718 : /* US-RD-01 */
719 163 : restart_mock();
720 163 : PtySession *s = tui_open_to_list();
721 162 : ASSERT(s != NULL, "show UID header: opens");
722 162 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
723 162 : pty_settle(s, SETTLE_MS);
724 162 : pty_send_key(s, PTY_KEY_ENTER);
725 162 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
726 162 : pty_settle(s, SETTLE_MS);
727 162 : ASSERT_SCREEN_CONTAINS(s, "UID:");
728 162 : pty_send_key(s, PTY_KEY_BACK);
729 162 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
730 162 : pty_send_key(s, PTY_KEY_ESC);
731 162 : pty_close(s);
732 : }
733 :
734 : /*
735 : * ══════════════════════════════════════════════════════════════════════
736 : * US-RD-02: As a user, I want to toggle between rendered and raw source
737 : * view with 'v' so I can inspect headers and raw MIME content.
738 : * Acceptance criteria:
739 : * - Default view is rendered (HTML/text body visible).
740 : * - 'v' switches to raw source: MIME headers (Content-Type:) visible.
741 : * - Statusbar shows "v=rendered" in source mode, "v=source" in rendered.
742 : * - Second 'v' returns to rendered view.
743 : * ══════════════════════════════════════════════════════════════════════
744 : */
745 162 : static void test_interactive_show_source_toggle(void) {
746 : /* US-RD-02. The v=source / v=rendered hint sits near the end of the
747 : * reader status bar, which is wider than 100 columns for a message with
748 : * attachments — use a wide terminal so it is not truncated away. */
749 162 : restart_mock();
750 162 : PtySession *s = tui_open_to_list_size(160, ROWS);
751 161 : ASSERT(s != NULL, "show source toggle: opens");
752 161 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
753 161 : pty_settle(s, SETTLE_MS);
754 161 : pty_send_key(s, PTY_KEY_ENTER);
755 161 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
756 161 : pty_settle(s, SETTLE_MS);
757 : /* Default: rendered view */
758 161 : ASSERT_SCREEN_CONTAINS(s, "Hello from Mock Server");
759 : /* Press v → raw source view */
760 161 : pty_send_str(s, "v");
761 161 : pty_settle(s, SETTLE_MS);
762 161 : ASSERT_SCREEN_CONTAINS(s, "Content-Type:");
763 161 : ASSERT_WAIT_FOR(s, "v=rendered", WAIT_MS);
764 : /* Press v again → back to rendered */
765 161 : pty_send_str(s, "v");
766 161 : pty_settle(s, SETTLE_MS);
767 161 : ASSERT_SCREEN_CONTAINS(s, "Hello from Mock Server");
768 161 : ASSERT_SCREEN_CONTAINS(s, "v=source");
769 161 : pty_send_key(s, PTY_KEY_BACK);
770 161 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
771 161 : pty_send_key(s, PTY_KEY_ESC);
772 161 : pty_close(s);
773 : }
774 :
775 : /*
776 : * ══════════════════════════════════════════════════════════════════════
777 : * US-RD-03: As a user, I want to search within a message body using '/'
778 : * so I can quickly jump to relevant content.
779 : * Acceptance criteria:
780 : * - '/' opens an inline search prompt on the bottom row.
781 : * - Typing and Enter jumps to the first matching line.
782 : * - 'n' goes to the next match; 'N' goes to the previous match.
783 : * - ESC in search prompt cancels without jumping.
784 : * - Non-matching search shows "No match:" in the info line.
785 : * ══════════════════════════════════════════════════════════════════════
786 : */
787 161 : static void test_interactive_show_search_finds(void) {
788 : /* US-RD-03 */
789 161 : restart_mock();
790 161 : PtySession *s = tui_open_to_list();
791 160 : ASSERT(s != NULL, "show search: opens");
792 160 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
793 160 : pty_settle(s, SETTLE_MS);
794 160 : pty_send_key(s, PTY_KEY_ENTER);
795 160 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
796 160 : pty_settle(s, SETTLE_MS);
797 : /* Search for "Mock" — present in body */
798 160 : pty_send_str(s, "/");
799 160 : pty_settle(s, SETTLE_MS / 2);
800 160 : pty_send_str(s, "Mock");
801 160 : pty_send_key(s, PTY_KEY_ENTER);
802 160 : pty_settle(s, SETTLE_MS);
803 : /* Heading still visible, no "No match" error */
804 160 : ASSERT_SCREEN_CONTAINS(s, "From:");
805 160 : int r = find_row(s, "No match");
806 160 : ASSERT(r < 0, "show search: no 'No match' error");
807 160 : pty_send_key(s, PTY_KEY_BACK);
808 160 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
809 160 : pty_send_key(s, PTY_KEY_ESC);
810 160 : pty_close(s);
811 : }
812 :
813 160 : static void test_interactive_show_search_no_match(void) {
814 160 : restart_mock();
815 160 : PtySession *s = tui_open_to_list();
816 159 : ASSERT(s != NULL, "show search no match: opens");
817 159 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
818 159 : pty_settle(s, SETTLE_MS);
819 159 : pty_send_key(s, PTY_KEY_ENTER);
820 159 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
821 159 : pty_settle(s, SETTLE_MS);
822 159 : pty_send_str(s, "/");
823 159 : pty_settle(s, SETTLE_MS / 2);
824 159 : pty_send_str(s, "xyzzy_no_such_text");
825 159 : pty_send_key(s, PTY_KEY_ENTER);
826 159 : pty_settle(s, SETTLE_MS);
827 159 : ASSERT_SCREEN_CONTAINS(s, "No match");
828 159 : pty_send_key(s, PTY_KEY_BACK);
829 159 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
830 159 : pty_send_key(s, PTY_KEY_ESC);
831 159 : pty_close(s);
832 : }
833 :
834 : /*
835 : * ══════════════════════════════════════════════════════════════════════
836 : * US-RD-04: As a user, I want URLs rendered in blue so they stand out
837 : * from surrounding text and are easy to identify.
838 : * Acceptance criteria:
839 : * - URLs (https://…) appear on their own line.
840 : * - The URL text is rendered with ANSI foreground color 34 (blue).
841 : * - Non-URL text is NOT blue.
842 : * ══════════════════════════════════════════════════════════════════════
843 : */
844 159 : static void test_interactive_show_url_rendered(void) {
845 : /* US-RD-04 */
846 159 : restart_mock();
847 159 : PtySession *s = tui_open_to_list();
848 158 : ASSERT(s != NULL, "show URL rendered: opens");
849 158 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
850 158 : pty_settle(s, SETTLE_MS);
851 158 : pty_send_key(s, PTY_KEY_ENTER);
852 158 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
853 158 : pty_settle(s, SETTLE_MS);
854 : /* Scroll to end to reveal URL (body may be multi-page) */
855 158 : pty_send_key(s, PTY_KEY_END);
856 158 : pty_settle(s, SETTLE_MS);
857 158 : ASSERT_SCREEN_CONTAINS(s, "https://click.example.com/test");
858 : /* Verify URL is rendered in blue (ANSI color 34) */
859 158 : int url_row = find_row(s, "https://click.example.com/test");
860 158 : ASSERT(url_row >= 0, "show URL: row found");
861 158 : if (url_row >= 0)
862 158 : ASSERT(pty_cell_fg(s, url_row, 0) == 34, "show URL: blue color (34)");
863 158 : pty_send_key(s, PTY_KEY_BACK);
864 158 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
865 158 : pty_send_key(s, PTY_KEY_ESC);
866 158 : pty_close(s);
867 : }
868 :
869 166 : static void test_interactive_show_q_to_list(void) {
870 166 : restart_mock();
871 166 : PtySession *s = tui_open_to_list();
872 165 : ASSERT(s != NULL, "show q→list: opens");
873 165 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
874 165 : pty_settle(s, SETTLE_MS);
875 165 : pty_send_key(s, PTY_KEY_ENTER);
876 165 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
877 165 : pty_settle(s, SETTLE_MS);
878 : /* 'q' from show view returns to the message list */
879 165 : pty_send_str(s, "q");
880 165 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
881 165 : pty_send_key(s, PTY_KEY_ESC);
882 165 : pty_close(s);
883 : }
884 :
885 165 : static void test_interactive_show_pgdn(void) {
886 165 : restart_mock();
887 165 : PtySession *s = tui_open_to_list();
888 164 : ASSERT(s != NULL, "show PgDn: opens");
889 164 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
890 164 : pty_settle(s, SETTLE_MS);
891 164 : pty_send_key(s, PTY_KEY_ENTER);
892 164 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
893 164 : pty_settle(s, SETTLE_MS);
894 : /* PgDn scrolls a full page; header (From:) stays pinned at top */
895 164 : pty_send_key(s, PTY_KEY_PGDN);
896 164 : pty_settle(s, SETTLE_MS);
897 164 : ASSERT_SCREEN_CONTAINS(s, "From:");
898 164 : pty_send_key(s, PTY_KEY_PGUP);
899 164 : pty_settle(s, SETTLE_MS);
900 164 : ASSERT_SCREEN_CONTAINS(s, "From:");
901 164 : pty_send_key(s, PTY_KEY_ESC);
902 164 : pty_close(s);
903 : }
904 :
905 164 : static void test_interactive_show_arrow_scroll(void) {
906 : /* US 12: ↓/↑ scroll one line at a time (not a full page like PgDn/PgUp).
907 : * Use a small terminal (10 rows → rows_avail=4) with a 9-line body so
908 : * there are 3 pages. PgDn jumps to page 2; ↑ steps back to page 1;
909 : * ↓ steps forward to page 2 again — confirming single-line granularity. */
910 164 : restart_mock();
911 : /* Open email-tui in a short terminal and navigate to the message list.
912 : * 16 rows: the reader header block (From/Subject/Date/UID/File/DMARC/Attach
913 : * plus separator) needs 8 of them, and the rest still forces multi-page
914 : * scrolling — which is what this test exercises. */
915 164 : const char *tui_args[] = {g_tui_bin, NULL};
916 164 : PtySession *s = pty_open(COLS, 16);
917 164 : ASSERT(s != NULL, "show arrow scroll: opens");
918 164 : if (pty_run(s, tui_args) != 0) { pty_close(s); return; }
919 163 : if (pty_wait_for(s, "Email Account", WAIT_MS) != 0) { pty_close(s); return; }
920 163 : pty_send_key(s, PTY_KEY_ENTER);
921 163 : if (pty_wait_for(s, "Folders", WAIT_MS) != 0) { pty_close(s); return; }
922 163 : folders_goto_inbox(s);
923 163 : pty_send_key(s, PTY_KEY_ENTER);
924 163 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
925 163 : pty_settle(s, SETTLE_MS);
926 163 : pty_send_key(s, PTY_KEY_ENTER);
927 163 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
928 163 : pty_settle(s, SETTLE_MS);
929 : /* Jump a full page to page 2 */
930 163 : pty_send_key(s, PTY_KEY_PGDN);
931 163 : ASSERT_WAIT_FOR(s, "2/", WAIT_MS);
932 : /* ↑ steps back one line → page 1 */
933 163 : pty_send_key(s, PTY_KEY_UP);
934 163 : pty_settle(s, SETTLE_MS);
935 163 : ASSERT_SCREEN_CONTAINS(s, "1/");
936 : /* ↓ steps forward one line → page 2 again */
937 163 : pty_send_key(s, PTY_KEY_DOWN);
938 163 : ASSERT_WAIT_FOR(s, "2/", WAIT_MS);
939 163 : pty_send_key(s, PTY_KEY_ESC);
940 163 : pty_close(s);
941 : }
942 :
943 : /* ══════════════════════════════════════════════════════════════════════
944 : * INTERACTIVE FOLDER BROWSER
945 : * ══════════════════════════════════════════════════════════════════════ */
946 :
947 158 : static void test_interactive_folders_content(void) {
948 158 : restart_mock();
949 158 : PtySession *s = tui_open_to_list();
950 157 : ASSERT(s != NULL, "list-folders content: opens");
951 157 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
952 157 : pty_settle(s, SETTLE_MS);
953 157 : pty_send_key(s, PTY_KEY_BACK);
954 157 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
955 157 : pty_settle(s, SETTLE_MS);
956 157 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
957 157 : ASSERT_SCREEN_CONTAINS(s, "Sent");
958 157 : ASSERT_SCREEN_CONTAINS(s, "Trash");
959 157 : pty_send_key(s, PTY_KEY_ESC);
960 157 : pty_close(s);
961 : }
962 :
963 157 : static void test_interactive_folders_statusbar(void) {
964 157 : restart_mock();
965 157 : PtySession *s = tui_open_to_list();
966 156 : ASSERT(s != NULL, "list-folders sb: opens");
967 156 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
968 156 : pty_settle(s, SETTLE_MS);
969 156 : pty_send_key(s, PTY_KEY_BACK);
970 156 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
971 156 : pty_settle(s, SETTLE_MS);
972 :
973 156 : int sb = find_row(s, "Enter=open/select");
974 156 : ASSERT(sb >= 0, "list-folders sb: found Enter=open/select");
975 156 : if (sb >= 0)
976 156 : ASSERT_CELL_ATTR(s, sb, 2, PTY_ATTR_REVERSE);
977 156 : pty_send_key(s, PTY_KEY_ESC);
978 156 : pty_close(s);
979 : }
980 :
981 156 : static void test_interactive_folders_toggle(void) {
982 156 : restart_mock();
983 156 : PtySession *s = tui_open_to_list();
984 155 : ASSERT(s != NULL, "list-folders toggle: opens");
985 155 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
986 155 : pty_settle(s, SETTLE_MS);
987 155 : pty_send_key(s, PTY_KEY_BACK);
988 155 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
989 155 : pty_settle(s, SETTLE_MS);
990 :
991 : /* Toggle to flat */
992 155 : pty_send_str(s, "t");
993 155 : pty_settle(s, SETTLE_MS);
994 155 : ASSERT_SCREEN_CONTAINS(s, "t=tree");
995 :
996 : /* Toggle back to tree */
997 155 : pty_send_str(s, "t");
998 155 : pty_settle(s, SETTLE_MS);
999 155 : ASSERT_SCREEN_CONTAINS(s, "t=flat");
1000 :
1001 155 : pty_send_key(s, PTY_KEY_ESC);
1002 155 : pty_close(s);
1003 : }
1004 :
1005 155 : static void test_interactive_folders_select(void) {
1006 155 : restart_mock();
1007 155 : PtySession *s = tui_open_to_list();
1008 154 : ASSERT(s != NULL, "list-folders select: opens");
1009 154 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1010 154 : pty_settle(s, SETTLE_MS);
1011 154 : pty_send_key(s, PTY_KEY_BACK);
1012 154 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1013 154 : pty_settle(s, SETTLE_MS);
1014 154 : pty_send_key(s, PTY_KEY_ENTER);
1015 154 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1016 154 : pty_send_key(s, PTY_KEY_ESC);
1017 154 : pty_close(s);
1018 : }
1019 :
1020 154 : static void test_interactive_folders_nav(void) {
1021 154 : restart_mock();
1022 154 : PtySession *s = tui_open_to_list();
1023 153 : ASSERT(s != NULL, "list-folders nav: opens");
1024 153 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1025 153 : pty_settle(s, SETTLE_MS);
1026 153 : pty_send_key(s, PTY_KEY_BACK);
1027 153 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1028 153 : pty_settle(s, SETTLE_MS);
1029 : /* Navigate down and back up — list-folders remain visible */
1030 153 : pty_send_key(s, PTY_KEY_DOWN);
1031 153 : pty_settle(s, SETTLE_MS);
1032 153 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
1033 153 : pty_send_key(s, PTY_KEY_UP);
1034 153 : pty_settle(s, SETTLE_MS);
1035 153 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
1036 153 : pty_send_key(s, PTY_KEY_PGDN);
1037 153 : pty_settle(s, SETTLE_MS);
1038 153 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
1039 153 : pty_send_key(s, PTY_KEY_PGUP);
1040 153 : pty_settle(s, SETTLE_MS);
1041 153 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
1042 153 : pty_send_key(s, PTY_KEY_ESC);
1043 153 : pty_close(s);
1044 : }
1045 :
1046 153 : static void test_interactive_folders_back_to_list(void) {
1047 : /* Backspace from folder browser (root) returns to accounts screen in email-tui */
1048 153 : restart_mock();
1049 153 : PtySession *s = tui_open_to_list();
1050 152 : ASSERT(s != NULL, "list-folders back→list: opens");
1051 152 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1052 152 : pty_settle(s, SETTLE_MS);
1053 152 : pty_send_key(s, PTY_KEY_BACK);
1054 152 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1055 152 : pty_settle(s, SETTLE_MS);
1056 : /* Backspace from folder browser (root) returns to accounts screen */
1057 152 : pty_send_key(s, PTY_KEY_BACK);
1058 152 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
1059 152 : pty_send_key(s, PTY_KEY_ESC);
1060 152 : pty_close(s);
1061 : }
1062 :
1063 152 : static void test_interactive_folders_flat_navigate_up(void) {
1064 : /* US 15: in flat view, Enter on a folder with children navigates into it
1065 : * (sets current_prefix); Backspace navigates back up one level. */
1066 152 : restart_mock();
1067 152 : PtySession *s = tui_open_to_list();
1068 151 : ASSERT(s != NULL, "list-folders flat nav up: opens");
1069 151 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1070 151 : pty_settle(s, SETTLE_MS);
1071 151 : pty_send_key(s, PTY_KEY_BACK); /* open folder browser */
1072 151 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1073 151 : pty_settle(s, SETTLE_MS);
1074 151 : pty_send_str(s, "t"); /* toggle to flat view */
1075 151 : ASSERT_WAIT_FOR(s, "t=tree", WAIT_MS); /* flat mode: hint shows "t=tree" */
1076 151 : pty_settle(s, SETTLE_MS);
1077 : /* Flat view at root shows only top-level list-folders (no '.' in name) */
1078 151 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
1079 151 : pty_send_key(s, PTY_KEY_ENTER); /* navigate into INBOX */
1080 151 : ASSERT_WAIT_FOR(s, "Backspace=up", WAIT_MS);
1081 151 : pty_settle(s, SETTLE_MS);
1082 : /* Header shows "Folders: INBOX/ (3)"; flat mode shows last component only */
1083 151 : ASSERT_SCREEN_CONTAINS(s, "INBOX/");
1084 151 : ASSERT_SCREEN_CONTAINS(s, "Sent");
1085 151 : pty_send_key(s, PTY_KEY_BACK); /* navigate up to root */
1086 151 : ASSERT_WAIT_FOR(s, "Backspace=back", WAIT_MS);
1087 151 : pty_send_str(s, "t"); /* toggle back to tree mode */
1088 151 : ASSERT_WAIT_FOR(s, "t=flat", WAIT_MS); /* tree mode shows "t=flat" hint */
1089 151 : pty_send_key(s, PTY_KEY_ESC);
1090 151 : pty_close(s);
1091 : }
1092 :
1093 151 : static void test_interactive_folders_esc_quit(void) {
1094 : /* ESC from folder browser quits the entire application */
1095 151 : restart_mock();
1096 151 : PtySession *s = tui_open_to_list();
1097 150 : ASSERT(s != NULL, "list-folders ESC quit: opens");
1098 150 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1099 150 : pty_settle(s, SETTLE_MS);
1100 150 : pty_send_key(s, PTY_KEY_BACK);
1101 150 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1102 150 : pty_settle(s, SETTLE_MS);
1103 : /* ESC exits the application from the folder browser */
1104 150 : pty_send_key(s, PTY_KEY_ESC);
1105 150 : pty_settle(s, SETTLE_MS);
1106 150 : pty_close(s);
1107 : }
1108 :
1109 150 : static void test_tui_folder_browser_search(void) {
1110 : /* '/' in the folder browser opens an inline search bar */
1111 150 : restart_mock();
1112 150 : PtySession *s = tui_open_to_list();
1113 149 : ASSERT(s != NULL, "folder search: opens");
1114 149 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1115 149 : pty_settle(s, SETTLE_MS);
1116 149 : pty_send_key(s, PTY_KEY_BACK);
1117 149 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1118 149 : pty_settle(s, SETTLE_MS);
1119 : /* Activate search */
1120 149 : pty_send_str(s, "/");
1121 149 : ASSERT_WAIT_FOR(s, "Search", WAIT_MS);
1122 149 : pty_settle(s, SETTLE_MS);
1123 : /* Type a search term */
1124 149 : pty_send_str(s, "Inbox");
1125 149 : pty_settle(s, SETTLE_MS);
1126 : /* TAB cycles scope: Subject → From → To → Body */
1127 149 : pty_send_key(s, PTY_KEY_TAB);
1128 149 : pty_settle(s, SETTLE_MS / 2);
1129 : /* BACK removes last character */
1130 149 : pty_send_key(s, PTY_KEY_BACK);
1131 149 : pty_settle(s, SETTLE_MS / 2);
1132 : /* ESC cancels search, returns to folder browser */
1133 149 : pty_send_key(s, PTY_KEY_ESC);
1134 149 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1135 149 : pty_send_key(s, PTY_KEY_ESC);
1136 149 : pty_close(s);
1137 : }
1138 :
1139 : /* ══════════════════════════════════════════════════════════════════════
1140 : * EMPTY FOLDER
1141 : * ══════════════════════════════════════════════════════════════════════ */
1142 :
1143 142 : static void test_interactive_empty_folder(void) {
1144 142 : restart_mock();
1145 142 : PtySession *s = tui_open_to_list();
1146 141 : ASSERT(s != NULL, "empty: opens");
1147 141 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1148 141 : pty_settle(s, SETTLE_MS);
1149 141 : pty_send_key(s, PTY_KEY_BACK);
1150 141 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1151 141 : pty_settle(s, SETTLE_MS);
1152 :
1153 : /* Navigate to INBOX.Empty. Folders are sorted byte-wise, so the mock's
1154 : * LIST reply yields: INBOX(0), INBOX.AT&T(1), INBOX.Empty(2), INBOX.Sent(3),
1155 : * INBOX.Trash(4), INBOX.Träger(5), INBOX.中(6), INBOX.😀(7). */
1156 141 : pty_send_key(s, PTY_KEY_DOWN);
1157 141 : pty_send_key(s, PTY_KEY_DOWN);
1158 141 : pty_settle(s, 200);
1159 141 : pty_send_key(s, PTY_KEY_ENTER);
1160 :
1161 : /* Formal empty-folder layout: inverse title + column headers + (empty) */
1162 141 : ASSERT_WAIT_FOR(s, "0 of 0 message(s) in", WAIT_MS);
1163 141 : pty_settle(s, SETTLE_MS);
1164 141 : ASSERT_SCREEN_CONTAINS(s, "Subject");
1165 141 : ASSERT_SCREEN_CONTAINS(s, "(empty)");
1166 141 : ASSERT_SCREEN_CONTAINS(s, "Backspace=folders");
1167 :
1168 : /* Backspace returns to folder browser */
1169 141 : pty_send_key(s, PTY_KEY_BACK);
1170 141 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1171 141 : pty_send_key(s, PTY_KEY_ESC);
1172 141 : pty_close(s);
1173 : }
1174 :
1175 141 : static void test_interactive_empty_folder_cron(void) {
1176 : /* Cron mode: navigate to INBOX.Empty — cron config triggers ⚠ no-cache view */
1177 141 : write_config_with_interval(15);
1178 : /* Reset persisted folder so tui_open_to_list() lands on INBOX (the default) */
1179 141 : { char p[512]; snprintf(p, sizeof(p), "%s/.local/share/email-cli/ui.ini", g_test_home); remove(p); }
1180 141 : restart_mock();
1181 141 : PtySession *s = tui_open_to_list();
1182 140 : ASSERT(s != NULL, "empty cron: opens");
1183 140 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1184 140 : pty_settle(s, SETTLE_MS);
1185 140 : pty_send_key(s, PTY_KEY_BACK); /* open folder browser */
1186 140 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1187 140 : pty_settle(s, SETTLE_MS);
1188 140 : pty_send_key(s, PTY_KEY_DOWN); /* INBOX → INBOX.AT&T */
1189 140 : pty_send_key(s, PTY_KEY_DOWN); /* → INBOX.Empty */
1190 140 : pty_settle(s, 200);
1191 140 : pty_send_key(s, PTY_KEY_ENTER); /* open empty folder */
1192 140 : ASSERT_WAIT_FOR(s, "0 of 0 message(s) in", WAIT_MS);
1193 140 : pty_settle(s, SETTLE_MS);
1194 140 : ASSERT_SCREEN_CONTAINS(s, "(empty)");
1195 140 : ASSERT_SCREEN_CONTAINS(s, "No cached data");
1196 140 : ASSERT_SCREEN_CONTAINS(s, "Backspace=folders");
1197 : /* Restore folder cursor to INBOX so subsequent tests open INBOX */
1198 140 : pty_send_key(s, PTY_KEY_BACK); /* back to folder browser */
1199 140 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
1200 140 : pty_settle(s, SETTLE_MS);
1201 140 : pty_send_key(s, PTY_KEY_UP); /* cursor back up to INBOX */
1202 140 : pty_settle(s, 200);
1203 140 : pty_send_key(s, PTY_KEY_ENTER); /* open INBOX → saves cursor */
1204 140 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1205 140 : pty_send_key(s, PTY_KEY_ESC);
1206 140 : pty_close(s);
1207 140 : write_config(); /* restore normal config */
1208 : }
1209 :
1210 : /* ══════════════════════════════════════════════════════════════════════
1211 : * ATTACHMENT SAVE TESTS
1212 : *
1213 : * The mock server message is multipart/mixed with two list-attachments:
1214 : * notes.txt (content: "Hello World")
1215 : * data.bin (content: "test data")
1216 : *
1217 : * attachment_save_dir() returns $HOME (= g_test_home) because there is
1218 : * no ~/Downloads in the isolated test environment.
1219 : * ══════════════════════════════════════════════════════════════════════ */
1220 :
1221 : /** Helper: open the show view of the single test message.
1222 : * Waits until both headers AND attachment status bar are fully rendered. */
1223 1022 : static PtySession *open_show_view(void) {
1224 1022 : restart_mock();
1225 1022 : PtySession *s = tui_open_to_list();
1226 1015 : if (!s) return NULL;
1227 1015 : if (pty_wait_for(s, "Test Message", WAIT_MS) != 0) { pty_close(s); return NULL; }
1228 1015 : pty_settle(s, SETTLE_MS);
1229 1015 : pty_send_key(s, PTY_KEY_ENTER);
1230 : /* Wait for the attachment status bar — proves the full show view rendered */
1231 1015 : if (pty_wait_for(s, "A=save-all", WAIT_MS) != 0) { pty_close(s); return NULL; }
1232 1015 : pty_settle(s, SETTLE_MS);
1233 1015 : return s;
1234 : }
1235 :
1236 : /** Status bar shows a=save A=save-all(2) when list-attachments present. */
1237 149 : static void test_show_attachment_statusbar(void) {
1238 149 : PtySession *s = open_show_view();
1239 148 : ASSERT(s != NULL, "att statusbar: opens");
1240 :
1241 148 : ASSERT_SCREEN_CONTAINS(s, "a=save");
1242 148 : ASSERT_SCREEN_CONTAINS(s, "A=save-all(2)");
1243 :
1244 148 : pty_send_key(s, PTY_KEY_ESC);
1245 148 : pty_close(s);
1246 : }
1247 :
1248 : /** Attachment picker is shown when 'a' is pressed (2 list-attachments). */
1249 148 : static void test_show_attachment_picker(void) {
1250 148 : PtySession *s = open_show_view();
1251 147 : ASSERT(s != NULL, "att picker: opens");
1252 :
1253 147 : pty_send_str(s, "a");
1254 147 : ASSERT_WAIT_FOR(s, "Attachments", WAIT_MS);
1255 147 : pty_settle(s, SETTLE_MS);
1256 147 : ASSERT_SCREEN_CONTAINS(s, "notes.txt");
1257 147 : ASSERT_SCREEN_CONTAINS(s, "data.bin");
1258 :
1259 : /* Backspace returns to show view */
1260 147 : pty_send_key(s, PTY_KEY_BACK);
1261 147 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
1262 147 : pty_send_key(s, PTY_KEY_ESC);
1263 147 : pty_close(s);
1264 : }
1265 :
1266 : /** Pressing 'a', selecting first attachment, confirming saves it. */
1267 147 : static void test_show_save_single(void) {
1268 147 : PtySession *s = open_show_view();
1269 146 : ASSERT(s != NULL, "save single: opens");
1270 :
1271 146 : pty_send_str(s, "a");
1272 146 : ASSERT_WAIT_FOR(s, "Attachments", WAIT_MS);
1273 146 : pty_settle(s, SETTLE_MS);
1274 :
1275 : /* Select first attachment (notes.txt) */
1276 146 : pty_send_key(s, PTY_KEY_ENTER);
1277 146 : ASSERT_WAIT_FOR(s, "Save as:", WAIT_MS);
1278 146 : pty_settle(s, SETTLE_MS);
1279 :
1280 : /* Accept pre-filled path */
1281 146 : pty_send_key(s, PTY_KEY_ENTER);
1282 146 : ASSERT_WAIT_FOR(s, "Saved:", WAIT_MS);
1283 :
1284 : /* File must exist on disk */
1285 : char path[512];
1286 146 : snprintf(path, sizeof(path), "%s/notes.txt", g_test_home);
1287 146 : ASSERT(access(path, F_OK) == 0, "notes.txt saved to disk");
1288 :
1289 146 : pty_send_key(s, PTY_KEY_ESC);
1290 146 : pty_close(s);
1291 : }
1292 :
1293 : /** Pressing 'A' then ESC cancels without saving any file. */
1294 146 : static void test_show_save_all_cancel(void) {
1295 146 : PtySession *s = open_show_view();
1296 145 : ASSERT(s != NULL, "save-all cancel: opens");
1297 :
1298 : /* Remove any pre-existing file from a previous test run */
1299 : char path[512];
1300 145 : snprintf(path, sizeof(path), "%s/data.bin", g_test_home);
1301 145 : remove(path);
1302 :
1303 145 : pty_send_str(s, "A");
1304 145 : ASSERT_WAIT_FOR(s, "Save all to:", WAIT_MS);
1305 145 : pty_settle(s, SETTLE_MS);
1306 :
1307 145 : pty_send_key(s, PTY_KEY_ESC);
1308 145 : pty_settle(s, SETTLE_MS);
1309 :
1310 : /* No "Saved" status must appear after ESC */
1311 145 : ASSERT(pty_screen_contains(s, "Saved 2/2") == 0, "no save on ESC");
1312 145 : ASSERT(access(path, F_OK) != 0, "data.bin NOT on disk after ESC");
1313 :
1314 145 : pty_send_key(s, PTY_KEY_ESC);
1315 145 : pty_close(s);
1316 : }
1317 :
1318 : /** Pressing 'A' then Enter saves all list-attachments to the default dir. */
1319 145 : static void test_show_save_all_confirm(void) {
1320 145 : PtySession *s = open_show_view();
1321 144 : ASSERT(s != NULL, "save-all confirm: opens");
1322 :
1323 144 : pty_send_str(s, "A");
1324 144 : ASSERT_WAIT_FOR(s, "Save all to:", WAIT_MS);
1325 144 : pty_settle(s, SETTLE_MS);
1326 :
1327 : /* Accept the pre-filled default (g_test_home) */
1328 144 : pty_send_key(s, PTY_KEY_ENTER);
1329 144 : ASSERT_WAIT_FOR(s, "Saved 2/2", WAIT_MS);
1330 :
1331 : /* Both files must exist on disk */
1332 : char p1[512], p2[512];
1333 144 : snprintf(p1, sizeof(p1), "%s/notes.txt", g_test_home);
1334 144 : snprintf(p2, sizeof(p2), "%s/data.bin", g_test_home);
1335 144 : ASSERT(access(p1, F_OK) == 0, "notes.txt saved");
1336 144 : ASSERT(access(p2, F_OK) == 0, "data.bin saved");
1337 :
1338 : /* Verify content of notes.txt ("Hello World") */
1339 144 : FILE *f = fopen(p1, "r");
1340 144 : if (f) {
1341 144 : char buf[64] = "";
1342 144 : size_t n = fread(buf, 1, sizeof(buf) - 1, f);
1343 144 : fclose(f);
1344 144 : buf[n] = '\0';
1345 144 : ASSERT(strcmp(buf, "Hello World") == 0, "notes.txt content correct");
1346 : }
1347 :
1348 144 : pty_send_key(s, PTY_KEY_ESC);
1349 144 : pty_close(s);
1350 : }
1351 :
1352 : /** Exercises HOME/END/LEFT/RIGHT/BACK/DELETE cursor keys inside input_line_run. */
1353 144 : static void test_show_save_input_line_cursor(void) {
1354 144 : PtySession *s = open_show_view();
1355 143 : ASSERT(s != NULL, "input cursor: opens show view");
1356 :
1357 143 : pty_send_str(s, "a");
1358 143 : ASSERT_WAIT_FOR(s, "Attachments", WAIT_MS);
1359 143 : pty_settle(s, SETTLE_MS);
1360 :
1361 143 : pty_send_key(s, PTY_KEY_ENTER); /* select first attachment */
1362 143 : ASSERT_WAIT_FOR(s, "Save as:", WAIT_MS);
1363 143 : pty_settle(s, SETTLE_MS);
1364 :
1365 : /* Exercise cursor movement keys while input_line_run is active */
1366 143 : pty_send_key(s, PTY_KEY_HOME); /* TERM_KEY_HOME → cursor = 0 */
1367 143 : pty_send_key(s, PTY_KEY_END); /* TERM_KEY_END → cursor = len */
1368 143 : pty_send_key(s, PTY_KEY_LEFT); /* TERM_KEY_LEFT → il_move_left */
1369 143 : pty_send_key(s, PTY_KEY_RIGHT); /* TERM_KEY_RIGHT → il_move_right */
1370 143 : pty_send_key(s, PTY_KEY_BACK); /* TERM_KEY_BACK → il_backspace */
1371 143 : pty_send_key(s, PTY_KEY_HOME); /* back to start so DELETE acts on a char */
1372 143 : pty_send_str(s, "\033[3~"); /* TERM_KEY_DELETE → il_delete_fwd */
1373 :
1374 143 : pty_send_key(s, PTY_KEY_ESC); /* cancel — no file written */
1375 143 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
1376 143 : pty_send_key(s, PTY_KEY_ESC);
1377 143 : pty_close(s);
1378 : }
1379 :
1380 : /** Exercises TAB/Shift+Tab path completion inside the Save-all dialog. */
1381 143 : static void test_show_save_tab_completion(void) {
1382 : /* Ensure two known files exist in the test home for completion to find. */
1383 : char tab1[512], tab2[512];
1384 143 : snprintf(tab1, sizeof(tab1), "%s/zztab1.txt", g_test_home);
1385 143 : snprintf(tab2, sizeof(tab2), "%s/zztab2.txt", g_test_home);
1386 143 : { FILE *f = fopen(tab1, "w"); if (f) fclose(f); }
1387 143 : { FILE *f = fopen(tab2, "w"); if (f) fclose(f); }
1388 :
1389 143 : PtySession *s = open_show_view();
1390 142 : ASSERT(s != NULL, "tab complete: opens show view");
1391 :
1392 142 : pty_send_str(s, "A"); /* "Save all to:" dialog */
1393 142 : ASSERT_WAIT_FOR(s, "Save all to:", WAIT_MS);
1394 142 : pty_settle(s, SETTLE_MS);
1395 :
1396 : /* Pre-filled: $HOME. Append "/zz" so TAB prefix is "zz". */
1397 142 : pty_send_key(s, PTY_KEY_END);
1398 142 : pty_send_str(s, "/zz");
1399 :
1400 : /* First TAB: scans dir, finds zztab1/zztab2, completes to zztab1 */
1401 142 : pty_send_key(s, PTY_KEY_TAB);
1402 142 : ASSERT_WAIT_FOR(s, "zztab1", WAIT_MS);
1403 :
1404 : /* Second TAB: cycles to zztab2 */
1405 142 : pty_send_key(s, PTY_KEY_TAB);
1406 142 : ASSERT_WAIT_FOR(s, "zztab2", WAIT_MS);
1407 :
1408 : /* Shift+Tab: cycles back to zztab1 */
1409 142 : pty_send_str(s, "\033[Z");
1410 142 : ASSERT_WAIT_FOR(s, "zztab1", WAIT_MS);
1411 :
1412 142 : pty_send_key(s, PTY_KEY_ESC); /* cancel — no file written */
1413 142 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
1414 142 : pty_send_key(s, PTY_KEY_ESC);
1415 142 : pty_close(s);
1416 :
1417 142 : remove(tab1);
1418 142 : remove(tab2);
1419 : }
1420 :
1421 : /* ══════════════════════════════════════════════════════════════════════
1422 : * email-cli-ro EXCLUSIVE TESTS
1423 : * ══════════════════════════════════════════════════════════════════════ */
1424 :
1425 134 : static void test_ro_help_general(void) {
1426 134 : const char *a[] = {"--help", NULL};
1427 134 : PtySession *s = cli_open_size(120, 50, a);
1428 133 : ASSERT(s != NULL, "ro help: opens");
1429 133 : ASSERT_WAIT_FOR(s, "Reading:", WAIT_MS);
1430 133 : pty_settle(s, 300);
1431 133 : ASSERT_SCREEN_CONTAINS(s, "list");
1432 133 : ASSERT_SCREEN_CONTAINS(s, "show");
1433 133 : ASSERT_SCREEN_CONTAINS(s, "list-folders");
1434 133 : ASSERT_SCREEN_CONTAINS(s, "list-attachments");
1435 133 : ASSERT_SCREEN_CONTAINS(s, "save-attachment");
1436 133 : ASSERT_SCREEN_CONTAINS(s, "help");
1437 133 : pty_close(s);
1438 : }
1439 :
1440 133 : static void test_ro_help_list(void) {
1441 133 : const char *a[] = {"list", "--help", NULL};
1442 133 : PtySession *s = cli_open_size(120, 80, a) /* list --help is ~60 lines */;
1443 132 : ASSERT(s != NULL, "ro help list: opens");
1444 132 : ASSERT_WAIT_FOR(s, "Usage: email-cli-ro", WAIT_MS);
1445 132 : pty_settle(s, 300);
1446 132 : ASSERT_SCREEN_CONTAINS(s, "--folder");
1447 132 : pty_close(s);
1448 : }
1449 :
1450 130 : static void test_ro_help_attachments(void) {
1451 130 : const char *a[] = {"list-attachments", "--help", NULL};
1452 130 : PtySession *s = cli_open_size(120, 50, a);
1453 129 : ASSERT(s != NULL, "ro help list-attachments: opens");
1454 129 : ASSERT_WAIT_FOR(s, "list-attachments <uid>", WAIT_MS);
1455 129 : pty_close(s);
1456 : }
1457 :
1458 129 : static void test_ro_help_save_attachment(void) {
1459 129 : const char *a[] = {"save-attachment", "--help", NULL};
1460 129 : PtySession *s = cli_open_size(120, 50, a);
1461 128 : ASSERT(s != NULL, "ro help save-attachment: opens");
1462 128 : ASSERT_WAIT_FOR(s, "save-attachment", WAIT_MS);
1463 128 : pty_close(s);
1464 : }
1465 :
1466 123 : static void test_ro_list_folder(void) {
1467 123 : const char *a[] = {"list", "--folder", "INBOX.Sent", NULL};
1468 123 : PtySession *s = cli_run(a);
1469 122 : ASSERT(s != NULL, "ro list --folder: opens");
1470 122 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1471 122 : pty_settle(s, SETTLE_MS);
1472 122 : ASSERT_SCREEN_CONTAINS(s, "INBOX.Sent");
1473 122 : pty_close(s);
1474 : }
1475 :
1476 122 : static void test_ro_list_limit(void) {
1477 122 : const char *a[] = {"list", "--limit", "1", NULL};
1478 122 : PtySession *s = cli_run(a);
1479 121 : ASSERT(s != NULL, "ro list --limit: opens");
1480 121 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1481 121 : pty_settle(s, SETTLE_MS);
1482 121 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
1483 121 : pty_close(s);
1484 : }
1485 :
1486 121 : static void test_ro_list_offset(void) {
1487 121 : const char *a[] = {"list", "--offset", "1", NULL};
1488 121 : PtySession *s = cli_run(a);
1489 120 : ASSERT(s != NULL, "ro list --offset: opens");
1490 120 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1491 120 : pty_close(s);
1492 : }
1493 :
1494 120 : static void test_ro_sync_unknown(void) {
1495 120 : const char *a[] = {"sync", NULL};
1496 120 : PtySession *s = cli_run(a);
1497 119 : ASSERT(s != NULL, "ro sync unknown: opens");
1498 119 : ASSERT_WAIT_FOR(s, "Unknown command", WAIT_MS);
1499 119 : pty_close(s);
1500 : }
1501 :
1502 119 : static void test_ro_attachments(void) {
1503 119 : restart_mock();
1504 119 : const char *a[] = {"list-attachments", "1", NULL};
1505 119 : PtySession *s = cli_run(a);
1506 118 : ASSERT(s != NULL, "ro list-attachments: opens");
1507 118 : ASSERT_WAIT_FOR(s, "notes.txt", WAIT_MS);
1508 118 : pty_settle(s, SETTLE_MS);
1509 118 : ASSERT_SCREEN_CONTAINS(s, "data.bin");
1510 118 : pty_close(s);
1511 : }
1512 :
1513 118 : static void test_ro_save_attachment(void) {
1514 118 : restart_mock();
1515 : char tmpdir[300];
1516 118 : snprintf(tmpdir, sizeof(tmpdir), "%s/att_save", g_test_home);
1517 118 : mkdir(tmpdir, 0700);
1518 118 : const char *a[] = {"save-attachment", "1", "notes.txt", tmpdir, NULL};
1519 118 : PtySession *s = cli_run(a);
1520 117 : ASSERT(s != NULL, "ro save-attachment: opens");
1521 117 : ASSERT_WAIT_FOR(s, "Saved:", WAIT_MS);
1522 117 : pty_settle(s, SETTLE_MS);
1523 : char expected[600];
1524 117 : snprintf(expected, sizeof(expected), "%s/notes.txt", tmpdir);
1525 : struct stat st;
1526 117 : ASSERT(stat(expected, &st) == 0, "ro save-attachment: file exists");
1527 117 : ASSERT(st.st_size > 0, "ro save-attachment: file non-empty");
1528 117 : pty_close(s);
1529 : }
1530 :
1531 117 : static void test_ro_no_config(void) {
1532 : /* Run with a HOME that contains no email-cli configuration */
1533 : char no_home[300];
1534 117 : snprintf(no_home, sizeof(no_home), "%s/no_config", g_test_home);
1535 117 : mkdir(no_home, 0700);
1536 117 : setenv("HOME", no_home, 1);
1537 117 : unsetenv("XDG_CONFIG_HOME");
1538 :
1539 117 : const char *a[] = {"list", NULL};
1540 117 : PtySession *s = cli_run(a);
1541 116 : ASSERT(s != NULL, "ro no config: opens");
1542 116 : ASSERT_WAIT_FOR(s, "No configuration found", WAIT_MS);
1543 116 : pty_close(s);
1544 :
1545 : /* Restore test HOME */
1546 116 : setenv("HOME", g_test_home, 1);
1547 : }
1548 :
1549 : /* ══════════════════════════════════════════════════════════════════════
1550 : * NON-TTY FALLBACK (US 01)
1551 : * ══════════════════════════════════════════════════════════════════════ */
1552 :
1553 111 : static void test_nonttty_shows_help(void) {
1554 : /* Run email-cli with stdout piped (non-TTY) — must print help and exit 0. */
1555 : char cmd[768];
1556 111 : snprintf(cmd, sizeof(cmd), "%s 2>/dev/null", g_cli_bin);
1557 111 : FILE *fp = popen(cmd, "r");
1558 111 : ASSERT(fp != NULL, "non-tty: popen");
1559 111 : char out[4096] = {0};
1560 111 : if (fp) {
1561 111 : size_t n = fread(out, 1, sizeof(out) - 1, fp);
1562 : (void)n;
1563 111 : pclose(fp);
1564 : }
1565 111 : ASSERT(strstr(out, "Reading:") != NULL, "non-tty: shows general help (Reading:)");
1566 : }
1567 :
1568 : /* ══════════════════════════════════════════════════════════════════════
1569 : * SETUP WIZARD (US 10)
1570 : * ══════════════════════════════════════════════════════════════════════ */
1571 :
1572 185 : static void test_wizard_abort(void) {
1573 : char wiz_home[300];
1574 185 : snprintf(wiz_home, sizeof(wiz_home), "%s/wizard_abort", g_test_home);
1575 185 : mkdir(wiz_home, 0700);
1576 185 : setenv("HOME", wiz_home, 1);
1577 185 : unsetenv("XDG_CONFIG_HOME");
1578 :
1579 185 : const char *wiz[] = {"add-account", NULL};
1580 185 : PtySession *s = cli_run(wiz);
1581 183 : ASSERT(s != NULL, "wizard abort: opens");
1582 183 : ASSERT_WAIT_FOR(s, "Account type", WAIT_MS);
1583 183 : pty_send_key(s, PTY_KEY_CTRL_D); /* EOF on stdin → getline returns -1 → wizard aborts */
1584 183 : ASSERT_WAIT_FOR(s, "cancelled", WAIT_MS);
1585 183 : pty_close(s);
1586 :
1587 183 : setenv("HOME", g_test_home, 1);
1588 : }
1589 :
1590 115 : static void test_wizard_complete(void) {
1591 : char wiz_home[300];
1592 115 : snprintf(wiz_home, sizeof(wiz_home), "%s/wizard_complete", g_test_home);
1593 115 : mkdir(wiz_home, 0700);
1594 115 : setenv("HOME", wiz_home, 1);
1595 115 : unsetenv("XDG_CONFIG_HOME");
1596 :
1597 115 : restart_mock();
1598 115 : const char *wiz[] = {"add-account", NULL};
1599 115 : PtySession *s = cli_run(wiz);
1600 114 : ASSERT(s != NULL, "wizard complete: opens");
1601 114 : ASSERT_WAIT_FOR(s, "Account type", WAIT_MS);
1602 114 : pty_send_str(s, "1\n"); /* IMAP */
1603 114 : ASSERT_WAIT_FOR(s, "IMAP Host", WAIT_MS);
1604 114 : pty_send_str(s, "imaps://localhost:9993\n"); /* explicit correct protocol */
1605 114 : ASSERT_WAIT_FOR(s, "Port", WAIT_MS);
1606 114 : pty_send_str(s, "\n"); /* accept default 993 */
1607 114 : ASSERT_WAIT_FOR(s, "sername", WAIT_MS);
1608 114 : pty_send_str(s, "testuser\n");
1609 114 : ASSERT_WAIT_FOR(s, "assword", WAIT_MS);
1610 114 : pty_send_str(s, "testpass\n");
1611 114 : ASSERT_WAIT_FOR(s, "older", WAIT_MS);
1612 114 : pty_send_str(s, "INBOX\n");
1613 114 : ASSERT_WAIT_FOR(s, "SMTP Host", WAIT_MS);
1614 114 : pty_send_str(s, "\n"); /* skip SMTP config */
1615 114 : ASSERT_WAIT_FOR(s, "added.", WAIT_MS);
1616 114 : pty_close(s);
1617 :
1618 114 : setenv("HOME", g_test_home, 1);
1619 : }
1620 :
1621 : /* Wizard: plain hostname (no protocol) → auto-completes to imaps:// */
1622 114 : static void test_wizard_host_autocomplete(void) {
1623 : char wiz_home[300];
1624 114 : snprintf(wiz_home, sizeof(wiz_home), "%s/wizard_autocomplete", g_test_home);
1625 114 : mkdir(wiz_home, 0700);
1626 114 : setenv("HOME", wiz_home, 1);
1627 114 : unsetenv("XDG_CONFIG_HOME");
1628 :
1629 114 : const char *wiz[] = {"add-account", NULL};
1630 114 : PtySession *s = cli_run(wiz);
1631 113 : ASSERT(s != NULL, "wizard autocomplete: opens");
1632 113 : ASSERT_WAIT_FOR(s, "Account type", WAIT_MS);
1633 113 : pty_send_str(s, "1\n"); /* IMAP */
1634 113 : ASSERT_WAIT_FOR(s, "IMAP Host", WAIT_MS);
1635 113 : pty_send_str(s, "localhost:9993\n"); /* no protocol → auto imaps:// */
1636 113 : ASSERT_WAIT_FOR(s, "imaps://", WAIT_MS); /* confirmation line printed */
1637 113 : ASSERT_WAIT_FOR(s, "Port", WAIT_MS);
1638 113 : pty_send_str(s, "\n"); /* accept default 993 */
1639 113 : ASSERT_WAIT_FOR(s, "sername", WAIT_MS);
1640 113 : pty_send_str(s, "testuser\n");
1641 113 : ASSERT_WAIT_FOR(s, "assword", WAIT_MS);
1642 113 : pty_send_str(s, "testpass\n");
1643 113 : ASSERT_WAIT_FOR(s, "older", WAIT_MS);
1644 113 : pty_send_str(s, "\n"); /* accept default INBOX */
1645 113 : ASSERT_WAIT_FOR(s, "SMTP Host", WAIT_MS);
1646 113 : pty_send_str(s, "\n"); /* skip SMTP */
1647 113 : ASSERT_WAIT_FOR(s, "added.", WAIT_MS);
1648 113 : pty_close(s);
1649 :
1650 113 : setenv("HOME", g_test_home, 1);
1651 : }
1652 :
1653 : /* Wizard: explicit wrong protocol → error + re-prompt → correct → completes */
1654 113 : static void test_wizard_bad_protocol_rejected(void) {
1655 : char wiz_home[300];
1656 113 : snprintf(wiz_home, sizeof(wiz_home), "%s/wizard_badproto", g_test_home);
1657 113 : mkdir(wiz_home, 0700);
1658 113 : setenv("HOME", wiz_home, 1);
1659 113 : unsetenv("XDG_CONFIG_HOME");
1660 :
1661 113 : const char *wiz[] = {"add-account", NULL};
1662 113 : PtySession *s = cli_run(wiz);
1663 112 : ASSERT(s != NULL, "wizard bad proto: opens");
1664 112 : ASSERT_WAIT_FOR(s, "Account type", WAIT_MS);
1665 112 : pty_send_str(s, "1\n"); /* IMAP */
1666 112 : ASSERT_WAIT_FOR(s, "IMAP Host", WAIT_MS);
1667 112 : pty_send_str(s, "imap://localhost:9993\n"); /* wrong protocol */
1668 112 : ASSERT_WAIT_FOR(s, "unsupported protocol", WAIT_MS);
1669 112 : ASSERT_WAIT_FOR(s, "IMAP Host", WAIT_MS); /* re-prompted */
1670 112 : pty_send_str(s, "imaps://localhost:9993\n"); /* now correct */
1671 112 : ASSERT_WAIT_FOR(s, "Port", WAIT_MS);
1672 112 : pty_send_str(s, "\n"); /* accept default 993 */
1673 112 : ASSERT_WAIT_FOR(s, "sername", WAIT_MS);
1674 112 : pty_send_key(s, PTY_KEY_CTRL_D); /* abort to keep test short */
1675 112 : ASSERT_WAIT_FOR(s, "cancelled", WAIT_MS);
1676 112 : pty_close(s);
1677 :
1678 112 : setenv("HOME", g_test_home, 1);
1679 : }
1680 :
1681 : /* Wizard: enter SMTP host (plain name, no protocol) to cover normalize_smtp_host */
1682 112 : static void test_wizard_with_smtp(void) {
1683 : char wiz_home[300];
1684 112 : snprintf(wiz_home, sizeof(wiz_home), "%s/wizard_smtp", g_test_home);
1685 112 : mkdir(wiz_home, 0700);
1686 112 : setenv("HOME", wiz_home, 1);
1687 112 : unsetenv("XDG_CONFIG_HOME");
1688 :
1689 112 : restart_mock();
1690 112 : const char *wiz[] = {"add-account", NULL};
1691 112 : PtySession *s = cli_run(wiz);
1692 111 : ASSERT(s != NULL, "wizard smtp: opens");
1693 111 : ASSERT_WAIT_FOR(s, "Account type", WAIT_MS);
1694 111 : pty_send_str(s, "1\n"); /* IMAP */
1695 111 : ASSERT_WAIT_FOR(s, "IMAP Host", WAIT_MS);
1696 111 : pty_send_str(s, "localhost:9993\n"); /* plain name → imaps:// prepended */
1697 111 : ASSERT_WAIT_FOR(s, "Port", WAIT_MS);
1698 111 : pty_send_str(s, "\n"); /* accept default 993 */
1699 111 : ASSERT_WAIT_FOR(s, "sername", WAIT_MS);
1700 111 : pty_send_str(s, "testuser\n");
1701 111 : ASSERT_WAIT_FOR(s, "assword", WAIT_MS);
1702 111 : pty_send_str(s, "testpass\n");
1703 111 : ASSERT_WAIT_FOR(s, "older", WAIT_MS);
1704 111 : pty_send_str(s, "INBOX\n");
1705 111 : ASSERT_WAIT_FOR(s, "SMTP Host", WAIT_MS);
1706 : /* Enter a bad SMTP protocol first → error → re-prompt */
1707 111 : pty_send_str(s, "smtp://localhost\n"); /* bad protocol */
1708 111 : ASSERT_WAIT_FOR(s, "unsupported protocol", WAIT_MS);
1709 111 : ASSERT_WAIT_FOR(s, "SMTP Host", WAIT_MS); /* re-prompted */
1710 : /* Now enter plain hostname → normalize_smtp_host prepends smtps:// */
1711 111 : pty_send_str(s, "localhost\n");
1712 111 : ASSERT_WAIT_FOR(s, "SMTP Port", WAIT_MS);
1713 111 : pty_send_str(s, "\n"); /* accept default 587 */
1714 111 : ASSERT_WAIT_FOR(s, "SMTP Username", WAIT_MS);
1715 111 : pty_send_str(s, "\n"); /* same as IMAP */
1716 111 : ASSERT_WAIT_FOR(s, "SMTP Password", WAIT_MS);
1717 111 : pty_send_str(s, "\n"); /* same as IMAP */
1718 111 : ASSERT_WAIT_FOR(s, "added.", WAIT_MS);
1719 111 : pty_close(s);
1720 :
1721 111 : setenv("HOME", g_test_home, 1);
1722 : }
1723 :
1724 : /* ══════════════════════════════════════════════════════════════════════
1725 : * CRON MANAGEMENT (US 06, 07, 08)
1726 : * ══════════════════════════════════════════════════════════════════════ */
1727 :
1728 : /** Remove any email-sync cron entry — used for cleanup between cron tests. */
1729 394 : static void cron_cleanup(void) {
1730 394 : const char *a[] = {g_sync_bin, "cron", "remove", NULL};
1731 394 : PtySession *s = pty_open(COLS, ROWS);
1732 394 : if (!s) return;
1733 394 : if (pty_run(s, a) != 0) { pty_close(s); return; }
1734 : /* Wait until crontab is fully written before closing */
1735 390 : pty_wait_for(s, "ron", WAIT_MS); /* "Cron job removed." or "No email-sync cron entry found." */
1736 390 : pty_close(s);
1737 : }
1738 :
1739 179 : static void test_cron_status_not_found(void) {
1740 179 : cron_cleanup(); /* ensure clean state */
1741 177 : const char *a[] = {g_sync_bin, "cron", "status", NULL};
1742 177 : PtySession *s = pty_open(COLS, ROWS);
1743 177 : ASSERT(s != NULL, "cron status not found: opens");
1744 177 : ASSERT(pty_run(s, a) == 0, "cron status not found: pty_run");
1745 175 : ASSERT_WAIT_FOR(s, "No email-sync cron entry found", WAIT_MS);
1746 175 : pty_close(s);
1747 : }
1748 :
1749 109 : static void test_cron_setup_default_interval(void) {
1750 : /* Standard config has no SYNC_INTERVAL → defaults to 5 min */
1751 109 : write_config();
1752 109 : const char *a[] = {g_sync_bin, "cron", "setup", NULL};
1753 109 : PtySession *s = pty_open(COLS, ROWS);
1754 109 : ASSERT(s != NULL, "cron setup default interval: opens");
1755 109 : ASSERT(pty_run(s, a) == 0, "cron setup default interval: pty_run");
1756 108 : ASSERT_WAIT_FOR(s, "sync_interval not configured", WAIT_MS);
1757 108 : ASSERT_WAIT_FOR(s, "Cron job installed:", WAIT_MS);
1758 108 : pty_close(s);
1759 108 : cron_cleanup();
1760 : }
1761 :
1762 107 : static void test_cron_setup_installs(void) {
1763 107 : cron_cleanup();
1764 106 : write_config_with_interval(15);
1765 106 : const char *a[] = {g_sync_bin, "cron", "setup", NULL};
1766 106 : PtySession *s = pty_open(COLS, ROWS);
1767 106 : ASSERT(s != NULL, "cron setup installs: opens");
1768 106 : ASSERT(pty_run(s, a) == 0, "cron setup installs: pty_run");
1769 105 : ASSERT_WAIT_FOR(s, "Cron job installed:", WAIT_MS);
1770 105 : pty_close(s);
1771 : /* leave entry for next test */
1772 : }
1773 :
1774 105 : static void test_cron_status_found(void) {
1775 : /* Entry was left by test_cron_setup_installs */
1776 105 : const char *a[] = {g_sync_bin, "cron", "status", NULL};
1777 105 : PtySession *s = pty_open(COLS, ROWS);
1778 105 : ASSERT(s != NULL, "cron status found: opens");
1779 105 : ASSERT(pty_run(s, a) == 0, "cron status found: pty_run");
1780 104 : ASSERT_WAIT_FOR(s, "Cron entry found:", WAIT_MS);
1781 104 : pty_close(s);
1782 : }
1783 :
1784 104 : static void test_cron_setup_already_installed(void) {
1785 : /* Entry still present from test_cron_setup_installs */
1786 104 : const char *a[] = {g_sync_bin, "cron", "setup", NULL};
1787 104 : PtySession *s = pty_open(COLS, ROWS);
1788 104 : ASSERT(s != NULL, "cron setup already installed: opens");
1789 104 : ASSERT(pty_run(s, a) == 0, "cron setup already installed: pty_run");
1790 103 : ASSERT_WAIT_FOR(s, "already installed", WAIT_MS);
1791 103 : pty_close(s);
1792 : }
1793 :
1794 103 : static void test_cron_remove_entry(void) {
1795 : /* Entry still present — remove it */
1796 103 : const char *a[] = {g_sync_bin, "cron", "remove", NULL};
1797 103 : PtySession *s = pty_open(COLS, ROWS);
1798 103 : ASSERT(s != NULL, "cron remove entry: opens");
1799 103 : ASSERT(pty_run(s, a) == 0, "cron remove entry: pty_run");
1800 102 : ASSERT_WAIT_FOR(s, "Cron job removed", WAIT_MS);
1801 102 : pty_close(s);
1802 : }
1803 :
1804 102 : static void test_cron_remove_not_found(void) {
1805 : /* Entry was removed by test_cron_remove_entry */
1806 102 : const char *a[] = {g_sync_bin, "cron", "remove", NULL};
1807 102 : PtySession *s = pty_open(COLS, ROWS);
1808 102 : ASSERT(s != NULL, "cron remove not found: opens");
1809 102 : ASSERT(pty_run(s, a) == 0, "cron remove not found: pty_run");
1810 101 : ASSERT_WAIT_FOR(s, "No email-sync cron entry found", WAIT_MS);
1811 101 : pty_close(s);
1812 101 : write_config(); /* restore standard config (no sync_interval) */
1813 : }
1814 :
1815 : /* ══════════════════════════════════════════════════════════════════════
1816 : * SYNC PROGRESS (US 05)
1817 : * ══════════════════════════════════════════════════════════════════════ */
1818 :
1819 140 : static void test_sync_progress(void) {
1820 140 : restart_mock();
1821 140 : PtySession *s = pty_open(COLS, ROWS);
1822 140 : ASSERT(s != NULL, "sync progress: opens");
1823 140 : const char *a[] = {g_sync_bin, NULL};
1824 140 : ASSERT(pty_run(s, a) == 0, "sync progress: pty_run");
1825 139 : ASSERT_WAIT_FOR(s, "Syncing", WAIT_MS);
1826 139 : ASSERT_WAIT_FOR(s, "fetched", WAIT_MS);
1827 139 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
1828 139 : pty_close(s);
1829 : }
1830 :
1831 : /* ══════════════════════════════════════════════════════════════════════
1832 : * SHOW CACHE HIT (US 03)
1833 : * ══════════════════════════════════════════════════════════════════════ */
1834 :
1835 139 : static void test_show_cache_hit(void) {
1836 : /* Sync to populate local store */
1837 139 : restart_mock();
1838 139 : { PtySession *s = pty_open(COLS, ROWS);
1839 139 : ASSERT(s != NULL, "show cache: sync opens");
1840 139 : const char *a[] = {g_sync_bin, NULL};
1841 139 : ASSERT(pty_run(s, a) == 0, "show cache: sync pty_run");
1842 138 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
1843 138 : pty_close(s); }
1844 :
1845 : /* Show must work from cache even with no server.
1846 : * 60 rows: the rendered mock message is longer than a 24-row screen and
1847 : * would scroll the header block away before the assertion runs. */
1848 138 : stop_mock_server();
1849 138 : { const char *a[] = {"show", "1", "--batch", NULL};
1850 138 : PtySession *s = cli_open_size(COLS, 60, a);
1851 137 : ASSERT(s != NULL, "show cache: show opens");
1852 137 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
1853 137 : pty_close(s); }
1854 :
1855 137 : restart_mock();
1856 : }
1857 :
1858 : /* ══════════════════════════════════════════════════════════════════════
1859 : * OFFLINE / CRON MODE (US 11)
1860 : * ══════════════════════════════════════════════════════════════════════ */
1861 :
1862 137 : static void test_offline_list(void) {
1863 : /* Sync first to populate manifest */
1864 137 : restart_mock();
1865 137 : { PtySession *s = pty_open(COLS, ROWS);
1866 137 : ASSERT(s != NULL, "offline list: sync opens");
1867 137 : const char *a[] = {g_sync_bin, NULL};
1868 137 : ASSERT(pty_run(s, a) == 0, "offline list: sync pty_run");
1869 136 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
1870 136 : pty_close(s); }
1871 :
1872 : /* Switch to cron/offline mode (sync_interval > 0) */
1873 136 : write_config_with_interval(5);
1874 :
1875 : /* Stop server — list must be served entirely from manifest */
1876 136 : stop_mock_server();
1877 136 : { const char *a[] = {"list", "--batch", NULL};
1878 136 : PtySession *s = cli_run(a);
1879 135 : ASSERT(s != NULL, "offline list: list opens");
1880 135 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1881 135 : pty_close(s); }
1882 :
1883 : /* Restore */
1884 135 : write_config();
1885 135 : restart_mock();
1886 : }
1887 :
1888 135 : static void test_offline_show_not_cached(void) {
1889 : /* US 11: opening a non-cached message in cron/offline mode shows an error */
1890 135 : write_config_with_interval(5);
1891 135 : stop_mock_server();
1892 :
1893 : /* UID 999 has never been synced → not in local store */
1894 135 : const char *a[] = {"show", "999", "--batch", NULL};
1895 135 : PtySession *s = cli_run(a);
1896 134 : ASSERT(s != NULL, "offline show not cached: opens");
1897 134 : ASSERT_WAIT_FOR(s, "Could not load", WAIT_MS);
1898 134 : pty_close(s);
1899 :
1900 134 : write_config();
1901 134 : restart_mock();
1902 : }
1903 :
1904 : /* ── email-tui helper: open interactive TUI and navigate to message list ── */
1905 :
1906 : /**
1907 : * Open email-tui with no args, navigate through the accounts screen (Enter),
1908 : * and return the PTY session sitting on the message list view.
1909 : * Returns NULL if any step fails.
1910 : */
1911 7181 : static PtySession *tui_open_to_list_size(int cols, int rows) {
1912 7181 : const char *args[] = {g_tui_bin, NULL};
1913 7181 : PtySession *s = pty_open(cols, rows);
1914 7181 : if (!s) return NULL;
1915 7181 : if (pty_run(s, args) != 0) { pty_close(s); return NULL; }
1916 : /* email-tui opens the accounts screen first; press Enter to open account */
1917 7113 : if (pty_wait_for(s, "Email Account", WAIT_MS) != 0) {
1918 0 : pty_close(s);
1919 0 : return NULL;
1920 : }
1921 7113 : pty_send_key(s, PTY_KEY_ENTER);
1922 : /* Folder browser appears; navigate to INBOX regardless of saved preference.
1923 : * HOME→VP_UNREAD(1) + 6×DOWN skips all 8 virtual rows and lands on INBOX
1924 : * (VPREFIX=8: Tags/Flags header + 6 virtual rows + Folders header = 8). */
1925 7113 : if (pty_wait_for(s, "Folders", WAIT_MS) != 0) {
1926 0 : pty_close(s);
1927 0 : return NULL;
1928 : }
1929 7113 : pty_send_key(s, PTY_KEY_HOME);
1930 49791 : for (int _i = 0; _i < 6; _i++) pty_send_key(s, PTY_KEY_DOWN);
1931 : /* Wait for INBOX to be visible before pressing Enter — IMAP LIST may be in flight */
1932 7113 : if (pty_wait_for(s, "INBOX", WAIT_MS) != 0) {
1933 0 : pty_close(s);
1934 0 : return NULL;
1935 : }
1936 7113 : pty_settle(s, SETTLE_MS);
1937 7113 : pty_send_key(s, PTY_KEY_ENTER);
1938 7113 : return s;
1939 : }
1940 :
1941 6777 : static PtySession *tui_open_to_list(void) {
1942 6777 : return tui_open_to_list_size(COLS, ROWS);
1943 : }
1944 :
1945 90 : static void test_tui_list_content(void) {
1946 90 : restart_mock();
1947 90 : PtySession *s = tui_open_to_list();
1948 89 : ASSERT(s != NULL, "tui list content: opens through accounts screen");
1949 89 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1950 89 : pty_settle(s, SETTLE_MS);
1951 89 : ASSERT_SCREEN_CONTAINS(s, "Test Message");
1952 89 : pty_send_key(s, PTY_KEY_ESC);
1953 89 : pty_close(s);
1954 : }
1955 :
1956 89 : static void test_tui_list_esc_quit(void) {
1957 89 : restart_mock();
1958 89 : PtySession *s = tui_open_to_list();
1959 88 : ASSERT(s != NULL, "tui list ESC: opens through accounts screen");
1960 88 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
1961 88 : pty_send_key(s, PTY_KEY_ESC);
1962 88 : pty_settle(s, SETTLE_MS);
1963 88 : pty_close(s);
1964 : }
1965 :
1966 88 : static void test_tui_show_esc_exits(void) {
1967 88 : restart_mock();
1968 88 : PtySession *s = tui_open_to_list();
1969 87 : ASSERT(s != NULL, "tui show ESC→exit: opens through accounts screen");
1970 87 : ASSERT_WAIT_FOR(s, "Test Message", WAIT_MS);
1971 87 : pty_settle(s, SETTLE_MS);
1972 87 : pty_send_key(s, PTY_KEY_ENTER);
1973 87 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
1974 87 : pty_settle(s, SETTLE_MS);
1975 : /* ESC exits the program, not back to list */
1976 87 : pty_send_key(s, PTY_KEY_ESC);
1977 87 : pty_settle(s, SETTLE_MS * 2);
1978 87 : int r = pty_wait_for(s, "message(s) in", 1500);
1979 87 : ASSERT(r != 0, "tui show ESC: does NOT return to list");
1980 87 : pty_close(s);
1981 : }
1982 :
1983 : /* ── email-tui help tests ────────────────────────────────────────────── */
1984 :
1985 97 : static void test_tui_help_general(void) {
1986 97 : const char *a[] = {"--help", NULL};
1987 97 : PtySession *s = cli_open_size(120, 50, a);
1988 96 : ASSERT(s != NULL, "tui help: opens");
1989 96 : ASSERT_WAIT_FOR(s, "Usage: email-tui", WAIT_MS);
1990 96 : pty_settle(s, 300);
1991 96 : ASSERT_SCREEN_CONTAINS(s, "Options:");
1992 96 : ASSERT_SCREEN_CONTAINS(s, "account selector");
1993 96 : pty_close(s);
1994 : }
1995 :
1996 96 : static void test_tui_help_list(void) {
1997 : /* email-tui does not accept arguments; verify the rejection message */
1998 96 : const char *a[] = {"list", NULL};
1999 96 : PtySession *s = cli_open_size(120, 50, a);
2000 95 : ASSERT(s != NULL, "tui no-args reject: opens");
2001 95 : ASSERT_WAIT_FOR(s, "does not accept arguments", WAIT_MS);
2002 95 : pty_close(s);
2003 : }
2004 :
2005 95 : static void test_tui_help_cron(void) {
2006 : /* email-tui does not accept arguments; verify the rejection message */
2007 95 : const char *a[] = {"cron", NULL};
2008 95 : PtySession *s = cli_open_size(120, 50, a);
2009 94 : ASSERT(s != NULL, "tui cron reject: opens");
2010 94 : ASSERT_WAIT_FOR(s, "does not accept arguments", WAIT_MS);
2011 94 : pty_close(s);
2012 : }
2013 :
2014 87 : static void test_tui_interactive_launch(void) {
2015 : /* US 18: email-tui with no args in a TTY shows accounts screen,
2016 : * then opens the message list after Enter */
2017 87 : PtySession *s = tui_open_to_list();
2018 86 : ASSERT(s != NULL, "tui no-args launch: opens through accounts screen");
2019 86 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2020 86 : pty_settle(s, SETTLE_MS);
2021 86 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
2022 86 : pty_send_key(s, PTY_KEY_ESC);
2023 86 : pty_close(s);
2024 : }
2025 :
2026 : /* ── email-sync tests ────────────────────────────────────────────────── */
2027 :
2028 1070 : static PtySession *sync_run(const char **args) {
2029 : const char *argv[16];
2030 1070 : int n = 0;
2031 1070 : argv[n++] = g_sync_bin;
2032 1070 : if (args)
2033 1492 : for (int i = 0; args[i] && n < 15; i++)
2034 422 : argv[n++] = args[i];
2035 1070 : argv[n] = NULL;
2036 :
2037 1070 : PtySession *s = pty_open(COLS, ROWS);
2038 1070 : if (!s) return NULL;
2039 1070 : if (pty_run(s, argv) != 0) { pty_close(s); return NULL; }
2040 1050 : return s;
2041 : }
2042 :
2043 101 : static void test_sync_help(void) {
2044 101 : const char *a[] = {"--help", NULL};
2045 101 : PtySession *s = sync_run(a);
2046 100 : ASSERT(s != NULL, "sync --help: opens");
2047 100 : ASSERT_WAIT_FOR(s, "email-sync", WAIT_MS);
2048 100 : ASSERT_WAIT_FOR(s, "--help", WAIT_MS);
2049 100 : ASSERT_WAIT_FOR(s, "Exit Codes", WAIT_MS);
2050 100 : pty_close(s);
2051 : }
2052 :
2053 100 : static void test_sync_run(void) {
2054 100 : restart_mock();
2055 100 : const char *a[] = {NULL};
2056 100 : PtySession *s = sync_run(a);
2057 99 : ASSERT(s != NULL, "sync run: opens");
2058 99 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
2059 99 : pty_close(s);
2060 : }
2061 :
2062 99 : static void test_sync_unknown_opt(void) {
2063 99 : const char *a[] = {"--bogus-option", NULL};
2064 99 : PtySession *s = sync_run(a);
2065 98 : ASSERT(s != NULL, "sync unknown opt: opens");
2066 98 : ASSERT_WAIT_FOR(s, "Unknown option", WAIT_MS);
2067 98 : pty_close(s);
2068 : }
2069 :
2070 98 : static void test_sync_no_config(void) {
2071 : char no_home[300];
2072 98 : snprintf(no_home, sizeof(no_home), "%s/no_config_sync", g_test_home);
2073 98 : mkdir(no_home, 0700);
2074 98 : setenv("HOME", no_home, 1);
2075 98 : unsetenv("XDG_CONFIG_HOME");
2076 :
2077 98 : const char *a[] = {NULL};
2078 98 : PtySession *s = sync_run(a);
2079 97 : ASSERT(s != NULL, "sync no config: opens");
2080 97 : ASSERT_WAIT_FOR(s, "No accounts configured", WAIT_MS);
2081 97 : pty_close(s);
2082 :
2083 97 : setenv("HOME", g_test_home, 1);
2084 : }
2085 :
2086 : /* ══════════════════════════════════════════════════════════════════════
2087 : * MULTI-ACCOUNT TUI (US-21)
2088 : * ══════════════════════════════════════════════════════════════════════ */
2089 :
2090 : /** Write a second account config under accounts/<name>/ */
2091 267 : static void write_second_account(const char *name) {
2092 : char dir1[400], dir2[450], path[500];
2093 267 : snprintf(dir1, sizeof(dir1), "%s/.config/email-cli/accounts", g_test_home);
2094 267 : snprintf(dir2, sizeof(dir2), "%s/.config/email-cli/accounts/%s", g_test_home, name);
2095 267 : mkdir(dir1, 0700);
2096 267 : mkdir(dir2, 0700);
2097 267 : snprintf(path, sizeof(path), "%s/config.ini", dir2);
2098 267 : FILE *fp = fopen(path, "w");
2099 267 : if (!fp) return;
2100 267 : fprintf(fp,
2101 : "EMAIL_HOST=imaps://localhost:9993\n"
2102 : "EMAIL_USER=%s\n"
2103 : "EMAIL_PASS=pass2\n"
2104 : "EMAIL_FOLDER=INBOX\n"
2105 : "SSL_NO_VERIFY=1\n", name); /* TLS with self-signed cert */
2106 267 : fclose(fp);
2107 267 : chmod(path, 0600);
2108 : }
2109 :
2110 82 : static void test_tui_accounts_screen_shows(void) {
2111 : /* US-21 AC1: launching email-tui always shows the Accounts screen */
2112 82 : restart_mock();
2113 82 : PtySession *s = cli_run(NULL);
2114 81 : ASSERT(s != NULL, "accounts screen: opens");
2115 81 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2116 81 : pty_settle(s, SETTLE_MS);
2117 81 : ASSERT_SCREEN_CONTAINS(s, "testuser");
2118 81 : pty_send_key(s, PTY_KEY_ESC);
2119 81 : pty_close(s);
2120 : }
2121 :
2122 81 : static void test_tui_accounts_esc_quit(void) {
2123 : /* US-21: ESC/q from accounts screen exits the TUI */
2124 81 : restart_mock();
2125 81 : PtySession *s = cli_run(NULL);
2126 80 : ASSERT(s != NULL, "accounts ESC quit: opens");
2127 80 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2128 80 : pty_settle(s, SETTLE_MS);
2129 80 : pty_send_key(s, PTY_KEY_ESC);
2130 80 : pty_settle(s, SETTLE_MS);
2131 80 : pty_close(s);
2132 : }
2133 :
2134 80 : static void test_tui_accounts_enter_opens_list(void) {
2135 : /* US-21 AC3: Enter on account opens inbox */
2136 80 : restart_mock();
2137 80 : PtySession *s = tui_open_to_list();
2138 79 : ASSERT(s != NULL, "accounts Enter: opens message list");
2139 79 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2140 79 : pty_settle(s, SETTLE_MS);
2141 79 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
2142 79 : pty_send_key(s, PTY_KEY_ESC);
2143 79 : pty_close(s);
2144 : }
2145 :
2146 79 : static void test_tui_accounts_backspace_from_list(void) {
2147 : /* US-21 AC7: Backspace from folder root returns to accounts screen */
2148 79 : restart_mock();
2149 79 : PtySession *s = tui_open_to_list();
2150 78 : ASSERT(s != NULL, "accounts backspace: opens message list");
2151 78 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2152 78 : pty_settle(s, SETTLE_MS);
2153 : /* Backspace → folder browser */
2154 78 : pty_send_key(s, PTY_KEY_BACK);
2155 78 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
2156 78 : pty_settle(s, SETTLE_MS);
2157 : /* Backspace at root → accounts screen */
2158 78 : pty_send_key(s, PTY_KEY_BACK);
2159 78 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2160 78 : pty_send_key(s, PTY_KEY_ESC);
2161 78 : pty_close(s);
2162 : }
2163 :
2164 78 : static void test_tui_accounts_multiple_shown(void) {
2165 : /* US-21 AC2: multiple accounts are listed */
2166 78 : write_second_account("second@example.com");
2167 78 : restart_mock();
2168 78 : PtySession *s = cli_run(NULL);
2169 77 : ASSERT(s != NULL, "accounts multiple: opens");
2170 77 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2171 77 : pty_settle(s, SETTLE_MS);
2172 77 : ASSERT_SCREEN_CONTAINS(s, "testuser");
2173 77 : ASSERT_SCREEN_CONTAINS(s, "second@example.com");
2174 77 : pty_send_key(s, PTY_KEY_ESC);
2175 77 : pty_close(s);
2176 : /* cleanup */
2177 : char path[500];
2178 77 : snprintf(path, sizeof(path),
2179 : "%s/.config/email-cli/accounts/second@example.com/config.ini",
2180 : g_test_home);
2181 77 : unlink(path);
2182 77 : snprintf(path, sizeof(path),
2183 : "%s/.config/email-cli/accounts/second@example.com", g_test_home);
2184 77 : rmdir(path);
2185 : }
2186 :
2187 77 : static void test_tui_accounts_backspace_ignored(void) {
2188 : /* US-21 AC10: Backspace at accounts screen is ignored, does not quit */
2189 77 : restart_mock();
2190 77 : PtySession *s = cli_run(NULL);
2191 76 : ASSERT(s != NULL, "accounts backspace ignored: opens");
2192 76 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2193 76 : pty_settle(s, SETTLE_MS);
2194 76 : pty_send_key(s, PTY_KEY_BACK);
2195 76 : pty_settle(s, SETTLE_MS);
2196 : /* Must still be on the accounts screen */
2197 76 : ASSERT_SCREEN_CONTAINS(s, "Email Accounts");
2198 76 : pty_send_key(s, PTY_KEY_ESC);
2199 76 : pty_close(s);
2200 : }
2201 :
2202 76 : static void test_tui_accounts_columns(void) {
2203 : /* US-21 AC2: accounts screen shows Unread/Flagged/Account/Server columns */
2204 76 : restart_mock();
2205 76 : PtySession *s = cli_run(NULL);
2206 75 : ASSERT(s != NULL, "accounts columns: opens");
2207 75 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2208 75 : pty_settle(s, SETTLE_MS);
2209 75 : ASSERT_SCREEN_CONTAINS(s, "Unread");
2210 75 : ASSERT_SCREEN_CONTAINS(s, "Flagged");
2211 75 : ASSERT_SCREEN_CONTAINS(s, "Account");
2212 75 : ASSERT_SCREEN_CONTAINS(s, "Server");
2213 75 : pty_send_key(s, PTY_KEY_ESC);
2214 75 : pty_close(s);
2215 : }
2216 :
2217 75 : static void test_tui_accounts_cursor_restored(void) {
2218 : /* US-21 AC11: cursor is restored to previously open account on return */
2219 75 : write_second_account("second@example.com");
2220 75 : restart_mock();
2221 75 : PtySession *s = cli_run(NULL);
2222 74 : ASSERT(s != NULL, "cursor restore: opens");
2223 74 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2224 74 : pty_settle(s, SETTLE_MS);
2225 : /* Move to second account and open it */
2226 74 : pty_send_key(s, PTY_KEY_DOWN);
2227 74 : pty_settle(s, SETTLE_MS);
2228 74 : pty_send_key(s, PTY_KEY_ENTER);
2229 : /* commit 5b4053b added folder browser step; press Enter to select INBOX */
2230 74 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
2231 74 : pty_send_key(s, PTY_KEY_ENTER);
2232 74 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2233 74 : pty_settle(s, SETTLE_MS);
2234 : /* Navigate back to accounts via Backspace × 2 */
2235 74 : pty_send_key(s, PTY_KEY_BACK);
2236 74 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
2237 74 : pty_settle(s, SETTLE_MS);
2238 74 : pty_send_key(s, PTY_KEY_BACK);
2239 74 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2240 74 : pty_settle(s, SETTLE_MS);
2241 : /* Selection arrow must be visible and second account must be on screen */
2242 74 : ASSERT_SCREEN_CONTAINS(s, "second@example.com");
2243 : /* The → arrow (UTF-8: \xe2\x86\x92) and second account must both appear */
2244 74 : ASSERT_SCREEN_CONTAINS(s, "\xe2\x86\x92");
2245 74 : pty_send_key(s, PTY_KEY_ESC);
2246 74 : pty_close(s);
2247 : /* cleanup */
2248 : char path[500];
2249 74 : snprintf(path, sizeof(path),
2250 : "%s/.config/email-cli/accounts/second@example.com/config.ini",
2251 : g_test_home);
2252 74 : unlink(path);
2253 74 : snprintf(path, sizeof(path),
2254 : "%s/.config/email-cli/accounts/second@example.com", g_test_home);
2255 74 : rmdir(path);
2256 : }
2257 :
2258 : /* US-18: email-tui always starts at accounts screen (no warm-start bypass) */
2259 86 : static void test_tui_always_starts_at_accounts(void) {
2260 : /* First session: navigate to inbox and quit — sets last_account pref */
2261 : {
2262 86 : PtySession *s = tui_open_to_list();
2263 85 : ASSERT(s != NULL, "always accounts: first run opens to list");
2264 85 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2265 85 : pty_send_key(s, PTY_KEY_ESC);
2266 85 : pty_close(s);
2267 : }
2268 : /* Second session: must still show accounts screen, NOT jump to inbox */
2269 : {
2270 85 : PtySession *s = cli_run(NULL);
2271 84 : ASSERT(s != NULL, "always accounts: second run opens");
2272 84 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2273 84 : pty_settle(s, SETTLE_MS);
2274 : /* Accounts screen is showing — not the inbox */
2275 84 : ASSERT_SCREEN_CONTAINS(s, "Email Accounts");
2276 84 : pty_send_key(s, PTY_KEY_ESC);
2277 84 : pty_close(s);
2278 : }
2279 : }
2280 :
2281 : /* US-18: per-account folder cursor persisted to ui.ini */
2282 84 : static void test_tui_folder_cursor_persisted(void) {
2283 : /* Navigate to a non-default folder (Down once = INBOX.Sent on mock server) */
2284 : {
2285 84 : PtySession *s = cli_run(NULL);
2286 83 : ASSERT(s != NULL, "folder cursor: first run opens");
2287 83 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2288 83 : pty_send_key(s, PTY_KEY_ENTER);
2289 83 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
2290 83 : pty_settle(s, SETTLE_MS);
2291 83 : pty_send_key(s, PTY_KEY_DOWN); /* move to second folder */
2292 83 : pty_settle(s, SETTLE_MS);
2293 83 : pty_send_key(s, PTY_KEY_ENTER); /* open it */
2294 83 : pty_settle(s, SETTLE_MS); /* wait for folder to load */
2295 83 : pty_send_key(s, PTY_KEY_ESC); /* quit */
2296 83 : pty_close(s);
2297 : }
2298 : /* Verify ui.ini has folder_cursor_testuser key */
2299 : char pref_path[512];
2300 83 : snprintf(pref_path, sizeof(pref_path),
2301 : "%s/.local/share/email-cli/ui.ini", g_test_home);
2302 83 : FILE *fp = fopen(pref_path, "r");
2303 83 : ASSERT(fp != NULL, "folder cursor: ui.ini exists after session");
2304 83 : if (fp) {
2305 83 : int found = 0;
2306 : char line[512];
2307 166 : while (fgets(line, sizeof(line), fp))
2308 166 : if (strncmp(line, "folder_cursor_testuser=", 23) == 0) { found = 1; break; }
2309 83 : fclose(fp);
2310 83 : ASSERT(found, "folder cursor: folder_cursor_testuser key saved in ui.ini");
2311 : }
2312 : /* Second session: folder browser must open on the saved folder */
2313 : {
2314 83 : PtySession *s = cli_run(NULL);
2315 82 : ASSERT(s != NULL, "folder cursor: second run opens");
2316 82 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2317 82 : pty_send_key(s, PTY_KEY_ENTER);
2318 82 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
2319 82 : pty_settle(s, SETTLE_MS);
2320 : /* Folder browser is showing with folder content */
2321 82 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
2322 82 : pty_send_key(s, PTY_KEY_ESC);
2323 82 : pty_close(s);
2324 : }
2325 : }
2326 :
2327 : /* ══════════════════════════════════════════════════════════════════════
2328 : * HELP PANEL (US-22)
2329 : * ══════════════════════════════════════════════════════════════════════ */
2330 :
2331 74 : static void test_tui_accounts_help_panel(void) {
2332 : /* US-22 AC7: 'h' in accounts screen shows help overlay */
2333 74 : restart_mock();
2334 74 : const char *args[] = {g_tui_bin, NULL};
2335 74 : PtySession *s = pty_open(COLS, ROWS);
2336 74 : ASSERT(s != NULL, "accounts help: pty_open");
2337 74 : if (pty_run(s, args) != 0) { pty_close(s); return; }
2338 73 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2339 73 : pty_settle(s, SETTLE_MS);
2340 73 : pty_send_str(s, "h");
2341 73 : ASSERT_WAIT_FOR(s, "Accounts shortcuts", WAIT_MS);
2342 73 : pty_settle(s, SETTLE_MS);
2343 73 : ASSERT_SCREEN_CONTAINS(s, "Press any key to close");
2344 : /* dismiss with any key */
2345 73 : pty_send_key(s, PTY_KEY_ENTER);
2346 73 : pty_settle(s, SETTLE_MS);
2347 : /* should be back on accounts screen */
2348 73 : ASSERT_SCREEN_CONTAINS(s, "Email Accounts");
2349 73 : pty_send_key(s, PTY_KEY_ESC);
2350 73 : pty_close(s);
2351 : }
2352 :
2353 73 : static void test_tui_list_help_panel(void) {
2354 : /* US-22 AC7: 'h' in message list shows help overlay.
2355 : * The popup only prints the "Press any key to close" footer when every
2356 : * shortcut row fits; otherwise it becomes scrollable and shows the
2357 : * scroll hint instead. Use a tall terminal so the full list fits. */
2358 73 : restart_mock();
2359 73 : PtySession *s = tui_open_to_list_size(COLS, 50);
2360 72 : ASSERT(s != NULL, "list help: opens");
2361 72 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2362 72 : pty_settle(s, SETTLE_MS);
2363 72 : pty_send_str(s, "h");
2364 72 : ASSERT_WAIT_FOR(s, "Message list shortcuts", WAIT_MS);
2365 72 : pty_settle(s, SETTLE_MS);
2366 72 : ASSERT_SCREEN_CONTAINS(s, "Press any key to close");
2367 72 : ASSERT_SCREEN_CONTAINS(s, "Compose new message");
2368 : /* dismiss */
2369 72 : pty_send_key(s, PTY_KEY_ENTER);
2370 72 : pty_settle(s, SETTLE_MS);
2371 : /* still in list view */
2372 72 : ASSERT_SCREEN_CONTAINS(s, "message(s) in");
2373 72 : pty_send_key(s, PTY_KEY_ESC);
2374 72 : pty_close(s);
2375 : }
2376 :
2377 72 : static void test_tui_show_help_panel(void) {
2378 : /* US-22 AC7: 'h' in message reader shows help overlay */
2379 72 : restart_mock();
2380 72 : PtySession *s = tui_open_to_list();
2381 71 : ASSERT(s != NULL, "show help: opens");
2382 71 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2383 71 : pty_settle(s, SETTLE_MS);
2384 71 : pty_send_key(s, PTY_KEY_ENTER);
2385 71 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
2386 71 : pty_settle(s, SETTLE_MS);
2387 71 : pty_send_str(s, "h");
2388 71 : ASSERT_WAIT_FOR(s, "Message reader shortcuts", WAIT_MS);
2389 71 : pty_settle(s, SETTLE_MS);
2390 71 : ASSERT_SCREEN_CONTAINS(s, "Press any key to close");
2391 71 : ASSERT_SCREEN_CONTAINS(s, "Reply to this message");
2392 71 : pty_send_key(s, PTY_KEY_ENTER);
2393 71 : pty_settle(s, SETTLE_MS);
2394 : /* back in reader */
2395 71 : ASSERT_SCREEN_CONTAINS(s, "r=reply");
2396 71 : pty_send_key(s, PTY_KEY_ESC);
2397 71 : pty_send_key(s, PTY_KEY_ESC);
2398 71 : pty_close(s);
2399 : }
2400 :
2401 71 : static void test_tui_folders_help_panel(void) {
2402 : /* US-22 AC7: 'h' in folder browser shows help overlay */
2403 71 : restart_mock();
2404 71 : PtySession *s = tui_open_to_list();
2405 70 : ASSERT(s != NULL, "list-folders help: opens");
2406 70 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2407 70 : pty_settle(s, SETTLE_MS);
2408 70 : pty_send_key(s, PTY_KEY_BACK);
2409 70 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
2410 70 : pty_settle(s, SETTLE_MS);
2411 70 : pty_send_str(s, "h");
2412 70 : ASSERT_WAIT_FOR(s, "Folder browser shortcuts", WAIT_MS);
2413 70 : pty_settle(s, SETTLE_MS);
2414 70 : ASSERT_SCREEN_CONTAINS(s, "Press any key to close");
2415 70 : ASSERT_SCREEN_CONTAINS(s, "Toggle tree");
2416 70 : pty_send_key(s, PTY_KEY_ENTER);
2417 70 : pty_settle(s, SETTLE_MS);
2418 : /* back in folder browser */
2419 70 : ASSERT_SCREEN_CONTAINS(s, "Folders");
2420 70 : pty_send_key(s, PTY_KEY_ESC);
2421 70 : pty_close(s);
2422 : }
2423 :
2424 70 : static void test_tui_help_panel_question_mark(void) {
2425 : /* US-22 AC1: '?' also opens the help panel (same as 'h') */
2426 70 : restart_mock();
2427 70 : PtySession *s = tui_open_to_list();
2428 69 : ASSERT(s != NULL, "? help: opens");
2429 69 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2430 69 : pty_settle(s, SETTLE_MS);
2431 69 : pty_send_str(s, "?");
2432 69 : ASSERT_WAIT_FOR(s, "Message list shortcuts", WAIT_MS);
2433 69 : pty_settle(s, SETTLE_MS);
2434 69 : pty_send_key(s, PTY_KEY_ESC); /* any key dismisses */
2435 69 : pty_settle(s, SETTLE_MS);
2436 69 : ASSERT_SCREEN_CONTAINS(s, "message(s) in");
2437 69 : pty_send_key(s, PTY_KEY_ESC);
2438 69 : pty_close(s);
2439 : }
2440 :
2441 : /* ══════════════════════════════════════════════════════════════════════
2442 : * TLS ENFORCEMENT (US-23)
2443 : * ══════════════════════════════════════════════════════════════════════ */
2444 :
2445 : /**
2446 : * Write a config with an insecure imap:// URL and WITHOUT SSL_NO_VERIFY=1.
2447 : * Used to test that the application rejects plain-text IMAP connections.
2448 : */
2449 19 : static void write_config_no_ssl_verify_imap(void) {
2450 : char d1[300], d2[300], d3[350], d4[400], path[450];
2451 19 : snprintf(d1, sizeof(d1), "%s/.config", g_test_home);
2452 19 : snprintf(d2, sizeof(d2), "%s/.config/email-cli", g_test_home);
2453 19 : snprintf(d3, sizeof(d3), "%s/.config/email-cli/accounts", g_test_home);
2454 19 : snprintf(d4, sizeof(d4), "%s/.config/email-cli/accounts/testuser", g_test_home);
2455 19 : mkdir(g_test_home, 0700);
2456 19 : mkdir(d1, 0700);
2457 19 : mkdir(d2, 0700);
2458 19 : mkdir(d3, 0700);
2459 19 : mkdir(d4, 0700);
2460 19 : snprintf(path, sizeof(path), "%s/config.ini", d4);
2461 19 : FILE *fp = fopen(path, "w");
2462 19 : if (!fp) return;
2463 19 : fprintf(fp,
2464 : "EMAIL_HOST=imap://localhost:9993\n"
2465 : "EMAIL_USER=testuser\n"
2466 : "EMAIL_PASS=testpass\n"
2467 : "EMAIL_FOLDER=INBOX\n");
2468 : /* No SSL_NO_VERIFY=1 — insecure URL must be rejected */
2469 19 : fclose(fp);
2470 19 : chmod(path, 0600);
2471 : }
2472 :
2473 : /**
2474 : * Write a config with a secure imaps:// IMAP URL but an insecure smtp:// SMTP
2475 : * URL and WITHOUT SSL_NO_VERIFY=1. Used to test that plain-text SMTP is
2476 : * rejected.
2477 : */
2478 18 : static void write_config_no_ssl_verify_smtp(void) {
2479 : char d1[300], d2[300], d3[350], d4[400], path[450];
2480 18 : snprintf(d1, sizeof(d1), "%s/.config", g_test_home);
2481 18 : snprintf(d2, sizeof(d2), "%s/.config/email-cli", g_test_home);
2482 18 : snprintf(d3, sizeof(d3), "%s/.config/email-cli/accounts", g_test_home);
2483 18 : snprintf(d4, sizeof(d4), "%s/.config/email-cli/accounts/testuser", g_test_home);
2484 18 : mkdir(g_test_home, 0700);
2485 18 : mkdir(d1, 0700);
2486 18 : mkdir(d2, 0700);
2487 18 : mkdir(d3, 0700);
2488 18 : mkdir(d4, 0700);
2489 18 : snprintf(path, sizeof(path), "%s/config.ini", d4);
2490 18 : FILE *fp = fopen(path, "w");
2491 18 : if (!fp) return;
2492 18 : fprintf(fp,
2493 : "EMAIL_HOST=imaps://localhost:9993\n"
2494 : "EMAIL_USER=testuser\n"
2495 : "EMAIL_PASS=testpass\n"
2496 : "EMAIL_FOLDER=INBOX\n"
2497 : "SMTP_HOST=smtp://localhost:9025\n"
2498 : "SMTP_PORT=9025\n"
2499 : "SMTP_USER=testuser\n"
2500 : "SMTP_PASS=testpass\n");
2501 : /* No SSL_NO_VERIFY=1 — insecure smtp:// URL must be rejected */
2502 18 : fclose(fp);
2503 18 : chmod(path, 0600);
2504 : }
2505 :
2506 19 : static void test_tls_imap_rejected(void) {
2507 : /* US-23 AC1: imap:// in EMAIL_HOST without SSL_NO_VERIFY=1 must be
2508 : * rejected with an error message mentioning imaps:// */
2509 19 : write_config_no_ssl_verify_imap();
2510 19 : const char *a[] = {"list", "--batch", NULL};
2511 19 : PtySession *s = cli_run(a);
2512 18 : ASSERT(s != NULL, "tls imap rejected: opens");
2513 18 : ASSERT_WAIT_FOR(s, "imaps://", WAIT_MS);
2514 18 : pty_close(s);
2515 18 : write_config(); /* restore safe config */
2516 : }
2517 :
2518 18 : static void test_tls_smtp_rejected(void) {
2519 : /* US-23 AC2: smtp:// in SMTP_HOST without SSL_NO_VERIFY=1 must be
2520 : * rejected with an error message mentioning smtps:// */
2521 18 : write_config_no_ssl_verify_smtp();
2522 : /* email-cli send: config_store rejects smtp:// before any network call */
2523 18 : const char *a[] = {"send",
2524 : "--to", "recipient@example.com",
2525 : "--subject", "TLS test",
2526 : "--body", "body",
2527 : NULL};
2528 18 : PtySession *s = cli_run(a);
2529 17 : ASSERT(s != NULL, "tls smtp rejected: opens");
2530 17 : ASSERT_WAIT_FOR(s, "smtps://", WAIT_MS);
2531 17 : pty_close(s);
2532 17 : write_config(); /* restore safe config */
2533 : }
2534 :
2535 : /* ══════════════════════════════════════════════════════════════════════
2536 : * TUI LIST: COMPOSE / REPLY FROM LIST (US-20)
2537 : * ══════════════════════════════════════════════════════════════════════ */
2538 :
2539 66 : static void test_tui_list_compose_key(void) {
2540 : /* US-20: 'c' in TUI list launches compose; EDITOR=true → abort → back to list */
2541 66 : restart_mock();
2542 66 : setenv("EDITOR", "true", 1); /* no-op editor exits without writing To: */
2543 66 : PtySession *s = tui_open_to_list();
2544 65 : ASSERT(s != NULL, "tui list compose key: opens");
2545 65 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2546 65 : pty_settle(s, SETTLE_MS);
2547 65 : pty_send_str(s, "c");
2548 65 : ASSERT_WAIT_FOR(s, "New Message", WAIT_MS); /* compose dialog title */
2549 : /* Enter confirms the dialog from any field; with an empty To it only
2550 : * raises the inline guard, so fill To: first (Tab moves between fields). */
2551 65 : pty_send_key(s, PTY_KEY_ENTER);
2552 65 : ASSERT_WAIT_FOR(s, "To: is empty", WAIT_MS);
2553 65 : pty_send_str(s, "someone@example.com");
2554 65 : pty_send_key(s, PTY_KEY_ENTER); /* confirm: no subject → y/n prompt */
2555 65 : ASSERT_WAIT_FOR(s, "Subject is empty", WAIT_MS);
2556 65 : pty_send_str(s, "y");
2557 : /* US-85: the editor runs, then the review screen appears; 'q' discards. */
2558 65 : ASSERT_WAIT_FOR(s, "Review: Message", WAIT_MS);
2559 65 : pty_send_str(s, "q");
2560 65 : ASSERT_WAIT_FOR(s, "Discard draft?", WAIT_MS);
2561 65 : pty_send_str(s, "y");
2562 65 : ASSERT_WAIT_FOR(s, "Cancelled. Draft discarded.", WAIT_MS);
2563 65 : ASSERT_WAIT_FOR(s, "[Press any key to return to inbox]", WAIT_MS);
2564 65 : pty_send_str(s, " "); /* any key — return to list */
2565 65 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2566 65 : pty_send_key(s, PTY_KEY_ESC);
2567 65 : pty_close(s);
2568 : }
2569 :
2570 65 : static void test_tui_list_reply_key(void) {
2571 : /* US-20: 'r' in TUI list launches reply; editor clears To: → abort → back to list */
2572 65 : restart_mock();
2573 : /* Write an editor script that blanks To: so compose aborts cleanly */
2574 : char editor_script[256];
2575 65 : snprintf(editor_script, sizeof(editor_script),
2576 65 : "/tmp/test_reply_list_editor_%d.sh", (int)getpid());
2577 65 : FILE *ef = fopen(editor_script, "w");
2578 65 : if (ef) {
2579 65 : fprintf(ef, "#!/bin/sh\n");
2580 65 : fprintf(ef,
2581 : "printf 'From: test@x.com\\nTo: \\nSubject: Re\\n\\nbody\\n' > \"$1\"\n");
2582 65 : fclose(ef);
2583 65 : chmod(editor_script, 0755);
2584 : }
2585 65 : setenv("EDITOR", editor_script, 1);
2586 65 : PtySession *s = tui_open_to_list();
2587 64 : ASSERT(s != NULL, "tui list reply key: opens");
2588 64 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2589 64 : pty_settle(s, SETTLE_MS);
2590 64 : pty_send_str(s, "r");
2591 64 : ASSERT_WAIT_FOR(s, "Reply", WAIT_MS); /* compose dialog title */
2592 64 : pty_send_key(s, PTY_KEY_ENTER); /* Cc → Bcc (To: pre-filled, starts at Cc) */
2593 64 : pty_send_key(s, PTY_KEY_ENTER); /* Bcc → Subject */
2594 64 : pty_send_key(s, PTY_KEY_ENTER); /* Subject → submit */
2595 : /* US-85: the editor blanks To:, then the review screen appears; 'q' discards. */
2596 64 : ASSERT_WAIT_FOR(s, "Review: Message", WAIT_MS * 2);
2597 64 : pty_send_str(s, "q");
2598 64 : ASSERT_WAIT_FOR(s, "Discard draft?", WAIT_MS);
2599 64 : pty_send_str(s, "y");
2600 64 : ASSERT_WAIT_FOR(s, "Cancelled. Draft discarded.", WAIT_MS);
2601 64 : ASSERT_WAIT_FOR(s, "[Press any key to return to inbox]", WAIT_MS);
2602 64 : pty_send_str(s, " ");
2603 64 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2604 64 : pty_send_key(s, PTY_KEY_ESC);
2605 64 : pty_close(s);
2606 64 : unlink(editor_script);
2607 : }
2608 :
2609 : /* ══════════════════════════════════════════════════════════════════════
2610 : * TUI LIST: BACKGROUND SYNC + SIGCHLD NOTIFICATION (US-19)
2611 : * ══════════════════════════════════════════════════════════════════════ */
2612 :
2613 64 : static void test_tui_list_sync_and_refresh(void) {
2614 : /* US-19: 's' starts background sync; next keypress shows notification;
2615 : * 'R' clears it and re-renders the normal count line.
2616 : *
2617 : * Design: run the TUI in cron mode (SYNC_INTERVAL) so it reads from the
2618 : * local cache and holds NO persistent IMAP connection. This lets the
2619 : * background email-sync subprocess connect to the single-threaded mock
2620 : * server freely and complete quickly. */
2621 64 : restart_mock();
2622 :
2623 : /* Pre-populate the local store so the cron-mode TUI shows a real list */
2624 : {
2625 64 : PtySession *ss = pty_open(COLS, ROWS);
2626 64 : ASSERT(ss != NULL, "tui sync+refresh: pre-sync opens");
2627 64 : const char *sa[] = {g_sync_bin, NULL};
2628 64 : ASSERT(pty_run(ss, sa) == 0, "tui sync+refresh: pre-sync run");
2629 63 : ASSERT_WAIT_FOR(ss, "Sync complete", WAIT_MS);
2630 63 : pty_close(ss);
2631 : }
2632 63 : write_config_with_interval(5); /* cron mode: TUI reads from local cache only */
2633 :
2634 63 : PtySession *s = tui_open_to_list();
2635 62 : ASSERT(s != NULL, "tui sync+refresh: opens");
2636 62 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2637 62 : pty_settle(s, SETTLE_MS);
2638 62 : pty_send_str(s, "s"); /* start background sync */
2639 62 : ASSERT_WAIT_FOR(s, "syncing", WAIT_MS);
2640 : /* Sync completes quickly: no IMAP conflict since TUI holds no connection.
2641 : * 3 s settle ensures SIGCHLD fires before the DOWN keypress. */
2642 62 : pty_settle(s, 3000);
2643 62 : pty_send_key(s, PTY_KEY_DOWN); /* re-render: bg_sync_done=1 → shows notification */
2644 62 : ASSERT_WAIT_FOR(s, "New mail", WAIT_MS);
2645 62 : pty_send_str(s, "U"); /* refresh: bg_sync_done=0, re-enters list */
2646 62 : int _ok = 0;
2647 62 : for (int _ms = 0; _ms < WAIT_MS; _ms += 100) {
2648 62 : pty_settle(s, 100);
2649 62 : if (!pty_screen_contains(s, "New mail")
2650 62 : && pty_screen_contains(s, "message(s) in")) {
2651 62 : _ok = 1; break;
2652 : }
2653 : }
2654 62 : pty_send_key(s, PTY_KEY_ESC);
2655 62 : pty_close(s); /* always close before final ASSERT to prevent zombie cascade */
2656 62 : write_config(); /* restore standard config for subsequent tests */
2657 62 : ASSERT(_ok, "tui sync+refresh: New mail notification cleared after R");
2658 : }
2659 :
2660 : /* ══════════════════════════════════════════════════════════════════════
2661 : * GMAIL WIZARD — Account type selection (US-27)
2662 : * ══════════════════════════════════════════════════════════════════════ */
2663 :
2664 62 : static void test_wizard_gmail_type_selection(void) {
2665 : /* Gmail flow: choose type 2 → "Email address" prompt appears */
2666 : char wiz_home[300];
2667 62 : snprintf(wiz_home, sizeof(wiz_home), "%s/wizard_gmail", g_test_home);
2668 62 : mkdir(wiz_home, 0700);
2669 62 : setenv("HOME", wiz_home, 1);
2670 62 : unsetenv("XDG_CONFIG_HOME");
2671 :
2672 62 : PtySession *s = cli_run(NULL);
2673 61 : ASSERT(s != NULL, "wizard gmail: opens");
2674 61 : ASSERT_WAIT_FOR(s, "Account type", WAIT_MS);
2675 61 : ASSERT_SCREEN_CONTAINS(s, "IMAP");
2676 61 : ASSERT_SCREEN_CONTAINS(s, "Gmail");
2677 61 : pty_send_str(s, "2\n"); /* Gmail */
2678 61 : ASSERT_WAIT_FOR(s, "Email address", WAIT_MS);
2679 61 : pty_send_key(s, PTY_KEY_CTRL_D); /* abort — no OAuth server available */
2680 61 : ASSERT_WAIT_FOR(s, "borted", WAIT_MS);
2681 61 : pty_close(s);
2682 :
2683 61 : setenv("HOME", g_test_home, 1);
2684 : }
2685 :
2686 60 : static void write_gmail_account(const char *name) {
2687 : char dir1[512], dir2[512], path[600];
2688 60 : snprintf(dir1, sizeof(dir1), "%s/.config/email-cli/accounts", g_test_home);
2689 60 : snprintf(dir2, sizeof(dir2), "%s/.config/email-cli/accounts/%s", g_test_home, name);
2690 60 : mkdir(dir1, 0700);
2691 60 : mkdir(dir2, 0700);
2692 60 : snprintf(path, sizeof(path), "%s/config.ini", dir2);
2693 60 : FILE *fp = fopen(path, "w");
2694 60 : if (!fp) return;
2695 60 : fprintf(fp,
2696 : "EMAIL_USER=%s\n"
2697 : "GMAIL_MODE=1\n"
2698 : "GMAIL_REFRESH_TOKEN=dummy_test_token\n"
2699 : "SSL_NO_VERIFY=1\n", name);
2700 60 : fclose(fp);
2701 60 : chmod(path, 0600);
2702 :
2703 : /* Create local store with a dummy label .idx so label list is non-empty.
2704 : * Use a flat layout: accounts/<name>/labels/INBOX.idx */
2705 : {
2706 : char d[1024];
2707 60 : snprintf(d, sizeof(d), "%s/.local", g_test_home); mkdir(d, 0700);
2708 60 : snprintf(d, sizeof(d), "%s/.local/share", g_test_home); mkdir(d, 0700);
2709 60 : snprintf(d, sizeof(d), "%s/.local/share/email-cli", g_test_home); mkdir(d, 0700);
2710 60 : snprintf(d, sizeof(d), "%s/.local/share/email-cli/accounts", g_test_home); mkdir(d, 0700);
2711 60 : snprintf(d, sizeof(d), "%s/.local/share/email-cli/accounts/%s", g_test_home, name); mkdir(d, 0700);
2712 60 : snprintf(d, sizeof(d), "%s/.local/share/email-cli/accounts/%s/labels", g_test_home, name); mkdir(d, 0700);
2713 : char idx[1100];
2714 60 : snprintf(idx, sizeof(idx), "%s/INBOX.idx", d);
2715 60 : fp = fopen(idx, "w");
2716 60 : if (fp) { fprintf(fp, "18c9b46d67a60001\n"); fclose(fp); }
2717 : }
2718 : }
2719 :
2720 60 : static void test_gmail_labels_backspace(void) {
2721 : /* Gmail account: Enter opens Labels view; Backspace → accounts */
2722 60 : write_gmail_account("gmailtest@gmail.com");
2723 60 : restart_mock();
2724 60 : PtySession *s = cli_run(NULL);
2725 59 : ASSERT(s != NULL, "gmail labels backspace: opens");
2726 59 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2727 59 : pty_settle(s, SETTLE_MS);
2728 : /* Gmail account should show "Gmail" in Type column */
2729 59 : ASSERT_SCREEN_CONTAINS(s, "Gmail");
2730 : /* gmail.com sorts before testuser alphabetically, BUT last_account may restore
2731 : * the cursor to testuser; use HOME to guarantee we're on gmailtest@gmail.com. */
2732 59 : pty_send_key(s, PTY_KEY_HOME);
2733 59 : pty_settle(s, SETTLE_MS);
2734 59 : pty_send_key(s, PTY_KEY_ENTER);
2735 59 : ASSERT_WAIT_FOR(s, "Labels", WAIT_MS);
2736 : /* Backspace → back to accounts */
2737 59 : pty_send_key(s, PTY_KEY_BACK);
2738 59 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2739 59 : pty_send_key(s, PTY_KEY_ESC);
2740 59 : pty_close(s);
2741 :
2742 : /* Cleanup */
2743 : char path[500];
2744 59 : snprintf(path, sizeof(path),
2745 : "%s/.config/email-cli/accounts/gmailtest@gmail.com/config.ini",
2746 : g_test_home);
2747 59 : unlink(path);
2748 59 : snprintf(path, sizeof(path),
2749 : "%s/.config/email-cli/accounts/gmailtest@gmail.com", g_test_home);
2750 59 : rmdir(path);
2751 : }
2752 :
2753 61 : static void test_account_list_type_column(void) {
2754 : /* Account list shows Type column with "IMAP" for standard accounts */
2755 61 : restart_mock();
2756 61 : PtySession *s = cli_run(NULL);
2757 60 : ASSERT(s != NULL, "account type column: opens");
2758 60 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2759 60 : pty_settle(s, SETTLE_MS);
2760 60 : ASSERT_SCREEN_CONTAINS(s, "Type");
2761 60 : ASSERT_SCREEN_CONTAINS(s, "IMAP");
2762 60 : pty_send_key(s, PTY_KEY_ESC);
2763 60 : pty_close(s);
2764 : }
2765 :
2766 : /* ══════════════════════════════════════════════════════════════════════
2767 : * TUI ACCOUNTS: ADD / DELETE / EDIT IMAP (US-21 AC4/5/13)
2768 : * ══════════════════════════════════════════════════════════════════════ */
2769 :
2770 59 : static void test_tui_accounts_new_key(void) {
2771 : /* US-21 AC4: 'n' launches Setup Wizard; Ctrl-D aborts → accounts screen */
2772 59 : restart_mock();
2773 59 : PtySession *s = cli_run(NULL);
2774 58 : ASSERT(s != NULL, "accounts new key: opens");
2775 58 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2776 58 : pty_settle(s, SETTLE_MS);
2777 58 : pty_send_str(s, "n");
2778 58 : ASSERT_WAIT_FOR(s, "Account type", WAIT_MS);
2779 58 : pty_send_str(s, "1\n"); /* IMAP */
2780 58 : ASSERT_WAIT_FOR(s, "IMAP Host", WAIT_MS);
2781 58 : pty_send_key(s, PTY_KEY_CTRL_D); /* EOF aborts wizard */
2782 58 : ASSERT_WAIT_FOR(s, "borted", WAIT_MS);
2783 58 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2784 58 : pty_send_key(s, PTY_KEY_ESC);
2785 58 : pty_close(s);
2786 : }
2787 :
2788 58 : static void test_tui_accounts_delete_key(void) {
2789 : /* US-21 AC5: 'd' deletes the selected account; it disappears from the list */
2790 58 : write_second_account("todelete@example.com");
2791 58 : restart_mock();
2792 58 : PtySession *s = cli_run(NULL);
2793 57 : ASSERT(s != NULL, "accounts delete key: opens");
2794 57 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2795 57 : pty_settle(s, SETTLE_MS);
2796 57 : ASSERT_SCREEN_CONTAINS(s, "todelete@example.com");
2797 : /* domain-first sort: todelete@example.com (domain: example.com) sorts before
2798 : * testuser (no domain); cursor starts at 0 = todelete. Use HOME to ensure it. */
2799 57 : pty_send_key(s, PTY_KEY_HOME);
2800 57 : pty_settle(s, SETTLE_MS);
2801 57 : pty_send_str(s, "d");
2802 57 : pty_settle(s, SETTLE_MS);
2803 57 : ASSERT(pty_screen_contains(s, "todelete@example.com") == 0,
2804 : "accounts delete: account removed from list");
2805 57 : pty_send_key(s, PTY_KEY_ESC);
2806 57 : pty_close(s);
2807 : /* cleanup: directory already removed by the TUI; unlink is defensive */
2808 : char path[500];
2809 57 : snprintf(path, sizeof(path),
2810 : "%s/.config/email-cli/accounts/todelete@example.com/config.ini",
2811 : g_test_home);
2812 57 : unlink(path);
2813 57 : snprintf(path, sizeof(path),
2814 : "%s/.config/email-cli/accounts/todelete@example.com", g_test_home);
2815 57 : rmdir(path);
2816 : }
2817 :
2818 57 : static void test_tui_accounts_imap_edit_key(void) {
2819 : /* US-21 AC13: 'i' opens IMAP wizard for selected account; Ctrl-D aborts */
2820 57 : restart_mock();
2821 57 : PtySession *s = cli_run(NULL);
2822 56 : ASSERT(s != NULL, "accounts imap edit: opens");
2823 56 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2824 56 : pty_settle(s, SETTLE_MS);
2825 56 : pty_send_str(s, "i");
2826 56 : ASSERT_WAIT_FOR(s, "current:", WAIT_MS); /* "IMAP Host [current: ...]" prompt */
2827 56 : pty_send_key(s, PTY_KEY_CTRL_D); /* abort wizard */
2828 56 : ASSERT_WAIT_FOR(s, "Email Accounts", WAIT_MS);
2829 56 : pty_send_key(s, PTY_KEY_ESC);
2830 56 : pty_close(s);
2831 : }
2832 :
2833 : /* ══════════════════════════════════════════════════════════════════════
2834 : * EMAIL-SYNC --ACCOUNT FILTER (US-25)
2835 : * ══════════════════════════════════════════════════════════════════════ */
2836 :
2837 56 : static void test_sync_account_filter_known(void) {
2838 : /* US-25: --account syncs only the named account */
2839 56 : write_second_account("testacct@test.com");
2840 56 : restart_mock();
2841 56 : const char *a[] = {"--account", "testacct@test.com", NULL};
2842 56 : PtySession *s = sync_run(a);
2843 55 : ASSERT(s != NULL, "sync account filter known: opens");
2844 55 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
2845 55 : pty_close(s);
2846 : /* cleanup */
2847 : char path[500];
2848 55 : snprintf(path, sizeof(path),
2849 : "%s/.config/email-cli/accounts/testacct@test.com/config.ini",
2850 : g_test_home);
2851 55 : unlink(path);
2852 55 : snprintf(path, sizeof(path),
2853 : "%s/.config/email-cli/accounts/testacct@test.com", g_test_home);
2854 55 : rmdir(path);
2855 : }
2856 :
2857 55 : static void test_sync_account_filter_unknown(void) {
2858 : /* US-25: --account with unknown name prints "not found" and exits non-zero */
2859 55 : const char *a[] = {"--account", "nobody@nowhere.invalid", NULL};
2860 55 : PtySession *s = sync_run(a);
2861 54 : ASSERT(s != NULL, "sync account filter unknown: opens");
2862 54 : ASSERT_WAIT_FOR(s, "not found", WAIT_MS);
2863 54 : pty_close(s);
2864 : }
2865 :
2866 : /* ══════════════════════════════════════════════════════════════════════
2867 : * PENDING FLAG OFFLINE QUEUE (US-26)
2868 : * ══════════════════════════════════════════════════════════════════════ */
2869 :
2870 54 : static void test_pending_flags_offline_queue(void) {
2871 : /* US-26: flag a message in offline/cron mode → manifest updated optimistically,
2872 : * pending_flags file created on disk for next sync to consume */
2873 :
2874 : /* Sync first to populate manifest with known UIDs */
2875 54 : restart_mock();
2876 54 : { const char *a[] = {NULL};
2877 54 : PtySession *s = sync_run(a);
2878 53 : ASSERT(s != NULL, "pending flags: sync opens");
2879 53 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
2880 53 : pty_close(s); }
2881 :
2882 : /* Switch to offline/cron mode and stop the server */
2883 53 : write_config_with_interval(5);
2884 53 : stop_mock_server();
2885 :
2886 : /* Open TUI list — works offline in cron mode (served from manifest) */
2887 53 : PtySession *s = tui_open_to_list();
2888 52 : ASSERT(s != NULL, "pending flags: tui opens offline");
2889 52 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
2890 52 : pty_settle(s, SETTLE_MS);
2891 :
2892 : /* Press 'f' to toggle Flagged on the first message */
2893 52 : pty_send_str(s, "f");
2894 52 : pty_settle(s, SETTLE_MS);
2895 : /* 'f' toggles Flagged; message may already be flagged from earlier tests,
2896 : * so either "Starred" or "Unstarred" is acceptable feedback. */
2897 52 : ASSERT(pty_screen_contains(s, "Starred") || pty_screen_contains(s, "Unstarred"),
2898 : "pending flags: 'f' produced star-feedback");
2899 52 : pty_send_key(s, PTY_KEY_ESC);
2900 52 : pty_close(s);
2901 :
2902 : /* pending_flags file must exist on disk */
2903 : char pf_path[512];
2904 52 : snprintf(pf_path, sizeof(pf_path),
2905 : "%s/.local/share/email-cli/accounts/testuser/pending_flags/INBOX.tsv",
2906 : g_test_home);
2907 52 : ASSERT(access(pf_path, F_OK) == 0,
2908 : "pending flags: INBOX.tsv written to disk after offline flag change");
2909 :
2910 : /* Restore standard config (no sync_interval) */
2911 52 : write_config();
2912 52 : restart_mock();
2913 : }
2914 :
2915 : /* ══════════════════════════════════════════════════════════════════════
2916 : * VIRTUAL UNREAD / FLAGGED LIST (Ticket 4 + IMAP bug fixes)
2917 : * ══════════════════════════════════════════════════════════════════════ */
2918 :
2919 : /* Helper: create directory chain for account data under g_test_home. */
2920 590 : static void make_account_dirs(void) {
2921 : char p[700];
2922 590 : snprintf(p, sizeof(p), "%s/.local", g_test_home); mkdir(p, 0700);
2923 590 : snprintf(p, sizeof(p), "%s/.local/share", g_test_home); mkdir(p, 0700);
2924 590 : snprintf(p, sizeof(p), "%s/.local/share/email-cli", g_test_home); mkdir(p, 0700);
2925 590 : snprintf(p, sizeof(p), "%s/.local/share/email-cli/accounts", g_test_home); mkdir(p, 0700);
2926 590 : snprintf(p, sizeof(p), "%s/.local/share/email-cli/accounts/testuser", g_test_home); mkdir(p, 0700);
2927 590 : snprintf(p, sizeof(p), "%s/.local/share/email-cli/accounts/testuser/manifests", g_test_home); mkdir(p, 0700);
2928 590 : }
2929 :
2930 : /*
2931 : * Helper: write a manifest TSV file for the given folder.
2932 : * Each element of `lines` must be a complete TSV line:
2933 : * uid TAB from TAB subject TAB date TAB flags
2934 : */
2935 544 : static void write_test_manifest(const char *folder,
2936 : const char **lines, int n) {
2937 544 : make_account_dirs();
2938 : char path[700];
2939 544 : snprintf(path, sizeof(path),
2940 : "%s/.local/share/email-cli/accounts/testuser/manifests/%s.tsv",
2941 : g_test_home, folder);
2942 544 : FILE *fp = fopen(path, "w");
2943 544 : if (!fp) return;
2944 1386 : for (int i = 0; i < n; i++) fprintf(fp, "%s\n", lines[i]);
2945 544 : fclose(fp);
2946 : }
2947 :
2948 : /* Helper: write a minimal .eml cache file so the reader can open a UID.
2949 : * UID "0000000000000001" → d1='1', d2='0' → store/INBOX/1/0/<uid>.eml */
2950 46 : static void write_test_eml(const char *folder, const char *uid,
2951 : const char *from, const char *subject,
2952 : const char *body) {
2953 46 : make_account_dirs();
2954 46 : char last = uid[strlen(uid) - 1];
2955 46 : char prev = strlen(uid) > 1 ? uid[strlen(uid) - 2] : '0';
2956 : char dir[700], path[800];
2957 46 : snprintf(dir, sizeof(dir),
2958 : "%s/.local/share/email-cli/accounts/testuser/store/%s/%c/%c",
2959 : g_test_home, folder, last, prev);
2960 : /* mkdir -p equivalent for four levels */
2961 : {
2962 : char tmp[700];
2963 46 : snprintf(tmp, sizeof(tmp),
2964 : "%s/.local/share/email-cli/accounts/testuser/store", g_test_home);
2965 46 : mkdir(tmp, 0700);
2966 46 : snprintf(tmp, sizeof(tmp),
2967 : "%s/.local/share/email-cli/accounts/testuser/store/%s", g_test_home, folder);
2968 46 : mkdir(tmp, 0700);
2969 : char d1dir[700];
2970 46 : snprintf(d1dir, sizeof(d1dir),
2971 : "%s/.local/share/email-cli/accounts/testuser/store/%s/%c", g_test_home, folder, last);
2972 46 : mkdir(d1dir, 0700);
2973 46 : mkdir(dir, 0700);
2974 : }
2975 46 : snprintf(path, sizeof(path), "%s/%s.eml", dir, uid);
2976 46 : FILE *fp = fopen(path, "w");
2977 46 : if (!fp) return;
2978 46 : fprintf(fp,
2979 : "From: %s\r\n"
2980 : "Subject: %s\r\n"
2981 : "MIME-Version: 1.0\r\n"
2982 : "Content-Type: text/plain; charset=utf-8\r\n"
2983 : "\r\n"
2984 : "%s\r\n", from, subject, body);
2985 46 : fclose(fp);
2986 : }
2987 :
2988 : /* Helper: move the folder-browser cursor onto INBOX, whatever the saved
2989 : * folder preference is. email-tui pre-selects the __unread__ virtual label
2990 : * when no preference exists, so tests must not assume INBOX is under the
2991 : * cursor. HOME lands on the first selectable row (Unread); six DOWN presses
2992 : * step over the remaining virtual rows and the "Folders" header (VPREFIX=8:
2993 : * one header + six virtual rows + one header). */
2994 314 : static void folders_goto_inbox(PtySession *s) {
2995 314 : pty_send_key(s, PTY_KEY_HOME);
2996 2198 : for (int i = 0; i < 6; i++) pty_send_key(s, PTY_KEY_DOWN);
2997 314 : pty_settle(s, 200);
2998 314 : }
2999 :
3000 : /* Helper: open email-tui and stop at the folder browser (before INBOX list).
3001 : * Caller must call pty_close(s) and ESC/settle as needed. */
3002 546 : static PtySession *tui_open_to_folders(void) {
3003 546 : PtySession *s = cli_run(NULL);
3004 532 : if (!s) return NULL;
3005 532 : if (pty_wait_for(s, "Email Account", WAIT_MS) != 0) { pty_close(s); return NULL; }
3006 532 : pty_send_key(s, PTY_KEY_ENTER);
3007 532 : if (pty_wait_for(s, "Folders", WAIT_MS) != 0) { pty_close(s); return NULL; }
3008 532 : return s;
3009 : }
3010 :
3011 52 : static void test_virtual_folder_sections_shown(void) {
3012 : /* Ticket 4: folder browser shows all virtual rows under Tags/Flags */
3013 52 : restart_mock();
3014 52 : PtySession *s = tui_open_to_folders();
3015 51 : ASSERT(s != NULL, "virtual sections: opens folder browser");
3016 51 : pty_settle(s, SETTLE_MS);
3017 51 : ASSERT_SCREEN_CONTAINS(s, "Tags / Flags");
3018 51 : ASSERT_SCREEN_CONTAINS(s, "Unread");
3019 51 : ASSERT_SCREEN_CONTAINS(s, "Flagged");
3020 51 : ASSERT_SCREEN_CONTAINS(s, "Junk");
3021 51 : ASSERT_SCREEN_CONTAINS(s, "Phishing");
3022 51 : ASSERT_SCREEN_CONTAINS(s, "Answered");
3023 51 : ASSERT_SCREEN_CONTAINS(s, "Forwarded");
3024 51 : ASSERT_SCREEN_CONTAINS(s, "Folders");
3025 51 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
3026 51 : pty_send_key(s, PTY_KEY_ESC);
3027 51 : pty_close(s);
3028 : }
3029 :
3030 51 : static void test_virtual_unread_count_from_manifests(void) {
3031 : /* Ticket 4 / Bug 1: Unread row in folder browser reflects local manifests.
3032 : * Two folders have unread messages → count is non-zero. */
3033 :
3034 : /* Sync first to populate the local folder list cache */
3035 51 : restart_mock();
3036 51 : { const char *a[] = {NULL};
3037 51 : PtySession *s = sync_run(a);
3038 50 : ASSERT(s != NULL, "unread count: sync opens");
3039 50 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3040 50 : pty_close(s); }
3041 :
3042 50 : write_config_with_interval(5);
3043 50 : stop_mock_server();
3044 :
3045 : /* Seed: INBOX has 2 unread (flag=1) and 1 read (flag=0) */
3046 50 : const char *inbox[] = {
3047 : "0000000000000011\tsender@example.com\tUnread One\t2026-04-25 10:00\t1",
3048 : "0000000000000012\tsender@example.com\tUnread Two\t2026-04-25 09:00\t1",
3049 : "0000000000000013\tsender@example.com\tAlready Read\t2026-04-25 08:00\t0",
3050 : };
3051 50 : write_test_manifest("INBOX", inbox, 3);
3052 : /* Seed: Sent has 1 unread */
3053 50 : const char *sent[] = {
3054 : "0000000000000021\tme@example.com\tSent Unread\t2026-04-24 08:00\t1",
3055 : };
3056 50 : write_test_manifest("Sent", sent, 1);
3057 :
3058 50 : PtySession *s = tui_open_to_folders();
3059 49 : ASSERT(s != NULL, "unread count: opens folder browser offline");
3060 49 : pty_settle(s, SETTLE_MS);
3061 :
3062 : /* Navigate UP twice to highlight the Unread row:
3063 : * VPREFIX(4=INBOX) → UP → VP_FLAGGED(2) → UP → VP_UNREAD(1) */
3064 49 : pty_send_key(s, PTY_KEY_UP);
3065 49 : pty_settle(s, SETTLE_MS);
3066 49 : pty_send_key(s, PTY_KEY_UP);
3067 49 : pty_settle(s, SETTLE_MS);
3068 :
3069 : /* The Unread row must show total unread = 3 (2 from INBOX + 1 from Sent) */
3070 49 : ASSERT_SCREEN_CONTAINS(s, "3");
3071 49 : pty_send_key(s, PTY_KEY_ESC);
3072 49 : pty_close(s);
3073 :
3074 49 : write_config();
3075 49 : restart_mock();
3076 : }
3077 :
3078 49 : static void test_virtual_unread_list_shows_messages(void) {
3079 : /* Bug 2 / Bug 3 prerequisite: navigating into the virtual Unread list
3080 : * shows all unread messages across folders. */
3081 :
3082 49 : restart_mock();
3083 49 : { const char *a[] = {NULL};
3084 49 : PtySession *s = sync_run(a);
3085 48 : ASSERT(s != NULL, "unread list: sync");
3086 48 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3087 48 : pty_close(s); }
3088 :
3089 48 : write_config_with_interval(5);
3090 48 : stop_mock_server();
3091 :
3092 48 : const char *inbox[] = {
3093 : "0000000000000031\tsender@example.com\tImportant Update\t2026-04-25 10:00\t1",
3094 : "0000000000000032\tsender@example.com\tAlready Read\t2026-04-25 09:00\t0",
3095 : };
3096 48 : write_test_manifest("INBOX", inbox, 2);
3097 :
3098 48 : PtySession *s = tui_open_to_folders();
3099 47 : ASSERT(s != NULL, "unread list: opens folder browser");
3100 47 : pty_settle(s, SETTLE_MS);
3101 :
3102 : /* Navigate to VP_UNREAD row: HOME goes directly to VP_UNREAD */
3103 47 : pty_send_key(s, PTY_KEY_HOME);
3104 47 : pty_settle(s, SETTLE_MS);
3105 47 : pty_send_key(s, PTY_KEY_ENTER);
3106 :
3107 47 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3108 47 : pty_settle(s, SETTLE_MS);
3109 :
3110 : /* Only the unread message should appear (the read one is filtered out) */
3111 47 : ASSERT_SCREEN_CONTAINS(s, "Important Update");
3112 47 : ASSERT(pty_screen_contains(s, "Already Read") == 0,
3113 : "unread list: read message must not appear in Unread virtual list");
3114 :
3115 47 : pty_send_key(s, PTY_KEY_ESC);
3116 47 : pty_close(s);
3117 :
3118 47 : write_config();
3119 47 : restart_mock();
3120 : }
3121 :
3122 47 : static void test_virtual_enter_opens_reader(void) {
3123 : /* Bug 3 fix: Enter in the virtual Unread list opens the message reader.
3124 : * Previously failed because entries[i].folder was empty. */
3125 :
3126 47 : restart_mock();
3127 47 : { const char *a[] = {NULL};
3128 47 : PtySession *s = sync_run(a);
3129 46 : ASSERT(s != NULL, "virtual enter: sync");
3130 46 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3131 46 : pty_close(s); }
3132 :
3133 46 : write_config_with_interval(5);
3134 46 : stop_mock_server();
3135 :
3136 46 : const char uid[] = "0000000000000041";
3137 46 : const char *inbox[] = {
3138 : "0000000000000041\tsender@example.com\tOpen Me Please\t2026-04-25 10:00\t1",
3139 : };
3140 46 : write_test_manifest("INBOX", inbox, 1);
3141 46 : write_test_eml("INBOX", uid, "sender@example.com", "Open Me Please",
3142 : "This is the message body.");
3143 :
3144 46 : PtySession *s = tui_open_to_folders();
3145 45 : ASSERT(s != NULL, "virtual enter: opens folder browser");
3146 45 : pty_settle(s, SETTLE_MS);
3147 :
3148 : /* Navigate to Unread: HOME goes directly to VP_UNREAD */
3149 45 : pty_send_key(s, PTY_KEY_HOME);
3150 45 : pty_settle(s, SETTLE_MS);
3151 45 : pty_send_key(s, PTY_KEY_ENTER);
3152 :
3153 45 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3154 45 : pty_settle(s, SETTLE_MS);
3155 45 : ASSERT_SCREEN_CONTAINS(s, "Open Me Please");
3156 :
3157 : /* Press Enter on the first (and only) message → opens reader */
3158 45 : pty_send_key(s, PTY_KEY_ENTER);
3159 45 : ASSERT_WAIT_FOR(s, "From:", WAIT_MS);
3160 45 : pty_settle(s, SETTLE_MS);
3161 45 : ASSERT_SCREEN_CONTAINS(s, "sender@example.com");
3162 :
3163 45 : pty_send_key(s, PTY_KEY_ESC);
3164 45 : pty_settle(s, SETTLE_MS);
3165 45 : pty_send_key(s, PTY_KEY_ESC);
3166 45 : pty_close(s);
3167 :
3168 45 : write_config();
3169 45 : restart_mock();
3170 : }
3171 :
3172 45 : static void test_virtual_n_marks_message_read(void) {
3173 : /* Bug 2 fix: pressing 'n' in the virtual Unread list marks the message read.
3174 : * Previously 'n' saved to __unread__.tsv instead of the real folder manifest,
3175 : * so the status marker did not change. */
3176 :
3177 45 : restart_mock();
3178 45 : { const char *a[] = {NULL};
3179 45 : PtySession *s = sync_run(a);
3180 44 : ASSERT(s != NULL, "virtual n: sync");
3181 44 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3182 44 : pty_close(s); }
3183 :
3184 44 : write_config_with_interval(5);
3185 44 : stop_mock_server();
3186 :
3187 44 : const char *inbox[] = {
3188 : "0000000000000051\tsender@example.com\tMark Me Read\t2026-04-25 10:00\t1",
3189 : };
3190 44 : write_test_manifest("INBOX", inbox, 1);
3191 :
3192 44 : PtySession *s = tui_open_to_folders();
3193 43 : ASSERT(s != NULL, "virtual n: opens folder browser");
3194 43 : pty_settle(s, SETTLE_MS);
3195 :
3196 : /* Navigate to Unread: HOME goes directly to VP_UNREAD */
3197 43 : pty_send_key(s, PTY_KEY_HOME);
3198 43 : pty_settle(s, SETTLE_MS);
3199 43 : pty_send_key(s, PTY_KEY_ENTER);
3200 :
3201 43 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3202 43 : pty_settle(s, SETTLE_MS);
3203 43 : ASSERT_SCREEN_CONTAINS(s, "Mark Me Read");
3204 :
3205 : /* Status column must show 'N' (unread marker) before toggling */
3206 43 : ASSERT_SCREEN_CONTAINS(s, "N");
3207 :
3208 : /* Press 'n' to mark as read */
3209 43 : pty_send_str(s, "n");
3210 43 : pty_settle(s, SETTLE_MS);
3211 :
3212 : /* 'N' status marker must disappear; message should show as read "----" */
3213 43 : ASSERT_SCREEN_CONTAINS(s, "----");
3214 :
3215 43 : pty_send_key(s, PTY_KEY_ESC);
3216 43 : pty_close(s);
3217 :
3218 : /* Verify the per-folder manifest on disk was updated (flags=0) */
3219 : char mpath[600];
3220 43 : snprintf(mpath, sizeof(mpath),
3221 : "%s/.local/share/email-cli/accounts/testuser/manifests/INBOX.tsv",
3222 : g_test_home);
3223 43 : FILE *fp = fopen(mpath, "r");
3224 43 : ASSERT(fp != NULL, "virtual n: INBOX manifest exists");
3225 43 : if (fp) {
3226 43 : char line[512] = "";
3227 43 : while (fgets(line, sizeof(line), fp)) {
3228 43 : if (strstr(line, "0000000000000051")) break;
3229 : }
3230 43 : fclose(fp);
3231 : /* Last field (flags) must be 0 after marking read */
3232 43 : ASSERT(strstr(line, "\t0\n") || strstr(line, "\t0\r"),
3233 : "virtual n: INBOX manifest updated to flags=0");
3234 : }
3235 :
3236 43 : write_config();
3237 43 : restart_mock();
3238 : }
3239 :
3240 43 : static void test_virtual_flagged_shows_messages(void) {
3241 : /* Ticket 4: Flagged virtual list shows messages with MSG_FLAG_FLAGGED(=2). */
3242 :
3243 43 : restart_mock();
3244 43 : { const char *a[] = {NULL};
3245 43 : PtySession *s = sync_run(a);
3246 42 : ASSERT(s != NULL, "virtual flagged: sync");
3247 42 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3248 42 : pty_close(s); }
3249 :
3250 42 : write_config_with_interval(5);
3251 42 : stop_mock_server();
3252 :
3253 42 : const char *inbox[] = {
3254 : "0000000000000061\tsender@example.com\tStarred Message\t2026-04-25 10:00\t2",
3255 : "0000000000000062\tsender@example.com\tNot Starred\t2026-04-25 09:00\t0",
3256 : };
3257 42 : write_test_manifest("INBOX", inbox, 2);
3258 :
3259 42 : PtySession *s = tui_open_to_folders();
3260 41 : ASSERT(s != NULL, "virtual flagged: opens folder browser");
3261 41 : pty_settle(s, SETTLE_MS);
3262 :
3263 : /* Navigate to VP_FLAGGED: HOME→VP_UNREAD(1), DOWN→VP_FLAGGED(2) */
3264 41 : pty_send_key(s, PTY_KEY_HOME);
3265 41 : pty_settle(s, SETTLE_MS);
3266 41 : pty_send_key(s, PTY_KEY_DOWN);
3267 41 : pty_settle(s, SETTLE_MS);
3268 41 : pty_send_key(s, PTY_KEY_ENTER);
3269 :
3270 41 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3271 41 : pty_settle(s, SETTLE_MS);
3272 :
3273 41 : ASSERT_SCREEN_CONTAINS(s, "Starred Message");
3274 41 : ASSERT(pty_screen_contains(s, "Not Starred") == 0,
3275 : "virtual flagged: unflagged message must not appear in Flagged list");
3276 :
3277 41 : pty_send_key(s, PTY_KEY_ESC);
3278 41 : pty_close(s);
3279 :
3280 41 : write_config();
3281 41 : restart_mock();
3282 : }
3283 :
3284 41 : static void test_unread_count_refreshes_after_mark(void) {
3285 : /* Bug 1 fix: after marking a message read in INBOX and going back to the
3286 : * folder browser, manifest_count_all_flags is re-called and shows the
3287 : * updated (lower) unread count. */
3288 :
3289 41 : restart_mock();
3290 41 : { const char *a[] = {NULL};
3291 41 : PtySession *s = sync_run(a);
3292 40 : ASSERT(s != NULL, "count refresh: sync");
3293 40 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3294 40 : pty_close(s); }
3295 :
3296 40 : write_config_with_interval(5);
3297 40 : stop_mock_server();
3298 :
3299 : /* Seed INBOX with exactly one unread message */
3300 40 : const char *inbox[] = {
3301 : "0000000000000071\tsender@example.com\tSingle Unread\t2026-04-25 10:00\t1",
3302 : };
3303 40 : write_test_manifest("INBOX", inbox, 1);
3304 :
3305 : /* Open folder browser; the Unread row should show "1" */
3306 40 : PtySession *s = tui_open_to_folders();
3307 39 : ASSERT(s != NULL, "count refresh: opens folder browser");
3308 39 : pty_settle(s, SETTLE_MS);
3309 39 : ASSERT_SCREEN_CONTAINS(s, "1");
3310 :
3311 : /* Navigate to INBOX and open the message list */
3312 39 : folders_goto_inbox(s);
3313 39 : pty_send_key(s, PTY_KEY_ENTER);
3314 39 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3315 39 : pty_settle(s, SETTLE_MS);
3316 39 : ASSERT_SCREEN_CONTAINS(s, "Single Unread");
3317 :
3318 : /* Mark it as read */
3319 39 : pty_send_str(s, "n");
3320 39 : pty_settle(s, SETTLE_MS);
3321 :
3322 : /* Go back to folder browser via Backspace */
3323 39 : pty_send_key(s, PTY_KEY_BACK);
3324 39 : ASSERT_WAIT_FOR(s, "Folders", WAIT_MS);
3325 39 : pty_settle(s, SETTLE_MS);
3326 :
3327 : /* Unread row must now show "0": HOME → VP_UNREAD(1) */
3328 39 : pty_send_key(s, PTY_KEY_HOME);
3329 39 : pty_settle(s, SETTLE_MS);
3330 39 : ASSERT_SCREEN_CONTAINS(s, "0");
3331 :
3332 39 : pty_send_key(s, PTY_KEY_ESC);
3333 39 : pty_close(s);
3334 :
3335 39 : write_config();
3336 39 : restart_mock();
3337 : }
3338 :
3339 : /* ══════════════════════════════════════════════════════════════════════
3340 : * VIRTUAL JUNK / ANSWERED / FORWARDED LISTS
3341 : * ══════════════════════════════════════════════════════════════════════ */
3342 :
3343 39 : static void test_virtual_junk_list_shows_messages(void) {
3344 : /* Junk virtual row opens a message list showing $Junk flagged messages. */
3345 39 : restart_mock();
3346 39 : { const char *a[] = {NULL};
3347 39 : PtySession *s = sync_run(a);
3348 38 : ASSERT(s != NULL, "virtual junk: sync");
3349 38 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3350 38 : pty_close(s); }
3351 :
3352 38 : write_config_with_interval(5);
3353 38 : stop_mock_server();
3354 :
3355 : /* flags=64 = MSG_FLAG_JUNK */
3356 38 : const char *inbox[] = {
3357 : "00000000000000e1\tspam@bad.com\tYou Won!\t2026-04-25 08:00\t64",
3358 : "00000000000000e2\tnormal@ok.com\tNormal mail\t2026-04-25 09:00\t0",
3359 : };
3360 38 : write_test_manifest("INBOX", inbox, 2);
3361 :
3362 38 : PtySession *s = tui_open_to_folders();
3363 37 : ASSERT(s != NULL, "virtual junk: opens folder browser");
3364 37 : pty_settle(s, SETTLE_MS);
3365 :
3366 : /* Junk row is VP_JUNK=3, which is 3 rows below VP_HDR_FLAGS(0):
3367 : * HOME lands at VP_UNREAD(1), then 2× DOWN reaches VP_JUNK(3) */
3368 37 : pty_send_key(s, PTY_KEY_HOME);
3369 37 : pty_settle(s, SETTLE_MS);
3370 37 : pty_send_key(s, PTY_KEY_DOWN);
3371 37 : pty_settle(s, SETTLE_MS);
3372 37 : pty_send_key(s, PTY_KEY_DOWN);
3373 37 : pty_settle(s, SETTLE_MS);
3374 37 : pty_send_key(s, PTY_KEY_ENTER);
3375 37 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3376 37 : pty_settle(s, SETTLE_MS);
3377 :
3378 37 : ASSERT_SCREEN_CONTAINS(s, "You Won!");
3379 : /* Normal mail must NOT appear in Junk list */
3380 37 : ASSERT_SCREEN_NOT_CONTAINS(s, "Normal mail");
3381 :
3382 37 : pty_send_key(s, PTY_KEY_ESC);
3383 37 : pty_close(s);
3384 :
3385 37 : write_config();
3386 37 : restart_mock();
3387 : }
3388 :
3389 37 : static void test_virtual_answered_list_shows_messages(void) {
3390 : /* Answered virtual row (VP_ANSWERED=5) opens a list of replied messages. */
3391 37 : restart_mock();
3392 37 : { const char *a[] = {NULL};
3393 37 : PtySession *s = sync_run(a);
3394 36 : ASSERT(s != NULL, "virtual answered: sync");
3395 36 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3396 36 : pty_close(s); }
3397 :
3398 36 : write_config_with_interval(5);
3399 36 : stop_mock_server();
3400 :
3401 : /* flags=16 = MSG_FLAG_ANSWERED */
3402 36 : const char *inbox[] = {
3403 : "00000000000000f1\tboss@acme.com\tRe: Budget\t2026-04-25 10:00\t16",
3404 : "00000000000000f2\tteam@acme.com\tTeam update\t2026-04-25 11:00\t0",
3405 : };
3406 36 : write_test_manifest("INBOX", inbox, 2);
3407 :
3408 36 : PtySession *s = tui_open_to_folders();
3409 35 : ASSERT(s != NULL, "virtual answered: opens folder browser");
3410 35 : pty_settle(s, SETTLE_MS);
3411 :
3412 : /* VP_ANSWERED=5: HOME → VP_UNREAD(1), then 4× DOWN skipping VP_HDR_FOLD(7)? No:
3413 : * order is HDR(0) UNREAD(1) FLAGGED(2) JUNK(3) PHISHING(4) ANSWERED(5) FORWARDED(6) HDR_FOLD(7)
3414 : * So from UNREAD(1): 4× DOWN = JUNK(3) PHISHING(4) ANSWERED(5)... wait that's 4 downs.
3415 : * Actually: 1→2→3→4→5 = 4 downs from VP_UNREAD */
3416 35 : pty_send_key(s, PTY_KEY_HOME);
3417 35 : pty_settle(s, SETTLE_MS);
3418 175 : for (int k = 0; k < 4; k++) { pty_send_key(s, PTY_KEY_DOWN); pty_settle(s, SETTLE_MS); }
3419 35 : pty_send_key(s, PTY_KEY_ENTER);
3420 35 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3421 35 : pty_settle(s, SETTLE_MS);
3422 :
3423 35 : ASSERT_SCREEN_CONTAINS(s, "Re: Budget");
3424 35 : ASSERT_SCREEN_NOT_CONTAINS(s, "Team update");
3425 :
3426 35 : pty_send_key(s, PTY_KEY_ESC);
3427 35 : pty_close(s);
3428 :
3429 35 : write_config();
3430 35 : restart_mock();
3431 : }
3432 :
3433 35 : static void test_virtual_forwarded_list_shows_messages(void) {
3434 : /* Forwarded virtual row (VP_FORWARDED=6) opens a list of forwarded messages. */
3435 35 : restart_mock();
3436 35 : { const char *a[] = {NULL};
3437 35 : PtySession *s = sync_run(a);
3438 34 : ASSERT(s != NULL, "virtual forwarded: sync");
3439 34 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3440 34 : pty_close(s); }
3441 :
3442 34 : write_config_with_interval(5);
3443 34 : stop_mock_server();
3444 :
3445 : /* flags=32 = MSG_FLAG_FORWARDED */
3446 34 : const char *inbox[] = {
3447 : "0000000000000101\tcolleague@acme.com\tFwd: Offer\t2026-04-25 12:00\t32",
3448 : "0000000000000102\tother@acme.com\tUnrelated\t2026-04-25 13:00\t0",
3449 : };
3450 34 : write_test_manifest("INBOX", inbox, 2);
3451 :
3452 34 : PtySession *s = tui_open_to_folders();
3453 33 : ASSERT(s != NULL, "virtual forwarded: opens folder browser");
3454 33 : pty_settle(s, SETTLE_MS);
3455 :
3456 : /* VP_FORWARDED=6: 5× DOWN from VP_UNREAD(1) */
3457 33 : pty_send_key(s, PTY_KEY_HOME);
3458 33 : pty_settle(s, SETTLE_MS);
3459 198 : for (int k = 0; k < 5; k++) { pty_send_key(s, PTY_KEY_DOWN); pty_settle(s, SETTLE_MS); }
3460 33 : pty_send_key(s, PTY_KEY_ENTER);
3461 33 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3462 33 : pty_settle(s, SETTLE_MS);
3463 :
3464 33 : ASSERT_SCREEN_CONTAINS(s, "Fwd: Offer");
3465 33 : ASSERT_SCREEN_NOT_CONTAINS(s, "Unrelated");
3466 :
3467 33 : pty_send_key(s, PTY_KEY_ESC);
3468 33 : pty_close(s);
3469 :
3470 33 : write_config();
3471 33 : restart_mock();
3472 : }
3473 :
3474 : /* ══════════════════════════════════════════════════════════════════════
3475 : * FLAG STATUS COLUMN (P/J/R/F markers)
3476 : * ══════════════════════════════════════════════════════════════════════ */
3477 :
3478 33 : static void test_status_junk_marker_shown(void) {
3479 : /* A message with MSG_FLAG_JUNK(=64) must display 'J' in the status column. */
3480 33 : restart_mock();
3481 33 : { const char *a[] = {NULL};
3482 33 : PtySession *s = sync_run(a);
3483 32 : ASSERT(s != NULL, "junk marker: sync");
3484 32 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3485 32 : pty_close(s); }
3486 :
3487 32 : write_config_with_interval(5);
3488 32 : stop_mock_server();
3489 :
3490 : /* flags=64 = MSG_FLAG_JUNK */
3491 32 : const char *inbox[] = {
3492 : "00000000000000a1\tspammer@bad.com\tWin a Prize!\t2026-04-25 10:00\t64",
3493 : };
3494 32 : write_test_manifest("INBOX", inbox, 1);
3495 :
3496 32 : PtySession *s = tui_open_to_folders();
3497 31 : ASSERT(s != NULL, "junk marker: opens folder browser");
3498 31 : pty_settle(s, SETTLE_MS);
3499 :
3500 : /* Open INBOX (cursor may start on a virtual row) */
3501 31 : folders_goto_inbox(s);
3502 31 : pty_send_key(s, PTY_KEY_ENTER);
3503 31 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3504 31 : pty_settle(s, SETTLE_MS);
3505 :
3506 31 : ASSERT_SCREEN_CONTAINS(s, "Win a Prize!");
3507 : /* Status column: position 1 must be 'J' (junk) */
3508 31 : ASSERT_SCREEN_CONTAINS(s, "J");
3509 :
3510 31 : pty_send_key(s, PTY_KEY_ESC);
3511 31 : pty_close(s);
3512 :
3513 31 : write_config();
3514 31 : restart_mock();
3515 : }
3516 :
3517 31 : static void test_status_phishing_marker_shown(void) {
3518 : /* A message with MSG_FLAG_PHISHING(=128) must display 'P' in the status column.
3519 : * P has higher priority than J. */
3520 31 : restart_mock();
3521 31 : { const char *a[] = {NULL};
3522 31 : PtySession *s = sync_run(a);
3523 30 : ASSERT(s != NULL, "phishing marker: sync");
3524 30 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3525 30 : pty_close(s); }
3526 :
3527 30 : write_config_with_interval(5);
3528 30 : stop_mock_server();
3529 :
3530 : /* flags=128 = MSG_FLAG_PHISHING */
3531 30 : const char *inbox[] = {
3532 : "00000000000000b1\tphisher@evil.com\tUpdate Your Password\t2026-04-25 11:00\t128",
3533 : };
3534 30 : write_test_manifest("INBOX", inbox, 1);
3535 :
3536 30 : PtySession *s = tui_open_to_folders();
3537 29 : ASSERT(s != NULL, "phishing marker: opens folder browser");
3538 29 : pty_settle(s, SETTLE_MS);
3539 :
3540 : /* Open INBOX (cursor may start on a virtual row) */
3541 29 : folders_goto_inbox(s);
3542 29 : pty_send_key(s, PTY_KEY_ENTER);
3543 29 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3544 29 : pty_settle(s, SETTLE_MS);
3545 :
3546 29 : ASSERT_SCREEN_CONTAINS(s, "Update Your Password");
3547 29 : ASSERT_SCREEN_CONTAINS(s, "P");
3548 :
3549 29 : pty_send_key(s, PTY_KEY_ESC);
3550 29 : pty_close(s);
3551 :
3552 29 : write_config();
3553 29 : restart_mock();
3554 : }
3555 :
3556 29 : static void test_status_answered_marker_shown(void) {
3557 : /* A message with MSG_FLAG_ANSWERED(=16) must display 'R' in position 5. */
3558 29 : restart_mock();
3559 29 : { const char *a[] = {NULL};
3560 29 : PtySession *s = sync_run(a);
3561 28 : ASSERT(s != NULL, "answered marker: sync");
3562 28 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3563 28 : pty_close(s); }
3564 :
3565 28 : write_config_with_interval(5);
3566 28 : stop_mock_server();
3567 :
3568 : /* flags=16 = MSG_FLAG_ANSWERED */
3569 28 : const char *inbox[] = {
3570 : "00000000000000c1\tboss@acme.com\tRe: Meeting\t2026-04-25 09:00\t16",
3571 : };
3572 28 : write_test_manifest("INBOX", inbox, 1);
3573 :
3574 28 : PtySession *s = tui_open_to_folders();
3575 27 : ASSERT(s != NULL, "answered marker: opens folder browser");
3576 27 : pty_settle(s, SETTLE_MS);
3577 :
3578 : /* Open INBOX (cursor may start on a virtual row) */
3579 27 : folders_goto_inbox(s);
3580 27 : pty_send_key(s, PTY_KEY_ENTER);
3581 27 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3582 27 : pty_settle(s, SETTLE_MS);
3583 :
3584 27 : ASSERT_SCREEN_CONTAINS(s, "Re: Meeting");
3585 : /* Status column position 5: 'R' for answered */
3586 27 : ASSERT_SCREEN_CONTAINS(s, "R");
3587 :
3588 27 : pty_send_key(s, PTY_KEY_ESC);
3589 27 : pty_close(s);
3590 :
3591 27 : write_config();
3592 27 : restart_mock();
3593 : }
3594 :
3595 27 : static void test_status_forwarded_marker_shown(void) {
3596 : /* A message with MSG_FLAG_FORWARDED(=32) must display 'F' in position 5. */
3597 27 : restart_mock();
3598 27 : { const char *a[] = {NULL};
3599 27 : PtySession *s = sync_run(a);
3600 26 : ASSERT(s != NULL, "forwarded marker: sync");
3601 26 : ASSERT_WAIT_FOR(s, "Sync complete", WAIT_MS);
3602 26 : pty_close(s); }
3603 :
3604 26 : write_config_with_interval(5);
3605 26 : stop_mock_server();
3606 :
3607 : /* flags=32 = MSG_FLAG_FORWARDED */
3608 26 : const char *inbox[] = {
3609 : "00000000000000d1\tcolleague@acme.com\tFwd: Proposal\t2026-04-25 08:30\t32",
3610 : };
3611 26 : write_test_manifest("INBOX", inbox, 1);
3612 :
3613 26 : PtySession *s = tui_open_to_folders();
3614 25 : ASSERT(s != NULL, "forwarded marker: opens folder browser");
3615 25 : pty_settle(s, SETTLE_MS);
3616 :
3617 : /* Open INBOX (cursor may start on a virtual row) */
3618 25 : folders_goto_inbox(s);
3619 25 : pty_send_key(s, PTY_KEY_ENTER);
3620 25 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3621 25 : pty_settle(s, SETTLE_MS);
3622 :
3623 25 : ASSERT_SCREEN_CONTAINS(s, "Fwd: Proposal");
3624 25 : ASSERT_SCREEN_CONTAINS(s, "F");
3625 :
3626 25 : pty_send_key(s, PTY_KEY_ESC);
3627 25 : pty_close(s);
3628 :
3629 25 : write_config();
3630 25 : restart_mock();
3631 : }
3632 :
3633 : /* ══════════════════════════════════════════════════════════════════════
3634 : * mark-junk / mark-notjunk CLI COMMANDS
3635 : * ══════════════════════════════════════════════════════════════════════ */
3636 :
3637 25 : static void test_mark_junk_help(void) {
3638 : /* 'mark-junk --help' must show usage with $Junk and SPAM references. */
3639 25 : const char *a[] = {"mark-junk", "--help", NULL};
3640 25 : PtySession *s = cli_run(a);
3641 24 : ASSERT(s != NULL, "mark-junk --help: opens");
3642 24 : ASSERT_WAIT_FOR(s, "mark-junk", WAIT_MS);
3643 24 : ASSERT_SCREEN_CONTAINS(s, "junk");
3644 24 : pty_close(s);
3645 : }
3646 :
3647 24 : static void test_mark_notjunk_help(void) {
3648 : /* 'mark-notjunk --help' must show usage. */
3649 24 : const char *a[] = {"mark-notjunk", "--help", NULL};
3650 24 : PtySession *s = cli_run(a);
3651 23 : ASSERT(s != NULL, "mark-notjunk --help: opens");
3652 23 : ASSERT_WAIT_FOR(s, "mark-notjunk", WAIT_MS);
3653 23 : ASSERT_SCREEN_CONTAINS(s, "junk");
3654 23 : pty_close(s);
3655 : }
3656 :
3657 23 : static void test_mark_junk_missing_arg(void) {
3658 : /* 'mark-junk' with no UID must print an error and show usage. */
3659 23 : restart_mock();
3660 23 : const char *a[] = {"mark-junk", NULL};
3661 23 : PtySession *s = cli_run(a);
3662 22 : ASSERT(s != NULL, "mark-junk no arg: opens");
3663 22 : ASSERT_WAIT_FOR(s, "mark-junk", WAIT_MS);
3664 22 : pty_close(s);
3665 : }
3666 :
3667 22 : static void test_mark_notjunk_missing_arg(void) {
3668 : /* 'mark-notjunk' with no UID must print an error and show usage. */
3669 22 : restart_mock();
3670 22 : const char *a[] = {"mark-notjunk", NULL};
3671 22 : PtySession *s = cli_run(a);
3672 21 : ASSERT(s != NULL, "mark-notjunk no arg: opens");
3673 21 : ASSERT_WAIT_FOR(s, "mark-notjunk", WAIT_MS);
3674 21 : pty_close(s);
3675 : }
3676 :
3677 21 : static void test_mark_junk_blocked_in_ro(void) {
3678 : /* email-cli-ro must reject mark-junk. */
3679 21 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_cli_ro_bin);
3680 21 : restart_mock();
3681 21 : const char *a[] = {"mark-junk", "0000000000000001", NULL};
3682 21 : PtySession *s = cli_run(a);
3683 20 : ASSERT(s != NULL, "mark-junk ro block: opens");
3684 20 : ASSERT_WAIT_FOR(s, "read-only", WAIT_MS);
3685 20 : pty_close(s);
3686 20 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_tui_bin);
3687 : }
3688 :
3689 20 : static void test_mark_notjunk_blocked_in_ro(void) {
3690 : /* email-cli-ro must reject mark-notjunk. */
3691 20 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_cli_ro_bin);
3692 20 : restart_mock();
3693 20 : const char *a[] = {"mark-notjunk", "0000000000000001", NULL};
3694 20 : PtySession *s = cli_run(a);
3695 19 : ASSERT(s != NULL, "mark-notjunk ro block: opens");
3696 19 : ASSERT_WAIT_FOR(s, "read-only", WAIT_MS);
3697 19 : pty_close(s);
3698 19 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_tui_bin);
3699 : }
3700 :
3701 : /* ══════════════════════════════════════════════════════════════════════
3702 : * TUI RULES EDITOR (US-62)
3703 : * ══════════════════════════════════════════════════════════════════════ */
3704 :
3705 : /*
3706 : * Rules editor operations are purely local (no network after list view opens).
3707 : * Use a shorter wait than the global WAIT_MS which is sized for IMAP latency.
3708 : */
3709 : #define RULES_WAIT_MS 1500
3710 :
3711 269 : static void write_rules_ini(void) {
3712 : char path[512];
3713 269 : snprintf(path, sizeof(path),
3714 : "%s/.config/email-cli/accounts/testuser/rules.ini",
3715 : g_test_home);
3716 269 : FILE *fp = fopen(path, "w");
3717 269 : if (!fp) return;
3718 269 : fprintf(fp,
3719 : "[rule \"SpamFilter\"]\n"
3720 : "if-from = *@spam.example.com\n"
3721 : "then-add-label = _junk\n"
3722 : "\n"
3723 : "[rule \"WorkMail\"]\n"
3724 : "if-subject = *[work]*\n"
3725 : "then-add-label = Work\n"
3726 : "\n"
3727 : /* No-condition rule: when_from_flat returns NULL → rule->when = NULL
3728 : * → mail_rule_matches takes the legacy flat-field path → glob_match covered. */
3729 : "[rule \"AlwaysTag\"]\n"
3730 : "then-add-label = AutoTagged\n"
3731 : "\n");
3732 269 : fclose(fp);
3733 : }
3734 :
3735 394 : static void remove_rules_ini(void) {
3736 : char path[512];
3737 394 : snprintf(path, sizeof(path),
3738 : "%s/.config/email-cli/accounts/testuser/rules.ini",
3739 : g_test_home);
3740 394 : unlink(path);
3741 394 : }
3742 :
3743 17 : static void test_tui_rules_editor_opens(void) {
3744 : /* US-62 AC1: 'l' from message list opens the rules editor screen */
3745 17 : restart_mock();
3746 17 : PtySession *s = tui_open_to_list();
3747 16 : ASSERT(s != NULL, "rules editor opens: reaches message list");
3748 16 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3749 16 : pty_settle(s, SETTLE_MS);
3750 16 : pty_send_str(s, "l");
3751 16 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
3752 16 : pty_settle(s, SETTLE_MS); /* status bar rendered after title fflush */
3753 16 : ASSERT_SCREEN_CONTAINS(s, "a=add");
3754 16 : pty_send_key(s, PTY_KEY_ESC);
3755 16 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3756 16 : pty_send_key(s, PTY_KEY_ESC);
3757 16 : pty_close(s);
3758 : }
3759 :
3760 16 : static void test_tui_rules_editor_empty_message(void) {
3761 : /* US-62 AC2: empty rules list shows the "no rules" hint */
3762 16 : remove_rules_ini();
3763 16 : restart_mock();
3764 16 : PtySession *s = tui_open_to_list();
3765 15 : ASSERT(s != NULL, "rules editor empty: reaches message list");
3766 15 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3767 15 : pty_settle(s, SETTLE_MS);
3768 15 : pty_send_str(s, "l");
3769 15 : ASSERT_WAIT_FOR(s, "no rules", RULES_WAIT_MS);
3770 15 : ASSERT_SCREEN_CONTAINS(s, "a=add");
3771 15 : pty_send_key(s, PTY_KEY_ESC);
3772 15 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3773 15 : pty_send_key(s, PTY_KEY_ESC);
3774 15 : pty_close(s);
3775 : }
3776 :
3777 15 : static void test_tui_rules_editor_lists_rules(void) {
3778 : /* US-62 AC3: pre-populated rules.ini → rule names visible in editor */
3779 15 : remove_rules_ini();
3780 15 : write_rules_ini();
3781 15 : restart_mock();
3782 15 : PtySession *s = tui_open_to_list();
3783 14 : ASSERT(s != NULL, "rules editor list: reaches message list");
3784 14 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3785 14 : pty_settle(s, SETTLE_MS);
3786 14 : pty_send_str(s, "l");
3787 14 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
3788 14 : pty_settle(s, SETTLE_MS);
3789 14 : ASSERT_SCREEN_CONTAINS(s, "SpamFilter");
3790 14 : ASSERT_SCREEN_CONTAINS(s, "WorkMail");
3791 14 : pty_send_key(s, PTY_KEY_ESC);
3792 14 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3793 14 : pty_send_key(s, PTY_KEY_ESC);
3794 14 : pty_close(s);
3795 14 : remove_rules_ini();
3796 : }
3797 :
3798 14 : static void test_tui_rules_editor_add_rule(void) {
3799 : /* US-62 AC4 / US-80: 'a' → two-step wizard → 'y' → rule saved
3800 : * Step 1: when list editor (a=add, enter text, q=confirm)
3801 : * Step 2: name + 8 action fields */
3802 14 : remove_rules_ini();
3803 14 : restart_mock();
3804 14 : PtySession *s = tui_open_to_list();
3805 13 : ASSERT(s != NULL, "rules editor add: reaches message list");
3806 13 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3807 13 : pty_settle(s, SETTLE_MS);
3808 13 : pty_send_str(s, "l");
3809 13 : ASSERT_WAIT_FOR(s, "no rules", RULES_WAIT_MS);
3810 13 : pty_send_str(s, "a");
3811 13 : ASSERT_WAIT_FOR(s, "Add new rule", RULES_WAIT_MS);
3812 : /* Step 1: when list editor */
3813 13 : pty_send_str(s, "a"); /* open "new:" input */
3814 13 : pty_send_str(s, "from:*@test.com\n"); /* type atom, confirm with Enter */
3815 13 : pty_send_str(s, "q"); /* confirm when list, proceed to step 2 */
3816 13 : ASSERT_WAIT_FOR(s, "step 2", RULES_WAIT_MS);
3817 : /* Step 2: name + action fields */
3818 13 : pty_send_str(s, "TestRule\n"); /* Name */
3819 13 : pty_send_str(s, "IMPORTANT\n"); /* add-label[1] */
3820 13 : pty_send_str(s, "\n"); /* add-label[2] (empty) */
3821 13 : pty_send_str(s, "\n"); /* add-label[3] (empty) */
3822 13 : pty_send_str(s, "\n"); /* rm-label[1] (empty) */
3823 13 : pty_send_str(s, "\n"); /* rm-label[2] (empty) */
3824 13 : pty_send_str(s, "\n"); /* rm-label[3] (empty) */
3825 13 : pty_send_str(s, "\n"); /* then-move-folder (empty) */
3826 13 : pty_send_str(s, "\n"); /* then-forward-to (empty) */
3827 13 : ASSERT_WAIT_FOR(s, "Save?", RULES_WAIT_MS);
3828 13 : pty_send_str(s, "y");
3829 13 : ASSERT_WAIT_FOR(s, "TestRule", RULES_WAIT_MS);
3830 13 : pty_send_key(s, PTY_KEY_ESC);
3831 13 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3832 13 : pty_send_key(s, PTY_KEY_ESC);
3833 13 : pty_close(s);
3834 13 : remove_rules_ini();
3835 : }
3836 :
3837 13 : static void test_tui_rules_editor_cancel_add(void) {
3838 : /* US-62 AC5: 'a' → empty name → "required" message → back to list */
3839 13 : remove_rules_ini();
3840 13 : restart_mock();
3841 13 : PtySession *s = tui_open_to_list();
3842 12 : ASSERT(s != NULL, "rules editor cancel add: reaches message list");
3843 12 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3844 12 : pty_settle(s, SETTLE_MS);
3845 12 : pty_send_str(s, "l");
3846 12 : ASSERT_WAIT_FOR(s, "no rules", RULES_WAIT_MS);
3847 12 : pty_send_str(s, "a");
3848 12 : ASSERT_WAIT_FOR(s, "Add new rule", RULES_WAIT_MS);
3849 : /* Step 1: when list editor — confirm immediately with q (empty list) */
3850 12 : pty_send_str(s, "q");
3851 12 : ASSERT_WAIT_FOR(s, "step 2", RULES_WAIT_MS);
3852 : /* Step 2: 9 fields all empty — name check fires after last field */
3853 12 : pty_send_str(s, "\n\n\n\n\n\n\n\n\n");
3854 12 : ASSERT_WAIT_FOR(s, "required", RULES_WAIT_MS);
3855 12 : pty_send_str(s, "\n"); /* dismiss "press any key" */
3856 12 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
3857 12 : pty_send_key(s, PTY_KEY_ESC);
3858 12 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3859 12 : pty_send_key(s, PTY_KEY_ESC);
3860 12 : pty_close(s);
3861 : }
3862 :
3863 12 : static void test_tui_rules_editor_delete_rule(void) {
3864 : /* US-62 AC6: cursor on first rule → 'd' → confirm 'y' → rule removed */
3865 12 : remove_rules_ini();
3866 12 : write_rules_ini();
3867 12 : restart_mock();
3868 12 : PtySession *s = tui_open_to_list();
3869 11 : ASSERT(s != NULL, "rules editor delete: reaches message list");
3870 11 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3871 11 : pty_settle(s, SETTLE_MS);
3872 11 : pty_send_str(s, "l");
3873 11 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
3874 11 : pty_settle(s, SETTLE_MS);
3875 : /* Cursor starts at index 0 (SpamFilter); press 'd' to delete it */
3876 11 : pty_send_str(s, "d");
3877 11 : ASSERT_WAIT_FOR(s, "(y/N)", RULES_WAIT_MS);
3878 11 : pty_send_str(s, "y");
3879 11 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
3880 11 : pty_settle(s, SETTLE_MS);
3881 11 : ASSERT(pty_screen_contains(s, "SpamFilter") == 0,
3882 : "rules editor delete: SpamFilter removed from list");
3883 11 : pty_send_key(s, PTY_KEY_ESC);
3884 11 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3885 11 : pty_send_key(s, PTY_KEY_ESC);
3886 11 : pty_close(s);
3887 11 : remove_rules_ini();
3888 : }
3889 :
3890 11 : static void test_tui_rules_editor_q_closes(void) {
3891 : /* US-62 AC7: 'q' also exits the rules editor (same as ESC) */
3892 11 : restart_mock();
3893 11 : PtySession *s = tui_open_to_list();
3894 10 : ASSERT(s != NULL, "rules editor q closes: reaches message list");
3895 10 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3896 10 : pty_settle(s, SETTLE_MS);
3897 10 : pty_send_str(s, "l");
3898 10 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
3899 10 : pty_send_str(s, "q");
3900 10 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3901 10 : pty_send_key(s, PTY_KEY_ESC);
3902 10 : pty_close(s);
3903 : }
3904 :
3905 : /* ── US-78: rules list navigation ───────────────────────────────────── */
3906 :
3907 10 : static void test_tui_rules_nav_down(void) {
3908 : /* US-78 AC1: j key moves cursor to second rule */
3909 10 : remove_rules_ini();
3910 10 : write_rules_ini();
3911 10 : restart_mock();
3912 10 : PtySession *s = tui_open_to_list();
3913 9 : ASSERT(s != NULL, "rules nav down: reaches message list");
3914 9 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3915 9 : pty_settle(s, SETTLE_MS);
3916 9 : pty_send_str(s, "l");
3917 9 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
3918 9 : pty_settle(s, SETTLE_MS);
3919 9 : pty_send_str(s, "j"); /* move cursor to WorkMail */
3920 9 : pty_send_key(s, PTY_KEY_ENTER); /* open detail */
3921 9 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
3922 9 : ASSERT_SCREEN_CONTAINS(s, "WorkMail");
3923 9 : pty_send_key(s, PTY_KEY_ESC);
3924 9 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
3925 9 : pty_send_key(s, PTY_KEY_ESC);
3926 9 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3927 9 : pty_send_key(s, PTY_KEY_ESC);
3928 9 : pty_close(s);
3929 9 : remove_rules_ini();
3930 : }
3931 :
3932 9 : static void test_tui_rules_nav_arrow_down(void) {
3933 : /* US-78 AC2: down-arrow also moves cursor */
3934 9 : remove_rules_ini();
3935 9 : write_rules_ini();
3936 9 : restart_mock();
3937 9 : PtySession *s = tui_open_to_list();
3938 8 : ASSERT(s != NULL, "rules nav arrow down: reaches message list");
3939 8 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3940 8 : pty_settle(s, SETTLE_MS);
3941 8 : pty_send_str(s, "l");
3942 8 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
3943 8 : pty_settle(s, SETTLE_MS);
3944 8 : pty_send_key(s, PTY_KEY_DOWN);
3945 8 : pty_send_key(s, PTY_KEY_ENTER);
3946 8 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
3947 8 : ASSERT_SCREEN_CONTAINS(s, "WorkMail");
3948 8 : pty_send_key(s, PTY_KEY_ESC);
3949 8 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
3950 8 : pty_send_key(s, PTY_KEY_ESC);
3951 8 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3952 8 : pty_send_key(s, PTY_KEY_ESC);
3953 8 : pty_close(s);
3954 8 : remove_rules_ini();
3955 : }
3956 :
3957 8 : static void test_tui_rules_enter_opens_first(void) {
3958 : /* US-78 AC3: Enter on default cursor opens first rule's detail */
3959 8 : remove_rules_ini();
3960 8 : write_rules_ini();
3961 8 : restart_mock();
3962 8 : PtySession *s = tui_open_to_list();
3963 7 : ASSERT(s != NULL, "rules enter opens first: reaches message list");
3964 7 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3965 7 : pty_settle(s, SETTLE_MS);
3966 7 : pty_send_str(s, "l");
3967 7 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
3968 7 : pty_settle(s, SETTLE_MS);
3969 7 : pty_send_key(s, PTY_KEY_ENTER);
3970 7 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
3971 7 : ASSERT_SCREEN_CONTAINS(s, "SpamFilter");
3972 7 : pty_send_key(s, PTY_KEY_ESC);
3973 7 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
3974 7 : pty_send_key(s, PTY_KEY_ESC);
3975 7 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
3976 7 : pty_send_key(s, PTY_KEY_ESC);
3977 7 : pty_close(s);
3978 7 : remove_rules_ini();
3979 : }
3980 :
3981 : /* ── US-79: rule detail view ─────────────────────────────────────────── */
3982 :
3983 7 : static void test_tui_rules_detail_shows_fields(void) {
3984 : /* US-79 AC1: detail view shows all non-empty rule fields */
3985 7 : remove_rules_ini();
3986 7 : write_rules_ini();
3987 7 : restart_mock();
3988 7 : PtySession *s = tui_open_to_list();
3989 6 : ASSERT(s != NULL, "rules detail fields: reaches message list");
3990 6 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
3991 6 : pty_settle(s, SETTLE_MS);
3992 6 : pty_send_str(s, "l");
3993 6 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
3994 6 : pty_settle(s, SETTLE_MS);
3995 6 : pty_send_key(s, PTY_KEY_ENTER);
3996 6 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
3997 6 : pty_settle(s, SETTLE_MS); /* wait for when/labels flushed after title */
3998 6 : ASSERT_SCREEN_CONTAINS(s, "*@spam.example.com");
3999 6 : ASSERT_SCREEN_CONTAINS(s, "_junk");
4000 6 : ASSERT_SCREEN_CONTAINS(s, "e=edit");
4001 6 : pty_send_key(s, PTY_KEY_ESC);
4002 6 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
4003 6 : pty_send_key(s, PTY_KEY_ESC);
4004 6 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
4005 6 : pty_send_key(s, PTY_KEY_ESC);
4006 6 : pty_close(s);
4007 6 : remove_rules_ini();
4008 : }
4009 :
4010 6 : static void test_tui_rules_detail_esc_back(void) {
4011 : /* US-79 AC2: ESC in detail view returns to the rules list */
4012 6 : remove_rules_ini();
4013 6 : write_rules_ini();
4014 6 : restart_mock();
4015 6 : PtySession *s = tui_open_to_list();
4016 5 : ASSERT(s != NULL, "rules detail esc back: reaches message list");
4017 5 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
4018 5 : pty_settle(s, SETTLE_MS);
4019 5 : pty_send_str(s, "l");
4020 5 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
4021 5 : pty_settle(s, SETTLE_MS);
4022 5 : pty_send_key(s, PTY_KEY_ENTER);
4023 5 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
4024 5 : pty_send_key(s, PTY_KEY_ESC);
4025 5 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
4026 5 : pty_send_key(s, PTY_KEY_ESC);
4027 5 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
4028 5 : pty_send_key(s, PTY_KEY_ESC);
4029 5 : pty_close(s);
4030 5 : remove_rules_ini();
4031 : }
4032 :
4033 5 : static void test_tui_rules_detail_delete(void) {
4034 : /* US-79 AC3: 'd' in detail view with 'y' confirm removes the rule */
4035 5 : remove_rules_ini();
4036 5 : write_rules_ini();
4037 5 : restart_mock();
4038 5 : PtySession *s = tui_open_to_list();
4039 4 : ASSERT(s != NULL, "rules detail delete: reaches message list");
4040 4 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
4041 4 : pty_settle(s, SETTLE_MS);
4042 4 : pty_send_str(s, "l");
4043 4 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
4044 4 : pty_settle(s, SETTLE_MS);
4045 4 : pty_send_key(s, PTY_KEY_ENTER);
4046 4 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
4047 4 : pty_send_str(s, "d");
4048 4 : ASSERT_WAIT_FOR(s, "(y/N)", RULES_WAIT_MS);
4049 4 : pty_send_str(s, "y");
4050 4 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
4051 4 : pty_settle(s, SETTLE_MS);
4052 4 : ASSERT(pty_screen_contains(s, "SpamFilter") == 0,
4053 : "rules detail delete: SpamFilter gone from list");
4054 4 : pty_send_key(s, PTY_KEY_ESC);
4055 4 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
4056 4 : pty_send_key(s, PTY_KEY_ESC);
4057 4 : pty_close(s);
4058 4 : remove_rules_ini();
4059 : }
4060 :
4061 : /* ── US-80: rule edit form with inline editing ───────────────────────── */
4062 :
4063 4 : static void test_tui_rules_edit_form_opens(void) {
4064 : /* US-80 AC1: 'e' from detail view opens the two-step edit form; step 2 shows "Edit rule for" */
4065 4 : remove_rules_ini();
4066 4 : write_rules_ini();
4067 4 : restart_mock();
4068 4 : PtySession *s = tui_open_to_list();
4069 3 : ASSERT(s != NULL, "rules edit form opens: reaches message list");
4070 3 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
4071 3 : pty_settle(s, SETTLE_MS);
4072 3 : pty_send_str(s, "l");
4073 3 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
4074 3 : pty_settle(s, SETTLE_MS);
4075 3 : pty_send_key(s, PTY_KEY_ENTER);
4076 3 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
4077 3 : pty_settle(s, SETTLE_MS);
4078 3 : pty_send_str(s, "e");
4079 3 : ASSERT_WAIT_FOR(s, "conditions (step 1/2)", WAIT_MS); /* step 1: when-list editor */
4080 3 : pty_send_str(s, "q"); /* confirm conditions, advance to step 2 */
4081 3 : ASSERT_WAIT_FOR(s, "Edit rule for", WAIT_MS); /* step 2: actions form */
4082 3 : pty_settle(s, SETTLE_MS); /* wait for name field to render */
4083 3 : ASSERT_SCREEN_CONTAINS(s, "SpamFilter"); /* name prefill visible */
4084 3 : pty_send_key(s, PTY_KEY_ESC); /* cancel step 2 */
4085 3 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS); /* back to detail */
4086 3 : pty_send_key(s, PTY_KEY_ESC);
4087 3 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
4088 3 : pty_send_key(s, PTY_KEY_ESC);
4089 3 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
4090 3 : pty_send_key(s, PTY_KEY_ESC);
4091 3 : pty_close(s);
4092 3 : remove_rules_ini();
4093 : }
4094 :
4095 3 : static void test_tui_rules_edit_form_prefill(void) {
4096 : /* US-80 AC2: when conditions from existing rule are shown in step 1 of the edit form */
4097 3 : remove_rules_ini();
4098 3 : write_rules_ini();
4099 3 : restart_mock();
4100 3 : PtySession *s = tui_open_to_list();
4101 2 : ASSERT(s != NULL, "rules edit prefill: reaches message list");
4102 2 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
4103 2 : pty_settle(s, SETTLE_MS);
4104 2 : pty_send_str(s, "l");
4105 2 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
4106 2 : pty_settle(s, SETTLE_MS);
4107 2 : pty_send_key(s, PTY_KEY_ENTER);
4108 2 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
4109 2 : pty_settle(s, SETTLE_MS);
4110 2 : pty_send_str(s, "e");
4111 2 : ASSERT_WAIT_FOR(s, "conditions (step 1/2)", WAIT_MS); /* step 1 */
4112 2 : pty_settle(s, SETTLE_MS);
4113 2 : ASSERT_SCREEN_CONTAINS(s, "*@spam.example.com"); /* if-from condition prefilled */
4114 2 : pty_send_key(s, PTY_KEY_ESC); /* cancel edit */
4115 2 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
4116 2 : pty_send_key(s, PTY_KEY_ESC);
4117 2 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
4118 2 : pty_send_key(s, PTY_KEY_ESC);
4119 2 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
4120 2 : pty_send_key(s, PTY_KEY_ESC);
4121 2 : pty_close(s);
4122 2 : remove_rules_ini();
4123 : }
4124 :
4125 2 : static void test_tui_rules_edit_form_esc_cancel(void) {
4126 : /* US-80 AC3: ESC in step 1 of the edit form cancels without modifying the rule */
4127 2 : remove_rules_ini();
4128 2 : write_rules_ini();
4129 2 : restart_mock();
4130 2 : PtySession *s = tui_open_to_list();
4131 1 : ASSERT(s != NULL, "rules edit cancel: reaches message list");
4132 1 : ASSERT_WAIT_FOR(s, "message(s) in", WAIT_MS);
4133 1 : pty_settle(s, SETTLE_MS);
4134 1 : pty_send_str(s, "l");
4135 1 : ASSERT_WAIT_FOR(s, "SpamFilter", RULES_WAIT_MS);
4136 1 : pty_settle(s, SETTLE_MS);
4137 1 : pty_send_key(s, PTY_KEY_ENTER);
4138 1 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS);
4139 1 : pty_settle(s, SETTLE_MS);
4140 1 : pty_send_str(s, "e");
4141 1 : ASSERT_WAIT_FOR(s, "conditions (step 1/2)", WAIT_MS); /* step 1 opens */
4142 1 : pty_send_key(s, PTY_KEY_ESC); /* cancel — no change */
4143 1 : ASSERT_WAIT_FOR(s, "Rule:", RULES_WAIT_MS); /* still in detail view */
4144 1 : ASSERT_SCREEN_CONTAINS(s, "SpamFilter"); /* rule unchanged */
4145 1 : pty_send_key(s, PTY_KEY_ESC);
4146 1 : ASSERT_WAIT_FOR(s, "Rules for", RULES_WAIT_MS);
4147 1 : ASSERT_SCREEN_CONTAINS(s, "SpamFilter");
4148 1 : pty_send_key(s, PTY_KEY_ESC);
4149 1 : ASSERT_WAIT_FOR(s, "message(s) in", RULES_WAIT_MS);
4150 1 : pty_send_key(s, PTY_KEY_ESC);
4151 1 : pty_close(s);
4152 1 : remove_rules_ini();
4153 : }
4154 :
4155 : #undef RULES_WAIT_MS
4156 :
4157 : /* ── Main ────────────────────────────────────────────────────────────── */
4158 :
4159 : #define RUN_TEST(fn) do { printf(" %s...\n", #fn); fn(); } while(0)
4160 :
4161 204 : int main(int argc, char *argv[]) {
4162 204 : printf("--- email-cli PTY View Tests ---\n\n");
4163 :
4164 204 : if (argc < 6) {
4165 0 : fprintf(stderr, "Usage: %s <email-cli> <mock-server> <email-cli-ro> <email-sync> <email-tui>\n", argv[0]);
4166 0 : return EXIT_FAILURE;
4167 : }
4168 204 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", argv[1]);
4169 204 : snprintf(g_batch_cli_bin, sizeof(g_batch_cli_bin), "%s", argv[1]);
4170 204 : snprintf(g_mock_bin, sizeof(g_mock_bin), "%s", argv[2]);
4171 204 : snprintf(g_cli_ro_bin, sizeof(g_cli_ro_bin), "%s", argv[3]);
4172 204 : snprintf(g_sync_bin, sizeof(g_sync_bin), "%s", argv[4]);
4173 204 : snprintf(g_tui_bin, sizeof(g_tui_bin), "%s", argv[5]);
4174 :
4175 204 : const char *home = getenv("HOME");
4176 204 : if (home) snprintf(g_old_home, sizeof(g_old_home), "%s", home);
4177 :
4178 204 : snprintf(g_test_home, sizeof(g_test_home),
4179 : "/tmp/email-cli-pty-test-%d", getpid());
4180 204 : setenv("HOME", g_test_home, 1);
4181 204 : unsetenv("XDG_CONFIG_HOME");
4182 204 : unsetenv("XDG_CACHE_HOME");
4183 204 : unsetenv("XDG_DATA_HOME");
4184 204 : write_config();
4185 :
4186 204 : printf("--- Help pages ---\n");
4187 204 : RUN_TEST(test_help_general);
4188 203 : RUN_TEST(test_help_list);
4189 202 : RUN_TEST(test_help_show);
4190 201 : RUN_TEST(test_help_folders);
4191 200 : RUN_TEST(test_help_sync);
4192 199 : RUN_TEST(test_help_cron);
4193 :
4194 198 : printf("\n--- Starting mock IMAP server ---\n");
4195 198 : if (start_mock_server() != 0) {
4196 0 : fprintf(stderr, "FATAL: cannot start mock server\n");
4197 0 : goto done;
4198 : }
4199 :
4200 198 : printf("\n--- Batch mode ---\n");
4201 198 : RUN_TEST(test_batch_list);
4202 197 : RUN_TEST(test_batch_list_all);
4203 196 : RUN_TEST(test_batch_list_empty);
4204 195 : RUN_TEST(test_batch_list_folder);
4205 194 : RUN_TEST(test_batch_list_limit);
4206 193 : RUN_TEST(test_batch_list_offset);
4207 192 : RUN_TEST(test_batch_show);
4208 191 : RUN_TEST(test_batch_folders_flat);
4209 190 : RUN_TEST(test_batch_folders_tree);
4210 189 : RUN_TEST(test_batch_sync);
4211 188 : RUN_TEST(test_batch_rules_apply);
4212 187 : RUN_TEST(test_batch_cron_status);
4213 :
4214 186 : printf("\n--- Command separation: labels vs folders ---\n");
4215 186 : RUN_TEST(test_list_labels_blocked_on_imap);
4216 185 : RUN_TEST(test_list_folders_works_on_imap);
4217 184 : RUN_TEST(test_create_label_blocked_on_imap);
4218 183 : RUN_TEST(test_delete_label_blocked_on_imap);
4219 182 : RUN_TEST(test_create_folder_help);
4220 181 : RUN_TEST(test_delete_folder_help);
4221 180 : RUN_TEST(test_create_folder_missing_arg);
4222 179 : RUN_TEST(test_delete_folder_missing_arg);
4223 :
4224 : /* Interactive tests require the TUI binary */
4225 178 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_tui_bin);
4226 :
4227 178 : printf("\n--- Interactive list ---\n");
4228 178 : RUN_TEST(test_interactive_list_content);
4229 177 : RUN_TEST(test_interactive_list_separator);
4230 176 : RUN_TEST(test_interactive_list_statusbar);
4231 175 : RUN_TEST(test_interactive_list_esc_quit);
4232 174 : RUN_TEST(test_interactive_list_nav);
4233 173 : RUN_TEST(test_interactive_list_flags);
4234 172 : RUN_TEST(test_tui_list_search_filter);
4235 :
4236 171 : printf("\n--- Interactive show ---\n");
4237 171 : RUN_TEST(test_interactive_show_content);
4238 170 : RUN_TEST(test_interactive_show_separator);
4239 169 : RUN_TEST(test_interactive_show_statusbar);
4240 168 : RUN_TEST(test_interactive_show_backspace);
4241 167 : RUN_TEST(test_interactive_show_esc_exits);
4242 166 : RUN_TEST(test_interactive_show_q_to_list);
4243 165 : RUN_TEST(test_interactive_show_pgdn);
4244 164 : RUN_TEST(test_interactive_show_arrow_scroll);
4245 163 : RUN_TEST(test_interactive_show_uid_in_header);
4246 162 : RUN_TEST(test_interactive_show_source_toggle);
4247 161 : RUN_TEST(test_interactive_show_search_finds);
4248 160 : RUN_TEST(test_interactive_show_search_no_match);
4249 159 : RUN_TEST(test_interactive_show_url_rendered);
4250 :
4251 158 : printf("\n--- Interactive list-folders ---\n");
4252 158 : RUN_TEST(test_interactive_folders_content);
4253 157 : RUN_TEST(test_interactive_folders_statusbar);
4254 156 : RUN_TEST(test_interactive_folders_toggle);
4255 155 : RUN_TEST(test_interactive_folders_select);
4256 154 : RUN_TEST(test_interactive_folders_nav);
4257 153 : RUN_TEST(test_interactive_folders_back_to_list);
4258 152 : RUN_TEST(test_interactive_folders_flat_navigate_up);
4259 151 : RUN_TEST(test_interactive_folders_esc_quit);
4260 150 : RUN_TEST(test_tui_folder_browser_search);
4261 :
4262 149 : printf("\n--- Attachment save ---\n");
4263 149 : RUN_TEST(test_show_attachment_statusbar);
4264 148 : RUN_TEST(test_show_attachment_picker);
4265 147 : RUN_TEST(test_show_save_single);
4266 146 : RUN_TEST(test_show_save_all_cancel);
4267 145 : RUN_TEST(test_show_save_all_confirm);
4268 144 : RUN_TEST(test_show_save_input_line_cursor);
4269 143 : RUN_TEST(test_show_save_tab_completion);
4270 :
4271 142 : printf("\n--- Empty folder ---\n");
4272 142 : RUN_TEST(test_interactive_empty_folder);
4273 141 : RUN_TEST(test_interactive_empty_folder_cron);
4274 :
4275 : /* Remaining sections use batch commands — restore email-cli */
4276 140 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", argv[1]);
4277 :
4278 140 : printf("\n--- Sync progress ---\n");
4279 140 : RUN_TEST(test_sync_progress);
4280 :
4281 139 : printf("\n--- Show cache hit ---\n");
4282 139 : RUN_TEST(test_show_cache_hit);
4283 :
4284 137 : printf("\n--- Offline / cron mode ---\n");
4285 137 : RUN_TEST(test_offline_list);
4286 135 : RUN_TEST(test_offline_show_not_cached);
4287 :
4288 : /* ── email-cli-ro: batch-only subset ─────────────────────────────── */
4289 134 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_cli_ro_bin);
4290 :
4291 134 : printf("\n--- email-cli-ro: help pages ---\n");
4292 134 : RUN_TEST(test_ro_help_general);
4293 133 : RUN_TEST(test_ro_help_list);
4294 132 : RUN_TEST(test_help_show);
4295 131 : RUN_TEST(test_help_folders);
4296 130 : RUN_TEST(test_ro_help_attachments);
4297 129 : RUN_TEST(test_ro_help_save_attachment);
4298 :
4299 128 : printf("\n--- email-cli-ro: batch mode ---\n");
4300 128 : restart_mock();
4301 128 : RUN_TEST(test_batch_list);
4302 127 : RUN_TEST(test_batch_list_empty);
4303 126 : RUN_TEST(test_batch_show);
4304 125 : RUN_TEST(test_batch_folders_flat);
4305 124 : RUN_TEST(test_batch_folders_tree);
4306 :
4307 123 : printf("\n--- email-cli-ro: exclusive capabilities ---\n");
4308 123 : RUN_TEST(test_ro_list_folder);
4309 122 : RUN_TEST(test_ro_list_limit);
4310 121 : RUN_TEST(test_ro_list_offset);
4311 120 : RUN_TEST(test_ro_sync_unknown);
4312 119 : RUN_TEST(test_ro_attachments);
4313 118 : RUN_TEST(test_ro_save_attachment);
4314 117 : RUN_TEST(test_ro_no_config);
4315 :
4316 : /* Restore full email-cli binary for the remaining tests */
4317 116 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", argv[1]);
4318 :
4319 : /* ── Setup wizard ────────────────────────────────────────────────── */
4320 116 : printf("\n--- Setup wizard ---\n");
4321 116 : RUN_TEST(test_wizard_abort);
4322 115 : RUN_TEST(test_wizard_complete);
4323 114 : RUN_TEST(test_wizard_host_autocomplete);
4324 113 : RUN_TEST(test_wizard_bad_protocol_rejected);
4325 112 : RUN_TEST(test_wizard_with_smtp);
4326 :
4327 : /* ── Non-TTY fallback ────────────────────────────────────────────── */
4328 111 : printf("\n--- Non-TTY fallback ---\n");
4329 111 : RUN_TEST(test_nonttty_shows_help);
4330 :
4331 : /* ── Cron management ─────────────────────────────────────────────── */
4332 111 : printf("\n--- Cron management ---\n");
4333 111 : RUN_TEST(test_cron_status_not_found);
4334 109 : RUN_TEST(test_cron_setup_default_interval);
4335 107 : RUN_TEST(test_cron_setup_installs);
4336 105 : RUN_TEST(test_cron_status_found);
4337 104 : RUN_TEST(test_cron_setup_already_installed);
4338 103 : RUN_TEST(test_cron_remove_entry);
4339 102 : RUN_TEST(test_cron_remove_not_found);
4340 :
4341 : /* ── email-sync: standalone sync binary ──────────────────────────── */
4342 101 : printf("\n--- email-sync ---\n");
4343 101 : RUN_TEST(test_sync_help);
4344 100 : RUN_TEST(test_sync_run);
4345 99 : RUN_TEST(test_sync_unknown_opt);
4346 98 : RUN_TEST(test_sync_no_config);
4347 :
4348 : /* ── email-tui: full interactive TUI + future write operations ─────── */
4349 97 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_tui_bin);
4350 :
4351 97 : printf("\n--- email-tui: help pages ---\n");
4352 97 : RUN_TEST(test_tui_help_general);
4353 96 : RUN_TEST(test_tui_help_list);
4354 95 : RUN_TEST(test_tui_help_cron);
4355 :
4356 : /* Batch-mode tests use email-cli; TUI binary does not accept arguments */
4357 94 : printf("\n--- email-tui: batch mode (via email-cli) ---\n");
4358 94 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", argv[1]);
4359 94 : restart_mock();
4360 94 : RUN_TEST(test_batch_list);
4361 93 : RUN_TEST(test_batch_list_all);
4362 92 : RUN_TEST(test_batch_show);
4363 91 : RUN_TEST(test_batch_folders_flat);
4364 90 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_tui_bin);
4365 :
4366 90 : printf("\n--- email-tui: interactive ---\n");
4367 90 : RUN_TEST(test_tui_list_content);
4368 89 : RUN_TEST(test_tui_list_esc_quit);
4369 88 : RUN_TEST(test_tui_show_esc_exits);
4370 :
4371 87 : printf("\n--- email-tui: TUI launch ---\n");
4372 87 : restart_mock();
4373 87 : RUN_TEST(test_tui_interactive_launch);
4374 :
4375 86 : printf("\n--- email-tui: startup behaviour (US-18) ---\n");
4376 86 : restart_mock();
4377 86 : RUN_TEST(test_tui_always_starts_at_accounts);
4378 84 : RUN_TEST(test_tui_folder_cursor_persisted);
4379 :
4380 82 : printf("\n--- email-tui: multi-account (US-21) ---\n");
4381 82 : restart_mock();
4382 82 : RUN_TEST(test_tui_accounts_screen_shows);
4383 81 : RUN_TEST(test_tui_accounts_esc_quit);
4384 80 : RUN_TEST(test_tui_accounts_enter_opens_list);
4385 79 : RUN_TEST(test_tui_accounts_backspace_from_list);
4386 78 : RUN_TEST(test_tui_accounts_multiple_shown);
4387 77 : RUN_TEST(test_tui_accounts_backspace_ignored);
4388 76 : RUN_TEST(test_tui_accounts_columns);
4389 75 : RUN_TEST(test_tui_accounts_cursor_restored);
4390 :
4391 74 : printf("\n--- email-tui: help panel (US-22) ---\n");
4392 74 : restart_mock();
4393 74 : RUN_TEST(test_tui_accounts_help_panel);
4394 73 : RUN_TEST(test_tui_list_help_panel);
4395 72 : RUN_TEST(test_tui_show_help_panel);
4396 71 : RUN_TEST(test_tui_folders_help_panel);
4397 70 : RUN_TEST(test_tui_help_panel_question_mark);
4398 :
4399 69 : printf("\n--- email-tui: wizard + cron ---\n");
4400 69 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_batch_cli_bin);
4401 69 : RUN_TEST(test_wizard_abort);
4402 68 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_tui_bin);
4403 68 : RUN_TEST(test_cron_status_not_found);
4404 :
4405 66 : printf("\n--- email-tui: list compose/reply (US-20) ---\n");
4406 66 : restart_mock();
4407 66 : RUN_TEST(test_tui_list_compose_key);
4408 65 : RUN_TEST(test_tui_list_reply_key);
4409 :
4410 64 : printf("\n--- email-tui: sync and refresh (US-19) ---\n");
4411 64 : restart_mock();
4412 64 : RUN_TEST(test_tui_list_sync_and_refresh);
4413 :
4414 62 : printf("\n--- Gmail wizard & account type column (US-27) ---\n");
4415 62 : restart_mock();
4416 62 : RUN_TEST(test_wizard_gmail_type_selection);
4417 61 : RUN_TEST(test_account_list_type_column);
4418 60 : RUN_TEST(test_gmail_labels_backspace);
4419 :
4420 59 : printf("\n--- email-tui: accounts management (US-21) ---\n");
4421 59 : restart_mock();
4422 59 : RUN_TEST(test_tui_accounts_new_key);
4423 58 : RUN_TEST(test_tui_accounts_delete_key);
4424 57 : RUN_TEST(test_tui_accounts_imap_edit_key);
4425 :
4426 56 : printf("\n--- email-sync: account filter (US-25) ---\n");
4427 56 : restart_mock();
4428 56 : RUN_TEST(test_sync_account_filter_known);
4429 55 : RUN_TEST(test_sync_account_filter_unknown);
4430 :
4431 54 : printf("\n--- pending flags offline queue (US-26) ---\n");
4432 54 : restart_mock();
4433 54 : RUN_TEST(test_pending_flags_offline_queue);
4434 :
4435 52 : printf("\n--- virtual Unread/Flagged list ---\n");
4436 52 : restart_mock();
4437 52 : RUN_TEST(test_virtual_folder_sections_shown);
4438 51 : RUN_TEST(test_virtual_unread_count_from_manifests);
4439 49 : RUN_TEST(test_virtual_unread_list_shows_messages);
4440 47 : RUN_TEST(test_virtual_enter_opens_reader);
4441 45 : RUN_TEST(test_virtual_n_marks_message_read);
4442 43 : RUN_TEST(test_virtual_flagged_shows_messages);
4443 41 : RUN_TEST(test_unread_count_refreshes_after_mark);
4444 :
4445 39 : printf("\n--- virtual Junk/Answered/Forwarded lists ---\n");
4446 39 : restart_mock();
4447 39 : RUN_TEST(test_virtual_junk_list_shows_messages);
4448 37 : RUN_TEST(test_virtual_answered_list_shows_messages);
4449 35 : RUN_TEST(test_virtual_forwarded_list_shows_messages);
4450 :
4451 33 : printf("\n--- flag status column (P/J/R/F markers) ---\n");
4452 33 : restart_mock();
4453 33 : RUN_TEST(test_status_junk_marker_shown);
4454 31 : RUN_TEST(test_status_phishing_marker_shown);
4455 29 : RUN_TEST(test_status_answered_marker_shown);
4456 27 : RUN_TEST(test_status_forwarded_marker_shown);
4457 :
4458 : /* mark-junk/mark-notjunk are email-cli commands, not TUI */
4459 25 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", argv[1]);
4460 :
4461 25 : printf("\n--- mark-junk / mark-notjunk commands ---\n");
4462 25 : restart_mock();
4463 25 : RUN_TEST(test_mark_junk_help);
4464 24 : RUN_TEST(test_mark_notjunk_help);
4465 23 : RUN_TEST(test_mark_junk_missing_arg);
4466 22 : RUN_TEST(test_mark_notjunk_missing_arg);
4467 21 : RUN_TEST(test_mark_junk_blocked_in_ro);
4468 20 : RUN_TEST(test_mark_notjunk_blocked_in_ro);
4469 :
4470 : /* Restore full email-cli binary */
4471 19 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", argv[1]);
4472 :
4473 : /* ── TLS enforcement (US-23) ──────────────────────────────────────── */
4474 19 : printf("\n--- TLS enforcement (US-23) ---\n");
4475 19 : RUN_TEST(test_tls_imap_rejected);
4476 18 : RUN_TEST(test_tls_smtp_rejected);
4477 :
4478 : /* ── TUI rules editor (US-62) ────────────────────────────────────── */
4479 17 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", g_tui_bin);
4480 17 : printf("\n--- email-tui: rules editor (US-62) ---\n");
4481 17 : restart_mock();
4482 17 : RUN_TEST(test_tui_rules_editor_opens);
4483 16 : RUN_TEST(test_tui_rules_editor_empty_message);
4484 15 : RUN_TEST(test_tui_rules_editor_lists_rules);
4485 14 : RUN_TEST(test_tui_rules_editor_add_rule);
4486 13 : RUN_TEST(test_tui_rules_editor_cancel_add);
4487 12 : RUN_TEST(test_tui_rules_editor_delete_rule);
4488 11 : RUN_TEST(test_tui_rules_editor_q_closes);
4489 :
4490 : /* ── TUI rules list navigation (US-78) ──────────────────────────── */
4491 10 : printf("\n--- email-tui: rules list navigation (US-78) ---\n");
4492 10 : restart_mock();
4493 10 : RUN_TEST(test_tui_rules_nav_down);
4494 9 : RUN_TEST(test_tui_rules_nav_arrow_down);
4495 8 : RUN_TEST(test_tui_rules_enter_opens_first);
4496 :
4497 : /* ── TUI rule detail view (US-79) ───────────────────────────────── */
4498 7 : printf("\n--- email-tui: rule detail view (US-79) ---\n");
4499 7 : restart_mock();
4500 7 : RUN_TEST(test_tui_rules_detail_shows_fields);
4501 6 : RUN_TEST(test_tui_rules_detail_esc_back);
4502 5 : RUN_TEST(test_tui_rules_detail_delete);
4503 :
4504 : /* ── TUI rule edit form (US-80) ─────────────────────────────────── */
4505 4 : printf("\n--- email-tui: rule edit form inline editing (US-80) ---\n");
4506 4 : restart_mock();
4507 4 : RUN_TEST(test_tui_rules_edit_form_opens);
4508 3 : RUN_TEST(test_tui_rules_edit_form_prefill);
4509 2 : RUN_TEST(test_tui_rules_edit_form_esc_cancel);
4510 :
4511 1 : done:
4512 1 : stop_mock_server();
4513 1 : if (g_old_home[0]) setenv("HOME", g_old_home, 1);
4514 :
4515 1 : printf("\n--- PTY Test Results ---\n");
4516 1 : printf("Tests Run: %d\n", g_tests_run);
4517 1 : printf("Tests Passed: %d\n", g_tests_run - g_tests_failed);
4518 1 : printf("Tests Failed: %d\n", g_tests_failed);
4519 :
4520 1 : return g_tests_failed > 0 ? EXIT_FAILURE : EXIT_SUCCESS;
4521 : }
|