LCOV - code coverage report
Current view: top level - tests/pty - test_pty_gmail_tui.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 92.7 % 1152 1068
Test Date: 2026-08-21 10:11:28 Functions: 100.0 % 57 57

            Line data    Source code
       1              : /**
       2              :  * @file test_pty_gmail_tui.c
       3              :  * @brief PTY tests for Gmail TUI interactions.
       4              :  *
       5              :  * Covers:
       6              :  *   - 'd' key pending-remove visual feedback (pending marker character)
       7              :  *
       8              :  * Usage: test-pty-gmail-tui <email-tui> <email-sync> <mock-gmail-server>
       9              :  *
      10              :  * The mock Gmail API server is started on GMAIL_PTY_PORT; the environment
      11              :  * variable GMAIL_API_BASE_URL is set so that both email-sync and email-tui
      12              :  * talk to the mock instead of the real Gmail API.
      13              :  *
      14              :  * Message ordering in the list:
      15              :  *   The TUI sorts entries by date descending (then UID descending as
      16              :  *   tiebreaker).  The mock server's 5 messages all share the same date,
      17              :  *   so they are ordered by UID descending: Message 5, 4, 3, 2, 1.
      18              :  *   Cursor starts at position 0 → "Message 5".
      19              :  */
      20              : 
      21              : #define _DEFAULT_SOURCE
      22              : #define _XOPEN_SOURCE 600
      23              : 
      24              : #include "ptytest.h"
      25              : #include "pty_assert.h"
      26              : #include <stdio.h>
      27              : #include <stdlib.h>
      28              : #include <string.h>
      29              : #include <signal.h>
      30              : #include <unistd.h>
      31              : #include <fcntl.h>
      32              : #include <sys/stat.h>
      33              : #include <sys/wait.h>
      34              : #include <sys/socket.h>
      35              : #include <netinet/in.h>
      36              : 
      37              : int g_tests_run    = 0;
      38              : int g_tests_failed = 0;
      39              : 
      40              : #define RUN_TEST(fn) do { printf("  %s...\n", #fn); fn(); } while(0)
      41              : 
      42              : /* ── Configuration ───────────────────────────────────────────────────── */
      43              : 
      44              : #define GMAIL_PTY_PORT   9096
      45              : #define GMAIL_PTY_COUNT  5
      46              : #define GMAIL_TEST_EMAIL "ptygtest@gmail.com"
      47              : /* Top-most message in the list (sorted by UID descending, same date) */
      48              : #define GMAIL_TOP_MSG    "Message 5"
      49              : /* Bottom-most message (lowest UID) */
      50              : #define GMAIL_BOT_MSG    "Message 1"
      51              : #define WAIT_MS  8000
      52              : #define SETTLE_MS 700
      53              : #define ROWS 30
      54              : #define COLS 120
      55              : #define FEEDBACK_ROW (ROWS - 2)   /* 0-indexed PTY row for the infoline */
      56              : 
      57              : /* ── Global state ────────────────────────────────────────────────────── */
      58              : 
      59              : static pid_t g_mock_pid = -1;
      60              : static char  g_test_home[256];
      61              : static char  g_tui_bin[512];
      62              : static char  g_sync_bin[512];
      63              : static char  g_mock_bin[512];
      64              : static char  g_api_url[256];
      65              : 
      66              : /* ── Infrastructure ──────────────────────────────────────────────────── */
      67              : 
      68           44 : static void write_gmail_config(void) {
      69              :     char d1[350], d2[400], d3[450], path[500];
      70           44 :     snprintf(d1,   sizeof(d1),   "%s/.config/email-cli/accounts",              g_test_home);
      71           44 :     snprintf(d2,   sizeof(d2),   "%s/.config/email-cli/accounts/%s",           g_test_home, GMAIL_TEST_EMAIL);
      72           44 :     snprintf(path, sizeof(path), "%s/.config/email-cli/accounts/%s/config.ini",g_test_home, GMAIL_TEST_EMAIL);
      73              : 
      74              :     /* Ensure directory chain exists */
      75           44 :     snprintf(d3, sizeof(d3), "%s/.config", g_test_home);
      76           44 :     mkdir(g_test_home, 0700);
      77           44 :     mkdir(d3,           0700);
      78           44 :     snprintf(d3, sizeof(d3), "%s/.config/email-cli", g_test_home);
      79           44 :     mkdir(d3, 0700);
      80           44 :     mkdir(d1, 0700);
      81           44 :     mkdir(d2, 0700);
      82              : 
      83           44 :     FILE *fp = fopen(path, "w");
      84           44 :     if (!fp) { perror("fopen config.ini"); return; }
      85           44 :     fprintf(fp,
      86              :         "GMAIL_MODE=1\n"
      87              :         "EMAIL_USER=%s\n"
      88              :         "GMAIL_ACCESS_TOKEN=fake_token\n"
      89              :         "GMAIL_REFRESH_TOKEN=fake_refresh\n"
      90              :         "GMAIL_TOKEN_EXPIRY=9999999999\n"
      91              :         "EMAIL_FOLDER=INBOX\n",
      92              :         GMAIL_TEST_EMAIL);
      93           44 :     fclose(fp);
      94           44 :     chmod(path, 0600);
      95              : }
      96              : 
      97           44 : static int start_gmail_mock(void) {
      98           44 :     g_mock_pid = fork();
      99           45 :     if (g_mock_pid < 0) return -1;
     100           45 :     if (g_mock_pid == 0) {
     101              :         char port_str[16], count_str[16];
     102            1 :         snprintf(port_str,  sizeof(port_str),  "%d", GMAIL_PTY_PORT);
     103            1 :         snprintf(count_str, sizeof(count_str), "%d", GMAIL_PTY_COUNT);
     104            1 :         setenv("MOCK_GMAIL_PORT",           port_str,          1);
     105            1 :         setenv("MOCK_GMAIL_COUNT",          count_str,         1);
     106            1 :         setenv("MOCK_GMAIL_EMAIL",          GMAIL_TEST_EMAIL,  1);
     107            1 :         int devnull = open("/dev/null", O_WRONLY);
     108            1 :         if (devnull >= 0) { dup2(devnull, 1); dup2(devnull, 2); close(devnull); }
     109            1 :         execl(g_mock_bin, "mock_gmail_api_server", (char *)NULL);
     110            1 :         _exit(127);
     111              :     }
     112              :     /* Poll until the server accepts connections (max 3 s) */
     113           44 :     for (int i = 0; i < 30; i++) {
     114           44 :         usleep(100000);
     115           44 :         int fd = socket(AF_INET, SOCK_STREAM, 0);
     116           44 :         if (fd < 0) continue;
     117           44 :         struct sockaddr_in addr = {
     118              :             .sin_family      = AF_INET,
     119           44 :             .sin_port        = htons(GMAIL_PTY_PORT),
     120           44 :             .sin_addr.s_addr = htonl(INADDR_LOOPBACK)
     121              :         };
     122           44 :         int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
     123           44 :         close(fd);
     124           44 :         if (rc == 0) return 0;
     125              :     }
     126            0 :     return -1;
     127              : }
     128              : 
     129            1 : static void stop_gmail_mock(void) {
     130            1 :     if (g_mock_pid > 0) {
     131            1 :         kill(g_mock_pid, SIGKILL);
     132            1 :         waitpid(g_mock_pid, NULL, 0);
     133            1 :         g_mock_pid = -1;
     134              :     }
     135            1 : }
     136              : 
     137              : /** Run email-sync synchronously (inherits HOME + GMAIL_API_BASE_URL from env). */
     138          834 : static int run_gmail_sync(void) {
     139          834 :     pid_t pid = fork();
     140          877 :     if (pid < 0) return -1;
     141          877 :     if (pid == 0) {
     142           43 :         int devnull = open("/dev/null", O_WRONLY);
     143           43 :         if (devnull >= 0) { dup2(devnull, 1); dup2(devnull, 2); close(devnull); }
     144           43 :         execl(g_sync_bin, "email-sync", (char *)NULL);
     145           43 :         _exit(127);
     146              :     }
     147              :     int status;
     148          834 :     waitpid(pid, &status, 0);
     149          834 :     return (WIFEXITED(status) && WEXITSTATUS(status) == 0) ? 0 : -1;
     150              : }
     151              : 
     152              : /**
     153              :  * Persist the per-account folder cursor in the shared UI prefs file.
     154              :  *
     155              :  * email-tui reads "folder_cursor_<account>" on startup and pre-selects that
     156              :  * folder in the label browser; with no entry it defaults to the __unread__
     157              :  * virtual label.
     158              :  */
     159          826 : static void write_folder_pref(const char *folder) {
     160              :     char dir[400], path[450];
     161          826 :     snprintf(dir, sizeof(dir), "%s/.local", g_test_home);              mkdir(dir, 0700);
     162          826 :     snprintf(dir, sizeof(dir), "%s/.local/share", g_test_home);        mkdir(dir, 0700);
     163          826 :     snprintf(dir, sizeof(dir), "%s/.local/share/email-cli", g_test_home); mkdir(dir, 0700);
     164          826 :     snprintf(path, sizeof(path), "%s/ui.ini", dir);
     165          826 :     FILE *fp = fopen(path, "w");
     166          826 :     if (!fp) return;
     167          826 :     fprintf(fp, "folder_cursor_%s=%s\n", GMAIL_TEST_EMAIL, folder);
     168          826 :     fclose(fp);
     169              : }
     170              : 
     171              : /**
     172              :  * Reset account local data and re-populate from the mock server.
     173              :  *
     174              :  * Deletes the account's local store (labels/, headers/, history ID, etc.)
     175              :  * so the next email-sync performs a full fresh sync instead of an
     176              :  * incremental one.  This ensures every test starts from the same clean
     177              :  * state (all 5 messages in INBOX with their original labels).
     178              :  */
     179          826 : static int reset_and_sync(void) {
     180              :     /* Remove the per-account local store */
     181              :     char cmd[600];
     182          826 :     snprintf(cmd, sizeof(cmd),
     183              :              "rm -rf '/tmp/email-cli-gmail-pty-%d/.local/share/email-cli/accounts/%s'",
     184          826 :              (int)getpid(), GMAIL_TEST_EMAIL);
     185          826 :     int _rc1 = system(cmd);  (void)_rc1;  /* test-only, path is controlled */
     186              : 
     187              :     /* Also clear the shared UI prefs file (it remembers the last-used folder
     188              :      * cursor; stale entries would make subsequent tests open the wrong label). */
     189              :     char cmd2[600];
     190          826 :     snprintf(cmd2, sizeof(cmd2),
     191              :              "rm -f '%s/.local/share/email-cli/ui.ini'",
     192              :              g_test_home);
     193          826 :     int _rc2 = system(cmd2);  (void)_rc2;
     194              : 
     195              :     /* Pin the folder cursor to INBOX.  Without a saved preference email-tui
     196              :      * opens the __unread__ virtual label instead, and navigate_to_inbox()
     197              :      * would land on a virtual view where label operations ('d') are no-ops. */
     198          826 :     write_folder_pref("INBOX");
     199              : 
     200          826 :     return run_gmail_sync();
     201              : }
     202              : 
     203              : /** Find the first screen row containing @p text; returns -1 if absent. */
     204          839 : static int find_row(PtySession *s, const char *text) {
     205         4351 :     for (int r = 0; r < ROWS; r++)
     206         4351 :         if (pty_row_contains(s, r, text)) return r;
     207            0 :     return -1;
     208              : }
     209              : 
     210              : /**
     211              :  * Navigate email-tui from startup to the INBOX message list.
     212              :  *
     213              :  * The TUI presents three screens in sequence:
     214              :  *   1. Account selector — wait for "Email Accounts", press Enter
     215              :  *   2. Label browser    — wait for "Labels",          press Enter (INBOX pre-selected)
     216              :  *   3. Message list     — wait for GMAIL_TOP_MSG ("Message 5", the topmost entry)
     217              :  *
     218              :  * Returns 0 on success, -1 if any step times out.
     219              :  */
     220          741 : static int navigate_to_inbox(PtySession *s) {
     221              :     /* Step 1: account selector */
     222          741 :     if (pty_wait_for(s, "Email Accounts", WAIT_MS) != 0) {
     223            0 :         printf("  [FAIL] navigate_to_inbox: timed out waiting for \"Email Accounts\"\n");
     224            0 :         return -1;
     225              :     }
     226          741 :     pty_settle(s, SETTLE_MS);
     227          741 :     pty_send_key(s, PTY_KEY_ENTER);  /* open the Gmail account */
     228              : 
     229              :     /* Step 2: label browser (INBOX pre-selected via EMAIL_FOLDER=INBOX) */
     230          741 :     if (pty_wait_for(s, "Labels", WAIT_MS) != 0) {
     231            0 :         printf("  [FAIL] navigate_to_inbox: timed out waiting for \"Labels\"\n");
     232            0 :         return -1;
     233              :     }
     234          741 :     pty_settle(s, SETTLE_MS);
     235          741 :     pty_send_key(s, PTY_KEY_ENTER);  /* open INBOX */
     236              : 
     237              :     /* Step 3: message list (cursor starts at top = highest UID = GMAIL_TOP_MSG) */
     238          741 :     if (pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS) != 0) {
     239            0 :         printf("  [FAIL] navigate_to_inbox: timed out waiting for \"%s\"\n",
     240              :                GMAIL_TOP_MSG);
     241            0 :         return -1;
     242              :     }
     243          741 :     pty_settle(s, SETTLE_MS);
     244              : 
     245          741 :     return 0;
     246              : }
     247              : 
     248              : /* ── Navigation helpers ─────────────────────────────────────────────── */
     249              : 
     250              : /**
     251              :  * From the accounts screen, navigate to the Trash label view.
     252              :  * Trash is always the last entry in the label browser (PTY_KEY_END).
     253              :  */
     254          125 : static int navigate_to_trash(PtySession *s) {
     255          125 :     if (pty_wait_for(s, "Email Accounts", WAIT_MS) != 0) {
     256            0 :         printf("  [FAIL] navigate_to_trash: timed out at \"Email Accounts\"\n");
     257            0 :         return -1;
     258              :     }
     259          125 :     pty_settle(s, SETTLE_MS);
     260          125 :     pty_send_key(s, PTY_KEY_ENTER);
     261          125 :     if (pty_wait_for(s, "Labels", WAIT_MS) != 0) {
     262            0 :         printf("  [FAIL] navigate_to_trash: timed out at \"Labels\"\n");
     263            0 :         return -1;
     264              :     }
     265          125 :     pty_settle(s, SETTLE_MS);
     266          125 :     pty_send_key(s, PTY_KEY_END);   /* Trash is always the last entry */
     267          125 :     pty_settle(s, SETTLE_MS / 2);
     268          125 :     pty_send_key(s, PTY_KEY_ENTER);
     269          125 :     pty_settle(s, SETTLE_MS);
     270          125 :     return 0;
     271              : }
     272              : 
     273              : /**
     274              :  * Within an already-visible Labels browser, navigate to Archive (_nolabel).
     275              :  * Label order (no user labels in test env): …DRAFTS | Archive Spam Trash
     276              :  * Archive = End − 2 steps.
     277              :  */
     278          117 : static int navigate_labels_to_archive(PtySession *s) {
     279          117 :     pty_send_key(s, PTY_KEY_END);   /* → Trash (last) */
     280          117 :     pty_settle(s, SETTLE_MS / 2);
     281          117 :     pty_send_key(s, PTY_KEY_UP);    /* → Spam */
     282          117 :     pty_send_key(s, PTY_KEY_UP);    /* → Archive */
     283          117 :     pty_settle(s, SETTLE_MS / 2);
     284          117 :     pty_send_key(s, PTY_KEY_ENTER);
     285          117 :     pty_settle(s, SETTLE_MS);
     286          117 :     return 0;
     287              : }
     288              : 
     289              : /* ══════════════════════════════════════════════════════════════════════
     290              :  *  TEST: 'd' key → pending-remove row remains visible
     291              :  * ══════════════════════════════════════════════════════════════════════ */
     292              : 
     293              : /**
     294              :  * After pressing 'd' (remove current label) in the Gmail TUI, the message
     295              :  * must NOT be removed from the list immediately.  The row stays visible so
     296              :  * the user can see what they acted on and undo if needed.
     297              :  *
     298              :  * The cursor starts on GMAIL_TOP_MSG ("Message 5"), which is the first
     299              :  * displayed entry (highest UID, sorted newest-first).
     300              :  */
     301           44 : static void test_d_key_row_stays_visible(void) {
     302           44 :     const char *args[] = { g_tui_bin, NULL };
     303           44 :     PtySession *s = pty_open(COLS, ROWS);
     304           44 :     ASSERT(s != NULL, "d-vis: pty_open");
     305           44 :     if (!s) return;
     306           44 :     ASSERT(pty_run(s, args) == 0, "d-vis: pty_run");
     307              : 
     308              :     /* Navigate through account selector and label browser to message list */
     309           43 :     ASSERT(navigate_to_inbox(s) == 0, "d-vis: navigate_to_inbox");
     310              : 
     311              :     /* Press 'd' — removes current label (INBOX) from the cursor row */
     312           43 :     pty_send_str(s, "d");
     313           43 :     pty_settle(s, SETTLE_MS);
     314              : 
     315              :     /* The cursor row (GMAIL_TOP_MSG) must still be visible after label removal */
     316           43 :     ASSERT_SCREEN_CONTAINS(s, GMAIL_TOP_MSG);
     317              : 
     318           43 :     pty_send_key(s, PTY_KEY_ESC);
     319           43 :     pty_close(s);
     320              : }
     321              : 
     322              : /* ══════════════════════════════════════════════════════════════════════
     323              :  *  TEST: 'd' key → pending-remove row has pending marker character
     324              :  * ══════════════════════════════════════════════════════════════════════ */
     325              : 
     326              : /**
     327              :  * The pending-remove row must be rendered with a visible 'd' marker at
     328              :  * column 0 so the user immediately sees that the label removal is queued.
     329              :  *
     330              :  * The cursor row (GMAIL_TOP_MSG = "Message 5") is the one that gets the
     331              :  * marker after 'd' is pressed.
     332              :  */
     333           43 : static void test_d_key_row_strikethrough(void) {
     334           43 :     const char *args[] = { g_tui_bin, NULL };
     335           43 :     PtySession *s = pty_open(COLS, ROWS);
     336           43 :     ASSERT(s != NULL, "d-strike: pty_open");
     337           43 :     if (!s) return;
     338           43 :     ASSERT(pty_run(s, args) == 0, "d-strike: pty_run");
     339              : 
     340           42 :     ASSERT(navigate_to_inbox(s) == 0, "d-strike: navigate_to_inbox");
     341              : 
     342              :     /* The cursor row is GMAIL_TOP_MSG (the first/topmost entry in the list) */
     343           42 :     int row_before = find_row(s, GMAIL_TOP_MSG);
     344           42 :     ASSERT(row_before >= 0, "d-strike: " GMAIL_TOP_MSG " present before d");
     345              : 
     346              :     /* Check that the cursor row does NOT have a pending marker BEFORE pressing 'd' */
     347           42 :     if (row_before >= 0) {
     348           42 :         ASSERT(strcmp(pty_cell_text(s, row_before, 0), " ") == 0,
     349              :                "d-strike: no marker before d");
     350              :     }
     351              : 
     352              :     /* Press 'd' — removes INBOX label from the cursor row */
     353           42 :     pty_send_str(s, "d");
     354           42 :     pty_settle(s, SETTLE_MS);
     355              : 
     356              :     /* The cursor row must still be visible (pending-remove keeps it) */
     357           42 :     int row_after = find_row(s, GMAIL_TOP_MSG);
     358           42 :     ASSERT(row_after >= 0, "d-strike: " GMAIL_TOP_MSG " still present after d");
     359              : 
     360           42 :     if (row_after >= 0) {
     361              :         /* The pending-remove row is rendered with a yellow 'd' marker at col 0. */
     362           42 :         ASSERT(strcmp(pty_cell_text(s, row_after, 0), "d") == 0,
     363              :                "d-strike: cursor row has 'd' marker after d");
     364              :     }
     365              : 
     366           42 :     pty_send_key(s, PTY_KEY_ESC);
     367           42 :     pty_close(s);
     368              : }
     369              : 
     370              : /* ══════════════════════════════════════════════════════════════════════
     371              :  *  TEST: 'd' key → pending-remove row retains inverse cursor highlight
     372              :  * ══════════════════════════════════════════════════════════════════════ */
     373              : 
     374              : /**
     375              :  * After pressing 'd', the cursor row is rendered with a yellow 'd' marker
     376              :  * AND inverse-video so the cursor position remains clearly visible.
     377              :  * PTY_ATTR_REVERSE must be set on the row's cells alongside the pending marker.
     378              :  *
     379              :  * The cursor starts on GMAIL_TOP_MSG ("Message 5").
     380              :  */
     381           42 : static void test_d_key_cursor_row_has_reverse(void) {
     382           42 :     const char *args[] = { g_tui_bin, NULL };
     383           42 :     PtySession *s = pty_open(COLS, ROWS);
     384           42 :     ASSERT(s != NULL, "d-rev: pty_open");
     385           42 :     if (!s) return;
     386           42 :     ASSERT(pty_run(s, args) == 0, "d-rev: pty_run");
     387              : 
     388           41 :     ASSERT(navigate_to_inbox(s) == 0, "d-rev: navigate_to_inbox");
     389              : 
     390           41 :     int row_before = find_row(s, GMAIL_TOP_MSG);
     391           41 :     ASSERT(row_before >= 0, "d-rev: " GMAIL_TOP_MSG " present before d");
     392              : 
     393              :     /* Before 'd': cursor row must have REVERSE, must NOT have a pending marker */
     394           41 :     if (row_before >= 0) {
     395           41 :         int attr = pty_cell_attr(s, row_before, 4);
     396           41 :         ASSERT(attr & PTY_ATTR_REVERSE,  "d-rev: cursor row has REVERSE before d");
     397           41 :         ASSERT(strcmp(pty_cell_text(s, row_before, 0), " ") == 0,
     398              :                "d-rev: no marker before d");
     399              :     }
     400              : 
     401           41 :     pty_send_str(s, "d");
     402           41 :     pty_settle(s, SETTLE_MS);
     403              : 
     404           41 :     int row_after = find_row(s, GMAIL_TOP_MSG);
     405           41 :     ASSERT(row_after >= 0, "d-rev: " GMAIL_TOP_MSG " still present after d");
     406              : 
     407              :     /* After 'd': cursor row must have REVERSE and the 'd' pending marker */
     408           41 :     if (row_after >= 0) {
     409           41 :         int attr = pty_cell_attr(s, row_after, 1);
     410           41 :         ASSERT(attr & PTY_ATTR_REVERSE, "d-rev: cursor row has REVERSE after d");
     411           41 :         ASSERT(strcmp(pty_cell_text(s, row_after, 0), "d") == 0,
     412              :                "d-rev: cursor row has 'd' marker after d");
     413              :     }
     414              : 
     415           41 :     pty_send_key(s, PTY_KEY_ESC);
     416           41 :     pty_close(s);
     417              : }
     418              : 
     419              : /* ══════════════════════════════════════════════════════════════════════
     420              :  *  TEST: second 'd' undoes the pending-remove (toggle)
     421              :  * ══════════════════════════════════════════════════════════════════════ */
     422              : 
     423              : /**
     424              :  * Pressing 'd' twice on the same row must cancel the pending-remove:
     425              :  *   - After first 'd': row has yellow 'd' pending marker at col 0.
     426              :  *   - After second 'd': marker is gone; label is restored locally;
     427              :  *     pressing 'R' (refresh) must keep the row visible.
     428              :  */
     429           41 : static void test_d_key_toggle_undo(void) {
     430           41 :     const char *args[] = { g_tui_bin, NULL };
     431           41 :     PtySession *s = pty_open(COLS, ROWS);
     432           41 :     ASSERT(s != NULL, "d-undo: pty_open");
     433           41 :     if (!s) return;
     434           41 :     ASSERT(pty_run(s, args) == 0, "d-undo: pty_run");
     435              : 
     436           40 :     ASSERT(navigate_to_inbox(s) == 0, "d-undo: navigate_to_inbox");
     437              : 
     438              :     /* First 'd': mark for removal */
     439           40 :     pty_send_str(s, "d");
     440           40 :     pty_settle(s, SETTLE_MS);
     441              : 
     442           40 :     int row1 = find_row(s, GMAIL_TOP_MSG);
     443           40 :     ASSERT(row1 >= 0, "d-undo: " GMAIL_TOP_MSG " present after first d");
     444           40 :     if (row1 >= 0)
     445           40 :         ASSERT(strcmp(pty_cell_text(s, row1, 0), "d") == 0,
     446              :                "d-undo: 'd' marker set after first d");
     447              : 
     448              :     /* Second 'd': undo */
     449           40 :     pty_send_str(s, "d");
     450           40 :     pty_settle(s, SETTLE_MS);
     451              : 
     452           40 :     int row2 = find_row(s, GMAIL_TOP_MSG);
     453           40 :     ASSERT(row2 >= 0, "d-undo: " GMAIL_TOP_MSG " still present after second d");
     454           40 :     if (row2 >= 0)
     455           40 :         ASSERT(strcmp(pty_cell_text(s, row2, 0), " ") == 0,
     456              :                "d-undo: marker cleared after second d");
     457              : 
     458              :     /* Refresh — row must survive (label was restored) */
     459           40 :     pty_send_str(s, "U");
     460           40 :     pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
     461           40 :     pty_settle(s, SETTLE_MS);
     462           40 :     ASSERT(pty_screen_contains(s, GMAIL_TOP_MSG),
     463              :            "d-undo: " GMAIL_TOP_MSG " visible after refresh (undo preserved)");
     464              : 
     465           40 :     pty_send_key(s, PTY_KEY_ESC);
     466           40 :     pty_close(s);
     467              : }
     468              : 
     469              : /* ══════════════════════════════════════════════════════════════════════
     470              :  *  TEST: label picker shows UNREAD
     471              :  * ══════════════════════════════════════════════════════════════════════ */
     472              : 
     473              : /** Pressing 't' opens the Toggle Labels popup which must list UNREAD. */
     474           39 : static void test_label_picker_shows_unread(void) {
     475           39 :     const char *args[] = { g_tui_bin, NULL };
     476           39 :     PtySession *s = pty_open(COLS, ROWS);
     477           39 :     ASSERT(s != NULL, "picker-unread: pty_open");
     478           39 :     if (!s) return;
     479           39 :     ASSERT(pty_run(s, args) == 0, "picker-unread: pty_run");
     480           38 :     ASSERT(navigate_to_inbox(s) == 0, "picker-unread: navigate_to_inbox");
     481              : 
     482           38 :     pty_send_str(s, "t");       /* open label picker */
     483           38 :     pty_wait_for(s, "Toggle Labels", WAIT_MS);
     484           38 :     pty_settle(s, SETTLE_MS);
     485              : 
     486           38 :     ASSERT_SCREEN_CONTAINS(s, "UNREAD");
     487              : 
     488           38 :     pty_send_key(s, PTY_KEY_ESC);
     489           38 :     pty_send_key(s, PTY_KEY_ESC);
     490           38 :     pty_close(s);
     491              : }
     492              : 
     493              : /* ══════════════════════════════════════════════════════════════════════
     494              :  *  TEST: 'a' key → row has yellow marker 'd' (immediate feedback)
     495              :  * ══════════════════════════════════════════════════════════════════════ */
     496              : 
     497              : /**
     498              :  * After pressing 'a' (archive), the cursor row must stay visible with
     499              :  * a yellow 'd' pending marker at column 0, giving immediate feedback.
     500              :  */
     501           38 : static void test_archive_row_has_strikethrough(void) {
     502           38 :     const char *args[] = { g_tui_bin, NULL };
     503           38 :     PtySession *s = pty_open(COLS, ROWS);
     504           38 :     ASSERT(s != NULL, "arch-strike: pty_open");
     505           38 :     if (!s) return;
     506           38 :     ASSERT(pty_run(s, args) == 0, "arch-strike: pty_run");
     507           37 :     ASSERT(navigate_to_inbox(s) == 0, "arch-strike: navigate_to_inbox");
     508              : 
     509           37 :     int row_before = find_row(s, GMAIL_TOP_MSG);
     510           37 :     ASSERT(row_before >= 0, "arch-strike: " GMAIL_TOP_MSG " present before a");
     511           37 :     if (row_before >= 0)
     512           37 :         ASSERT(strcmp(pty_cell_text(s, row_before, 0), " ") == 0,
     513              :                "arch-strike: no pending marker before a");
     514              : 
     515           37 :     pty_send_str(s, "a");
     516           37 :     pty_settle(s, SETTLE_MS);
     517              : 
     518           37 :     int row_after = find_row(s, GMAIL_TOP_MSG);
     519           37 :     ASSERT(row_after >= 0, "arch-strike: " GMAIL_TOP_MSG " still visible after a");
     520           37 :     if (row_after >= 0)
     521           37 :         ASSERT(strcmp(pty_cell_text(s, row_after, 0), "d") == 0,
     522              :                "arch-strike: 'd' pending marker set after a");
     523              : 
     524           37 :     pty_send_key(s, PTY_KEY_ESC);
     525           37 :     pty_close(s);
     526              : }
     527              : 
     528              : /* ══════════════════════════════════════════════════════════════════════
     529              :  *  TEST: 'a' key → message gone from INBOX after refresh
     530              :  * ══════════════════════════════════════════════════════════════════════ */
     531              : 
     532              : /** After 'a' + 'R', the archived message must no longer appear in INBOX. */
     533           37 : static void test_archive_removes_from_inbox(void) {
     534           37 :     const char *args[] = { g_tui_bin, NULL };
     535           37 :     PtySession *s = pty_open(COLS, ROWS);
     536           37 :     ASSERT(s != NULL, "arch-gone: pty_open");
     537           37 :     if (!s) return;
     538           37 :     ASSERT(pty_run(s, args) == 0, "arch-gone: pty_run");
     539           36 :     ASSERT(navigate_to_inbox(s) == 0, "arch-gone: navigate_to_inbox");
     540              : 
     541           36 :     pty_send_str(s, "a");
     542           36 :     pty_settle(s, SETTLE_MS);
     543           36 :     pty_send_str(s, "U");
     544           36 :     pty_wait_for(s, GMAIL_BOT_MSG, WAIT_MS);  /* wait for list to reload */
     545           36 :     pty_settle(s, SETTLE_MS);
     546              : 
     547           36 :     ASSERT(!pty_screen_contains(s, GMAIL_TOP_MSG),
     548              :            "arch-gone: " GMAIL_TOP_MSG " absent from INBOX after R");
     549           36 :     ASSERT(pty_screen_contains(s, GMAIL_BOT_MSG),
     550              :            "arch-gone: other messages still present");
     551              : 
     552           36 :     pty_send_key(s, PTY_KEY_ESC);
     553           36 :     pty_close(s);
     554              : }
     555              : 
     556              : /* ══════════════════════════════════════════════════════════════════════
     557              :  *  TEST: 'a' key → archived message appears in Archive view
     558              :  * ══════════════════════════════════════════════════════════════════════ */
     559              : 
     560              : /**
     561              :  * After archiving, navigate to Archive (_nolabel) and verify the message is there.
     562              :  */
     563           36 : static void test_archive_message_in_archive_view(void) {
     564           36 :     const char *args[] = { g_tui_bin, NULL };
     565           36 :     PtySession *s = pty_open(COLS, ROWS);
     566           36 :     ASSERT(s != NULL, "arch-view: pty_open");
     567           36 :     if (!s) return;
     568           36 :     ASSERT(pty_run(s, args) == 0, "arch-view: pty_run");
     569           35 :     ASSERT(navigate_to_inbox(s) == 0, "arch-view: navigate_to_inbox");
     570              : 
     571              :     /* Archive top message */
     572           35 :     pty_send_str(s, "a");
     573           35 :     pty_settle(s, SETTLE_MS);
     574              :     /* Press 'R' so the inbox index is refreshed and the row is removed */
     575           35 :     pty_send_str(s, "U");
     576           35 :     pty_settle(s, SETTLE_MS);
     577              : 
     578              :     /* Go back to label browser */
     579           35 :     pty_send_key(s, PTY_KEY_BACK);
     580           35 :     if (pty_wait_for(s, "Labels", WAIT_MS) != 0) {
     581            0 :         printf("  [FAIL] arch-view: timed out waiting for Labels screen\n");
     582            0 :         pty_close(s); g_tests_failed++; return;
     583              :     }
     584           35 :     pty_settle(s, SETTLE_MS);
     585              : 
     586              :     /* Navigate to Archive (End → Trash, Up×2 → Archive) */
     587           35 :     navigate_labels_to_archive(s);
     588           35 :     pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
     589           35 :     pty_settle(s, SETTLE_MS);
     590              : 
     591           35 :     ASSERT(pty_screen_contains(s, GMAIL_TOP_MSG),
     592              :            "arch-view: " GMAIL_TOP_MSG " appears in Archive view");
     593              : 
     594           35 :     pty_send_key(s, PTY_KEY_ESC);
     595           35 :     pty_close(s);
     596              : }
     597              : 
     598              : /* ══════════════════════════════════════════════════════════════════════
     599              :  *  TEST: label picker → unarchive (add INBOX to archived message)
     600              :  * ══════════════════════════════════════════════════════════════════════ */
     601              : 
     602              : /**
     603              :  * After archiving a message, open the Archive view, use the label picker
     604              :  * to add INBOX, and verify the message is removed from Archive on refresh.
     605              :  */
     606           35 : static void test_label_picker_unarchive(void) {
     607           35 :     const char *args[] = { g_tui_bin, NULL };
     608           35 :     PtySession *s = pty_open(COLS, ROWS);
     609           35 :     ASSERT(s != NULL, "unarchive: pty_open");
     610           35 :     if (!s) return;
     611           35 :     ASSERT(pty_run(s, args) == 0, "unarchive: pty_run");
     612           34 :     ASSERT(navigate_to_inbox(s) == 0, "unarchive: navigate_to_inbox");
     613              : 
     614              :     /* Archive Message 5 */
     615           34 :     pty_send_str(s, "a");
     616           34 :     pty_settle(s, SETTLE_MS);
     617           34 :     pty_send_str(s, "U");
     618           34 :     pty_settle(s, SETTLE_MS);
     619              : 
     620              :     /* Back to labels */
     621           34 :     pty_send_key(s, PTY_KEY_BACK);
     622           34 :     pty_wait_for(s, "Labels", WAIT_MS);
     623           34 :     pty_settle(s, SETTLE_MS);
     624              : 
     625              :     /* Navigate to Archive */
     626           34 :     navigate_labels_to_archive(s);
     627           34 :     pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
     628           34 :     pty_settle(s, SETTLE_MS);
     629              : 
     630           34 :     ASSERT(pty_screen_contains(s, GMAIL_TOP_MSG),
     631              :            "unarchive: " GMAIL_TOP_MSG " in Archive view before restore");
     632              : 
     633              :     /* Open label picker, add INBOX (INBOX is 2nd item: UNREAD is first, Down once) */
     634           34 :     pty_send_str(s, "t");
     635           34 :     pty_wait_for(s, "Toggle Labels", WAIT_MS);
     636           34 :     pty_settle(s, SETTLE_MS / 2);
     637           34 :     pty_send_key(s, PTY_KEY_DOWN);  /* cursor → INBOX */
     638           34 :     pty_send_key(s, PTY_KEY_ENTER); /* toggle INBOX on */
     639           34 :     pty_settle(s, SETTLE_MS / 2);
     640           34 :     pty_send_key(s, PTY_KEY_ESC);   /* close picker */
     641           34 :     pty_settle(s, SETTLE_MS);
     642              : 
     643              :     /* Refresh Archive view — message must be gone (now in INBOX) */
     644           34 :     pty_send_str(s, "U");
     645           34 :     pty_settle(s, WAIT_MS / 4);
     646           34 :     pty_settle(s, SETTLE_MS);
     647              : 
     648           34 :     ASSERT(!pty_screen_contains(s, GMAIL_TOP_MSG),
     649              :            "unarchive: " GMAIL_TOP_MSG " gone from Archive after INBOX added");
     650              : 
     651           34 :     pty_send_key(s, PTY_KEY_ESC);
     652           34 :     pty_close(s);
     653              : }
     654              : 
     655              : /* ══════════════════════════════════════════════════════════════════════
     656              :  *  TEST: Trash statusbar shows u=restore hint
     657              :  * ══════════════════════════════════════════════════════════════════════ */
     658              : 
     659              : /**
     660              :  * When viewing the Trash folder, the status bar must show "u=restore" to
     661              :  * guide the user.  This test works even with an empty Trash.
     662              :  */
     663           34 : static void test_trash_statusbar_restore_hint(void) {
     664           34 :     const char *args[] = { g_tui_bin, NULL };
     665           34 :     PtySession *s = pty_open(COLS, ROWS);
     666           34 :     ASSERT(s != NULL, "trash-sb: pty_open");
     667           34 :     if (!s) return;
     668           34 :     ASSERT(pty_run(s, args) == 0, "trash-sb: pty_run");
     669           33 :     ASSERT(navigate_to_trash(s) == 0, "trash-sb: navigate_to_trash");
     670              : 
     671              :     /* The status bar (last row) must contain "u=restore" */
     672           33 :     ASSERT_SCREEN_CONTAINS(s, "u=restore");
     673              : 
     674           33 :     pty_send_key(s, PTY_KEY_ESC);
     675           33 :     pty_close(s);
     676              : }
     677              : 
     678              : /* ══════════════════════════════════════════════════════════════════════
     679              :  *  TEST: label browser — Up on the first selectable row is a no-op
     680              :  * ══════════════════════════════════════════════════════════════════════ */
     681              : 
     682              : /**
     683              :  * Row 0 of the label browser is the "Tags / Flags" section header, and row 1
     684              :  * is the first selectable entry ("Unread").  Header rows are drawn without the
     685              :  * selection highlight, so a cursor parked on one is invisible to the user.
     686              :  *
     687              :  * Holding Up on "Unread" (key auto-repeat) must therefore leave the cursor on
     688              :  * "Unread" — the status bar keeps reporting position 2 — instead of walking
     689              :  * onto the header at position 1.
     690              :  */
     691           33 : static void test_labels_up_at_top_is_noop(void) {
     692           33 :     const char *args[] = { g_tui_bin, NULL };
     693           33 :     PtySession *s = pty_open(COLS, ROWS);
     694           33 :     ASSERT(s != NULL, "lbl-up: pty_open");
     695           33 :     if (!s) return;
     696           33 :     ASSERT(pty_run(s, args) == 0, "lbl-up: pty_run");
     697              : 
     698           32 :     ASSERT(pty_wait_for(s, "Email Accounts", WAIT_MS) == 0, "lbl-up: accounts screen");
     699           32 :     pty_settle(s, SETTLE_MS);
     700           32 :     pty_send_key(s, PTY_KEY_ENTER);
     701           32 :     ASSERT(pty_wait_for(s, "Labels", WAIT_MS) == 0, "lbl-up: label browser");
     702           32 :     pty_settle(s, SETTLE_MS);
     703              : 
     704              :     /* Go to the first selectable row, then keep pressing Up (auto-repeat). */
     705           32 :     pty_send_key(s, PTY_KEY_HOME);
     706           32 :     pty_settle(s, SETTLE_MS / 2);
     707          288 :     for (int i = 0; i < 8; i++) pty_send_key(s, PTY_KEY_UP);
     708           32 :     pty_settle(s, SETTLE_MS);
     709              : 
     710              :     /* Status bar shows a 1-based cursor position: "[2/N]" = first selectable
     711              :      * row.  "[1/N]" would mean the cursor sits on the section header. */
     712           32 :     ASSERT_SCREEN_CONTAINS(s, "[2/");
     713           32 :     ASSERT_SCREEN_NOT_CONTAINS(s, "[1/");
     714              : 
     715           32 :     pty_send_key(s, PTY_KEY_ESC);
     716           32 :     pty_close(s);
     717              : }
     718              : 
     719              : /* ══════════════════════════════════════════════════════════════════════
     720              :  *  TEST: 'D' key → row has red marker 'D' (immediate feedback)
     721              :  * ══════════════════════════════════════════════════════════════════════ */
     722              : 
     723              : /** After pressing 'D' (trash), cursor row must show red 'D' pending marker immediately. */
     724           32 : static void test_D_key_row_has_strikethrough(void) {
     725           32 :     const char *args[] = { g_tui_bin, NULL };
     726           32 :     PtySession *s = pty_open(COLS, ROWS);
     727           32 :     ASSERT(s != NULL, "D-strike: pty_open");
     728           32 :     if (!s) return;
     729           32 :     ASSERT(pty_run(s, args) == 0, "D-strike: pty_run");
     730           31 :     ASSERT(navigate_to_inbox(s) == 0, "D-strike: navigate_to_inbox");
     731              : 
     732           31 :     int row_before = find_row(s, GMAIL_TOP_MSG);
     733           31 :     ASSERT(row_before >= 0, "D-strike: " GMAIL_TOP_MSG " present before D");
     734           31 :     if (row_before >= 0)
     735           31 :         ASSERT(strcmp(pty_cell_text(s, row_before, 0), " ") == 0,
     736              :                "D-strike: no pending marker before D");
     737              : 
     738           31 :     pty_send_str(s, "D");
     739           31 :     pty_settle(s, SETTLE_MS);
     740              : 
     741           31 :     int row_after = find_row(s, GMAIL_TOP_MSG);
     742           31 :     ASSERT(row_after >= 0, "D-strike: " GMAIL_TOP_MSG " still visible after D");
     743           31 :     if (row_after >= 0)
     744           31 :         ASSERT(strcmp(pty_cell_text(s, row_after, 0), "D") == 0,
     745              :                "D-strike: 'D' pending marker set after D");
     746              : 
     747           31 :     pty_send_key(s, PTY_KEY_ESC);
     748           31 :     pty_close(s);
     749              : }
     750              : 
     751              : /* ══════════════════════════════════════════════════════════════════════
     752              :  *  TEST: 'u' key → row has green marker '↩' (immediate feedback)
     753              :  * ══════════════════════════════════════════════════════════════════════ */
     754              : 
     755              : /**
     756              :  * After pressing 'u' (restore from trash) in the Trash view, the row must
     757              :  * remain visible with a green '↩' pending marker at column 0 until 'R'.
     758              :  */
     759           31 : static void test_u_key_row_has_strikethrough(void) {
     760              :     /* First, trash a message so the Trash view has something */
     761           31 :     const char *args[] = { g_tui_bin, NULL };
     762              : 
     763              :     /* Session 1: trash Message 5 */
     764              :     {
     765           31 :         PtySession *s = pty_open(COLS, ROWS);
     766           31 :         ASSERT(s != NULL, "u-strike: pty_open (s1)");
     767           31 :         if (!s) return;
     768           31 :         ASSERT(pty_run(s, args) == 0, "u-strike: pty_run (s1)");
     769           30 :         ASSERT(navigate_to_inbox(s) == 0, "u-strike: navigate_to_inbox");
     770           30 :         pty_send_str(s, "D");   /* trash Message 5 */
     771           30 :         pty_settle(s, SETTLE_MS);
     772           30 :         pty_send_key(s, PTY_KEY_ESC);
     773           30 :         pty_close(s);
     774              :     }
     775              : 
     776              :     /* Session 2: open Trash, verify Message 5 there, press 'u', check pending marker */
     777              :     {
     778           30 :         PtySession *s = pty_open(COLS, ROWS);
     779           30 :         ASSERT(s != NULL, "u-strike: pty_open (s2)");
     780           30 :         if (!s) return;
     781           30 :         ASSERT(pty_run(s, args) == 0, "u-strike: pty_run (s2)");
     782           29 :         ASSERT(navigate_to_trash(s) == 0, "u-strike: navigate_to_trash");
     783              : 
     784           29 :         pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
     785           29 :         pty_settle(s, SETTLE_MS);
     786              : 
     787           29 :         int row_before = find_row(s, GMAIL_TOP_MSG);
     788           29 :         ASSERT(row_before >= 0, "u-strike: " GMAIL_TOP_MSG " in Trash before u");
     789           29 :         if (row_before >= 0)
     790           29 :             ASSERT(strcmp(pty_cell_text(s, row_before, 0), " ") == 0,
     791              :                    "u-strike: no pending marker before u");
     792              : 
     793           29 :         pty_send_str(s, "u");   /* restore from trash */
     794           29 :         pty_settle(s, SETTLE_MS);
     795              : 
     796           29 :         int row_after = find_row(s, GMAIL_TOP_MSG);
     797           29 :         ASSERT(row_after >= 0, "u-strike: " GMAIL_TOP_MSG " still visible after u");
     798           29 :         if (row_after >= 0)
     799           29 :             ASSERT(strcmp(pty_cell_text(s, row_after, 0), "u") == 0,
     800              :                    "u-strike: 'u' pending marker at col 0 after u (green restore marker)");
     801              : 
     802           29 :         pty_send_key(s, PTY_KEY_ESC);
     803           29 :         pty_close(s);
     804              :     }
     805              : }
     806              : 
     807              : /* ══════════════════════════════════════════════════════════════════════
     808              :  *  TEST: 'u' key → message gone from Trash after refresh
     809              :  * ══════════════════════════════════════════════════════════════════════ */
     810              : 
     811              : /** After 'u' + 'R', the restored message must no longer appear in Trash. */
     812           29 : static void test_u_key_row_gone_after_refresh(void) {
     813           29 :     const char *args[] = { g_tui_bin, NULL };
     814              : 
     815              :     /* Session 1: trash Message 5 */
     816              :     {
     817           29 :         PtySession *s = pty_open(COLS, ROWS);
     818           29 :         ASSERT(s != NULL, "u-gone: pty_open (s1)");
     819           29 :         if (!s) return;
     820           29 :         ASSERT(pty_run(s, args) == 0, "u-gone: pty_run (s1)");
     821           28 :         ASSERT(navigate_to_inbox(s) == 0, "u-gone: navigate_to_inbox");
     822           28 :         pty_send_str(s, "D");
     823           28 :         pty_settle(s, SETTLE_MS);
     824           28 :         pty_send_key(s, PTY_KEY_ESC);
     825           28 :         pty_close(s);
     826              :     }
     827              : 
     828              :     /* Session 2: open Trash, press 'u', press 'R', Message 5 must be gone */
     829              :     {
     830           28 :         PtySession *s = pty_open(COLS, ROWS);
     831           28 :         ASSERT(s != NULL, "u-gone: pty_open (s2)");
     832           28 :         if (!s) return;
     833           28 :         ASSERT(pty_run(s, args) == 0, "u-gone: pty_run (s2)");
     834           27 :         ASSERT(navigate_to_trash(s) == 0, "u-gone: navigate_to_trash");
     835              : 
     836           27 :         pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
     837           27 :         pty_settle(s, SETTLE_MS);
     838              : 
     839           27 :         pty_send_str(s, "u");
     840           27 :         pty_settle(s, SETTLE_MS);
     841           27 :         pty_send_str(s, "U");
     842           27 :         pty_settle(s, WAIT_MS / 4);
     843           27 :         pty_settle(s, SETTLE_MS);
     844              : 
     845           27 :         ASSERT(!pty_screen_contains(s, GMAIL_TOP_MSG),
     846              :                "u-gone: " GMAIL_TOP_MSG " absent from Trash after u+R");
     847              : 
     848           27 :         pty_send_key(s, PTY_KEY_ESC);
     849           27 :         pty_close(s);
     850              :     }
     851              : }
     852              : 
     853              : /* ══════════════════════════════════════════════════════════════════════
     854              :  *  TEST: 'd' key → message absent after explicit refresh
     855              :  * ══════════════════════════════════════════════════════════════════════ */
     856              : 
     857              : /**
     858              :  * After pressing 'd' on the cursor row and then 'R' (refresh), the
     859              :  * message should no longer appear in the INBOX view because its INBOX
     860              :  * label was removed from the local index.  'R' re-reads the list from
     861              :  * INBOX.idx which no longer contains the UID.
     862              :  *
     863              :  * This test navigates to the bottom-most entry (GMAIL_BOT_MSG = "Message 1")
     864              :  * using the End key before pressing 'd', so the lower boundary of the list
     865              :  * is exercised independently of any prior test state.
     866              :  */
     867           40 : static void test_d_key_row_gone_after_refresh(void) {
     868           40 :     const char *args[] = { g_tui_bin, NULL };
     869           40 :     PtySession *s = pty_open(COLS, ROWS);
     870           40 :     ASSERT(s != NULL, "d-gone: pty_open");
     871           40 :     if (!s) return;
     872           40 :     ASSERT(pty_run(s, args) == 0, "d-gone: pty_run");
     873              : 
     874           39 :     ASSERT(navigate_to_inbox(s) == 0, "d-gone: navigate_to_inbox");
     875              : 
     876              :     /* Move cursor to the bottom of the list (GMAIL_BOT_MSG = "Message 1").
     877              :      * The End key sets cursor = show_count - 1. */
     878           39 :     pty_send_key(s, PTY_KEY_END);
     879           39 :     ASSERT(pty_wait_for(s, GMAIL_BOT_MSG, WAIT_MS) >= 0 ||
     880              :            find_row(s, GMAIL_BOT_MSG) >= 0,
     881              :            "d-gone: cursor moved to " GMAIL_BOT_MSG);
     882           39 :     pty_settle(s, SETTLE_MS);
     883              : 
     884              :     /* Verify cursor is on GMAIL_BOT_MSG */
     885           39 :     ASSERT(find_row(s, GMAIL_BOT_MSG) >= 0,
     886              :            "d-gone: " GMAIL_BOT_MSG " visible before d");
     887              : 
     888              :     /* Remove INBOX label from the cursor row (GMAIL_BOT_MSG) */
     889           39 :     pty_send_str(s, "d");
     890           39 :     pty_settle(s, SETTLE_MS);
     891              : 
     892              :     /* 'R' re-reads the list from INBOX.idx — the UID is no longer there */
     893           39 :     pty_send_str(s, "U");
     894              : 
     895              :     /* Wait for the refreshed list to appear (another message still present) */
     896           39 :     pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
     897           39 :     pty_settle(s, SETTLE_MS);
     898              : 
     899              :     /* GMAIL_BOT_MSG was in INBOX (the current label).  After removing the
     900              :      * INBOX label and refreshing, it must no longer appear in this view. */
     901           39 :     ASSERT(!pty_screen_contains(s, GMAIL_BOT_MSG),
     902              :            "d-gone: " GMAIL_BOT_MSG " absent from view after refresh");
     903              : 
     904           39 :     pty_send_key(s, PTY_KEY_ESC);
     905           39 :     pty_close(s);
     906              : }
     907              : 
     908              : /* ══════════════════════════════════════════════════════════════════════
     909              :  *  TEST: 'd' key → yellow foreground pending marker (US-43 criterion 1)
     910              :  * ══════════════════════════════════════════════════════════════════════ */
     911              : 
     912              : /**
     913              :  * Label removal ('d') must render with a yellow foreground 'd' marker,
     914              :  * not red, so it is visually distinct from destructive trash ('D').
     915              :  */
     916           27 : static void test_d_row_yellow_fg(void) {
     917           27 :     const char *args[] = { g_tui_bin, NULL };
     918           27 :     PtySession *s = pty_open(COLS, ROWS);
     919           27 :     ASSERT(s != NULL, "d-yellow: pty_open");
     920           27 :     if (!s) return;
     921           27 :     ASSERT(pty_run(s, args) == 0, "d-yellow: pty_run");
     922           26 :     ASSERT(navigate_to_inbox(s) == 0, "d-yellow: navigate_to_inbox");
     923              : 
     924           26 :     int row = find_row(s, GMAIL_TOP_MSG);
     925           26 :     ASSERT(row >= 0, "d-yellow: " GMAIL_TOP_MSG " present");
     926              : 
     927           26 :     pty_send_str(s, "d");
     928           26 :     pty_settle(s, SETTLE_MS);
     929              : 
     930           26 :     row = find_row(s, GMAIL_TOP_MSG);
     931           26 :     ASSERT(row >= 0, "d-yellow: row still visible after d");
     932           26 :     if (row >= 0) {
     933           26 :         int fg = pty_cell_fg(s, row, 0);
     934           26 :         ASSERT(strcmp(pty_cell_text(s, row, 0), "d") == 0, "d-yellow: 'd' marker set");
     935           26 :         ASSERT(fg == PTY_FG_YELLOW, "d-yellow: fg is yellow (33), not red");
     936              :     }
     937              : 
     938           26 :     pty_send_key(s, PTY_KEY_ESC);
     939           26 :     pty_close(s);
     940              : }
     941              : 
     942              : /* ══════════════════════════════════════════════════════════════════════
     943              :  *  TEST: 'D' key → red foreground pending marker (US-43 criterion 2)
     944              :  * ══════════════════════════════════════════════════════════════════════ */
     945              : 
     946              : /**
     947              :  * Trash ('D') must render with a red foreground 'D' marker to distinguish
     948              :  * it from the yellow label-removal feedback.
     949              :  */
     950           26 : static void test_D_row_red_fg(void) {
     951           26 :     const char *args[] = { g_tui_bin, NULL };
     952           26 :     PtySession *s = pty_open(COLS, ROWS);
     953           26 :     ASSERT(s != NULL, "D-red: pty_open");
     954           26 :     if (!s) return;
     955           26 :     ASSERT(pty_run(s, args) == 0, "D-red: pty_run");
     956           25 :     ASSERT(navigate_to_inbox(s) == 0, "D-red: navigate_to_inbox");
     957              : 
     958           25 :     int row = find_row(s, GMAIL_TOP_MSG);
     959           25 :     ASSERT(row >= 0, "D-red: " GMAIL_TOP_MSG " present");
     960              : 
     961           25 :     pty_send_str(s, "D");
     962           25 :     pty_settle(s, SETTLE_MS);
     963              : 
     964           25 :     row = find_row(s, GMAIL_TOP_MSG);
     965           25 :     ASSERT(row >= 0, "D-red: row still visible after D");
     966           25 :     if (row >= 0) {
     967           25 :         int fg = pty_cell_fg(s, row, 0);
     968           25 :         ASSERT(strcmp(pty_cell_text(s, row, 0), "D") == 0, "D-red: 'D' marker set");
     969           25 :         ASSERT(fg == PTY_FG_RED, "D-red: fg is red (31)");
     970              :     }
     971              : 
     972           25 :     pty_send_key(s, PTY_KEY_ESC);
     973           25 :     pty_close(s);
     974              : }
     975              : 
     976              : /* ══════════════════════════════════════════════════════════════════════
     977              :  *  TEST: 'a' key (archive from INBOX) → yellow foreground pending marker
     978              :  * ══════════════════════════════════════════════════════════════════════ */
     979              : 
     980              : /**
     981              :  * Archiving ('a') must render with a yellow foreground 'd' marker —
     982              :  * same as label removal ('d'), distinct from red destructive trash ('D').
     983              :  */
     984           25 : static void test_a_row_yellow_fg(void) {
     985           25 :     const char *args[] = { g_tui_bin, NULL };
     986           25 :     PtySession *s = pty_open(COLS, ROWS);
     987           25 :     ASSERT(s != NULL, "a-yellow: pty_open");
     988           25 :     if (!s) return;
     989           25 :     ASSERT(pty_run(s, args) == 0, "a-yellow: pty_run");
     990           24 :     ASSERT(navigate_to_inbox(s) == 0, "a-yellow: navigate_to_inbox");
     991              : 
     992           24 :     int row = find_row(s, GMAIL_TOP_MSG);
     993           24 :     ASSERT(row >= 0, "a-yellow: " GMAIL_TOP_MSG " present");
     994              : 
     995           24 :     pty_send_str(s, "a");
     996           24 :     pty_settle(s, SETTLE_MS);
     997              : 
     998           24 :     row = find_row(s, GMAIL_TOP_MSG);
     999           24 :     ASSERT(row >= 0, "a-yellow: row still visible after a");
    1000           24 :     if (row >= 0) {
    1001           24 :         int fg = pty_cell_fg(s, row, 0);
    1002           24 :         ASSERT(strcmp(pty_cell_text(s, row, 0), "d") == 0, "a-yellow: 'd' marker set");
    1003           24 :         ASSERT(fg == PTY_FG_YELLOW, "a-yellow: fg is yellow (33), not red");
    1004              :     }
    1005              : 
    1006           24 :     pty_send_key(s, PTY_KEY_ESC);
    1007           24 :     pty_close(s);
    1008              : }
    1009              : 
    1010              : /* ══════════════════════════════════════════════════════════════════════
    1011              :  *  TEST: 'a' in Archive view → no visual change (US-43 criterion 3)
    1012              :  * ══════════════════════════════════════════════════════════════════════ */
    1013              : 
    1014              : /**
    1015              :  * Pressing 'a' while already in the Archive (_nolabel) view is a no-op:
    1016              :  * the message is already archived, so no pending marker must appear.
    1017              :  */
    1018           24 : static void test_a_in_archive_no_strikethrough(void) {
    1019           24 :     const char *args[] = { g_tui_bin, NULL };
    1020              : 
    1021              :     /* Session 1: archive a message so Archive view has content */
    1022              :     {
    1023           24 :         PtySession *s = pty_open(COLS, ROWS);
    1024           24 :         ASSERT(s != NULL, "a-noop: pty_open s1");
    1025           24 :         if (!s) return;
    1026           24 :         ASSERT(pty_run(s, args) == 0, "a-noop: pty_run s1");
    1027           23 :         ASSERT(navigate_to_inbox(s) == 0, "a-noop: navigate_to_inbox");
    1028           23 :         pty_send_str(s, "a");   /* archive top message */
    1029           23 :         pty_settle(s, SETTLE_MS);
    1030           23 :         pty_send_key(s, PTY_KEY_ESC);
    1031           23 :         pty_close(s);
    1032              :     }
    1033              : 
    1034              :     /* Session 2: open Archive, press 'a' → no pending marker */
    1035              :     {
    1036           23 :         PtySession *s = pty_open(COLS, ROWS);
    1037           23 :         ASSERT(s != NULL, "a-noop: pty_open s2");
    1038           23 :         if (!s) return;
    1039           23 :         ASSERT(pty_run(s, args) == 0, "a-noop: pty_run s2");
    1040              : 
    1041              :         /* Navigate to Archive view */
    1042           22 :         pty_wait_for(s, "Accounts", WAIT_MS);
    1043           22 :         pty_send_key(s, PTY_KEY_ENTER); /* into Labels */
    1044           22 :         pty_wait_for(s, "Labels", WAIT_MS);
    1045           22 :         pty_settle(s, SETTLE_MS / 2);
    1046           22 :         navigate_labels_to_archive(s);
    1047           22 :         pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
    1048           22 :         pty_settle(s, SETTLE_MS);
    1049              : 
    1050           22 :         int row = find_row(s, GMAIL_TOP_MSG);
    1051           22 :         ASSERT(row >= 0, "a-noop: " GMAIL_TOP_MSG " visible in Archive");
    1052              : 
    1053              :         /* Press 'a' — message is already archived, must be a no-op */
    1054           22 :         pty_send_str(s, "a");
    1055           22 :         pty_settle(s, SETTLE_MS);
    1056              : 
    1057           22 :         row = find_row(s, GMAIL_TOP_MSG);
    1058           22 :         ASSERT(row >= 0, "a-noop: row still present after a");
    1059           22 :         if (row >= 0) {
    1060           22 :             ASSERT(strcmp(pty_cell_text(s, row, 0), " ") == 0,
    1061              :                    "a-noop: no pending marker in Archive view (already archived)");
    1062              :         }
    1063              : 
    1064           22 :         pty_send_key(s, PTY_KEY_ESC);
    1065           22 :         pty_close(s);
    1066              :     }
    1067              : }
    1068              : 
    1069              : /* ══════════════════════════════════════════════════════════════════════
    1070              :  *  TEST: label picker unarchive → green '↩' pending marker (US-43 criterion 5)
    1071              :  * ══════════════════════════════════════════════════════════════════════ */
    1072              : 
    1073              : /**
    1074              :  * When a real label is added via the label picker in Archive view (which
    1075              :  * removes the message from _nolabel), the row must show a green '↩' pending
    1076              :  * marker — restorative, not destructive.
    1077              :  */
    1078           22 : static void test_label_picker_unarchive_green_fg(void) {
    1079           22 :     const char *args[] = { g_tui_bin, NULL };
    1080              : 
    1081              :     /* Session 1: archive the top message */
    1082              :     {
    1083           22 :         PtySession *s = pty_open(COLS, ROWS);
    1084           22 :         ASSERT(s != NULL, "unarch-green: pty_open s1");
    1085           22 :         if (!s) return;
    1086           22 :         ASSERT(pty_run(s, args) == 0, "unarch-green: pty_run s1");
    1087           21 :         ASSERT(navigate_to_inbox(s) == 0, "unarch-green: nav inbox");
    1088           21 :         pty_send_str(s, "a");
    1089           21 :         pty_settle(s, SETTLE_MS);
    1090           21 :         pty_send_key(s, PTY_KEY_ESC);
    1091           21 :         pty_close(s);
    1092              :     }
    1093              : 
    1094              :     /* Session 2: navigate to Archive, open label picker, add INBOX */
    1095              :     {
    1096           21 :         PtySession *s = pty_open(COLS, ROWS);
    1097           21 :         ASSERT(s != NULL, "unarch-green: pty_open s2");
    1098           21 :         if (!s) return;
    1099           21 :         ASSERT(pty_run(s, args) == 0, "unarch-green: pty_run s2");
    1100              : 
    1101           20 :         pty_wait_for(s, "Accounts", WAIT_MS);
    1102           20 :         pty_send_key(s, PTY_KEY_ENTER);
    1103           20 :         pty_wait_for(s, "Labels", WAIT_MS);
    1104           20 :         pty_settle(s, SETTLE_MS / 2);
    1105           20 :         navigate_labels_to_archive(s);
    1106           20 :         pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
    1107           20 :         pty_settle(s, SETTLE_MS);
    1108              : 
    1109           20 :         int row = find_row(s, GMAIL_TOP_MSG);
    1110           20 :         ASSERT(row >= 0, "unarch-green: " GMAIL_TOP_MSG " in Archive");
    1111              : 
    1112              :         /* Open label picker, Down → INBOX, Enter to toggle on, ESC close */
    1113           20 :         pty_send_str(s, "t");
    1114           20 :         pty_wait_for(s, "Toggle Labels", WAIT_MS);
    1115           20 :         pty_settle(s, SETTLE_MS / 2);
    1116           20 :         pty_send_key(s, PTY_KEY_DOWN);   /* UNREAD → INBOX */
    1117           20 :         pty_send_key(s, PTY_KEY_ENTER);  /* add INBOX */
    1118           20 :         pty_settle(s, SETTLE_MS / 2);
    1119           20 :         pty_send_key(s, PTY_KEY_ESC);    /* close picker */
    1120           20 :         pty_settle(s, SETTLE_MS);
    1121              : 
    1122              :         /* Row must now show green 'u' pending marker (message left _nolabel) */
    1123           20 :         row = find_row(s, GMAIL_TOP_MSG);
    1124           20 :         ASSERT(row >= 0, "unarch-green: row still visible after picker");
    1125           20 :         if (row >= 0) {
    1126           20 :             int fg = pty_cell_fg(s, row, 0);
    1127           20 :             ASSERT(strcmp(pty_cell_text(s, row, 0), "u") == 0,
    1128              :                    "unarch-green: 'u' pending marker at col 0");
    1129           20 :             ASSERT(fg == PTY_FG_GREEN, "unarch-green: fg is green (32)");
    1130              :         }
    1131              : 
    1132           20 :         pty_send_key(s, PTY_KEY_ESC);
    1133           20 :         pty_close(s);
    1134              :     }
    1135              : }
    1136              : 
    1137              : /* ══════════════════════════════════════════════════════════════════════
    1138              :  *  TEST: label picker adds INBOX to trashed message → green 'u' marker
    1139              :  * ══════════════════════════════════════════════════════════════════════ */
    1140              : 
    1141              : /**
    1142              :  * After 'D' (trash), open label picker via 't', navigate to INBOX and add it.
    1143              :  * The row must immediately show the green 'u' pending marker (restored from Trash).
    1144              :  */
    1145           20 : static void test_label_picker_untrash_green_fg(void) {
    1146           20 :     const char *args[] = { g_tui_bin, NULL };
    1147              : 
    1148              :     /* Session 1: trash the top message */
    1149              :     {
    1150           20 :         PtySession *s = pty_open(COLS, ROWS);
    1151           20 :         ASSERT(s != NULL, "untrash-green: pty_open s1");
    1152           20 :         if (!s) return;
    1153           20 :         ASSERT(pty_run(s, args) == 0, "untrash-green: pty_run s1");
    1154           19 :         ASSERT(navigate_to_inbox(s) == 0, "untrash-green: nav inbox");
    1155           19 :         pty_send_str(s, "D");   /* trash Message 5 */
    1156           19 :         pty_settle(s, SETTLE_MS);
    1157           19 :         pty_send_key(s, PTY_KEY_ESC);
    1158           19 :         pty_close(s);
    1159              :     }
    1160              : 
    1161              :     /* Session 2: open Trash, open label picker, add INBOX */
    1162              :     {
    1163           19 :         PtySession *s = pty_open(COLS, ROWS);
    1164           19 :         ASSERT(s != NULL, "untrash-green: pty_open s2");
    1165           19 :         if (!s) return;
    1166           19 :         ASSERT(pty_run(s, args) == 0, "untrash-green: pty_run s2");
    1167           18 :         ASSERT(navigate_to_trash(s) == 0, "untrash-green: navigate_to_trash");
    1168           18 :         pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
    1169           18 :         pty_settle(s, SETTLE_MS);
    1170              : 
    1171           18 :         int row = find_row(s, GMAIL_TOP_MSG);
    1172           18 :         ASSERT(row >= 0, "untrash-green: " GMAIL_TOP_MSG " in Trash");
    1173              : 
    1174              :         /* Open label picker, Down → INBOX, Enter to toggle on, ESC close */
    1175           18 :         pty_send_str(s, "t");
    1176           18 :         pty_wait_for(s, "Toggle Labels", WAIT_MS);
    1177           18 :         pty_settle(s, SETTLE_MS / 2);
    1178           18 :         pty_send_key(s, PTY_KEY_DOWN);   /* UNREAD → INBOX */
    1179           18 :         pty_send_key(s, PTY_KEY_ENTER);  /* add INBOX */
    1180           18 :         pty_settle(s, SETTLE_MS / 2);
    1181           18 :         pty_send_key(s, PTY_KEY_ESC);    /* close picker */
    1182           18 :         pty_settle(s, SETTLE_MS);
    1183              : 
    1184              :         /* Row must show green 'u' pending marker (message left _trash) */
    1185           18 :         row = find_row(s, GMAIL_TOP_MSG);
    1186           18 :         ASSERT(row >= 0, "untrash-green: row still visible after picker");
    1187           18 :         if (row >= 0) {
    1188           18 :             ASSERT(strcmp(pty_cell_text(s, row, 0), "u") == 0,
    1189              :                    "untrash-green: 'u' pending marker at col 0");
    1190           18 :             ASSERT(pty_cell_fg(s, row, 0) == PTY_FG_GREEN,
    1191              :                    "untrash-green: fg is green (32)");
    1192              :         }
    1193              : 
    1194           18 :         pty_send_key(s, PTY_KEY_ESC);
    1195           18 :         pty_close(s);
    1196              :     }
    1197              : }
    1198              : 
    1199              : /* ══════════════════════════════════════════════════════════════════════
    1200              :  *  TEST: 'D','D' undo — second D restores row to normal (no pending marker)
    1201              :  * ══════════════════════════════════════════════════════════════════════ */
    1202              : 
    1203              : /**
    1204              :  * In Trash view, pressing 'D' permanently deletes the message (US-83).
    1205              :  * The row gets the red pending marker and the feedback line reports
    1206              :  * "Permanently deleted".
    1207              :  *
    1208              :  * This replaced the earlier no-op ("Already in Trash") behaviour when
    1209              :  * D=trash was introduced for both IMAP and Gmail.
    1210              :  */
    1211           18 : static void test_D_permanent_delete_in_trash_view(void) {
    1212              :     /* Session 1: trash a message */
    1213           18 :     reset_and_sync();
    1214              :     {
    1215           18 :         const char *args[] = { g_tui_bin, NULL };
    1216           18 :         PtySession *s = pty_open(COLS, ROWS);
    1217           18 :         ASSERT(s != NULL, "D-noop: pty_open s1");
    1218           18 :         if (!s) return;
    1219           18 :         ASSERT(pty_run(s, args) == 0, "D-noop: pty_run s1");
    1220           17 :         ASSERT(navigate_to_inbox(s) == 0, "D-noop: nav inbox s1");
    1221           17 :         pty_send_str(s, "D");
    1222           17 :         pty_settle(s, SETTLE_MS);
    1223           17 :         pty_send_key(s, PTY_KEY_ESC);
    1224           17 :         pty_close(s);
    1225              :     }
    1226              :     /* Session 2: open Trash, press 'D' — permanently deletes */
    1227              :     {
    1228           17 :         const char *args[] = { g_tui_bin, NULL };
    1229           17 :         PtySession *s = pty_open(COLS, ROWS);
    1230           17 :         ASSERT(s != NULL, "D-perm: pty_open s2");
    1231           17 :         if (!s) return;
    1232           17 :         ASSERT(pty_run(s, args) == 0, "D-perm: pty_run s2");
    1233           16 :         ASSERT(navigate_to_trash(s) == 0, "D-perm: navigate_to_trash");
    1234           16 :         pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
    1235           16 :         pty_settle(s, SETTLE_MS);
    1236              : 
    1237           16 :         int row = find_row(s, GMAIL_TOP_MSG);
    1238           16 :         ASSERT(row >= 0, "D-perm: " GMAIL_TOP_MSG " visible in Trash");
    1239              : 
    1240           16 :         pty_send_str(s, "D");
    1241           16 :         pty_settle(s, SETTLE_MS);
    1242              : 
    1243              :         /* The row stays visible with a red 'D' marker until the next refresh,
    1244              :          * so the user can see what was deleted. */
    1245           16 :         row = find_row(s, GMAIL_TOP_MSG);
    1246           16 :         ASSERT(row >= 0, "D-perm: row still present after D");
    1247           16 :         if (row >= 0)
    1248           16 :             ASSERT(strcmp(pty_cell_text(s, row, 0), "D") == 0,
    1249              :                    "D-perm: 'D' pending marker in Trash view after D");
    1250           16 :         ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Permanently deleted"),
    1251              :                "D-perm: feedback shows 'Permanently deleted'");
    1252              : 
    1253           16 :         pty_send_key(s, PTY_KEY_ESC);
    1254           16 :         pty_close(s);
    1255              :     }
    1256              : }
    1257              : 
    1258              : /**
    1259              :  * First 'D' moves to Trash (red 'D' pending marker).
    1260              :  * Second 'D' undoes the trash: row returns to normal, no pending marker.
    1261              :  */
    1262           16 : static void test_D_D_undo_clears_strikethrough(void) {
    1263           16 :     const char *args[] = { g_tui_bin, NULL };
    1264           16 :     PtySession *s = pty_open(COLS, ROWS);
    1265           16 :     ASSERT(s != NULL, "D-undo: pty_open");
    1266           16 :     if (!s) return;
    1267           16 :     ASSERT(pty_run(s, args) == 0, "D-undo: pty_run");
    1268           15 :     ASSERT(navigate_to_inbox(s) == 0, "D-undo: navigate_to_inbox");
    1269              : 
    1270           15 :     int row = find_row(s, GMAIL_TOP_MSG);
    1271           15 :     ASSERT(row >= 0, "D-undo: " GMAIL_TOP_MSG " present");
    1272              : 
    1273              :     /* First D: trash */
    1274           15 :     pty_send_str(s, "D");
    1275           15 :     pty_settle(s, SETTLE_MS);
    1276           15 :     row = find_row(s, GMAIL_TOP_MSG);
    1277           15 :     ASSERT(row >= 0, "D-undo: row visible after first D");
    1278           15 :     if (row >= 0)
    1279           15 :         ASSERT(strcmp(pty_cell_text(s, row, 0), "D") == 0,
    1280              :                "D-undo: 'D' marker after first D");
    1281              : 
    1282              :     /* Second D: undo trash */
    1283           15 :     pty_send_str(s, "D");
    1284           15 :     pty_settle(s, SETTLE_MS);
    1285           15 :     row = find_row(s, GMAIL_TOP_MSG);
    1286           15 :     ASSERT(row >= 0, "D-undo: row still visible after undo");
    1287           15 :     if (row >= 0)
    1288           15 :         ASSERT(strcmp(pty_cell_text(s, row, 0), " ") == 0,
    1289              :                "D-undo: no pending marker after second D (undo)");
    1290              : 
    1291           15 :     pty_send_key(s, PTY_KEY_ESC);
    1292           15 :     pty_close(s);
    1293              : }
    1294              : 
    1295              : /**
    1296              :  * After 'D','D' (undo trash), feedback shows "Undo: INBOX restored".
    1297              :  */
    1298           15 : static void test_D_D_undo_feedback(void) {
    1299           15 :     reset_and_sync();
    1300           15 :     const char *args[] = { g_tui_bin, NULL };
    1301           15 :     PtySession *s = pty_open(COLS, ROWS);
    1302           15 :     ASSERT(s != NULL, "D-undo-fb: pty_open");
    1303           15 :     if (!s) return;
    1304           15 :     ASSERT(pty_run(s, args) == 0, "D-undo-fb: pty_run");
    1305           14 :     ASSERT(navigate_to_inbox(s) == 0, "D-undo-fb: navigate_to_inbox");
    1306           14 :     pty_send_str(s, "D");
    1307           14 :     pty_settle(s, SETTLE_MS);
    1308           14 :     pty_send_str(s, "D");   /* undo */
    1309           14 :     pty_settle(s, SETTLE_MS);
    1310           14 :     ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Undo: INBOX restored"),
    1311              :            "D-undo-fb: feedback shows 'Undo: INBOX restored'");
    1312           14 :     pty_send_key(s, PTY_KEY_ESC);
    1313           14 :     pty_close(s);
    1314              : }
    1315              : 
    1316              : /**
    1317              :  * After 'D' (trash) + 'd' (label remove), row shows yellow 'd' marker — not red 'D'.
    1318              :  * 'd' overrides the pending-remove state.
    1319              :  */
    1320           14 : static void test_D_then_d_shows_yellow(void) {
    1321           14 :     reset_and_sync();
    1322           14 :     const char *args[] = { g_tui_bin, NULL };
    1323           14 :     PtySession *s = pty_open(COLS, ROWS);
    1324           14 :     ASSERT(s != NULL, "D-d-yellow: pty_open");
    1325           14 :     if (!s) return;
    1326           14 :     ASSERT(pty_run(s, args) == 0, "D-d-yellow: pty_run");
    1327           13 :     ASSERT(navigate_to_inbox(s) == 0, "D-d-yellow: navigate_to_inbox");
    1328           13 :     pty_send_str(s, "D");   /* trash first */
    1329           13 :     pty_settle(s, SETTLE_MS);
    1330           13 :     pty_send_str(s, "d");   /* remove label: should override red 'D' → yellow 'd' */
    1331           13 :     pty_settle(s, SETTLE_MS);
    1332           13 :     int row = find_row(s, GMAIL_TOP_MSG);
    1333           13 :     ASSERT(row >= 0, "D-d-yellow: row still visible");
    1334           13 :     if (row >= 0) {
    1335           13 :         int fg = pty_cell_fg(s, row, 0);
    1336           13 :         ASSERT(strcmp(pty_cell_text(s, row, 0), "d") == 0,
    1337              :                "D-d-yellow: 'd' marker set after d overrides D");
    1338           13 :         ASSERT(fg == PTY_FG_YELLOW, "D-d-yellow: fg is yellow after d overrides D");
    1339              :     }
    1340           13 :     pty_send_key(s, PTY_KEY_ESC);
    1341           13 :     pty_close(s);
    1342              : }
    1343              : 
    1344              : /* ══════════════════════════════════════════════════════════════════════
    1345              :  *  FEEDBACK LINE TESTS  (US-44 / US-52-56)
    1346              :  *
    1347              :  *  The feedback line is the second-to-last terminal row (ROWS-2, 0-indexed).
    1348              :  *  Each test checks that pty_row_contains(s, ROWS-2, expected) returns 1.
    1349              :  * ══════════════════════════════════════════════════════════════════════ */
    1350              : 
    1351              : /** Feedback row must be blank (no feedback text) when the list is first opened. */
    1352           13 : static void test_feedback_empty_on_open(void) {
    1353           13 :     reset_and_sync();
    1354           13 :     const char *args[] = { g_tui_bin, NULL };
    1355           13 :     PtySession *s = pty_open(COLS, ROWS);
    1356           13 :     ASSERT(s != NULL, "fb-open: pty_open");
    1357           13 :     if (!s) return;
    1358           13 :     ASSERT(pty_run(s, args) == 0, "fb-open: pty_run");
    1359           12 :     ASSERT(navigate_to_inbox(s) == 0, "fb-open: navigate_to_inbox");
    1360              :     /* No key pressed yet — feedback row must not contain any op-result text */
    1361           12 :     ASSERT(!pty_row_contains(s, FEEDBACK_ROW, "Label removed"),
    1362              :            "fb-open: no 'Label removed' on feedback row at open");
    1363           12 :     ASSERT(!pty_row_contains(s, FEEDBACK_ROW, "Archived"),
    1364              :            "fb-open: no 'Archived' on feedback row at open");
    1365           12 :     ASSERT(!pty_row_contains(s, FEEDBACK_ROW, "Moved to Trash"),
    1366              :            "fb-open: no 'Moved to Trash' on feedback row at open");
    1367           12 :     pty_send_key(s, PTY_KEY_ESC);
    1368           12 :     pty_close(s);
    1369              : }
    1370              : 
    1371              : /** After 'D' (trash), feedback line shows "Moved to Trash". */
    1372           12 : static void test_feedback_D_trash(void) {
    1373           12 :     reset_and_sync();
    1374           12 :     const char *args[] = { g_tui_bin, NULL };
    1375           12 :     PtySession *s = pty_open(COLS, ROWS);
    1376           12 :     ASSERT(s != NULL, "fb-D: pty_open");
    1377           12 :     if (!s) return;
    1378           12 :     ASSERT(pty_run(s, args) == 0, "fb-D: pty_run");
    1379           11 :     ASSERT(navigate_to_inbox(s) == 0, "fb-D: navigate_to_inbox");
    1380           11 :     pty_send_str(s, "D");
    1381           11 :     pty_settle(s, SETTLE_MS);
    1382           11 :     ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Moved to Trash"),
    1383              :            "fb-D: feedback row shows 'Moved to Trash'");
    1384           11 :     pty_send_key(s, PTY_KEY_ESC);
    1385           11 :     pty_close(s);
    1386              : }
    1387              : 
    1388              : /** After 'd' (remove label), feedback line shows "Label removed: INBOX". */
    1389           11 : static void test_feedback_d_label_removed(void) {
    1390           11 :     reset_and_sync();
    1391           11 :     const char *args[] = { g_tui_bin, NULL };
    1392           11 :     PtySession *s = pty_open(COLS, ROWS);
    1393           11 :     ASSERT(s != NULL, "fb-d: pty_open");
    1394           11 :     if (!s) return;
    1395           11 :     ASSERT(pty_run(s, args) == 0, "fb-d: pty_run");
    1396           10 :     ASSERT(navigate_to_inbox(s) == 0, "fb-d: navigate_to_inbox");
    1397           10 :     pty_send_str(s, "d");
    1398           10 :     pty_settle(s, SETTLE_MS);
    1399           10 :     ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Label removed: INBOX"),
    1400              :            "fb-d: feedback row shows 'Label removed: INBOX'");
    1401           10 :     pty_send_key(s, PTY_KEY_ESC);
    1402           10 :     pty_close(s);
    1403              : }
    1404              : 
    1405              : /** After 'd','d' (remove then undo), feedback line shows "Undo: INBOX restored". */
    1406           10 : static void test_feedback_d_undo(void) {
    1407           10 :     reset_and_sync();
    1408           10 :     const char *args[] = { g_tui_bin, NULL };
    1409           10 :     PtySession *s = pty_open(COLS, ROWS);
    1410           10 :     ASSERT(s != NULL, "fb-dundo: pty_open");
    1411           10 :     if (!s) return;
    1412           10 :     ASSERT(pty_run(s, args) == 0, "fb-dundo: pty_run");
    1413            9 :     ASSERT(navigate_to_inbox(s) == 0, "fb-dundo: navigate_to_inbox");
    1414            9 :     pty_send_str(s, "d");
    1415            9 :     pty_settle(s, SETTLE_MS);
    1416            9 :     pty_send_str(s, "d");  /* undo */
    1417            9 :     pty_settle(s, SETTLE_MS);
    1418            9 :     ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Undo: INBOX restored"),
    1419              :            "fb-dundo: feedback row shows 'Undo: INBOX restored'");
    1420            9 :     pty_send_key(s, PTY_KEY_ESC);
    1421            9 :     pty_close(s);
    1422              : }
    1423              : 
    1424              : /** After 'a' (archive) in INBOX, feedback line shows "Archived". */
    1425            9 : static void test_feedback_a_archive(void) {
    1426            9 :     reset_and_sync();
    1427            9 :     const char *args[] = { g_tui_bin, NULL };
    1428            9 :     PtySession *s = pty_open(COLS, ROWS);
    1429            9 :     ASSERT(s != NULL, "fb-arch: pty_open");
    1430            9 :     if (!s) return;
    1431            9 :     ASSERT(pty_run(s, args) == 0, "fb-arch: pty_run");
    1432            8 :     ASSERT(navigate_to_inbox(s) == 0, "fb-arch: navigate_to_inbox");
    1433            8 :     pty_send_str(s, "a");
    1434            8 :     pty_settle(s, SETTLE_MS);
    1435            8 :     ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Archived"),
    1436              :            "fb-arch: feedback row shows 'Archived'");
    1437            8 :     pty_send_key(s, PTY_KEY_ESC);
    1438            8 :     pty_close(s);
    1439              : }
    1440              : 
    1441              : /** After 'a' in Archive view (already archived), feedback shows "Already in Archive". */
    1442            8 : static void test_feedback_a_noop(void) {
    1443              :     /* First archive a message */
    1444            8 :     reset_and_sync();
    1445              :     {
    1446            8 :         const char *args[] = { g_tui_bin, NULL };
    1447            8 :         PtySession *s = pty_open(COLS, ROWS);
    1448            8 :         ASSERT(s != NULL, "fb-anoop: pty_open s1");
    1449            8 :         if (!s) return;
    1450            8 :         ASSERT(pty_run(s, args) == 0, "fb-anoop: pty_run s1");
    1451            7 :         ASSERT(navigate_to_inbox(s) == 0, "fb-anoop: nav inbox s1");
    1452            7 :         pty_send_str(s, "a");   /* archive Message 5 */
    1453            7 :         pty_settle(s, SETTLE_MS);
    1454            7 :         pty_send_key(s, PTY_KEY_ESC);
    1455            7 :         pty_close(s);
    1456              :     }
    1457              :     /* Now open Archive view and press 'a' again */
    1458              :     {
    1459            7 :         const char *args[] = { g_tui_bin, NULL };
    1460            7 :         PtySession *s = pty_open(COLS, ROWS);
    1461            7 :         ASSERT(s != NULL, "fb-anoop: pty_open s2");
    1462            7 :         if (!s) return;
    1463            7 :         ASSERT(pty_run(s, args) == 0, "fb-anoop: pty_run s2");
    1464            6 :         pty_wait_for(s, "Accounts", WAIT_MS);
    1465            6 :         pty_send_key(s, PTY_KEY_ENTER);
    1466            6 :         pty_wait_for(s, "Labels", WAIT_MS);
    1467            6 :         pty_settle(s, SETTLE_MS / 2);
    1468            6 :         navigate_labels_to_archive(s);
    1469            6 :         pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
    1470            6 :         pty_settle(s, SETTLE_MS);
    1471            6 :         pty_send_str(s, "a");   /* no-op: already in Archive */
    1472            6 :         pty_settle(s, SETTLE_MS);
    1473            6 :         ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Already in Archive"),
    1474              :                "fb-anoop: feedback shows 'Already in Archive'");
    1475            6 :         pty_send_key(s, PTY_KEY_ESC);
    1476            6 :         pty_close(s);
    1477              :     }
    1478              : }
    1479              : 
    1480              : /** After 'f' (first press = star), feedback line shows "Starred". */
    1481            6 : static void test_feedback_f_star(void) {
    1482            6 :     reset_and_sync();
    1483            6 :     const char *args[] = { g_tui_bin, NULL };
    1484            6 :     PtySession *s = pty_open(COLS, ROWS);
    1485            6 :     ASSERT(s != NULL, "fb-f: pty_open");
    1486            6 :     if (!s) return;
    1487            6 :     ASSERT(pty_run(s, args) == 0, "fb-f: pty_run");
    1488            5 :     ASSERT(navigate_to_inbox(s) == 0, "fb-f: navigate_to_inbox");
    1489            5 :     pty_send_str(s, "f");   /* message 5 is not starred → becomes starred */
    1490            5 :     pty_settle(s, SETTLE_MS);
    1491            5 :     ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Starred"),
    1492              :            "fb-f: feedback row shows 'Starred'");
    1493            5 :     pty_send_key(s, PTY_KEY_ESC);
    1494            5 :     pty_close(s);
    1495              : }
    1496              : 
    1497              : /** After 'n' (first press: unread→read), feedback line shows "Marked as read". */
    1498            5 : static void test_feedback_n_read(void) {
    1499            5 :     reset_and_sync();
    1500            5 :     const char *args[] = { g_tui_bin, NULL };
    1501            5 :     PtySession *s = pty_open(COLS, ROWS);
    1502            5 :     ASSERT(s != NULL, "fb-n: pty_open");
    1503            5 :     if (!s) return;
    1504            5 :     ASSERT(pty_run(s, args) == 0, "fb-n: pty_run");
    1505            4 :     ASSERT(navigate_to_inbox(s) == 0, "fb-n: navigate_to_inbox");
    1506              :     /* Message 5 starts with UNREAD label → pressing 'n' marks it as read */
    1507            4 :     pty_send_str(s, "n");
    1508            4 :     pty_settle(s, SETTLE_MS);
    1509            4 :     ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Marked as read"),
    1510              :            "fb-n: feedback row shows 'Marked as read'");
    1511            4 :     pty_send_key(s, PTY_KEY_ESC);
    1512            4 :     pty_close(s);
    1513              : }
    1514              : 
    1515              : /** After 'u' (restore) in Trash view, feedback line shows "Restored to Inbox". */
    1516            4 : static void test_feedback_u_restore(void) {
    1517              :     /* Session 1: trash Message 5 */
    1518            4 :     reset_and_sync();
    1519              :     {
    1520            4 :         const char *args[] = { g_tui_bin, NULL };
    1521            4 :         PtySession *s = pty_open(COLS, ROWS);
    1522            4 :         ASSERT(s != NULL, "fb-u: pty_open s1");
    1523            4 :         if (!s) return;
    1524            4 :         ASSERT(pty_run(s, args) == 0, "fb-u: pty_run s1");
    1525            3 :         ASSERT(navigate_to_inbox(s) == 0, "fb-u: nav inbox s1");
    1526            3 :         pty_send_str(s, "D");   /* trash Message 5 */
    1527            3 :         pty_settle(s, SETTLE_MS);
    1528            3 :         pty_send_key(s, PTY_KEY_ESC);
    1529            3 :         pty_close(s);
    1530              :     }
    1531              :     /* Session 2: open Trash, press 'u', check feedback */
    1532              :     {
    1533            3 :         const char *args[] = { g_tui_bin, NULL };
    1534            3 :         PtySession *s = pty_open(COLS, ROWS);
    1535            3 :         ASSERT(s != NULL, "fb-u: pty_open s2");
    1536            3 :         if (!s) return;
    1537            3 :         ASSERT(pty_run(s, args) == 0, "fb-u: pty_run s2");
    1538            2 :         ASSERT(navigate_to_trash(s) == 0, "fb-u: navigate_to_trash");
    1539            2 :         pty_wait_for(s, GMAIL_TOP_MSG, WAIT_MS);
    1540            2 :         pty_settle(s, SETTLE_MS);
    1541            2 :         pty_send_str(s, "u");   /* restore from trash */
    1542            2 :         pty_settle(s, SETTLE_MS);
    1543            2 :         ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Restored to Inbox"),
    1544              :                "fb-u: feedback row shows 'Restored to Inbox'");
    1545            2 :         pty_send_key(s, PTY_KEY_ESC);
    1546            2 :         pty_close(s);
    1547              :     }
    1548              : }
    1549              : 
    1550              : /** After 't' + add label, feedback shows "Label added: <name>". */
    1551            2 : static void test_feedback_t_label_added(void) {
    1552            2 :     reset_and_sync();
    1553            2 :     const char *args[] = { g_tui_bin, NULL };
    1554            2 :     PtySession *s = pty_open(COLS, ROWS);
    1555            2 :     ASSERT(s != NULL, "fb-t: pty_open");
    1556            2 :     if (!s) return;
    1557            2 :     ASSERT(pty_run(s, args) == 0, "fb-t: pty_run");
    1558            1 :     ASSERT(navigate_to_inbox(s) == 0, "fb-t: navigate_to_inbox");
    1559              :     /* Open label picker, navigate to STARRED (Down×2), add it, ESC close */
    1560            1 :     pty_send_str(s, "t");
    1561            1 :     pty_wait_for(s, "Toggle Labels", WAIT_MS);
    1562            1 :     pty_settle(s, SETTLE_MS / 2);
    1563            1 :     pty_send_key(s, PTY_KEY_DOWN);  /* UNREAD → INBOX */
    1564            1 :     pty_send_key(s, PTY_KEY_DOWN);  /* INBOX  → STARRED */
    1565            1 :     pty_send_key(s, PTY_KEY_ENTER); /* add STARRED (currently off) */
    1566            1 :     pty_settle(s, SETTLE_MS / 2);
    1567            1 :     pty_send_key(s, PTY_KEY_ESC);   /* close picker */
    1568            1 :     pty_settle(s, SETTLE_MS);
    1569              :     /* Feedback must mention the label that was added */
    1570            1 :     ASSERT(pty_row_contains(s, FEEDBACK_ROW, "Label added"),
    1571              :            "fb-t: feedback row shows 'Label added'");
    1572            1 :     pty_send_key(s, PTY_KEY_ESC);
    1573            1 :     pty_close(s);
    1574              : }
    1575              : 
    1576              : /* ══════════════════════════════════════════════════════════════════════
    1577              :  *  US-72 helpers
    1578              :  * ══════════════════════════════════════════════════════════════════════ */
    1579              : 
    1580              : /** Clear account local store without running a sync. */
    1581            8 : static void clear_gmail_local_store(void) {
    1582              :     char cmd[640];
    1583            8 :     snprintf(cmd, sizeof(cmd),
    1584              :              "rm -rf '/tmp/email-cli-gmail-pty-%d/.local/share/email-cli/accounts/%s'",
    1585            8 :              (int)getpid(), GMAIL_TEST_EMAIL);
    1586            8 :     int _rc1 = system(cmd);  (void)_rc1;
    1587              :     char cmd2[640];
    1588            8 :     snprintf(cmd2, sizeof(cmd2),
    1589              :              "rm -f '%s/.local/share/email-cli/ui.ini'", g_test_home);
    1590            8 :     int _rc2 = system(cmd2); (void)_rc2;
    1591            8 : }
    1592              : 
    1593              : /** Run email-sync and capture stdout + stderr into out_buf. */
    1594            4 : static int run_gmail_sync_capture(char *out_buf, size_t out_size) {
    1595              :     int pfd[2];
    1596            4 :     if (pipe(pfd) != 0) return -1;
    1597            4 :     pid_t pid = fork();
    1598            8 :     if (pid < 0) { close(pfd[0]); close(pfd[1]); return -1; }
    1599            8 :     if (pid == 0) {
    1600            4 :         close(pfd[0]);
    1601            4 :         dup2(pfd[1], STDOUT_FILENO);
    1602            4 :         dup2(pfd[1], STDERR_FILENO);
    1603            4 :         close(pfd[1]);
    1604            4 :         execl(g_sync_bin, "email-sync", (char *)NULL);
    1605            4 :         _exit(127);
    1606              :     }
    1607            4 :     close(pfd[1]);
    1608            4 :     size_t total = 0;
    1609              :     ssize_t n;
    1610           56 :     while (total < out_size - 1 &&
    1611           28 :            (n = read(pfd[0], out_buf + total, out_size - total - 1)) > 0)
    1612           24 :         total += (size_t)n;
    1613            4 :     out_buf[total] = '\0';
    1614            4 :     close(pfd[0]);
    1615              :     int status;
    1616            4 :     waitpid(pid, &status, 0);
    1617            4 :     return (WIFEXITED(status) && WEXITSTATUS(status) == 0) ? 0 : -1;
    1618              : }
    1619              : 
    1620              : /** Build path to a file inside the account local store. */
    1621            5 : static void account_store_path(char *out, size_t sz, const char *file) {
    1622            5 :     snprintf(out, sz, "%s/.local/share/email-cli/accounts/%s/%s",
    1623              :              g_test_home, GMAIL_TEST_EMAIL, file);
    1624            5 : }
    1625              : 
    1626              : /* ══════════════════════════════════════════════════════════════════════
    1627              :  *  US-72: Gmail Smart Sync Orchestration
    1628              :  * ══════════════════════════════════════════════════════════════════════ */
    1629              : 
    1630            1 : static void test_gmail_sync_first_run_reconcile(void) {
    1631              :     /* US-72 AC1: first sync (no historyId) triggers reconcile */
    1632            1 :     clear_gmail_local_store();
    1633            1 :     char out[8192] = {0};
    1634            1 :     int rc = run_gmail_sync_capture(out, sizeof(out));
    1635            1 :     ASSERT(rc == 0, "first run reconcile: sync exits 0");
    1636            1 :     ASSERT(strstr(out, "No saved historyId") != NULL ||
    1637              :            strstr(out, "Listing messages")   != NULL,
    1638              :            "first run reconcile: reconcile path indicated in output");
    1639              : }
    1640              : 
    1641            1 : static void test_gmail_sync_history_id_saved(void) {
    1642              :     /* US-72 AC1: gmail_history_id file written after first sync */
    1643            1 :     clear_gmail_local_store();
    1644            1 :     ASSERT(run_gmail_sync() == 0, "history id saved: sync ok");
    1645              :     char path[512];
    1646            1 :     account_store_path(path, sizeof(path), "gmail_history_id");
    1647            1 :     FILE *fp = fopen(path, "r");
    1648            1 :     ASSERT(fp != NULL, "history id saved: file exists");
    1649            1 :     if (fp) {
    1650            1 :         char buf[64] = {0};
    1651            1 :         char *line = fgets(buf, sizeof(buf), fp);
    1652            1 :         fclose(fp);
    1653            1 :         ASSERT(line != NULL && buf[0] != '\0', "history id saved: file non-empty");
    1654              :     }
    1655              : }
    1656              : 
    1657            1 : static void test_gmail_sync_pending_empty_after_sync(void) {
    1658              :     /* US-72 AC2+AC8: pending_fetch.tsv empty (or absent) after a complete first sync */
    1659            1 :     clear_gmail_local_store();
    1660            1 :     ASSERT(run_gmail_sync() == 0, "pending empty: sync ok");
    1661              :     char path[512];
    1662            1 :     account_store_path(path, sizeof(path), "pending_fetch.tsv");
    1663            1 :     FILE *fp = fopen(path, "r");
    1664            1 :     if (fp) {
    1665            1 :         char buf[64] = {0};
    1666            1 :         char *line = fgets(buf, sizeof(buf), fp);
    1667            1 :         fclose(fp);
    1668            1 :         ASSERT(line == NULL || buf[0] == '\0',
    1669              :                "pending empty: pending_fetch.tsv is empty after complete sync");
    1670              :     }
    1671              :     /* File absent entirely also satisfies the criterion */
    1672              : }
    1673              : 
    1674            1 : static void test_gmail_sync_second_run_incremental(void) {
    1675              :     /* US-72 AC3: second sync uses fast incremental path, no full reconcile */
    1676            1 :     clear_gmail_local_store();
    1677            1 :     ASSERT(run_gmail_sync() == 0, "second run incremental: first sync ok");
    1678            1 :     char out[8192] = {0};
    1679            1 :     int rc = run_gmail_sync_capture(out, sizeof(out));
    1680            1 :     ASSERT(rc == 0, "second run incremental: second sync exits 0");
    1681            1 :     ASSERT(strstr(out, "Incremental sync") != NULL,
    1682              :            "second run incremental: incremental path taken");
    1683            1 :     ASSERT(strstr(out, "Listing messages") == NULL,
    1684              :            "second run incremental: no full reconcile on second sync");
    1685              : }
    1686              : 
    1687            1 : static void test_gmail_sync_drain_then_incremental(void) {
    1688              :     /* US-72 AC4: pending_fetch.tsv non-empty + valid historyId → drain + incremental */
    1689            1 :     clear_gmail_local_store();
    1690            1 :     ASSERT(run_gmail_sync() == 0, "drain+incremental: first sync ok");
    1691              :     /* Re-queue one UID to simulate an interrupted previous sync */
    1692              :     char path[512];
    1693            1 :     account_store_path(path, sizeof(path), "pending_fetch.tsv");
    1694            1 :     FILE *fp = fopen(path, "w");
    1695            1 :     ASSERT(fp != NULL, "drain+incremental: write pending_fetch.tsv");
    1696            1 :     if (fp) { fprintf(fp, "0000000000000001\n"); fclose(fp); }
    1697            1 :     char out[8192] = {0};
    1698            1 :     int rc = run_gmail_sync_capture(out, sizeof(out));
    1699            1 :     ASSERT(rc == 0, "drain+incremental: sync with pending exits 0");
    1700            1 :     ASSERT(strstr(out, "Listing messages") == NULL,
    1701              :            "drain+incremental: no full reconcile when historyId valid");
    1702              :     /* AC8: UID removed from pending_fetch.tsv after successful download */
    1703            1 :     FILE *fp2 = fopen(path, "r");
    1704            1 :     if (fp2) {
    1705            1 :         char buf[64] = {0};
    1706            1 :         char *line = fgets(buf, sizeof(buf), fp2);
    1707            1 :         fclose(fp2);
    1708            1 :         ASSERT(line == NULL || buf[0] == '\0',
    1709              :                "drain+incremental: pending_fetch.tsv emptied after drain");
    1710              :     }
    1711              : }
    1712              : 
    1713            1 : static void test_gmail_sync_force_reconcile(void) {
    1714              :     /* US-72 AC5: deleting gmail_history_id triggers full reconcile on next sync */
    1715            1 :     clear_gmail_local_store();
    1716            1 :     ASSERT(run_gmail_sync() == 0, "force reconcile: first sync ok");
    1717              :     char path[512];
    1718            1 :     account_store_path(path, sizeof(path), "gmail_history_id");
    1719            1 :     unlink(path);
    1720            1 :     char out[8192] = {0};
    1721            1 :     int rc = run_gmail_sync_capture(out, sizeof(out));
    1722            1 :     ASSERT(rc == 0, "force reconcile: re-sync exits 0");
    1723            1 :     ASSERT(strstr(out, "No saved historyId") != NULL ||
    1724              :            strstr(out, "Listing messages")   != NULL,
    1725              :            "force reconcile: reconcile path taken after historyId deleted");
    1726              : }
    1727              : 
    1728            1 : static void test_gmail_sync_pending_fetch_path(void) {
    1729              :     /* US-72 AC6: account store directory exists at the expected path */
    1730            1 :     clear_gmail_local_store();
    1731            1 :     ASSERT(run_gmail_sync() == 0, "pending_fetch path: sync ok");
    1732              :     char dir_path[512];
    1733            1 :     snprintf(dir_path, sizeof(dir_path),
    1734              :              "%s/.local/share/email-cli/accounts/%s",
    1735              :              g_test_home, GMAIL_TEST_EMAIL);
    1736              :     struct stat st;
    1737            1 :     ASSERT(stat(dir_path, &st) == 0 && S_ISDIR(st.st_mode),
    1738              :            "pending_fetch path: account store dir at expected path");
    1739              : }
    1740              : 
    1741            1 : static void test_gmail_sync_pending_fetch_uid_format(void) {
    1742              :     /* US-72 AC7: pending_fetch.tsv entries are 16-char hex UIDs, one per line.
    1743              :      * Verified by writing a known-format UID and confirming the drain consumes it. */
    1744            1 :     clear_gmail_local_store();
    1745            1 :     ASSERT(run_gmail_sync() == 0, "pending_fetch format: first sync ok");
    1746              :     char path[512];
    1747            1 :     account_store_path(path, sizeof(path), "pending_fetch.tsv");
    1748              :     /* Write one 16-char hex UID per AC7 spec */
    1749            1 :     FILE *wp = fopen(path, "w");
    1750            1 :     ASSERT(wp != NULL, "pending_fetch format: create test file");
    1751            1 :     if (wp) { fprintf(wp, "0000000000000001\n"); fclose(wp); }
    1752              :     /* Verify format: 16 chars, all hex digits */
    1753            1 :     FILE *rp = fopen(path, "r");
    1754            1 :     ASSERT(rp != NULL, "pending_fetch format: re-open");
    1755            1 :     if (rp) {
    1756            1 :         char line[64] = {0};
    1757            1 :         char *got = fgets(line, sizeof(line), rp);
    1758            1 :         fclose(rp);
    1759            1 :         ASSERT(got != NULL, "pending_fetch format: entry readable");
    1760            1 :         size_t len = strlen(line);
    1761            2 :         while (len > 0 && (line[len-1]=='\n'||line[len-1]=='\r')) line[--len] = '\0';
    1762            1 :         ASSERT(len == 16, "pending_fetch format: entry is 16 characters");
    1763            1 :         int all_hex = 1;
    1764           17 :         for (size_t i = 0; i < len; i++) {
    1765           16 :             char c = line[i];
    1766           16 :             if (!((c>='0'&&c<='9')||(c>='a'&&c<='f')||(c>='A'&&c<='F'))) all_hex = 0;
    1767              :         }
    1768            1 :         ASSERT(all_hex, "pending_fetch format: entry is hexadecimal");
    1769              :     }
    1770              :     /* Drain: proves the 16-char hex format is what the code reads (AC8) */
    1771            1 :     ASSERT(run_gmail_sync() == 0, "pending_fetch format: drain sync ok");
    1772            1 :     FILE *ep = fopen(path, "r");
    1773            1 :     if (ep) {
    1774            1 :         char buf[64] = {0};
    1775            1 :         char *got = fgets(buf, sizeof(buf), ep);
    1776            1 :         fclose(ep);
    1777            1 :         ASSERT(got == NULL || buf[0] == '\0',
    1778              :                "pending_fetch format: UID removed after successful download");
    1779              :     }
    1780              : }
    1781              : 
    1782              : /* ══════════════════════════════════════════════════════════════════════
    1783              :  *  main
    1784              :  * ══════════════════════════════════════════════════════════════════════ */
    1785              : 
    1786           44 : int main(int argc, char *argv[]) {
    1787           44 :     if (argc < 4) {
    1788            0 :         fprintf(stderr,
    1789              :                 "Usage: %s <email-tui> <email-sync> <mock-gmail-server>\n",
    1790              :                 argv[0]);
    1791            0 :         return EXIT_FAILURE;
    1792              :     }
    1793           44 :     snprintf(g_tui_bin,  sizeof(g_tui_bin),  "%s", argv[1]);
    1794           44 :     snprintf(g_sync_bin, sizeof(g_sync_bin), "%s", argv[2]);
    1795           44 :     snprintf(g_mock_bin, sizeof(g_mock_bin), "%s", argv[3]);
    1796           44 :     snprintf(g_api_url,  sizeof(g_api_url),
    1797              :              "http://localhost:%d/gmail/v1/users/me", GMAIL_PTY_PORT);
    1798              : 
    1799              :     /* Redirect the test to a private HOME so it never touches the real inbox */
    1800           44 :     const char *old_home = getenv("HOME");
    1801           44 :     snprintf(g_test_home, sizeof(g_test_home),
    1802              :              "/tmp/email-cli-gmail-pty-%d", getpid());
    1803              : 
    1804           44 :     setenv("HOME",               g_test_home,     1);
    1805           44 :     setenv("GMAIL_API_BASE_URL", g_api_url,        1);
    1806           44 :     setenv("GMAIL_TEST_TOKEN",   "test_fake_token", 1);
    1807           44 :     unsetenv("XDG_CONFIG_HOME");
    1808           44 :     unsetenv("XDG_CACHE_HOME");
    1809           44 :     unsetenv("XDG_DATA_HOME");
    1810              : 
    1811           44 :     write_gmail_config();
    1812              : 
    1813           44 :     printf("--- Starting mock Gmail API server (port %d, %d messages) ---\n",
    1814              :            GMAIL_PTY_PORT, GMAIL_PTY_COUNT);
    1815           44 :     if (start_gmail_mock() != 0) {
    1816            0 :         fprintf(stderr, "FATAL: cannot start mock Gmail API server\n");
    1817            0 :         goto done;
    1818              :     }
    1819              : 
    1820              :     /* Reset and re-populate the cache before each test so every test
    1821              :      * starts from the same clean state (all 5 messages in INBOX). */
    1822              : 
    1823           44 :     printf("\n--- Gmail TUI: 'd' key pending-remove feedback ---\n");
    1824              : 
    1825           44 :     printf("--- Fresh sync (test 1) ---\n");
    1826           44 :     if (reset_and_sync() != 0) {
    1827            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 1\n");
    1828            0 :         stop_gmail_mock();
    1829            0 :         goto done;
    1830              :     }
    1831           44 :     RUN_TEST(test_d_key_row_stays_visible);
    1832              : 
    1833           43 :     printf("--- Fresh sync (test 2) ---\n");
    1834           43 :     if (reset_and_sync() != 0) {
    1835            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 2\n");
    1836            0 :         stop_gmail_mock();
    1837            0 :         goto done;
    1838              :     }
    1839           43 :     RUN_TEST(test_d_key_row_strikethrough);
    1840              : 
    1841           42 :     printf("--- Fresh sync (test 3) ---\n");
    1842           42 :     if (reset_and_sync() != 0) {
    1843            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 3\n");
    1844            0 :         stop_gmail_mock();
    1845            0 :         goto done;
    1846              :     }
    1847           42 :     RUN_TEST(test_d_key_cursor_row_has_reverse);
    1848              : 
    1849           41 :     printf("--- Fresh sync (test 4) ---\n");
    1850           41 :     if (reset_and_sync() != 0) {
    1851            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 4\n");
    1852            0 :         stop_gmail_mock();
    1853            0 :         goto done;
    1854              :     }
    1855           41 :     RUN_TEST(test_d_key_toggle_undo);
    1856              : 
    1857           40 :     printf("--- Fresh sync (test 5) ---\n");
    1858           40 :     if (reset_and_sync() != 0) {
    1859            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 5\n");
    1860            0 :         stop_gmail_mock();
    1861            0 :         goto done;
    1862              :     }
    1863           40 :     RUN_TEST(test_d_key_row_gone_after_refresh);
    1864              : 
    1865           39 :     printf("\n--- Gmail TUI: label picker / archive / trash / restore ---\n");
    1866              : 
    1867           39 :     printf("--- Fresh sync (test 6) ---\n");
    1868           39 :     if (reset_and_sync() != 0) {
    1869            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 6\n");
    1870            0 :         stop_gmail_mock();
    1871            0 :         goto done;
    1872              :     }
    1873           39 :     RUN_TEST(test_label_picker_shows_unread);
    1874              : 
    1875           38 :     printf("--- Fresh sync (test 7) ---\n");
    1876           38 :     if (reset_and_sync() != 0) {
    1877            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 7\n");
    1878            0 :         stop_gmail_mock();
    1879            0 :         goto done;
    1880              :     }
    1881           38 :     RUN_TEST(test_archive_row_has_strikethrough);
    1882              : 
    1883           37 :     printf("--- Fresh sync (test 8) ---\n");
    1884           37 :     if (reset_and_sync() != 0) {
    1885            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 8\n");
    1886            0 :         stop_gmail_mock();
    1887            0 :         goto done;
    1888              :     }
    1889           37 :     RUN_TEST(test_archive_removes_from_inbox);
    1890              : 
    1891           36 :     printf("--- Fresh sync (test 9) ---\n");
    1892           36 :     if (reset_and_sync() != 0) {
    1893            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 9\n");
    1894            0 :         stop_gmail_mock();
    1895            0 :         goto done;
    1896              :     }
    1897           36 :     RUN_TEST(test_archive_message_in_archive_view);
    1898              : 
    1899           35 :     printf("--- Fresh sync (test 10) ---\n");
    1900           35 :     if (reset_and_sync() != 0) {
    1901            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 10\n");
    1902            0 :         stop_gmail_mock();
    1903            0 :         goto done;
    1904              :     }
    1905           35 :     RUN_TEST(test_label_picker_unarchive);
    1906              : 
    1907           34 :     printf("--- Fresh sync (test 11) ---\n");
    1908           34 :     if (reset_and_sync() != 0) {
    1909            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 11\n");
    1910            0 :         stop_gmail_mock();
    1911            0 :         goto done;
    1912              :     }
    1913           34 :     RUN_TEST(test_trash_statusbar_restore_hint);
    1914           33 :     RUN_TEST(test_labels_up_at_top_is_noop);
    1915              : 
    1916           32 :     printf("--- Fresh sync (test 12) ---\n");
    1917           32 :     if (reset_and_sync() != 0) {
    1918            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 12\n");
    1919            0 :         stop_gmail_mock();
    1920            0 :         goto done;
    1921              :     }
    1922           32 :     RUN_TEST(test_D_key_row_has_strikethrough);
    1923              : 
    1924              :     /* Tests 13–14: two-session tests — reset_and_sync() covers session 1;
    1925              :      * session 2 reuses the state left by session 1 (trashed message). */
    1926           31 :     printf("--- Fresh sync (test 13) ---\n");
    1927           31 :     if (reset_and_sync() != 0) {
    1928            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 13\n");
    1929            0 :         stop_gmail_mock();
    1930            0 :         goto done;
    1931              :     }
    1932           31 :     RUN_TEST(test_u_key_row_has_strikethrough);
    1933              : 
    1934           29 :     printf("--- Fresh sync (test 14) ---\n");
    1935           29 :     if (reset_and_sync() != 0) {
    1936            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 14\n");
    1937            0 :         stop_gmail_mock();
    1938            0 :         goto done;
    1939              :     }
    1940           29 :     RUN_TEST(test_u_key_row_gone_after_refresh);
    1941              : 
    1942              :     /* ── US-43: pending-row colour semantics ──────────────────────── */
    1943              : 
    1944           27 :     printf("--- Fresh sync (test 15: d=yellow) ---\n");
    1945           27 :     if (reset_and_sync() != 0) {
    1946            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 15\n");
    1947            0 :         stop_gmail_mock();
    1948            0 :         goto done;
    1949              :     }
    1950           27 :     RUN_TEST(test_d_row_yellow_fg);
    1951              : 
    1952           26 :     printf("--- Fresh sync (test 16: D=red) ---\n");
    1953           26 :     if (reset_and_sync() != 0) {
    1954            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 16\n");
    1955            0 :         stop_gmail_mock();
    1956            0 :         goto done;
    1957              :     }
    1958           26 :     RUN_TEST(test_D_row_red_fg);
    1959              : 
    1960           25 :     printf("--- Fresh sync (test 17: a=yellow) ---\n");
    1961           25 :     if (reset_and_sync() != 0) {
    1962            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 17\n");
    1963            0 :         stop_gmail_mock();
    1964            0 :         goto done;
    1965              :     }
    1966           25 :     RUN_TEST(test_a_row_yellow_fg);
    1967              : 
    1968           24 :     printf("--- Fresh sync (test 18: a in Archive = noop) ---\n");
    1969           24 :     if (reset_and_sync() != 0) {
    1970            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 18\n");
    1971            0 :         stop_gmail_mock();
    1972            0 :         goto done;
    1973              :     }
    1974           24 :     RUN_TEST(test_a_in_archive_no_strikethrough);
    1975              : 
    1976           22 :     printf("--- Fresh sync (test 19: label picker unarchive = green) ---\n");
    1977           22 :     if (reset_and_sync() != 0) {
    1978            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 19\n");
    1979            0 :         stop_gmail_mock();
    1980            0 :         goto done;
    1981              :     }
    1982           22 :     RUN_TEST(test_label_picker_unarchive_green_fg);
    1983              : 
    1984           20 :     printf("--- Fresh sync (test 20: label picker untrash = green) ---\n");
    1985           20 :     if (reset_and_sync() != 0) {
    1986            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 20\n");
    1987            0 :         stop_gmail_mock();
    1988            0 :         goto done;
    1989              :     }
    1990           20 :     RUN_TEST(test_label_picker_untrash_green_fg);
    1991              : 
    1992              :     /* ── 'D' no-op in Trash, 'D','D' undo, 'D'+'d' colour tests ── */
    1993           18 :     printf("--- Fresh sync (test 20: D noop in Trash) ---\n");
    1994           18 :     if (reset_and_sync() != 0) {
    1995            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 20\n");
    1996            0 :         stop_gmail_mock();
    1997            0 :         goto done;
    1998              :     }
    1999           18 :     RUN_TEST(test_D_permanent_delete_in_trash_view);
    2000              : 
    2001           16 :     printf("--- Fresh sync (test 21: D+D undo) ---\n");
    2002           16 :     if (reset_and_sync() != 0) {
    2003            0 :         fprintf(stderr, "FATAL: reset_and_sync failed for test 21\n");
    2004            0 :         stop_gmail_mock();
    2005            0 :         goto done;
    2006              :     }
    2007           16 :     RUN_TEST(test_D_D_undo_clears_strikethrough);
    2008           15 :     RUN_TEST(test_D_D_undo_feedback);
    2009           14 :     RUN_TEST(test_D_then_d_shows_yellow);
    2010              : 
    2011              :     /* ── Feedback line tests (US-44 / US-52-56) ── */
    2012           13 :     RUN_TEST(test_feedback_empty_on_open);
    2013           12 :     RUN_TEST(test_feedback_D_trash);
    2014           11 :     RUN_TEST(test_feedback_d_label_removed);
    2015           10 :     RUN_TEST(test_feedback_d_undo);
    2016            9 :     RUN_TEST(test_feedback_a_archive);
    2017            8 :     RUN_TEST(test_feedback_a_noop);
    2018            6 :     RUN_TEST(test_feedback_f_star);
    2019            5 :     RUN_TEST(test_feedback_n_read);
    2020            4 :     RUN_TEST(test_feedback_u_restore);
    2021            2 :     RUN_TEST(test_feedback_t_label_added);
    2022              : 
    2023              :     /* ── US-72: Gmail Smart Sync Orchestration ── */
    2024            1 :     printf("\n--- Gmail Smart Sync orchestration (US-72) ---\n");
    2025            1 :     RUN_TEST(test_gmail_sync_first_run_reconcile);
    2026            1 :     RUN_TEST(test_gmail_sync_history_id_saved);
    2027            1 :     RUN_TEST(test_gmail_sync_pending_empty_after_sync);
    2028            1 :     RUN_TEST(test_gmail_sync_second_run_incremental);
    2029            1 :     RUN_TEST(test_gmail_sync_drain_then_incremental);
    2030            1 :     RUN_TEST(test_gmail_sync_force_reconcile);
    2031            1 :     RUN_TEST(test_gmail_sync_pending_fetch_path);
    2032            1 :     RUN_TEST(test_gmail_sync_pending_fetch_uid_format);
    2033              : 
    2034            1 :     stop_gmail_mock();
    2035              : 
    2036            1 : done:
    2037            1 :     if (old_home) setenv("HOME", old_home, 1);
    2038              : 
    2039            1 :     printf("\n--- Gmail TUI PTY Test Results ---\n");
    2040            1 :     printf("Tests Run:    %d\n", g_tests_run);
    2041            1 :     printf("Tests Passed: %d\n", g_tests_run - g_tests_failed);
    2042            1 :     printf("Tests Failed: %d\n", g_tests_failed);
    2043              : 
    2044            1 :     return g_tests_failed > 0 ? EXIT_FAILURE : EXIT_SUCCESS;
    2045              : }
        

Generated by: LCOV version 2.0-1