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 & 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. ‌ entity → invisible (zero-width), does not appear as text */
508 : {
509 1 : char *r = html_render("A‌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 "‌" */
515 1 : ASSERT(strstr(r, "‌") == 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 : }
|