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 % 3024 3013
Test Date: 2026-05-07 15:53:08 Functions: 100.0 % 192 192

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

Generated by: LCOV version 2.0-1