LCOV - code coverage report
Current view: top level - tests/unit - test_html_render.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 99.8 % 560 559
Test Date: 2026-04-15 21:12:52 Functions: 100.0 % 8 8

            Line data    Source code
       1              : #include "test_helpers.h"
       2              : #include "html_render.h"
       3              : #include <string.h>
       4              : #include <stdlib.h>
       5              : 
       6              : /* All tests use html_medium_stub (every printable codepoint = 1 column). */
       7              : 
       8              : /* ── Style-balance helpers ────────────────────────────────────────────── */
       9              : 
      10              : /** Count non-overlapping occurrences of needle in s. */
      11          960 : static int count_str(const char *s, const char *needle) {
      12          960 :     int n = 0;
      13          960 :     size_t nl = strlen(needle);
      14         1174 :     for (const char *p = s; (p = strstr(p, needle)) != NULL; p += nl) n++;
      15          960 :     return n;
      16              : }
      17              : 
      18              : /**
      19              :  * Render html with ansi=1 and assert that every ANSI "style-on" escape is
      20              :  * paired with its "style-off" counterpart (equal occurrence counts).
      21              :  *
      22              :  * Checks: bold \033[1m/\033[22m, italic \033[3m/\033[23m,
      23              :  *         underline \033[4m/\033[24m, strikethrough \033[9m/\033[29m,
      24              :  *         fg color \033[38;2;/\033[39m, bg color \033[48;2;/\033[49m.
      25              :  */
      26           48 : static void assert_style_balanced(const char *html, const char *label) {
      27           48 :     char *r = html_render(html, 0, 1);
      28           48 :     if (!r) { ASSERT(0, label); return; }
      29              : 
      30              :     /* bold */
      31           48 :     int bold_on  = count_str(r, "\033[1m");
      32           48 :     int bold_off = count_str(r, "\033[22m");
      33           48 :     ASSERT(bold_on == bold_off,   label);
      34              : 
      35              :     /* italic */
      36           48 :     int ital_on  = count_str(r, "\033[3m");
      37           48 :     int ital_off = count_str(r, "\033[23m");
      38           48 :     ASSERT(ital_on == ital_off,   label);
      39              : 
      40              :     /* underline */
      41           48 :     int uline_on  = count_str(r, "\033[4m");
      42           48 :     int uline_off = count_str(r, "\033[24m");
      43           48 :     ASSERT(uline_on == uline_off, label);
      44              : 
      45              :     /* strikethrough */
      46           48 :     int strike_on  = count_str(r, "\033[9m");
      47           48 :     int strike_off = count_str(r, "\033[29m");
      48           48 :     ASSERT(strike_on == strike_off, label);
      49              : 
      50              :     /* fg color */
      51           48 :     int fg_on  = count_str(r, "\033[38;2;");
      52           48 :     int fg_off = count_str(r, "\033[39m");
      53           48 :     ASSERT(fg_on == fg_off,       label);
      54              : 
      55              :     /* bg color */
      56           48 :     int bg_on  = count_str(r, "\033[48;2;");
      57           48 :     int bg_off = count_str(r, "\033[49m");
      58           48 :     ASSERT(bg_on == bg_off,       label);
      59              : 
      60           48 :     free(r);
      61              : }
      62              : 
      63            1 : void test_html_render(void) {
      64              : 
      65              :     /* 1. NULL input → NULL */
      66              :     {
      67            1 :         char *r = html_render(NULL, 0, 0);
      68            1 :         ASSERT(r == NULL, "render NULL: returns NULL");
      69              :     }
      70              : 
      71              :     /* 2. Empty input → empty string */
      72              :     {
      73            1 :         char *r = html_render("", 0, 0);
      74            1 :         ASSERT(r != NULL, "render empty: not NULL");
      75            1 :         ASSERT(*r == '\0', "render empty: empty string");
      76            1 :         free(r);
      77              :     }
      78              : 
      79              :     /* 3. Plain text passthrough */
      80              :     {
      81            1 :         char *r = html_render("Hello World", 0, 0);
      82            1 :         ASSERT(r && strcmp(r, "Hello World") == 0, "plain text passthrough");
      83            1 :         free(r);
      84              :     }
      85              : 
      86              :     /* 4. <b>X</b> → ansi=1: bold escapes */
      87              :     {
      88            1 :         char *r = html_render("<b>X</b>", 0, 1);
      89            1 :         ASSERT(r != NULL, "bold ansi: not NULL");
      90            1 :         ASSERT(strstr(r, "\033[1m") != NULL, "bold ansi: bold on");
      91            1 :         ASSERT(strstr(r, "X") != NULL, "bold ansi: content");
      92            1 :         ASSERT(strstr(r, "\033[22m") != NULL, "bold ansi: bold off");
      93            1 :         free(r);
      94              :     }
      95              : 
      96              :     /* 5. <b>X</b> → ansi=0: no escapes, just X */
      97              :     {
      98            1 :         char *r = html_render("<b>X</b>", 0, 0);
      99            1 :         ASSERT(r && strcmp(r, "X") == 0, "bold plain: just X");
     100            1 :         free(r);
     101              :     }
     102              : 
     103              :     /* 6. <i>X</i> → ansi=1: italic escapes */
     104              :     {
     105            1 :         char *r = html_render("<i>X</i>", 0, 1);
     106            1 :         ASSERT(r && strstr(r, "\033[3m") && strstr(r, "\033[23m"),
     107              :                "italic ansi: escapes present");
     108            1 :         free(r);
     109              :     }
     110              : 
     111              :     /* 7. <u>X</u> → ansi=1: underline escapes */
     112              :     {
     113            1 :         char *r = html_render("<u>X</u>", 0, 1);
     114            1 :         ASSERT(r && strstr(r, "\033[4m") && strstr(r, "\033[24m"),
     115              :                "underline ansi: escapes present");
     116            1 :         free(r);
     117              :     }
     118              : 
     119              :     /* 8. <br> → \n */
     120              :     {
     121            1 :         char *r = html_render("<br>", 0, 0);
     122            1 :         ASSERT(r != NULL, "br: not NULL");
     123            1 :         ASSERT(strchr(r, '\n') != NULL, "br: contains newline");
     124            1 :         free(r);
     125              :     }
     126              : 
     127              :     /* 9. <p>A</p><p>B</p> → A\n\nB\n\n */
     128              :     {
     129            1 :         char *r = html_render("<p>A</p><p>B</p>", 0, 0);
     130            1 :         ASSERT(r != NULL, "p p: not NULL");
     131            1 :         ASSERT(strcmp(r, "A\n\nB\n\n") == 0, "p p: paragraph separation");
     132            1 :         free(r);
     133              :     }
     134              : 
     135              :     /* 10. <h1> bold + blank line (ansi=1) */
     136              :     {
     137            1 :         char *r = html_render("<h1>Title</h1>", 0, 1);
     138            1 :         ASSERT(r != NULL, "h1: not NULL");
     139            1 :         ASSERT(strstr(r, "\033[1m") != NULL, "h1: bold on");
     140            1 :         ASSERT(strstr(r, "Title") != NULL, "h1: title present");
     141              :         /* ends with \n\n */
     142            1 :         size_t len = strlen(r);
     143            1 :         ASSERT(len >= 2 && r[len-1] == '\n' && r[len-2] == '\n',
     144              :                "h1: ends with blank line");
     145            1 :         free(r);
     146              :     }
     147              : 
     148              :     /* 11. <ul><li>a</li><li>b</li></ul> → bullets */
     149              :     {
     150            1 :         char *r = html_render("<ul><li>a</li><li>b</li></ul>", 0, 0);
     151            1 :         ASSERT(r != NULL, "ul: not NULL");
     152              :         /* Should contain "a" and "b" on separate lines */
     153            1 :         ASSERT(strstr(r, "a") != NULL, "ul: contains a");
     154            1 :         ASSERT(strstr(r, "b") != NULL, "ul: contains b");
     155              :         /* Should contain bullet (U+2022 UTF-8: E2 80 A2) */
     156            1 :         ASSERT(strstr(r, "\xe2\x80\xa2") != NULL, "ul: contains bullet");
     157            1 :         free(r);
     158              :     }
     159              : 
     160              :     /* 12. <ol><li>a</li><li>b</li></ol> → numbered */
     161              :     {
     162            1 :         char *r = html_render("<ol><li>a</li><li>b</li></ol>", 0, 0);
     163            1 :         ASSERT(r != NULL, "ol: not NULL");
     164            1 :         ASSERT(strstr(r, "1.") != NULL, "ol: first item numbered");
     165            1 :         ASSERT(strstr(r, "2.") != NULL, "ol: second item numbered");
     166            1 :         ASSERT(strstr(r, "a") != NULL, "ol: item a");
     167            1 :         ASSERT(strstr(r, "b") != NULL, "ol: item b");
     168            1 :         free(r);
     169              :     }
     170              : 
     171              :     /* 13. <script> → empty output */
     172              :     {
     173            1 :         char *r = html_render("<script>js()</script>", 0, 0);
     174            1 :         ASSERT(r != NULL, "script: not NULL");
     175            1 :         ASSERT(*r == '\0', "script: empty output");
     176            1 :         free(r);
     177              :     }
     178              : 
     179              :     /* 14. <style> → empty output */
     180              :     {
     181            1 :         char *r = html_render("<style>body{color:red}</style>", 0, 0);
     182            1 :         ASSERT(r != NULL, "style: not NULL");
     183            1 :         ASSERT(*r == '\0', "style: empty output");
     184            1 :         free(r);
     185              :     }
     186              : 
     187              :     /* 15. Entities decoded in output */
     188              :     {
     189            1 :         char *r = html_render("a &amp; b", 0, 0);
     190            1 :         ASSERT(r && strcmp(r, "a & b") == 0, "entity in render: decoded");
     191            1 :         free(r);
     192              :     }
     193              : 
     194              :     /* 16. Word-wrap: width=10 → lines <= 10 chars */
     195              :     {
     196            1 :         char *r = html_render("Hello World Foo Bar Baz", 10, 0);
     197            1 :         ASSERT(r != NULL, "wrap: not NULL");
     198              :         /* Each line should be ≤ 10 chars */
     199            1 :         int ok = 1;
     200            1 :         const char *p = r;
     201            5 :         while (*p) {
     202            3 :             int linelen = 0;
     203           24 :             while (*p && *p != '\n') { linelen++; p++; }
     204            3 :             if (linelen > 10) { ok = 0; break; }
     205            3 :             if (*p == '\n') p++;
     206              :         }
     207            1 :         ASSERT(ok, "wrap width=10: all lines <= 10");
     208            1 :         free(r);
     209              :     }
     210              : 
     211              :     /* 17. width=0 → no line breaks from wrapping */
     212              :     {
     213            1 :         char *r = html_render("Hello World Foo Bar Baz", 0, 0);
     214            1 :         ASSERT(r != NULL, "no wrap: not NULL");
     215            1 :         ASSERT(strchr(r, '\n') == NULL, "no wrap: no newlines");
     216            1 :         free(r);
     217              :     }
     218              : 
     219              :     /* 18. <img alt="kep"> → [kep] */
     220              :     {
     221            1 :         char *r = html_render("<img alt=\"kep\">", 0, 0);
     222            1 :         ASSERT(r != NULL, "img alt: not NULL");
     223            1 :         ASSERT(strstr(r, "[kep]") != NULL, "img alt: [kep] in output");
     224            1 :         free(r);
     225              :     }
     226              : 
     227              :     /* 19. <a href="url">text</a> → link text + href URL emitted after */
     228              :     {
     229            1 :         char *r = html_render("<a href=\"http://example.com\">link text</a>", 0, 0);
     230            1 :         ASSERT(r != NULL, "a href: not NULL");
     231            1 :         ASSERT(strstr(r, "link text") != NULL, "a href: text present");
     232            1 :         ASSERT(strstr(r, "http://example.com") != NULL, "a href: URL present");
     233            1 :         free(r);
     234              :     }
     235              : 
     236              :     /* 20. <blockquote> → > prefix */
     237              :     {
     238            1 :         char *r = html_render("<blockquote>text</blockquote>", 0, 0);
     239            1 :         ASSERT(r != NULL, "blockquote: not NULL");
     240            1 :         ASSERT(strstr(r, "> text") != NULL || strstr(r, ">text") != NULL,
     241              :                "blockquote: > prefix present");
     242            1 :         free(r);
     243              :     }
     244              : 
     245              :     /* 21. <strong> same as <b> */
     246              :     {
     247            1 :         char *r = html_render("<strong>X</strong>", 0, 1);
     248            1 :         ASSERT(r && strstr(r, "\033[1m"), "strong: bold escape present");
     249            1 :         free(r);
     250              :     }
     251              : 
     252              :     /* 22. <em> same as <i> */
     253              :     {
     254            1 :         char *r = html_render("<em>X</em>", 0, 1);
     255            1 :         ASSERT(r && strstr(r, "\033[3m"), "em: italic escape present");
     256            1 :         free(r);
     257              :     }
     258              : 
     259              :     /* 23. Nested bold — only one pair of escapes */
     260              :     {
     261            1 :         char *r = html_render("<b><b>X</b></b>", 0, 1);
     262            1 :         ASSERT(r != NULL, "nested bold: not NULL");
     263              :         /* Count \033[1m occurrences: should be exactly 1 */
     264            1 :         int cnt = 0;
     265            1 :         const char *p = r;
     266            2 :         while ((p = strstr(p, "\033[1m")) != NULL) { cnt++; p += 4; }
     267            1 :         ASSERT(cnt == 1, "nested bold: only one bold-on escape");
     268            1 :         free(r);
     269              :     }
     270              : 
     271              :     /* 24. <s>X</s> → ansi=1: strikethrough escapes */
     272              :     {
     273            1 :         char *r = html_render("<s>X</s>", 0, 1);
     274            1 :         ASSERT(r != NULL, "strike: not NULL");
     275            1 :         ASSERT(strstr(r, "\033[9m") != NULL, "strike: strike-on escape");
     276            1 :         ASSERT(strstr(r, "\033[29m") != NULL, "strike: strike-off escape");
     277            1 :         free(r);
     278              :     }
     279              : 
     280              :     /* 25. <del>X</del> → same as <s> */
     281              :     {
     282            1 :         char *r = html_render("<del>X</del>", 0, 1);
     283            1 :         ASSERT(r != NULL, "del: not NULL");
     284            1 :         ASSERT(strstr(r, "\033[9m") != NULL, "del: strike-on escape");
     285            1 :         free(r);
     286              :     }
     287              : 
     288              :     /* 26. <h2>Title</h2> → bold + blank line */
     289              :     {
     290            1 :         char *r = html_render("<h2>Title</h2>", 0, 1);
     291            1 :         ASSERT(r != NULL, "h2: not NULL");
     292            1 :         ASSERT(strstr(r, "\033[1m") != NULL, "h2: bold on");
     293            1 :         size_t len = strlen(r);
     294            1 :         ASSERT(len >= 2 && r[len-1] == '\n' && r[len-2] == '\n',
     295              :                "h2: ends with blank line");
     296            1 :         free(r);
     297              :     }
     298              : 
     299              :     /* 27. <hr> → line of dashes */
     300              :     {
     301            1 :         char *r = html_render("<hr>", 0, 0);
     302            1 :         ASSERT(r != NULL, "hr: not NULL");
     303            1 :         ASSERT(strstr(r, "---") != NULL, "hr: contains dashes");
     304            1 :         free(r);
     305              :     }
     306              : 
     307              :     /* 28. <table><tr><td>A</td><td>B</td></tr></table> → A and B present */
     308              :     {
     309            1 :         char *r = html_render("<table><tr><td>A</td><td>B</td></tr></table>", 0, 0);
     310            1 :         ASSERT(r != NULL, "td: not NULL");
     311            1 :         ASSERT(strstr(r, "A") != NULL, "td: A present");
     312            1 :         ASSERT(strstr(r, "B") != NULL, "td: B present");
     313            1 :         free(r);
     314              :     }
     315              : 
     316              :     /* 29. <th>Head</th> → heading content present */
     317              :     {
     318            1 :         char *r = html_render("<table><tr><th>Head</th></tr></table>", 0, 0);
     319            1 :         ASSERT(r != NULL, "th: not NULL");
     320            1 :         ASSERT(strstr(r, "Head") != NULL, "th: heading present");
     321            1 :         free(r);
     322              :     }
     323              : 
     324              :     /* 30. <input value="val"> → value in output */
     325              :     {
     326            1 :         char *r = html_render("<input value=\"val\">", 0, 0);
     327            1 :         ASSERT(r != NULL, "input val: not NULL");
     328            1 :         ASSERT(strstr(r, "val") != NULL, "input val: value present");
     329            1 :         free(r);
     330              :     }
     331              : 
     332              :     /* 31. <pre> mode — no word-wrap, newlines preserved */
     333              :     {
     334            1 :         char *r = html_render("<pre>line1\nline2</pre>", 5, 0);
     335            1 :         ASSERT(r != NULL, "pre: not NULL");
     336            1 :         ASSERT(strstr(r, "line1") != NULL, "pre: line1 present");
     337            1 :         ASSERT(strstr(r, "line2") != NULL, "pre: line2 present");
     338              :         /* pre should not word-wrap even with width=5 */
     339            1 :         ASSERT(strstr(r, "line1\nline2") != NULL, "pre: newline preserved");
     340            1 :         free(r);
     341              :     }
     342              : 
     343              :     /* 32. Multi-byte UTF-8 word-wrap */
     344              :     {
     345              :         /* "árvíztűrő" is 9 chars, each 2-byte UTF-8 → 9 visible cols */
     346            1 :         char *r = html_render("\xc3\xa1rv\xc3\xadzt\xc5\xb1r\xc5\x91 world", 8, 0);
     347            1 :         ASSERT(r != NULL, "utf8 wrap: not NULL");
     348              :         /* must not crash and must contain both words */
     349            1 :         ASSERT(strstr(r, "world") != NULL, "utf8 wrap: world present");
     350            1 :         free(r);
     351              :     }
     352              : 
     353              :     /* 33. ANSI skip in visible width — ANSI escapes not counted as columns */
     354              :     {
     355            1 :         char *r = html_render("<b>Hello World Foo Bar</b>", 12, 1);
     356            1 :         ASSERT(r != NULL, "ansi vis_width: not NULL");
     357              :         /* Lines should still wrap at 12 visible columns despite ANSI escapes */
     358            1 :         int ok = 1;
     359            1 :         const char *p = r;
     360            4 :         while (*p) {
     361            2 :             int vis = 0;
     362           22 :             while (*p && *p != '\n') {
     363           20 :                 if (*p == '\033') {
     364            9 :                     while (*p && *p != 'm') p++;
     365            2 :                     if (*p) p++;
     366           18 :                 } else { vis++; p++; }
     367              :             }
     368            2 :             if (vis > 12) { ok = 0; break; }
     369            2 :             if (*p == '\n') p++;
     370              :         }
     371            1 :         ASSERT(ok, "ansi vis_width: wrap respects visible cols");
     372            1 :         free(r);
     373              :     }
     374              : 
     375              :     /* 34. style="font-weight:bold" → bold escape (parse_style) */
     376              :     {
     377            1 :         char *r = html_render("<span style=\"font-weight:bold\">X</span>", 0, 1);
     378            1 :         ASSERT(r != NULL, "style bold: not NULL");
     379            1 :         ASSERT(strstr(r, "\033[1m") != NULL, "style bold: bold escape present");
     380            1 :         free(r);
     381              :     }
     382              : 
     383              :     /* 35. style="font-style:italic" → italic escape */
     384              :     {
     385            1 :         char *r = html_render("<span style=\"font-style:italic\">X</span>", 0, 1);
     386            1 :         ASSERT(r != NULL, "style italic: not NULL");
     387            1 :         ASSERT(strstr(r, "\033[3m") != NULL, "style italic: italic escape present");
     388            1 :         free(r);
     389              :     }
     390              : 
     391              :     /* 36. style="text-decoration:underline" → underline escape */
     392              :     {
     393            1 :         char *r = html_render("<span style=\"text-decoration:underline\">X</span>", 0, 1);
     394            1 :         ASSERT(r != NULL, "style uline: not NULL");
     395            1 :         ASSERT(strstr(r, "\033[4m") != NULL, "style uline: underline escape present");
     396            1 :         free(r);
     397              :     }
     398              : 
     399              :     /* 37. style="color:#FF0000" → 24-bit ANSI color escape */
     400              :     {
     401            1 :         char *r = html_render("<span style=\"color:#FF0000\">X</span>", 0, 1);
     402            1 :         ASSERT(r != NULL, "style color hex6: not NULL");
     403              :         /* Should contain \033[38;2;255;0;0m */
     404            1 :         ASSERT(strstr(r, "\033[38;2;255;0;0m") != NULL, "style color #RRGGBB: escape present");
     405            1 :         free(r);
     406              :     }
     407              : 
     408              :     /* 38. style="color:#F00" → 24-bit ANSI (shorthand #RGB) */
     409              :     {
     410            1 :         char *r = html_render("<span style=\"color:#F00\">X</span>", 0, 1);
     411            1 :         ASSERT(r != NULL, "style color hex3: not NULL");
     412            1 :         ASSERT(strstr(r, "\033[38;2;") != NULL, "style color #RGB: escape present");
     413            1 :         free(r);
     414              :     }
     415              : 
     416              :     /* 39. style="color:red" → named CSS color → ANSI escape */
     417              :     {
     418            1 :         char *r = html_render("<span style=\"color:red\">X</span>", 0, 1);
     419            1 :         ASSERT(r != NULL, "style color named: not NULL");
     420            1 :         ASSERT(strstr(r, "\033[38;2;") != NULL, "style color name: escape present");
     421            1 :         free(r);
     422              :     }
     423              : 
     424              :     /* 40. style="background-color:blue" → bg colors suppressed */
     425              :     {
     426            1 :         char *r = html_render("<span style=\"background-color:blue\">X</span>", 0, 1);
     427            1 :         ASSERT(r != NULL, "style bgcolor: not NULL");
     428            1 :         ASSERT(strstr(r, "\033[48;2;") == NULL, "style bgcolor: bg escape suppressed");
     429            1 :         ASSERT(strstr(r, "X") != NULL, "style bgcolor: text still present");
     430            1 :         free(r);
     431              :     }
     432              : 
     433              :     /* 41. style="background-color:#0000FF" → bg colors suppressed */
     434              :     {
     435            1 :         char *r = html_render("<span style=\"background-color:#0000FF\">X</span>", 0, 1);
     436            1 :         ASSERT(r != NULL, "style bgcolor hex: not NULL");
     437            1 :         ASSERT(strstr(r, "\033[48;2;") == NULL, "style bgcolor hex: bg escape suppressed");
     438            1 :         ASSERT(strstr(r, "X") != NULL, "style bgcolor hex: text still present");
     439            1 :         free(r);
     440              :     }
     441              : 
     442              :     /* 42. 3-byte UTF-8 text in html_render (triggers utf8_adv 3-byte path) */
     443              :     {
     444              :         /* U+4E2D = \xe4\xb8\xad (3-byte), U+6587 = \xe6\x96\x87 (3-byte) */
     445            1 :         char *r = html_render("\xe4\xb8\xad\xe6\x96\x87 hello", 0, 0);
     446            1 :         ASSERT(r != NULL, "utf8 3byte: not NULL");
     447            1 :         ASSERT(strstr(r, "hello") != NULL, "utf8 3byte: ASCII present");
     448            1 :         free(r);
     449              :     }
     450              : 
     451              :     /* 43. 4-byte UTF-8 text (triggers utf8_adv 4-byte path) */
     452              :     {
     453              :         /* U+1F600 = \xf0\x9f\x98\x80 (4-byte emoji) */
     454            1 :         char *r = html_render("\xf0\x9f\x98\x80 hello", 0, 0);
     455            1 :         ASSERT(r != NULL, "utf8 4byte: not NULL");
     456            1 :         ASSERT(strstr(r, "hello") != NULL, "utf8 4byte: ASCII present");
     457            1 :         free(r);
     458              :     }
     459              : 
     460              :     /* 44. Invalid hex digit in CSS color (hex_val returns 0 for unknown char)
     461              :      *     #GGGGGG parses to black (0,0,0); max=0 < 160 → dark fg suppressed */
     462              :     {
     463            1 :         char *r = html_render("<span style=\"color:#GGGGGG\">X</span>", 0, 1);
     464            1 :         ASSERT(r != NULL, "hex_val invalid: not NULL");
     465              :         /* Black (0,0,0) is too dark — suppressed under color-filter policy */
     466            1 :         ASSERT(strstr(r, "\033[38;2;") == NULL, "hex_val invalid: dark color suppressed");
     467            1 :         ASSERT(strstr(r, "X") != NULL, "hex_val invalid: text still present");
     468            1 :         free(r);
     469              :     }
     470              : 
     471              :     /* 45. ANSI escape in img alt → str_vis_width ANSI skip path (lines 73-74) */
     472              :     {
     473              :         /* alt attribute contains an ANSI escape → str_vis_width must skip it */
     474            1 :         char *r = html_render("<img alt=\"\033[1mBold\033[0m\">", 0, 0);
     475            1 :         ASSERT(r != NULL, "ansi in img alt: not NULL");
     476              :         /* The alt text is rendered as [<alt>] — content is visible part */
     477            1 :         ASSERT(strstr(r, "Bold") != NULL, "ansi in img alt: text visible");
     478            1 :         free(r);
     479              :     }
     480              : 
     481              :     /* 46. <img alt=" "> (space only) → no [ ] rendered (is_blank_str) */
     482              :     {
     483            1 :         char *r = html_render("<img alt=\" \">", 0, 0);
     484            1 :         ASSERT(r != NULL, "img blank alt space: not NULL");
     485            1 :         ASSERT(strstr(r, "[") == NULL, "img blank alt space: no [ ] output");
     486            1 :         free(r);
     487              :     }
     488              : 
     489              :     /* 47. <img alt> with nbsp-only (U+00A0) → no [ ] rendered */
     490              :     {
     491              :         /* \xC2\xA0 = UTF-8 encoding of U+00A0 NON-BREAKING SPACE */
     492            1 :         char *r = html_render("<img alt=\"\xC2\xA0\">", 0, 0);
     493            1 :         ASSERT(r != NULL, "img nbsp alt: not NULL");
     494            1 :         ASSERT(strstr(r, "[") == NULL, "img nbsp alt: no [ ] output");
     495            1 :         free(r);
     496              :     }
     497              : 
     498              :     /* 48. <img alt> with zwnj-only (U+200C) → no [ ] rendered */
     499              :     {
     500              :         /* \xE2\x80\x8C = UTF-8 encoding of U+200C ZERO WIDTH NON-JOINER */
     501            1 :         char *r = html_render("<img alt=\"\xE2\x80\x8C\">", 0, 0);
     502            1 :         ASSERT(r != NULL, "img zwnj alt: not NULL");
     503            1 :         ASSERT(strstr(r, "[") == NULL, "img zwnj alt: no [ ] output");
     504            1 :         free(r);
     505              :     }
     506              : 
     507              :     /* 49. &zwnj; entity → invisible (zero-width), does not appear as text */
     508              :     {
     509            1 :         char *r = html_render("A&zwnj;B", 0, 0);
     510            1 :         ASSERT(r != NULL, "zwnj entity: not NULL");
     511              :         /* zwnj is U+200C, zero-width: text must contain A and B */
     512            1 :         ASSERT(strstr(r, "A") != NULL, "zwnj entity: A present");
     513            1 :         ASSERT(strstr(r, "B") != NULL, "zwnj entity: B present");
     514              :         /* must NOT contain literal "&zwnj;" */
     515            1 :         ASSERT(strstr(r, "&zwnj;") == NULL, "zwnj entity: not literal");
     516            1 :         free(r);
     517              :     }
     518              : 
     519              :     /* 50. compact_lines: many consecutive blank lines collapsed to one */
     520              :     {
     521              :         /* Many <div> / <tr> blocks in a row produce multiple blank lines */
     522            1 :         char *r = html_render(
     523              :             "<div>A</div><div></div><div></div><div></div>"
     524              :             "<div></div><div></div><div>B</div>", 0, 0);
     525            1 :         ASSERT(r != NULL, "compact: not NULL");
     526            1 :         ASSERT(strstr(r, "A") != NULL, "compact: A present");
     527            1 :         ASSERT(strstr(r, "B") != NULL, "compact: B present");
     528              :         /* Must not have more than one consecutive blank line between A and B */
     529            1 :         const char *a = strstr(r, "A");
     530            1 :         const char *b = strstr(r, "B");
     531            1 :         ASSERT(a && b && b > a, "compact: A before B");
     532              :         /* Count blank lines (consecutive \n\n) between A and B */
     533            1 :         int max_blanks = 0, cur_blanks = 0;
     534            3 :         for (const char *p = a; p < b; p++) {
     535            2 :             if (*p == '\n') { cur_blanks++; if (cur_blanks > max_blanks) max_blanks = cur_blanks; }
     536            1 :             else            { cur_blanks = 0; }
     537              :         }
     538            1 :         ASSERT(max_blanks <= 2, "compact: at most one blank line between blocks");
     539            1 :         free(r);
     540              :     }
     541              : 
     542              :     /* 51. compact_lines: paragraph spacing preserved (≤1 blank line kept) */
     543              :     {
     544            1 :         char *r = html_render("<p>A</p><p>B</p>", 0, 0);
     545            1 :         ASSERT(r != NULL, "compact para: not NULL");
     546              :         /* compact_lines must not strip intentional paragraph blank line */
     547            1 :         ASSERT(strstr(r, "A\n\nB") != NULL, "compact para: blank line preserved");
     548            1 :         free(r);
     549              :     }
     550              : 
     551              :     /* 52. <a style="text-decoration:underline"> must not bleed underline to
     552              :      *     subsequent text — parse_style depth counters must be balanced by
     553              :      *     traverse() even when tag_close has no handler for <a>. */
     554              :     {
     555            1 :         char *r = html_render(
     556              :             "<a style=\"text-decoration:underline\">link</a> normal", 0, 1);
     557            1 :         ASSERT(r != NULL, "style bleed: not NULL");
     558              :         /* The underline-off escape must appear after the link */
     559            1 :         ASSERT(strstr(r, "\033[24m") != NULL, "style bleed: underline closed");
     560              :         /* After the underline close, 'normal' must follow without underline-on */
     561            1 :         const char *off = strstr(r, "\033[24m");
     562            1 :         ASSERT(off != NULL && strstr(off, "normal") != NULL,
     563              :                "style bleed: 'normal' comes after underline-off");
     564              :         /* Must not re-open underline before 'normal' */
     565            1 :         const char *after_off = off + 5;  /* skip \033[24m */
     566            1 :         const char *uline_on = strstr(after_off, "\033[4m");
     567            1 :         const char *normal_pos = strstr(after_off, "normal");
     568            1 :         ASSERT(normal_pos != NULL, "style bleed: 'normal' found after off");
     569            1 :         ASSERT(uline_on == NULL || uline_on > normal_pos,
     570              :                "style bleed: no underline-on before 'normal'");
     571            1 :         free(r);
     572              :     }
     573              : 
     574              :     /* 53. <span style="font-weight:bold"> on non-<b> element — bold must
     575              :      *     be closed when </span> is processed. */
     576              :     {
     577            1 :         char *r = html_render(
     578              :             "<span style=\"font-weight:bold\">bold</span> plain", 0, 1);
     579            1 :         ASSERT(r != NULL, "span bold: not NULL");
     580            1 :         ASSERT(strstr(r, "\033[22m") != NULL, "span bold: bold closed");
     581            1 :         const char *off = strstr(r, "\033[22m");
     582            1 :         ASSERT(off && strstr(off, "plain") != NULL,
     583              :                "span bold: 'plain' after bold-off");
     584            1 :         free(r);
     585              :     }
     586              : 
     587              :     /* 55. compact_lines: trailing whitespace trimmed from lines */
     588              :     {
     589              :         /* <pre> preserves whitespace; inject spaces at end of a line */
     590            1 :         char *r = html_render("<pre>hello   \nworld</pre>", 0, 0);
     591            1 :         ASSERT(r != NULL, "compact trim: not NULL");
     592              :         /* trailing spaces on "hello   " line must be trimmed */
     593            1 :         ASSERT(strstr(r, "hello   ") == NULL, "compact trim: trailing spaces removed");
     594            1 :         ASSERT(strstr(r, "hello") != NULL,    "compact trim: hello still present");
     595            1 :         free(r);
     596              :     }
     597              : 
     598              :     /* 56. <a style="color:#FF0000"> must not bleed foreground color to
     599              :      *     subsequent text — apply_color depth counter balanced by traverse(). */
     600              :     {
     601            1 :         char *r = html_render(
     602              :             "<a style=\"color:#FF0000\">red link</a> normal", 0, 1);
     603            1 :         ASSERT(r != NULL, "color bleed fg: not NULL");
     604              :         /* Default-fg reset \033[39m must appear after the link */
     605            1 :         ASSERT(strstr(r, "\033[39m") != NULL, "color bleed fg: fg reset present");
     606              :         /* 'normal' must come after the reset */
     607            1 :         const char *reset = strstr(r, "\033[39m");
     608            1 :         ASSERT(reset && strstr(reset, "normal") != NULL,
     609              :                "color bleed fg: 'normal' after fg reset");
     610            1 :         free(r);
     611              :     }
     612              : 
     613              :     /* 57. <span style="background-color:#0000FF"> — bg suppressed,
     614              :      *     no bleed possible, 'after' renders in default colors. */
     615              :     {
     616            1 :         char *r = html_render(
     617              :             "<span style=\"background-color:#0000FF\">bg</span> after", 0, 1);
     618            1 :         ASSERT(r != NULL, "color bleed bg: not NULL");
     619            1 :         ASSERT(strstr(r, "\033[48;2;") == NULL, "color bleed bg: bg escape suppressed");
     620            1 :         ASSERT(strstr(r, "\033[49m")   == NULL, "color bleed bg: no bg reset needed");
     621            1 :         ASSERT(strstr(r, "after") != NULL, "color bleed bg: 'after' present");
     622            1 :         free(r);
     623              :     }
     624              : }
     625              : 
     626              : /* ── Style-balance comprehensive tests ───────────────────────────────── */
     627              : 
     628            1 : void test_html_render_style_balance(void) {
     629              : 
     630              :     /* ── Semantic tags — well-formed (with closing tag) ──────────────── */
     631            1 :     assert_style_balanced("<b>X</b> Y",              "b closed");
     632            1 :     assert_style_balanced("<strong>X</strong> Y",    "strong closed");
     633            1 :     assert_style_balanced("<i>X</i> Y",              "i closed");
     634            1 :     assert_style_balanced("<em>X</em> Y",            "em closed");
     635            1 :     assert_style_balanced("<u>X</u> Y",              "u closed");
     636            1 :     assert_style_balanced("<s>X</s> Y",              "s closed");
     637            1 :     assert_style_balanced("<del>X</del> Y",          "del closed");
     638            1 :     assert_style_balanced("<strike>X</strike> Y",    "strike closed");
     639              : 
     640              :     /* ── Semantic tags — missing closing tag ─────────────────────────── */
     641            1 :     assert_style_balanced("<b>X Y",                  "b unclosed");
     642            1 :     assert_style_balanced("<strong>X Y",             "strong unclosed");
     643            1 :     assert_style_balanced("<i>X Y",                  "i unclosed");
     644            1 :     assert_style_balanced("<em>X Y",                 "em unclosed");
     645            1 :     assert_style_balanced("<u>X Y",                  "u unclosed");
     646            1 :     assert_style_balanced("<s>X Y",                  "s unclosed");
     647            1 :     assert_style_balanced("<del>X Y",                "del unclosed");
     648              : 
     649              :     /* ── Inline style on <span> — well-formed ────────────────────────── */
     650            1 :     assert_style_balanced(
     651              :         "<span style=\"font-weight:bold\">X</span> Y",
     652              :         "span bold closed");
     653            1 :     assert_style_balanced(
     654              :         "<span style=\"font-style:italic\">X</span> Y",
     655              :         "span italic closed");
     656            1 :     assert_style_balanced(
     657              :         "<span style=\"text-decoration:underline\">X</span> Y",
     658              :         "span underline closed");
     659            1 :     assert_style_balanced(
     660              :         "<span style=\"color:#FF0000\">X</span> Y",
     661              :         "span color closed");
     662            1 :     assert_style_balanced(
     663              :         "<span style=\"background-color:#0000FF\">X</span> Y",
     664              :         "span bgcolor closed");
     665              : 
     666              :     /* ── Inline style on <span> — missing closing tag ────────────────── */
     667            1 :     assert_style_balanced(
     668              :         "<span style=\"font-weight:bold\">X Y",
     669              :         "span bold unclosed");
     670            1 :     assert_style_balanced(
     671              :         "<span style=\"font-style:italic\">X Y",
     672              :         "span italic unclosed");
     673            1 :     assert_style_balanced(
     674              :         "<span style=\"text-decoration:underline\">X Y",
     675              :         "span underline unclosed");
     676            1 :     assert_style_balanced(
     677              :         "<span style=\"color:#FF0000\">X Y",
     678              :         "span color unclosed");
     679            1 :     assert_style_balanced(
     680              :         "<span style=\"background-color:#0000FF\">X Y",
     681              :         "span bgcolor unclosed");
     682              : 
     683              :     /* ── Inline style on <a> (common in newsletters) ─────────────────── */
     684            1 :     assert_style_balanced(
     685              :         "<a href=\"x\" style=\"color:#333;text-decoration:underline\">link</a> Y",
     686              :         "a color+uline closed");
     687            1 :     assert_style_balanced(
     688              :         "<a href=\"x\" style=\"color:#333;text-decoration:underline\">link Y",
     689              :         "a color+uline unclosed");
     690              : 
     691              :     /* ── Inline style on non-inline elements (<td>, <div>) ───────────── */
     692            1 :     assert_style_balanced(
     693              :         "<td style=\"font-weight:bold;color:#FF0000\">X</td> Y",
     694              :         "td bold+color closed");
     695            1 :     assert_style_balanced(
     696              :         "<div style=\"font-style:italic;background-color:#EEE\">X</div> Y",
     697              :         "div italic+bgcolor closed");
     698            1 :     assert_style_balanced(
     699              :         "<div style=\"font-style:italic;background-color:#EEE\">X Y",
     700              :         "div italic+bgcolor unclosed");
     701              : 
     702              :     /* ── Multiple styles combined on one element ─────────────────────── */
     703            1 :     assert_style_balanced(
     704              :         "<span style=\"font-weight:bold;font-style:italic;"
     705              :         "text-decoration:underline;color:#FF0000;"
     706              :         "background-color:#0000FF\">X</span> Y",
     707              :         "span all-styles closed");
     708            1 :     assert_style_balanced(
     709              :         "<span style=\"font-weight:bold;font-style:italic;"
     710              :         "text-decoration:underline;color:#FF0000;"
     711              :         "background-color:#0000FF\">X Y",
     712              :         "span all-styles unclosed");
     713              : 
     714              :     /* ── Deeply nested, all unclosed ─────────────────────────────────── */
     715            1 :     assert_style_balanced(
     716              :         "<b><i><u><span style=\"color:red\">deep text",
     717              :         "deeply nested unclosed");
     718              : 
     719              :     /* ── Deeply nested, properly closed ──────────────────────────────── */
     720            1 :     assert_style_balanced(
     721              :         "<b><i><u><span style=\"color:red\">deep</span></u></i></b> Y",
     722              :         "deeply nested closed");
     723              : 
     724              :     /* ── Mixed: some closed, some not ────────────────────────────────── */
     725            1 :     assert_style_balanced(
     726              :         "<b>bold</b> <i>italic <u>both",
     727              :         "mixed partial unclosed");
     728            1 :     assert_style_balanced(
     729              :         "<span style=\"color:red\">A</span>"
     730              :         "<span style=\"color:blue\">B</span>"
     731              :         "<span style=\"color:green\">C</span>",
     732              :         "three color spans closed");
     733            1 :     assert_style_balanced(
     734              :         "<span style=\"color:red\">A"
     735              :         "<span style=\"color:blue\">B"
     736              :         "<span style=\"color:green\">C",
     737              :         "three color spans unclosed nested");
     738              : 
     739              :     /* ── Named CSS colors ────────────────────────────────────────────── */
     740            1 :     assert_style_balanced(
     741              :         "<span style=\"color:red\">X</span> Y",        "named color red");
     742            1 :     assert_style_balanced(
     743              :         "<span style=\"color:blue\">X</span> Y",       "named color blue");
     744            1 :     assert_style_balanced(
     745              :         "<span style=\"background-color:yellow\">X</span> Y",
     746              :         "named bgcolor yellow");
     747              : 
     748              :     /* ── #RGB shorthand ──────────────────────────────────────────────── */
     749            1 :     assert_style_balanced(
     750              :         "<span style=\"color:#F00\">X</span> Y",       "color #RGB closed");
     751            1 :     assert_style_balanced(
     752              :         "<span style=\"color:#F00\">X Y",              "color #RGB unclosed");
     753              : 
     754              :     /* ── Realistic newsletter snippet ────────────────────────────────── */
     755            1 :     assert_style_balanced(
     756              :         "<div style=\"background-color:#f4f4f4\">"
     757              :           "<table><tr>"
     758              :             "<td style=\"color:#333333;font-weight:bold\">Title</td>"
     759              :             "<td style=\"color:#999999\">"
     760              :               "<a style=\"color:#999;text-decoration:underline\""
     761              :               " href=\"x\">click here</a>"
     762              :             "</td>"
     763              :           "</tr></table>"
     764              :         "</div>",
     765              :         "newsletter snippet all closed");
     766              : 
     767              :     /* Same snippet with several closing tags omitted */
     768            1 :     assert_style_balanced(
     769              :         "<div style=\"background-color:#f4f4f4\">"
     770              :           "<table><tr>"
     771              :             "<td style=\"color:#333333;font-weight:bold\">Title"
     772              :             "<td style=\"color:#999999\">"
     773              :               "<a style=\"color:#999;text-decoration:underline\""
     774              :               " href=\"x\">click here",
     775              :         "newsletter snippet partial unclosed");
     776            1 : }
     777              : 
     778              : /* ── Parent-close forces child style reset ───────────────────────────── */
     779              : 
     780              : /**
     781              :  * Renders html with ansi=1, finds 'marker' in the output, then checks that
     782              :  * the output prefix (everything BEFORE marker) has balanced on/off ANSI
     783              :  * escape counts.  This proves that closing the parent tag closed all child
     784              :  * styles before the next sibling (marker) was written.
     785              :  *
     786              :  * HTML must be structured so that 'marker' appears as plain text AFTER the
     787              :  * parent's closing tag, e.g.:  <parent><child>...</parent>MARKER
     788              :  */
     789           32 : static void assert_style_closed_before(const char *html, const char *marker,
     790              :                                        const char *label)
     791              : {
     792           32 :     char *r = html_render(html, 0, 1);
     793           32 :     if (!r) { ASSERT(0, label); return; }
     794              : 
     795           32 :     const char *m = strstr(r, marker);
     796           32 :     if (!m) {
     797              :         /* marker not present in output — fail with context */
     798            0 :         ASSERT(0, label);
     799              :         free(r);
     800              :         return;
     801              :     }
     802              : 
     803           32 :     size_t prefix_len = (size_t)(m - r);
     804           32 :     char *prefix = malloc(prefix_len + 1);
     805           32 :     if (!prefix) { ASSERT(0, label); free(r); return; }
     806           32 :     memcpy(prefix, r, prefix_len);
     807           32 :     prefix[prefix_len] = '\0';
     808              : 
     809           32 :     int bold_on  = count_str(prefix, "\033[1m");
     810           32 :     int bold_off = count_str(prefix, "\033[22m");
     811           32 :     ASSERT(bold_on == bold_off, label);
     812              : 
     813           32 :     int ital_on  = count_str(prefix, "\033[3m");
     814           32 :     int ital_off = count_str(prefix, "\033[23m");
     815           32 :     ASSERT(ital_on == ital_off, label);
     816              : 
     817           32 :     int uline_on  = count_str(prefix, "\033[4m");
     818           32 :     int uline_off = count_str(prefix, "\033[24m");
     819           32 :     ASSERT(uline_on == uline_off, label);
     820              : 
     821           32 :     int strike_on  = count_str(prefix, "\033[9m");
     822           32 :     int strike_off = count_str(prefix, "\033[29m");
     823           32 :     ASSERT(strike_on == strike_off, label);
     824              : 
     825           32 :     int fg_on  = count_str(prefix, "\033[38;2;");
     826           32 :     int fg_off = count_str(prefix, "\033[39m");
     827           32 :     ASSERT(fg_on == fg_off, label);
     828              : 
     829           32 :     int bg_on  = count_str(prefix, "\033[48;2;");
     830           32 :     int bg_off = count_str(prefix, "\033[49m");
     831           32 :     ASSERT(bg_on == bg_off, label);
     832              : 
     833           32 :     free(prefix);
     834           32 :     free(r);
     835              : }
     836              : 
     837            1 : void test_html_render_parent_close(void)
     838              : {
     839              :     /* Marker text appended as sibling after the parent's closing tag.
     840              :      * traverse() visits parent (with snapshot/restore), then visits MARKER.
     841              :      * All child styles must be closed BEFORE MARKER is emitted. */
     842              : 
     843              : #define MK "XMARKERX"
     844              : 
     845              :     /* ── <div> parent — single unclosed child style ───────────────────── */
     846            1 :     assert_style_closed_before(
     847              :         "<div><b>bold text</div>" MK,
     848              :         MK, "div > b unclosed");
     849            1 :     assert_style_closed_before(
     850              :         "<div><i>italic text</div>" MK,
     851              :         MK, "div > i unclosed");
     852            1 :     assert_style_closed_before(
     853              :         "<div><u>underline text</div>" MK,
     854              :         MK, "div > u unclosed");
     855            1 :     assert_style_closed_before(
     856              :         "<div><s>strike text</div>" MK,
     857              :         MK, "div > s unclosed");
     858            1 :     assert_style_closed_before(
     859              :         "<div><strong>strong text</div>" MK,
     860              :         MK, "div > strong unclosed");
     861            1 :     assert_style_closed_before(
     862              :         "<div><em>em text</div>" MK,
     863              :         MK, "div > em unclosed");
     864              : 
     865              :     /* ── <div> parent — inline-style child (non-semantic) ────────────── */
     866            1 :     assert_style_closed_before(
     867              :         "<div><span style=\"color:#FF0000\">red</div>" MK,
     868              :         MK, "div > span color unclosed");
     869            1 :     assert_style_closed_before(
     870              :         "<div><span style=\"background-color:#0000FF\">bg</div>" MK,
     871              :         MK, "div > span bgcolor unclosed");
     872            1 :     assert_style_closed_before(
     873              :         "<div><span style=\"font-weight:bold\">bold</div>" MK,
     874              :         MK, "div > span bold unclosed");
     875            1 :     assert_style_closed_before(
     876              :         "<div><span style=\"font-style:italic\">ital</div>" MK,
     877              :         MK, "div > span italic unclosed");
     878            1 :     assert_style_closed_before(
     879              :         "<div><span style=\"text-decoration:underline\">uline</div>" MK,
     880              :         MK, "div > span uline unclosed");
     881              : 
     882              :     /* ── <p> parent ───────────────────────────────────────────────────── */
     883            1 :     assert_style_closed_before(
     884              :         "<p><b>bold</p>" MK,
     885              :         MK, "p > b unclosed");
     886            1 :     assert_style_closed_before(
     887              :         "<p><span style=\"color:red\">red</p>" MK,
     888              :         MK, "p > span color unclosed");
     889              : 
     890              :     /* ── <ul>/<li> parent ─────────────────────────────────────────────── */
     891            1 :     assert_style_closed_before(
     892              :         "<ul><li><b>bold item</li></ul>" MK,
     893              :         MK, "ul > li > b closed");
     894            1 :     assert_style_closed_before(
     895              :         "<ul><li><i>italic item</ul>" MK,
     896              :         MK, "ul > li > i unclosed");
     897            1 :     assert_style_closed_before(
     898              :         "<ul><li><span style=\"color:#F00\">colored</ul>" MK,
     899              :         MK, "ul > li > span color unclosed");
     900              : 
     901              :     /* ── <ol> parent ──────────────────────────────────────────────────── */
     902            1 :     assert_style_closed_before(
     903              :         "<ol><li><u>underline item</li></ol>" MK,
     904              :         MK, "ol > li > u closed");
     905              : 
     906              :     /* ── <blockquote> parent ──────────────────────────────────────────── */
     907            1 :     assert_style_closed_before(
     908              :         "<blockquote><b>bold quote</blockquote>" MK,
     909              :         MK, "blockquote > b unclosed");
     910            1 :     assert_style_closed_before(
     911              :         "<blockquote><span style=\"color:blue\">blue</blockquote>" MK,
     912              :         MK, "blockquote > span color unclosed");
     913              : 
     914              :     /* ── <table>/<tr>/<td> chain ──────────────────────────────────────── */
     915            1 :     assert_style_closed_before(
     916              :         "<table><tr><td><b>bold cell</td></tr></table>" MK,
     917              :         MK, "table > tr > td > b closed");
     918            1 :     assert_style_closed_before(
     919              :         "<table><tr><td style=\"color:#333\"><b>styled</td></tr></table>" MK,
     920              :         MK, "table > tr > td style+b closed");
     921            1 :     assert_style_closed_before(
     922              :         "<table><tr><td style=\"font-weight:bold\">bold cell</table>" MK,
     923              :         MK, "table > td style bold unclosed");
     924            1 :     assert_style_closed_before(
     925              :         "<table><tr>"
     926              :           "<td style=\"color:#333333\"><b>A</td>"
     927              :           "<td style=\"color:#999999\"><i>B</td>"
     928              :         "</tr></table>" MK,
     929              :         MK, "table multi-td style unclosed");
     930              : 
     931              :     /* ── <div> parent — well-formed child (with closing tag) ─────────── */
     932            1 :     assert_style_closed_before(
     933              :         "<div><b>bold</b></div>" MK,
     934              :         MK, "div > b closed");
     935            1 :     assert_style_closed_before(
     936              :         "<div><span style=\"color:#FF0000\">red</span></div>" MK,
     937              :         MK, "div > span color closed");
     938              : 
     939              :     /* ── Multiple nested unclosed children ───────────────────────────── */
     940            1 :     assert_style_closed_before(
     941              :         "<div><b><i><u>triple nested</div>" MK,
     942              :         MK, "div > b>i>u all unclosed");
     943            1 :     assert_style_closed_before(
     944              :         "<div><b><i><u>"
     945              :           "<span style=\"color:red;background-color:#00F\">deep</div>" MK,
     946              :         MK, "div > deep nested all styles unclosed");
     947              : 
     948              :     /* ── <a> with inline style ────────────────────────────────────────── */
     949            1 :     assert_style_closed_before(
     950              :         "<div>"
     951              :           "<a style=\"color:#333;text-decoration:underline\" href=\"x\">link"
     952              :         "</div>" MK,
     953              :         MK, "div > a color+uline unclosed");
     954              : 
     955              :     /* ── All styles combined on single child ─────────────────────────── */
     956            1 :     assert_style_closed_before(
     957              :         "<div>"
     958              :           "<span style=\"font-weight:bold;font-style:italic;"
     959              :                         "text-decoration:underline;"
     960              :                         "color:#FF0000;background-color:#0000FF\">all"
     961              :         "</div>" MK,
     962              :         MK, "div > span all-styles unclosed");
     963              : 
     964              :     /* ── Nested divs: outer close should reset everything ────────────── */
     965            1 :     assert_style_closed_before(
     966              :         "<div>"
     967              :           "<div>"
     968              :             "<b><i><span style=\"color:red\">deep</span></i></b>"
     969              :           "</div>"
     970              :         "</div>" MK,
     971              :         MK, "nested divs properly closed");
     972            1 :     assert_style_closed_before(
     973              :         "<div>"
     974              :           "<div>"
     975              :             "<b><i><span style=\"color:red\">deep"
     976              :           "</div>"       /* no closing for inner div, b, i, span */
     977              :         "</div>" MK,
     978              :         MK, "nested divs outer close resets all");
     979              : 
     980              :     /* ── Realistic newsletter: div wrapper > table > td with styles ───── */
     981            1 :     assert_style_closed_before(
     982              :         "<div style=\"background-color:#f4f4f4\">"
     983              :           "<table><tr>"
     984              :             "<td style=\"color:#333333;font-weight:bold\">Title"
     985              :             "<td style=\"color:#999999\">"
     986              :               "<a style=\"color:#999;text-decoration:underline\""
     987              :               " href=\"x\">click here"
     988              :           "</tr></table>"
     989              :         "</div>" MK,
     990              :         MK, "newsletter all styles reset before marker");
     991              : 
     992              : #undef MK
     993            1 : }
     994              : 
     995              : /* ── Color filtering tests ────────────────────────────────────────────
     996              :  *
     997              :  * Policy:
     998              :  *   - Background colors  → suppressed entirely (no \033[48;2; emitted)
     999              :  *   - Dark fg colors     → suppressed (max(r,g,b) < 160)
    1000              :  *   - Bright fg colors   → allowed  (max(r,g,b) >= 160)
    1001              :  *
    1002              :  * Written BEFORE the fix so they define the expected behaviour.
    1003              :  * ─────────────────────────────────────────────────────────────────── */
    1004              : 
    1005            1 : void test_html_render_color_filter(void)
    1006              : {
    1007              :     /* ── Background colors: never emitted ─────────────────────────── */
    1008              : 
    1009              :     /* Named bg color */
    1010              :     {
    1011            1 :         char *r = html_render(
    1012              :             "<span style=\"background-color:blue\">X</span>", 0, 1);
    1013            1 :         ASSERT(r != NULL, "bg suppress named: not NULL");
    1014            1 :         ASSERT(strstr(r, "\033[48;2;") == NULL,
    1015              :                "bg suppress named: no bg escape emitted");
    1016            1 :         free(r);
    1017              :     }
    1018              : 
    1019              :     /* Hex #RRGGBB bg color */
    1020              :     {
    1021            1 :         char *r = html_render(
    1022              :             "<span style=\"background-color:#FF0000\">X</span>", 0, 1);
    1023            1 :         ASSERT(r != NULL, "bg suppress hex6: not NULL");
    1024            1 :         ASSERT(strstr(r, "\033[48;2;") == NULL,
    1025              :                "bg suppress hex6: no bg escape emitted");
    1026            1 :         free(r);
    1027              :     }
    1028              : 
    1029              :     /* Hex #RGB shorthand bg color */
    1030              :     {
    1031            1 :         char *r = html_render(
    1032              :             "<span style=\"background-color:#0F0\">X</span>", 0, 1);
    1033            1 :         ASSERT(r != NULL, "bg suppress hex3: not NULL");
    1034            1 :         ASSERT(strstr(r, "\033[48;2;") == NULL,
    1035              :                "bg suppress hex3: no bg escape emitted");
    1036            1 :         free(r);
    1037              :     }
    1038              : 
    1039              :     /* bg-color suppressed → no bg-reset \033[49m either */
    1040              :     {
    1041            1 :         char *r = html_render(
    1042              :             "<span style=\"background-color:white\">X</span> Y", 0, 1);
    1043            1 :         ASSERT(r != NULL, "bg suppress reset: not NULL");
    1044            1 :         ASSERT(strstr(r, "\033[48;2;") == NULL,
    1045              :                "bg suppress reset: no bg-open escape");
    1046            1 :         ASSERT(strstr(r, "\033[49m") == NULL,
    1047              :                "bg suppress reset: no bg-reset escape");
    1048            1 :         free(r);
    1049              :     }
    1050              : 
    1051              :     /* ansi=0: bg color must also be absent (already was, stays so) */
    1052              :     {
    1053            1 :         char *r = html_render(
    1054              :             "<span style=\"background-color:red\">X</span>", 0, 0);
    1055            1 :         ASSERT(r != NULL && strcmp(r, "X") == 0,
    1056              :                "bg ansi0: plain text only");
    1057            1 :         free(r);
    1058              :     }
    1059              : 
    1060              :     /* ── Dark foreground colors: suppressed (max(r,g,b) < 160) ────── */
    1061              : 
    1062              :     /* #333333 dark gray (max=51) */
    1063              :     {
    1064            1 :         char *r = html_render(
    1065              :             "<span style=\"color:#333333\">text</span>", 0, 1);
    1066            1 :         ASSERT(r != NULL, "dark fg #333: not NULL");
    1067            1 :         ASSERT(strstr(r, "\033[38;2;") == NULL,
    1068              :                "dark fg #333: no fg escape emitted");
    1069            1 :         free(r);
    1070              :     }
    1071              : 
    1072              :     /* #666666 medium dark gray (max=102) */
    1073              :     {
    1074            1 :         char *r = html_render(
    1075              :             "<span style=\"color:#666666\">text</span>", 0, 1);
    1076            1 :         ASSERT(r != NULL, "dark fg #666: not NULL");
    1077            1 :         ASSERT(strstr(r, "\033[38;2;") == NULL,
    1078              :                "dark fg #666: no fg escape emitted");
    1079            1 :         free(r);
    1080              :     }
    1081              : 
    1082              :     /* #808080 gray (max=128) — user said this is too dark */
    1083              :     {
    1084            1 :         char *r = html_render(
    1085              :             "<span style=\"color:#808080\">text</span>", 0, 1);
    1086            1 :         ASSERT(r != NULL, "dark fg gray: not NULL");
    1087            1 :         ASSERT(strstr(r, "\033[38;2;") == NULL,
    1088              :                "dark fg gray: no fg escape emitted");
    1089            1 :         free(r);
    1090              :     }
    1091              : 
    1092              :     /* CSS named 'gray' (#808080) */
    1093              :     {
    1094            1 :         char *r = html_render(
    1095              :             "<span style=\"color:gray\">text</span>", 0, 1);
    1096            1 :         ASSERT(r != NULL, "dark fg named gray: not NULL");
    1097            1 :         ASSERT(strstr(r, "\033[38;2;") == NULL,
    1098              :                "dark fg named gray: no fg escape emitted");
    1099            1 :         free(r);
    1100              :     }
    1101              : 
    1102              :     /* Dark navy #000080 (max=128) */
    1103              :     {
    1104            1 :         char *r = html_render(
    1105              :             "<span style=\"color:#000080\">text</span>", 0, 1);
    1106            1 :         ASSERT(r != NULL, "dark fg navy: not NULL");
    1107            1 :         ASSERT(strstr(r, "\033[38;2;") == NULL,
    1108              :                "dark fg navy: no fg escape emitted");
    1109            1 :         free(r);
    1110              :     }
    1111              : 
    1112              :     /* Dark suppressed → no fg-reset \033[39m either */
    1113              :     {
    1114            1 :         char *r = html_render(
    1115              :             "<span style=\"color:#333\">X</span> Y", 0, 1);
    1116            1 :         ASSERT(r != NULL, "dark fg reset: not NULL");
    1117            1 :         ASSERT(strstr(r, "\033[38;2;") == NULL,
    1118              :                "dark fg reset: no fg-open escape");
    1119            1 :         ASSERT(strstr(r, "\033[39m") == NULL,
    1120              :                "dark fg reset: no fg-reset escape");
    1121            1 :         free(r);
    1122              :     }
    1123              : 
    1124              :     /* Dark color text must still appear */
    1125              :     {
    1126            1 :         char *r = html_render(
    1127              :             "<span style=\"color:#333333\">hello</span>", 0, 1);
    1128            1 :         ASSERT(r && strstr(r, "hello") != NULL,
    1129              :                "dark fg text: text still present");
    1130            1 :         free(r);
    1131              :     }
    1132              : 
    1133              :     /* ── Bright foreground colors: allowed (max(r,g,b) >= 160) ─────── */
    1134              : 
    1135              :     /* White #FFFFFF (max=255) */
    1136              :     {
    1137            1 :         char *r = html_render(
    1138              :             "<span style=\"color:white\">X</span>", 0, 1);
    1139            1 :         ASSERT(r != NULL, "bright fg white: not NULL");
    1140            1 :         ASSERT(strstr(r, "\033[38;2;") != NULL,
    1141              :                "bright fg white: fg escape emitted");
    1142            1 :         free(r);
    1143              :     }
    1144              : 
    1145              :     /* Bright red #FF0000 (max=255) */
    1146              :     {
    1147            1 :         char *r = html_render(
    1148              :             "<span style=\"color:#FF0000\">X</span>", 0, 1);
    1149            1 :         ASSERT(r != NULL, "bright fg red: not NULL");
    1150            1 :         ASSERT(strstr(r, "\033[38;2;255;0;0m") != NULL,
    1151              :                "bright fg red: correct escape emitted");
    1152            1 :         free(r);
    1153              :     }
    1154              : 
    1155              :     /* CSS 'red' (#FF0000, max=255) */
    1156              :     {
    1157            1 :         char *r = html_render(
    1158              :             "<span style=\"color:red\">X</span>", 0, 1);
    1159            1 :         ASSERT(r != NULL, "bright fg named red: not NULL");
    1160            1 :         ASSERT(strstr(r, "\033[38;2;") != NULL,
    1161              :                "bright fg named red: fg escape emitted");
    1162            1 :         free(r);
    1163              :     }
    1164              : 
    1165              :     /* #0000CC dark-ish blue (max=204 >= 160) */
    1166              :     {
    1167            1 :         char *r = html_render(
    1168              :             "<span style=\"color:#0000CC\">X</span>", 0, 1);
    1169            1 :         ASSERT(r != NULL, "bright fg blue CC: not NULL");
    1170            1 :         ASSERT(strstr(r, "\033[38;2;") != NULL,
    1171              :                "bright fg blue CC: fg escape emitted");
    1172            1 :         free(r);
    1173              :     }
    1174              : 
    1175              :     /* Bright fg → fg-reset must also be present (to close the span) */
    1176              :     {
    1177            1 :         char *r = html_render(
    1178              :             "<span style=\"color:#FF0000\">X</span> Y", 0, 1);
    1179            1 :         ASSERT(r != NULL, "bright fg reset: not NULL");
    1180            1 :         ASSERT(strstr(r, "\033[38;2;") != NULL,
    1181              :                "bright fg reset: fg-open present");
    1182            1 :         ASSERT(strstr(r, "\033[39m") != NULL,
    1183              :                "bright fg reset: fg-reset present");
    1184            1 :         free(r);
    1185              :     }
    1186              : 
    1187              :     /* ── Style-balance still holds after filtering ──────────────────── */
    1188              :     /* (bg suppressed → both bg_on=0 and bg_off=0, trivially balanced)  */
    1189            1 :     assert_style_balanced(
    1190              :         "<span style=\"background-color:#FF0000\">X</span> Y",
    1191              :         "bg filtered: still balanced");
    1192            1 :     assert_style_balanced(
    1193              :         "<div style=\"background-color:#f4f4f4\">"
    1194              :           "<span style=\"color:#333\">dark text</span>"
    1195              :         "</div>",
    1196              :         "bg+dark fg filtered: still balanced");
    1197            1 :     assert_style_balanced(
    1198              :         "<span style=\"color:#FF0000\">bright</span>"
    1199              :         "<span style=\"color:#808080\">dark</span>",
    1200              :         "bright+dark fg mix: still balanced");
    1201              : }
    1202              : 
    1203              : /* ── URL isolation ─────────────────────────────────────────────────────── */
    1204              : 
    1205            1 : void test_html_render_url_isolation(void)
    1206              : {
    1207              : #define URL "https://example.com/path"
    1208              : #define URL2 "http://other.org/x"
    1209              : 
    1210              :     /* 1. URL mid-text: must start on its own line */
    1211              :     {
    1212            1 :         char *r = html_render("before " URL " after", 80, 0);
    1213            1 :         ASSERT(r != NULL, "url mid: not NULL");
    1214            1 :         const char *u = strstr(r, URL);
    1215            1 :         ASSERT(u != NULL, "url mid: URL present");
    1216            1 :         ASSERT(u == r || *(u - 1) == '\n', "url mid: URL starts after newline");
    1217            1 :         const char *a = u + strlen(URL);
    1218            1 :         ASSERT(*a == '\n' || *a == '\0', "url mid: URL ends with newline");
    1219            1 :         free(r);
    1220              :     }
    1221              : 
    1222              :     /* 2. URL at start of text: no spurious leading blank line */
    1223              :     {
    1224            1 :         char *r = html_render(URL " after", 80, 0);
    1225            1 :         ASSERT(r != NULL, "url start: not NULL");
    1226            1 :         const char *u = strstr(r, URL);
    1227            1 :         ASSERT(u != NULL, "url start: URL present");
    1228              :         /* URL is at start → either r itself or after '\n' */
    1229            1 :         ASSERT(u == r || *(u - 1) == '\n', "url start: URL at start or after newline");
    1230            1 :         const char *a = u + strlen(URL);
    1231            1 :         ASSERT(*a == '\n' || *a == '\0', "url start: URL followed by newline");
    1232              :         /* following text appears after the URL */
    1233            1 :         ASSERT(strstr(a, "after") != NULL || strstr(r, "after") > u,
    1234              :                "url start: text after URL is present");
    1235            1 :         free(r);
    1236              :     }
    1237              : 
    1238              :     /* 3. URL at end of text: just the URL, nothing after */
    1239              :     {
    1240            1 :         char *r = html_render("before " URL, 80, 0);
    1241            1 :         ASSERT(r != NULL, "url end: not NULL");
    1242            1 :         const char *u = strstr(r, URL);
    1243            1 :         ASSERT(u != NULL, "url end: URL present");
    1244            1 :         ASSERT(u == r || *(u - 1) == '\n', "url end: URL on own line");
    1245            1 :         free(r);
    1246              :     }
    1247              : 
    1248              :     /* 4. http:// scheme also isolated */
    1249              :     {
    1250            1 :         char *r = html_render("visit " URL2 " now", 80, 0);
    1251            1 :         ASSERT(r != NULL, "http url: not NULL");
    1252            1 :         const char *u = strstr(r, URL2);
    1253            1 :         ASSERT(u != NULL, "http url: URL present");
    1254            1 :         ASSERT(u == r || *(u - 1) == '\n', "http url: starts on own line");
    1255            1 :         free(r);
    1256              :     }
    1257              : 
    1258              :     /* 5. Two adjacent URLs each on their own line */
    1259              :     {
    1260            1 :         char *r = html_render(URL " " URL2, 80, 0);
    1261            1 :         ASSERT(r != NULL, "two urls: not NULL");
    1262            1 :         const char *u1 = strstr(r, URL);
    1263            1 :         const char *u2 = strstr(r, URL2);
    1264            1 :         ASSERT(u1 != NULL && u2 != NULL, "two urls: both present");
    1265            1 :         ASSERT(u1 < u2, "two urls: first before second");
    1266              :         /* There must be a newline between them */
    1267            1 :         char between[64] = {0};
    1268            1 :         size_t gap = (size_t)(u2 - (u1 + strlen(URL)));
    1269            1 :         if (gap < sizeof(between)) {
    1270            1 :             memcpy(between, u1 + strlen(URL), gap);
    1271            1 :             between[gap] = '\0';
    1272              :         }
    1273            1 :         ASSERT(strchr(between, '\n') != NULL, "two urls: newline between them");
    1274            1 :         free(r);
    1275              :     }
    1276              : 
    1277              :     /* 6. URL inside <p> tag: same isolation rules apply */
    1278              :     {
    1279            1 :         char *r = html_render("<p>see " URL " for details</p>", 80, 0);
    1280            1 :         ASSERT(r != NULL, "url in p: not NULL");
    1281            1 :         const char *u = strstr(r, URL);
    1282            1 :         ASSERT(u != NULL, "url in p: URL present");
    1283            1 :         ASSERT(u == r || *(u - 1) == '\n', "url in p: URL on own line");
    1284            1 :         const char *a = u + strlen(URL);
    1285            1 :         ASSERT(*a == '\n' || *a == '\0', "url in p: followed by newline");
    1286            1 :         free(r);
    1287              :     }
    1288              : 
    1289              :     /* 7. Style-balance is preserved when URL isolation adds newlines */
    1290            1 :     assert_style_balanced("<b>bold " URL " text</b>", "url isolation: style balanced");
    1291              : 
    1292              : /* Helper: check condition without early return (safe even if ASSERT would abort). */
    1293              : #define CHECK(cond, msg) do { \
    1294              :     g_tests_run++; \
    1295              :     if (!(cond)) { \
    1296              :         printf("  [FAIL] %s:%d: %s\n", __FILE__, __LINE__, msg); \
    1297              :         g_tests_failed++; \
    1298              :     } \
    1299              : } while (0)
    1300              : 
    1301              :     /* 8. <a href="...">text</a>: href URL must appear in output */
    1302              :     {
    1303            1 :         char *r = html_render("<a href=\"" URL "\">Click here</a>", 80, 0);
    1304            1 :         ASSERT(r != NULL, "anchor href: not NULL");
    1305            1 :         CHECK(strstr(r, "Click here") != NULL, "anchor href: link text present");
    1306            1 :         CHECK(strstr(r, URL) != NULL,           "anchor href: URL present in output");
    1307            1 :         const char *u = strstr(r, URL);
    1308            1 :         if (u) CHECK(u == r || *(u-1) == '\n',  "anchor href: URL on own line");
    1309            1 :         free(r);
    1310              :     }
    1311              : 
    1312              :     /* 9. <a href="#section">skip</a>: fragment-only href not emitted */
    1313              :     {
    1314            1 :         char *r = html_render("<a href=\"#section\">skip</a>", 80, 0);
    1315            1 :         ASSERT(r != NULL, "anchor fragment: not NULL");
    1316            1 :         CHECK(strstr(r, "#section") == NULL, "anchor fragment: not emitted");
    1317            1 :         free(r);
    1318              :     }
    1319              : 
    1320              :     /* 10. <a href="javascript:void(0)">skip</a>: js href not emitted */
    1321              :     {
    1322            1 :         char *r = html_render("<a href=\"javascript:void(0)\">skip</a>", 80, 0);
    1323            1 :         ASSERT(r != NULL, "anchor js: not NULL");
    1324            1 :         CHECK(strstr(r, "javascript:") == NULL, "anchor js: not emitted");
    1325            1 :         free(r);
    1326              :     }
    1327              : 
    1328              : #undef CHECK
    1329              : 
    1330              : #undef URL
    1331              : #undef URL2
    1332              : }
        

Generated by: LCOV version 2.0-1