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 : }
|