LCOV - code coverage report
Current view: top level - tests/unit - test_email_service.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 98.4 % 322 317
Test Date: 2026-04-15 21:12:52 Functions: 100.0 % 1 1

            Line data    Source code
       1              : #include "test_helpers.h"
       2              : #include <string.h>
       3              : #include <stdlib.h>
       4              : #include <stdint.h>
       5              : #include <locale.h>
       6              : #include <fcntl.h>
       7              : #include <unistd.h>
       8              : 
       9              : /*
      10              :  * Include the full domain source so all static helpers are visible in
      11              :  * this translation unit.  email_service.c is NOT added to CMakeLists.txt
      12              :  * as a separate source — the #include below is the only compilation unit
      13              :  * that defines its symbols.
      14              :  */
      15              : #include "../../libemail/src/domain/email_service.c"
      16              : 
      17            1 : void test_email_service(void) {
      18              : 
      19            1 :     setlocale(LC_ALL, "");
      20            1 :     local_store_init("imaps://test.example.com", "testuser");
      21              : 
      22              :     /* ── count_visual_rows ───────────────────────────────────────────── */
      23              : 
      24              :     /* Short lines: visual rows == logical lines (all fit within term_cols) */
      25            1 :     ASSERT(count_visual_rows(NULL,  80) == 0, "cvr: NULL → 0");
      26            1 :     ASSERT(count_visual_rows("",    80) == 0, "cvr: empty → 0");
      27            1 :     ASSERT(count_visual_rows("abc", 80) == 1, "cvr: single line → 1");
      28            1 :     ASSERT(count_visual_rows("a\nb", 80) == 2, "cvr: two lines → 2");
      29            1 :     ASSERT(count_visual_rows("a\nb\nc\n", 80) == 4, "cvr: trailing newline → 4");
      30              : 
      31              :     /* A line exactly term_cols wide → 1 visual row */
      32              :     {
      33            1 :         char exact[81]; memset(exact, 'X', 80); exact[80] = '\0';
      34            1 :         ASSERT(count_visual_rows(exact, 80) == 1, "cvr: 80-char line → 1 row");
      35              :     }
      36              : 
      37              :     /* A line wider than term_cols → multiple visual rows */
      38              :     {
      39            1 :         char wide[161]; memset(wide, 'X', 160); wide[160] = '\0';
      40              :         /* 160-char line on 80-col terminal → 2 visual rows (+ terminating segment) */
      41            1 :         char body[163]; snprintf(body, sizeof(body), "%s\n", wide);
      42            1 :         int vr = count_visual_rows(body, 80);
      43            1 :         ASSERT(vr == 3, "cvr: 160-char line+\\n → 3 rows (2 for URL, 1 trailing)");
      44              :     }
      45              : 
      46              :     /* A long URL (no newline) → single logical line counted as multiple visual rows */
      47              :     {
      48            1 :         char url[201]; memset(url, 'x', 200); url[200] = '\0';
      49              :         /* 200 chars on 80-col terminal = ceil(200/80) = 3 visual rows */
      50            1 :         ASSERT(count_visual_rows(url, 80) == 3, "cvr: 200-char url → 3 rows");
      51              :     }
      52              : 
      53              :     /* With ANSI escapes: invisible bytes not counted toward visible cols */
      54            1 :     ASSERT(count_visual_rows("\033[1mhello\033[22m", 80) == 1,
      55              :            "cvr: ANSI-wrapped line → 1 row");
      56              : 
      57              :     /* ── word_wrap ───────────────────────────────────────────────────── */
      58              : 
      59              :     /* NULL input → NULL */
      60              :     {
      61            1 :         char *r = word_wrap(NULL, 40);
      62            1 :         ASSERT(r == NULL, "word_wrap: NULL input → NULL");
      63              :     }
      64              : 
      65              :     /* Short text that fits entirely — no wrapping needed */
      66              :     {
      67            1 :         char *r = word_wrap("Hello world", 40);
      68            1 :         ASSERT(r != NULL, "word_wrap: short text not NULL");
      69            1 :         ASSERT(strstr(r, "Hello world") != NULL, "word_wrap: short text passthrough");
      70            1 :         free(r);
      71              :     }
      72              : 
      73              :     /* Word break at space (lines 169-174): width=25, long text with spaces */
      74              :     {
      75            1 :         char *r = word_wrap("The quick brown fox jumps over the lazy dog", 25);
      76            1 :         ASSERT(r != NULL, "word_wrap: word break not NULL");
      77            1 :         ASSERT(strstr(r, "\n") != NULL, "word_wrap: word break produces newline");
      78            1 :         free(r);
      79              :     }
      80              : 
      81              :     /* Hard break — no spaces (lines 185-188): width=20, 25-char word */
      82              :     {
      83            1 :         char *r = word_wrap("aaaaaaaaaaaaaaaaaaaaaaaaa", 20);
      84            1 :         ASSERT(r != NULL, "word_wrap: hard break not NULL");
      85            1 :         ASSERT(strstr(r, "\n") != NULL, "word_wrap: hard break produces newline");
      86            1 :         free(r);
      87              :     }
      88              : 
      89              :     /* 2-byte UTF-8 lead byte (line 143: *p < 0xE0): é = \xC3\xA9 */
      90              :     {
      91            1 :         char *r = word_wrap("\xC3\xA9\xC3\xA9\xC3\xA9 test", 40);
      92            1 :         ASSERT(r != NULL, "word_wrap: 2-byte UTF-8 not NULL");
      93            1 :         free(r);
      94              :     }
      95              : 
      96              :     /* 3-byte UTF-8 lead byte (line 144: *p < 0xF0): 中 = \xE4\xB8\xAD */
      97              :     {
      98            1 :         char *r = word_wrap("\xE4\xB8\xAD text", 40);
      99            1 :         ASSERT(r != NULL, "word_wrap: 3-byte UTF-8 not NULL");
     100            1 :         free(r);
     101              :     }
     102              : 
     103              :     /* 4-byte UTF-8 lead byte (line 145: *p < 0xF8): U+10000 = \xF0\x90\x80\x80 */
     104              :     {
     105            1 :         char *r = word_wrap("\xF0\x90\x80\x80 test", 40);
     106            1 :         ASSERT(r != NULL, "word_wrap: 4-byte UTF-8 not NULL");
     107            1 :         free(r);
     108              :     }
     109              : 
     110              :     /* Invalid lead byte < 0xC2 (line 142: continuation byte as lead) */
     111              :     {
     112            1 :         char *r = word_wrap("\x80 bad", 40);
     113            1 :         ASSERT(r != NULL, "word_wrap: 0x80 lead byte not NULL");
     114            1 :         free(r);
     115              :     }
     116              : 
     117              :     /* Invalid lead byte >= 0xF8 (line 146: else branch) */
     118              :     {
     119            1 :         char *r = word_wrap("\xFE bad", 40);
     120            1 :         ASSERT(r != NULL, "word_wrap: 0xFE lead byte not NULL");
     121            1 :         free(r);
     122              :     }
     123              : 
     124              :     /* Continuation byte mismatch (line 148): 2-byte start \xC3 + non-continuation \x41 */
     125              :     {
     126            1 :         char *r = word_wrap("\xC3\x41 bad", 40);
     127            1 :         ASSERT(r != NULL, "word_wrap: truncated multibyte not NULL");
     128            1 :         free(r);
     129              :     }
     130              : 
     131              :     /* Multi-line input — exercises the outer loop past eol */
     132              :     {
     133            1 :         char *r = word_wrap("first line\nsecond line\n", 40);
     134            1 :         ASSERT(r != NULL, "word_wrap: multi-line not NULL");
     135            1 :         ASSERT(strstr(r, "first line") != NULL, "word_wrap: multi-line first");
     136            1 :         ASSERT(strstr(r, "second line") != NULL, "word_wrap: multi-line second");
     137            1 :         free(r);
     138              :     }
     139              : 
     140              :     /* ── ansi_scan ───────────────────────────────────────────────────── */
     141              : 
     142              :     /* Empty content → all zeros */
     143              :     {
     144            1 :         AnsiState st = {0};
     145            1 :         ansi_scan("", "", &st);
     146            1 :         ASSERT(st.bold==0 && st.italic==0 && st.uline==0 && st.strike==0,
     147              :                "ansi_scan: empty → no state");
     148            1 :         ASSERT(st.fg_on==0 && st.bg_on==0, "ansi_scan: empty → no color");
     149              :     }
     150              : 
     151              :     /* Bold on/off */
     152              :     {
     153            1 :         AnsiState st = {0};
     154            1 :         const char *s = "\033[1mtext\033[22m";
     155            1 :         ansi_scan(s, s + strlen(s), &st);
     156            1 :         ASSERT(st.bold == 0, "ansi_scan: bold on then off → 0");
     157              : 
     158            1 :         AnsiState st2 = {0};
     159            1 :         const char *s2 = "\033[1mtext";
     160            1 :         ansi_scan(s2, s2 + strlen(s2), &st2);
     161            1 :         ASSERT(st2.bold == 1, "ansi_scan: bold on, no off → 1");
     162              :     }
     163              : 
     164              :     /* Italic on/off */
     165              :     {
     166            1 :         AnsiState st = {0};
     167            1 :         const char *s = "\033[3m";
     168            1 :         ansi_scan(s, s + strlen(s), &st);
     169            1 :         ASSERT(st.italic == 1, "ansi_scan: italic on → 1");
     170              : 
     171            1 :         st.italic = 1;
     172            1 :         const char *s2 = "\033[23m";
     173            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     174            1 :         ASSERT(st.italic == 0, "ansi_scan: italic off → 0");
     175              :     }
     176              : 
     177              :     /* Underline on/off */
     178              :     {
     179            1 :         AnsiState st = {0};
     180            1 :         const char *s = "\033[4m";
     181            1 :         ansi_scan(s, s + strlen(s), &st);
     182            1 :         ASSERT(st.uline == 1, "ansi_scan: uline on → 1");
     183              : 
     184            1 :         const char *s2 = "\033[24m";
     185            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     186            1 :         ASSERT(st.uline == 0, "ansi_scan: uline off → 0");
     187              :     }
     188              : 
     189              :     /* Strikethrough on/off */
     190              :     {
     191            1 :         AnsiState st = {0};
     192            1 :         const char *s = "\033[9m";
     193            1 :         ansi_scan(s, s + strlen(s), &st);
     194            1 :         ASSERT(st.strike == 1, "ansi_scan: strike on → 1");
     195              : 
     196            1 :         const char *s2 = "\033[29m";
     197            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     198            1 :         ASSERT(st.strike == 0, "ansi_scan: strike off → 0");
     199              :     }
     200              : 
     201              :     /* Foreground color set and reset */
     202              :     {
     203            1 :         AnsiState st = {0};
     204            1 :         const char *s = "\033[38;2;255;0;128m";
     205            1 :         ansi_scan(s, s + strlen(s), &st);
     206            1 :         ASSERT(st.fg_on == 1, "ansi_scan: fg on → 1");
     207            1 :         ASSERT(st.fg_r == 255 && st.fg_g == 0 && st.fg_b == 128,
     208              :                "ansi_scan: fg RGB correct");
     209              : 
     210            1 :         const char *s2 = "\033[39m";
     211            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     212            1 :         ASSERT(st.fg_on == 0, "ansi_scan: fg reset → 0");
     213              :     }
     214              : 
     215              :     /* Background color set and reset */
     216              :     {
     217            1 :         AnsiState st = {0};
     218            1 :         const char *s = "\033[48;2;0;64;255m";
     219            1 :         ansi_scan(s, s + strlen(s), &st);
     220            1 :         ASSERT(st.bg_on == 1, "ansi_scan: bg on → 1");
     221            1 :         ASSERT(st.bg_r == 0 && st.bg_g == 64 && st.bg_b == 255,
     222              :                "ansi_scan: bg RGB correct");
     223              : 
     224            1 :         const char *s2 = "\033[49m";
     225            1 :         ansi_scan(s2, s2 + strlen(s2), &st);
     226            1 :         ASSERT(st.bg_on == 0, "ansi_scan: bg reset → 0");
     227              :     }
     228              : 
     229              :     /* Full reset \033[0m clears all accumulated state */
     230              :     {
     231            1 :         AnsiState st = {0};
     232            1 :         const char *s = "\033[1m\033[3m\033[38;2;255;0;0m\033[0m";
     233            1 :         ansi_scan(s, s + strlen(s), &st);
     234            1 :         ASSERT(st.bold==0 && st.italic==0 && st.fg_on==0,
     235              :                "ansi_scan: full reset clears all");
     236              :     }
     237              : 
     238              :     /* Partial scan: only up to a mid-point in the string */
     239              :     {
     240              :         /* Scan only the first segment (bold+color open), stop before close */
     241            1 :         const char *body = "\033[1m\033[38;2;255;0;0mLine 0\nLine 1\n\033[22m\033[39m";
     242            1 :         const char *nl   = strchr(body, '\n');  /* end of "Line 0" */
     243            1 :         AnsiState st = {0};
     244            1 :         ansi_scan(body, nl, &st);
     245            1 :         ASSERT(st.bold == 1,  "ansi_scan: partial scan bold open");
     246            1 :         ASSERT(st.fg_on == 1, "ansi_scan: partial scan fg open");
     247              :     }
     248              : 
     249              :     /* ── print_body_page ─────────────────────────────────────────────── */
     250              :     /*
     251              :      * Redirect stdout to /dev/null so the printed lines do not pollute
     252              :      * the test runner output.  Restore after.
     253              :      */
     254              :     {
     255            1 :         fflush(stdout);
     256            1 :         int saved_fd = dup(STDOUT_FILENO);
     257            1 :         int null_fd  = open("/dev/null", O_WRONLY);
     258            1 :         if (null_fd >= 0) dup2(null_fd, STDOUT_FILENO);
     259            1 :         if (null_fd >= 0) close(null_fd);
     260              : 
     261              :         /* Print lines 1-2 of a 4-line body (normal newline path) */
     262            1 :         print_body_page("Line 0\nLine 1\nLine 2\nLine 3\n", 1, 2, 80);
     263              : 
     264              :         /* Body does not end with '\n': last segment hits the else branch
     265              :          * (printf("%s\n", p); break;) at lines 255-257 */
     266            1 :         print_body_page("Line 0\nNo newline here", 1, 5, 80);
     267              : 
     268              :         /* from_line == 0, single print */
     269            1 :         print_body_page("only line", 0, 1, 80);
     270              : 
     271            1 :         fflush(stdout);
     272            1 :         dup2(saved_fd, STDOUT_FILENO);
     273            1 :         close(saved_fd);
     274              :     }
     275              : 
     276              :     /*
     277              :      * Regression test: ANSI state must be replayed at page boundaries.
     278              :      *
     279              :      * A multi-line styled span (e.g. <div style="color:red">) produces:
     280              :      *   \033[38;2;255;0;0mLine 0\nLine 1\nLine 2\n\n\033[39m
     281              :      *
     282              :      * When paginating from line 1 onward, the fg-color escape from line 0
     283              :      * would have been SKIPPED.  Without the fix, Line 1 and Line 2 appeared
     284              :      * in the terminal's default color — and if the terminal had a dark theme
     285              :      * and the email also set background:white, the result was white-on-white.
     286              :      *
     287              :      * The fix (ansi_scan + ansi_replay) re-emits the color escape before the
     288              :      * first visible line.  This test captures stdout via a pipe and asserts
     289              :      * the replayed escape is present.
     290              :      */
     291              :     {
     292              :         /* Body that html_render() would produce for a multi-line color span */
     293            1 :         const char *body =
     294              :             "\033[38;2;255;0;0mLine 0\n"   /* fg red open on line 0 */
     295              :             "Line 1\n"
     296              :             "Line 2\n"
     297              :             "\033[39m";                    /* fg reset after last line */
     298              : 
     299            1 :         int pipefd[2];
     300            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "page ANSI replay: pipe failed"); goto skip_replay_fg; }
     301            1 :         fflush(stdout);
     302            1 :         int saved = dup(STDOUT_FILENO);
     303            1 :         dup2(pipefd[1], STDOUT_FILENO);
     304            1 :         close(pipefd[1]);
     305              : 
     306              :         /* Skip line 0; print lines 1-2 */
     307            1 :         print_body_page(body, 1, 2, 80);
     308              : 
     309            1 :         fflush(stdout);
     310            1 :         dup2(saved, STDOUT_FILENO);
     311            1 :         close(saved);
     312              : 
     313            1 :         char buf[256] = {0};
     314            1 :         ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
     315            1 :         close(pipefd[0]);
     316            1 :         buf[n > 0 ? n : 0] = '\0';
     317              : 
     318              :         /* The replayed fg-red escape must appear before "Line 1" */
     319            1 :         const char *esc  = strstr(buf, "\033[38;2;255;0;0m");
     320            1 :         const char *line1 = strstr(buf, "Line 1");
     321            1 :         ASSERT(esc != NULL,
     322              :                "page ANSI replay: fg color escape present in page-2 output");
     323            1 :         ASSERT(line1 != NULL,
     324              :                "page ANSI replay: Line 1 present in output");
     325            1 :         ASSERT(esc < line1,
     326              :                "page ANSI replay: fg escape precedes Line 1");
     327            1 :         skip_replay_fg:;
     328              :     }
     329              : 
     330              :     /*
     331              :      * Regression test: background color must also be replayed.
     332              :      * This models the exact scenario that caused white-on-white:
     333              :      * a <div style="background-color:white"> spanning multiple lines.
     334              :      */
     335              :     {
     336            1 :         const char *body =
     337              :             "\033[48;2;255;255;255mLine 0\n"   /* bg white on line 0 */
     338              :             "Line 1\n"
     339              :             "\033[49m";
     340              : 
     341            1 :         int pipefd[2];
     342            1 :         if (pipe(pipefd) != 0) { ASSERT(0, "page ANSI replay bg: pipe failed"); goto skip_replay_bg; }
     343            1 :         fflush(stdout);
     344            1 :         int saved = dup(STDOUT_FILENO);
     345            1 :         dup2(pipefd[1], STDOUT_FILENO);
     346            1 :         close(pipefd[1]);
     347              : 
     348            1 :         print_body_page(body, 1, 1, 80);
     349              : 
     350            1 :         fflush(stdout);
     351            1 :         dup2(saved, STDOUT_FILENO);
     352            1 :         close(saved);
     353              : 
     354            1 :         char buf[256] = {0};
     355            1 :         ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
     356            1 :         close(pipefd[0]);
     357            1 :         buf[n > 0 ? n : 0] = '\0';
     358              : 
     359            1 :         const char *esc   = strstr(buf, "\033[48;2;255;255;255m");
     360            1 :         const char *line1 = strstr(buf, "Line 1");
     361            1 :         ASSERT(esc != NULL,
     362              :                "page ANSI replay bg: bg color escape present in page-2 output");
     363            1 :         ASSERT(esc < line1,
     364              :                "page ANSI replay bg: bg escape precedes Line 1");
     365            1 :         skip_replay_bg:;
     366              :     }
     367              : 
     368              :     /* ── print_padded_col (non-ASCII paths, lines 83-91) ─────────────── */
     369              :     /*
     370              :      * Redirect stdout to /dev/null to avoid polluting test output.
     371              :      * print_padded_col writes to stdout via fwrite/putchar.
     372              :      */
     373              :     {
     374            1 :         fflush(stdout);
     375            1 :         int saved_fd2 = dup(STDOUT_FILENO);
     376            1 :         int null_fd2  = open("/dev/null", O_WRONLY);
     377            1 :         if (null_fd2 >= 0) dup2(null_fd2, STDOUT_FILENO);
     378            1 :         if (null_fd2 >= 0) close(null_fd2);
     379              : 
     380              :         /* 0x80 = invalid lead byte → line 83 */
     381            1 :         print_padded_col("\x80 bad", 20);
     382              : 
     383              :         /* 2-byte UTF-8: é = \xC3\xA9 → line 84 */
     384            1 :         print_padded_col("\xC3\xA9 cafe", 20);
     385              : 
     386              :         /* 3-byte UTF-8: 中 = \xE4\xB8\xAD → line 85 */
     387            1 :         print_padded_col("\xE4\xB8\xAD word", 20);
     388              : 
     389              :         /* 4-byte UTF-8: U+10000 = \xF0\x90\x80\x80 → line 86 */
     390            1 :         print_padded_col("\xF0\x90\x80\x80 hi", 20);
     391              : 
     392              :         /* 0xFE = invalid lead byte >= 0xF8 → line 87 */
     393            1 :         print_padded_col("\xFE bad", 20);
     394              : 
     395              :         /* Truncated 2-byte: \xC3 then 'A' (not continuation) → lines 90-91 */
     396            1 :         print_padded_col("\xC3\x41 trunc", 20);
     397              : 
     398            1 :         fflush(stdout);
     399            1 :         dup2(saved_fd2, STDOUT_FILENO);
     400            1 :         close(saved_fd2);
     401              :     }
     402              : 
     403              :     /*
     404              :      * Regression: visual row budget in print_body_page.
     405              :      *
     406              :      * Body has 1 normal line + 1 very wide line (wider than term_cols) +
     407              :      * 2 more normal lines.  With a visual row budget of 3 on a 40-col
     408              :      * terminal, the wide line consumes multiple visual rows, so the 3rd
     409              :      * normal line should NOT appear in the output.
     410              :      *
     411              :      * This proves print_body_page stops at the visual row budget, not the
     412              :      * logical line count.
     413              :      */
     414              :     {
     415              :         /* Build a 120-char URL-like token (fits on 1 logical line, 3 visual rows on 40-col) */
     416            1 :         char wide[121]; memset(wide, 'W', 120); wide[120] = '\0';
     417            1 :         char body_vr[256];
     418            1 :         snprintf(body_vr, sizeof(body_vr),
     419              :                  "NormalA\n%s\nNormalB\nNormalC\n", wide);
     420              : 
     421            1 :         int pipefd[2];
     422            1 :         if (pipe(pipefd) != 0) {
     423            0 :             ASSERT(0, "visual rows: pipe failed");
     424              :             goto skip_vr_test;
     425              :         }
     426            1 :         fflush(stdout);
     427            1 :         int saved_vr = dup(STDOUT_FILENO);
     428            1 :         dup2(pipefd[1], STDOUT_FILENO);
     429            1 :         close(pipefd[1]);
     430              : 
     431              :         /* budget = 4 visual rows on 40-col terminal:
     432              :          *   NormalA  = 1 row  (total 1)
     433              :          *   wide 120 = 3 rows (total 4) → fits in budget
     434              :          *   NormalB  = 1 row  (total 5 > 4) → should NOT appear
     435              :          *   NormalC  → should NOT appear */
     436            1 :         print_body_page(body_vr, 0, 4, 40);
     437              : 
     438            1 :         fflush(stdout);
     439            1 :         dup2(saved_vr, STDOUT_FILENO);
     440            1 :         close(saved_vr);
     441              : 
     442            1 :         char buf_vr[512] = {0};
     443            1 :         ssize_t n_vr = read(pipefd[0], buf_vr, sizeof(buf_vr) - 1);
     444            1 :         close(pipefd[0]);
     445            1 :         buf_vr[n_vr > 0 ? n_vr : 0] = '\0';
     446              : 
     447            1 :         ASSERT(strstr(buf_vr, "NormalA")  != NULL,
     448              :                "visual rows: NormalA shown (fits in budget)");
     449            1 :         ASSERT(strstr(buf_vr, wide)        != NULL,
     450              :                "visual rows: wide line shown (fits in budget)");
     451            1 :         ASSERT(strstr(buf_vr, "NormalB")  == NULL,
     452              :                "visual rows: NormalB NOT shown (budget exhausted)");
     453            1 :         ASSERT(strstr(buf_vr, "NormalC")  == NULL,
     454              :                "visual rows: NormalC NOT shown (budget exhausted)");
     455            1 :         skip_vr_test:;
     456              :     }
     457              : 
     458              :     /* ── print_clean — truncation at max_cols ───────────────────────── */
     459              :     /*
     460              :      * Regression test for ce09877: print_clean must stop emitting characters
     461              :      * once the visible column count reaches max_cols, so that header values
     462              :      * (From/Subject/Date) never overflow the 80-column display width.
     463              :      *
     464              :      * We capture stdout via a pipe, call print_clean with a 200-char ASCII
     465              :      * string and max_cols=10, then verify the captured output is ≤ 10 bytes.
     466              :      */
     467              :     {
     468            1 :         char long_str[201];
     469            1 :         memset(long_str, 'A', 200);
     470            1 :         long_str[200] = '\0';
     471              : 
     472            1 :         int pipefd[2];
     473            1 :         if (pipe(pipefd) != 0) {
     474            0 :             ASSERT(0, "print_clean truncation: pipe failed");
     475              :             goto skip_print_clean;
     476              :         }
     477            1 :         fflush(stdout);
     478            1 :         int saved_pc = dup(STDOUT_FILENO);
     479            1 :         dup2(pipefd[1], STDOUT_FILENO);
     480            1 :         close(pipefd[1]);
     481              : 
     482            1 :         print_clean(long_str, "(none)", 10);
     483              : 
     484            1 :         fflush(stdout);
     485            1 :         dup2(saved_pc, STDOUT_FILENO);
     486            1 :         close(saved_pc);
     487              : 
     488            1 :         char buf_pc[256] = {0};
     489            1 :         ssize_t n_pc = read(pipefd[0], buf_pc, sizeof(buf_pc) - 1);
     490            1 :         close(pipefd[0]);
     491            1 :         buf_pc[n_pc > 0 ? n_pc : 0] = '\0';
     492              : 
     493            1 :         ASSERT((int)strlen(buf_pc) <= 10,
     494              :                "print_clean: output truncated to max_cols=10");
     495            1 :         ASSERT(strlen(buf_pc) > 0,
     496              :                "print_clean: output is non-empty");
     497            1 :         skip_print_clean:;
     498              :     }
     499              : 
     500              :     /* NULL input falls back to fallback string */
     501              :     {
     502            1 :         int pipefd[2];
     503            1 :         if (pipe(pipefd) != 0) {
     504            0 :             ASSERT(0, "print_clean fallback: pipe failed");
     505              :             goto skip_print_clean_fb;
     506              :         }
     507            1 :         fflush(stdout);
     508            1 :         int saved_fb = dup(STDOUT_FILENO);
     509            1 :         dup2(pipefd[1], STDOUT_FILENO);
     510            1 :         close(pipefd[1]);
     511              : 
     512            1 :         print_clean(NULL, "(none)", 20);
     513              : 
     514            1 :         fflush(stdout);
     515            1 :         dup2(saved_fb, STDOUT_FILENO);
     516            1 :         close(saved_fb);
     517              : 
     518            1 :         char buf_fb[64] = {0};
     519            1 :         ssize_t n_fb = read(pipefd[0], buf_fb, sizeof(buf_fb) - 1);
     520            1 :         close(pipefd[0]);
     521            1 :         buf_fb[n_fb > 0 ? n_fb : 0] = '\0';
     522              : 
     523            1 :         ASSERT(strcmp(buf_fb, "(none)") == 0,
     524              :                "print_clean: NULL input uses fallback");
     525            1 :         skip_print_clean_fb:;
     526              :     }
     527              : 
     528              :     /* ── cmp_uid_entry ───────────────────────────────────────────────── */
     529              :     {
     530            1 :         MsgEntry a = {100, MSG_FLAG_UNSEEN, 1000};  /* unseen */
     531            1 :         MsgEntry b = {200, 0,               2000};  /* seen   */
     532              :         /* unseen before seen regardless of date */
     533            1 :         ASSERT(cmp_uid_entry(&a, &b) < 0, "cmp_uid_entry: unseen before seen");
     534            1 :         ASSERT(cmp_uid_entry(&b, &a) > 0, "cmp_uid_entry: seen after unseen");
     535              :     }
     536              :     {
     537            1 :         MsgEntry c = {100, MSG_FLAG_UNSEEN, 1000};
     538            1 :         MsgEntry d = {200, MSG_FLAG_UNSEEN, 2000};
     539              :         /* both unseen: newer date (higher epoch) first */
     540            1 :         ASSERT(cmp_uid_entry(&c, &d) > 0, "cmp_uid_entry: older date after newer");
     541            1 :         ASSERT(cmp_uid_entry(&d, &c) < 0, "cmp_uid_entry: newer date before older");
     542              :     }
     543              :     {
     544            1 :         MsgEntry e = {100, MSG_FLAG_FLAGGED, 500};
     545            1 :         MsgEntry f = {200, 0,                500};
     546              :         /* flagged (read) before plain read */
     547            1 :         ASSERT(cmp_uid_entry(&e, &f) < 0, "cmp_uid_entry: flagged before rest");
     548              :     }
     549              :     {
     550            1 :         MsgEntry g = {100, 0, 0};
     551            1 :         MsgEntry h = {100, 0, 0};
     552              :         /* equal: cmp == 0 */
     553            1 :         ASSERT(cmp_uid_entry(&g, &h) == 0, "cmp_uid_entry: equal entries → 0");
     554              :     }
     555              : 
     556              :     /* ── is_last_sibling ─────────────────────────────────────────────── */
     557              : 
     558              :     /* Root-level two items: first is not last, second is */
     559              :     {
     560            1 :         char *names[] = {"A", "B"};
     561            1 :         ASSERT(is_last_sibling(names, 2, 0, '.') == 0,
     562              :                "is_last_sibling: A not last (B follows)");
     563            1 :         ASSERT(is_last_sibling(names, 2, 1, '.') == 1,
     564              :                "is_last_sibling: B is last");
     565              :     }
     566              : 
     567              :     /* parent_len == 0 path (line 582): root-level item with multiple followers */
     568              :     {
     569            1 :         char *names[] = {"A", "B", "C"};
     570            1 :         ASSERT(is_last_sibling(names, 3, 0, '.') == 0,
     571              :                "is_last_sibling: root level, A not last");
     572              :     }
     573              : 
     574              :     /* line 587: jumped to a different parent subtree → return 1 */
     575              :     {
     576              :         /* INBOX, INBOX.A, INBOX.B, Other — sorted */
     577            1 :         char *names[] = {"INBOX", "INBOX.A", "INBOX.B", "Other"};
     578              :         /* INBOX.A: sibling INBOX.B follows → not last */
     579            1 :         ASSERT(is_last_sibling(names, 4, 1, '.') == 0,
     580              :                "is_last_sibling: INBOX.A not last");
     581              :         /* INBOX.B: next is Other (different parent subtree) → last */
     582            1 :         ASSERT(is_last_sibling(names, 4, 2, '.') == 1,
     583              :                "is_last_sibling: INBOX.B is last (diff parent)");
     584              :     }
     585              : 
     586              :     /* Single item → always last */
     587              :     {
     588            1 :         char *names[] = {"INBOX"};
     589            1 :         ASSERT(is_last_sibling(names, 1, 0, '.') == 1,
     590              :                "is_last_sibling: single item is last");
     591              :     }
     592              : 
     593              :     /* ── ancestor_is_last ────────────────────────────────────────────── */
     594              : 
     595              :     /* Root-level ancestor with a sibling following: parent_len == 0, return 0 (line 630) */
     596              :     {
     597            1 :         char *names[] = {"INBOX", "INBOX.A", "INBOX.B", "Sent"};
     598              :         /* For INBOX.A at level=0: ancestor "INBOX" is NOT the last root (Sent follows) */
     599            1 :         int r = ancestor_is_last(names, 4, 1, 0, '.');
     600            1 :         ASSERT(r == 0, "ancestor_is_last: INBOX not last root");
     601              :     }
     602              : 
     603              :     /* Root-level ancestor that IS last */
     604              :     {
     605            1 :         char *names[] = {"INBOX", "INBOX.A", "Sent"};
     606              :         /* For Sent (index=2) at level=0: nothing after it → last */
     607            1 :         int r = ancestor_is_last(names, 3, 2, 0, '.');
     608            1 :         ASSERT(r == 1, "ancestor_is_last: Sent is last root");
     609              :     }
     610              : 
     611              :     /* level=0 with follower: parent_len==0 → return 0 (covers line 630) */
     612              :     {
     613            1 :         char *names[] = {"A.X", "A.Y", "B.Z"};
     614              :         /* A.Y's root-level ancestor is "A"; "B.Z" follows at root → return 0 */
     615            1 :         int r = ancestor_is_last(names, 3, 1, 0, '.');
     616            1 :         ASSERT(r == 0, "ancestor_is_last: level=0, another root item follows → 0");
     617              :     }
     618              : 
     619              :     /* line 636: jumped to different parent subtree → return 1 (level > 0) */
     620              :     {
     621              :         /* A.B.Y's ancestor at level=1 is "A.B"; parent of "A.B" is "A".
     622              :          * After A.B.Y's subtree, C.D has parent "C" ≠ "A" → return 1. */
     623            1 :         char *names[] = {"A.B.X", "A.B.Y", "C.D"};
     624            1 :         int r = ancestor_is_last(names, 3, 1, 1, '.');
     625            1 :         ASSERT(r == 1, "ancestor_is_last: level=1, different grandparent → 1");
     626              :     }
     627              : 
     628              :     /* Only one root-level folder (INBOX) → ancestor is last */
     629              :     {
     630            1 :         char *names[] = {"INBOX.A", "INBOX.A.X", "INBOX.A.Y", "INBOX.B"};
     631              :         /* All entries share root "INBOX"; nothing at a different root → last=1 */
     632            1 :         int r = ancestor_is_last(names, 4, 2, 0, '.');
     633            1 :         ASSERT(r == 1, "ancestor_is_last: INBOX is only root → 1");
     634              :     }
     635              : 
     636              :     /* ── HTML-only MIME: CSS must not leak into rendered output ──────── */
     637              :     /*
     638              :      * Regression test for show_uid_interactive: when an email has only a
     639              :      * text/html part (no text/plain), the body must be rendered through
     640              :      * html_render(), not passed through as raw text.  In particular, any
     641              :      * <style> block must be suppressed and visible body text must appear.
     642              :      */
     643              :     {
     644              :         /* Minimal MIME message: HTML-only, with an embedded <style> block */
     645            1 :         const char *mime_msg =
     646              :             "MIME-Version: 1.0\r\n"
     647              :             "Content-Type: text/html; charset=UTF-8\r\n"
     648              :             "\r\n"
     649              :             "<html>"
     650              :             "<head><style>body { color: red; font-family: Arial; }</style></head>"
     651              :             "<body><b>Visible Text</b></body>"
     652              :             "</html>";
     653              : 
     654            1 :         char *html = mime_get_html_part(mime_msg);
     655            1 :         ASSERT(html != NULL, "html-only mime: html part found");
     656              : 
     657            1 :         char *rendered = html_render(html, 0, 0);
     658            1 :         free(html);
     659            1 :         ASSERT(rendered != NULL, "html-only mime: render not NULL");
     660              : 
     661              :         /* Visible content must appear */
     662            1 :         ASSERT(strstr(rendered, "Visible Text") != NULL,
     663              :                "html-only mime: body text present in output");
     664              : 
     665              :         /* CSS must be suppressed */
     666            1 :         ASSERT(strstr(rendered, "color") == NULL,
     667              :                "html-only mime: CSS property 'color' not in output");
     668            1 :         ASSERT(strstr(rendered, "font-family") == NULL,
     669              :                "html-only mime: CSS property 'font-family' not in output");
     670            1 :         ASSERT(strstr(rendered, "Arial") == NULL,
     671              :                "html-only mime: CSS value 'Arial' not in output");
     672              : 
     673            1 :         free(rendered);
     674              :     }
     675              : 
     676              :     /* ── show_uid_interactive: uses correct folder, not cfg->folder ──── */
     677              :     /*
     678              :      * Regression test for subfolder message open bug.
     679              :      *
     680              :      * When the user presses Enter on a message in a subfolder (e.g. "munka/ai"),
     681              :      * show_uid_interactive must look up the message in that subfolder's cache —
     682              :      * NOT in cfg->folder (which is always "INBOX").
     683              :      *
     684              :      * Setup:
     685              :      *   - Pre-populate cache under "test_subfolder" with UID 7777.
     686              :      *   - Config has .folder = "INBOX" (wrong folder — the bug).
     687              :      *   - Inject ESC via pipe into STDIN_FILENO so the function exits cleanly.
     688              :      *
     689              :      * If the function uses cfg->folder ("INBOX"):
     690              :      *   local_msg_exists("INBOX", 7777) → false → fetch fails → returns -1.
     691              :      * If the function uses the correct folder ("test_subfolder"):
     692              :      *   local_msg_exists("test_subfolder", 7777) → true → loads OK → ESC → returns 1.
     693              :      */
     694              :     {
     695              :         /* Minimal plain-text MIME message */
     696            1 :         const char *sf_mime =
     697              :             "MIME-Version: 1.0\r\n"
     698              :             "Content-Type: text/plain; charset=UTF-8\r\n"
     699              :             "Subject: Subfolder test\r\n"
     700              :             "From: test@example.com\r\n"
     701              :             "\r\n"
     702              :             "Subfolder message body.\r\n";
     703              : 
     704              :         /* Pre-populate cache under the correct subfolder */
     705            1 :         int saved_rc = local_msg_save("test_subfolder", 7777,
     706              :                                   sf_mime, strlen(sf_mime));
     707            1 :         if (saved_rc != 0) {
     708            0 :             ASSERT(0, "show_uid_interactive subfolder: local_msg_save failed");
     709              :             goto skip_subfolder_test;
     710              :         }
     711              : 
     712              :         /* Config intentionally has the wrong folder (the bug) */
     713            1 :         Config sf_cfg;
     714            1 :         memset(&sf_cfg, 0, sizeof(sf_cfg));
     715            1 :         sf_cfg.folder = "INBOX";
     716              : 
     717              :         /* Inject ESC (\033) into stdin via pipe so the function exits */
     718            1 :         int sf_pipe[2];
     719            1 :         if (pipe(sf_pipe) != 0) {
     720            0 :             ASSERT(0, "show_uid_interactive subfolder: pipe failed");
     721              :             goto skip_subfolder_test;
     722              :         }
     723            1 :         unsigned char esc_byte = '\033';
     724            1 :         ssize_t _w = write(sf_pipe[1], &esc_byte, 1);
     725              :         (void)_w;
     726            1 :         close(sf_pipe[1]);
     727              : 
     728              :         /* Redirect stdin to pipe read end */
     729            1 :         int saved_stdin = dup(STDIN_FILENO);
     730            1 :         dup2(sf_pipe[0], STDIN_FILENO);
     731            1 :         close(sf_pipe[0]);
     732              : 
     733              :         /* Redirect stdout + stderr to /dev/null (suppress TUI output) */
     734            1 :         fflush(stdout); fflush(stderr);
     735            1 :         int sf_null = open("/dev/null", O_WRONLY);
     736            1 :         int saved_stdout = dup(STDOUT_FILENO);
     737            1 :         int saved_stderr = dup(STDERR_FILENO);
     738            1 :         if (sf_null >= 0) {
     739            1 :             dup2(sf_null, STDOUT_FILENO);
     740            1 :             dup2(sf_null, STDERR_FILENO);
     741            1 :             close(sf_null);
     742              :         }
     743              : 
     744              :         /* Call with new signature: explicit folder parameter */
     745            1 :         int sf_ret = show_uid_interactive(&sf_cfg, "test_subfolder", 7777, 25);
     746              : 
     747              :         /* Restore stdin, stdout, stderr */
     748            1 :         fflush(stdout); fflush(stderr);
     749            1 :         dup2(saved_stdin,  STDIN_FILENO);  close(saved_stdin);
     750            1 :         dup2(saved_stdout, STDOUT_FILENO); close(saved_stdout);
     751            1 :         dup2(saved_stderr, STDERR_FILENO); close(saved_stderr);
     752              : 
     753              :         /* ESC → returns 0 (back to list); "not found in INBOX" → returns -1 */
     754            1 :         ASSERT(sf_ret == 0,
     755              :                "show_uid_interactive: uses correct folder (not cfg->folder)");
     756              : 
     757            1 :         skip_subfolder_test:;
     758              :     }
     759              : }
        

Generated by: LCOV version 2.0-1