LCOV - code coverage report
Current view: top level - tests/pty - test_pty_views.c (source / functions) Coverage Total Hit
Test: coverage-functional.info Lines: 99.6 % 3049 3038
Test Date: 2026-08-21 10:11:30 Functions: 100.0 % 194 194

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

Generated by: LCOV version 2.0-1