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