LCOV - code coverage report
Current view: top level - tests/unit - test_tui_screen.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 98.2 % 271 266
Test Date: 2026-04-20 19:54:22 Functions: 100.0 % 27 27

            Line data    Source code
       1              : /**
       2              :  * @file tests/unit/test_tui_screen.c
       3              :  * @brief Unit tests for the TUI double-buffered screen (US-11 v2).
       4              :  */
       5              : 
       6              : #include "test_helpers.h"
       7              : #include "tui/screen.h"
       8              : #include "platform/terminal.h"
       9              : 
      10              : #include <locale.h>
      11              : #include <stdio.h>
      12              : #include <stdlib.h>
      13              : #include <string.h>
      14              : 
      15              : /* Capture screen output into a heap-backed memstream. */
      16              : typedef struct {
      17              :     FILE  *stream;
      18              :     char  *buf;
      19              :     size_t len;
      20              : } Sink;
      21              : 
      22           10 : static void sink_open(Sink *s) {
      23           10 :     s->buf = NULL;
      24           10 :     s->len = 0;
      25           10 :     s->stream = open_memstream(&s->buf, &s->len);
      26           10 : }
      27              : 
      28              : /* Flush the memstream so buf/len reflect everything written so far. */
      29           13 : static void sink_flush(Sink *s) { fflush(s->stream); }
      30              : 
      31           10 : static void sink_close(Sink *s) {
      32           10 :     if (s->stream) { fclose(s->stream); s->stream = NULL; }
      33           10 :     free(s->buf); s->buf = NULL; s->len = 0;
      34           10 : }
      35              : 
      36            9 : static int sink_contains(const Sink *s, const char *needle) {
      37            9 :     if (!s->buf || !needle) return 0;
      38            9 :     return memmem(s->buf, s->len, needle, strlen(needle)) != NULL;
      39              : }
      40              : 
      41              : /* --- Tests --- */
      42              : 
      43            1 : static void test_init_allocates_and_free_releases(void) {
      44              :     Screen s;
      45            1 :     ASSERT(screen_init(&s, 10, 40) == 0, "init should succeed");
      46            1 :     ASSERT(s.front != NULL, "front grid allocated");
      47            1 :     ASSERT(s.back  != NULL, "back grid allocated");
      48            1 :     ASSERT(s.rows == 10 && s.cols == 40, "dims recorded");
      49            1 :     ASSERT(s.out == stdout, "out defaults to stdout");
      50            1 :     screen_free(&s);
      51            1 :     ASSERT(s.front == NULL, "front cleared on free");
      52            1 :     ASSERT(s.back == NULL, "back cleared on free");
      53              : }
      54              : 
      55            1 : static void test_init_rejects_bad_dims(void) {
      56              :     Screen s;
      57            1 :     ASSERT(screen_init(&s, 0, 40) != 0, "rows=0 rejected");
      58            1 :     ASSERT(screen_init(&s, 10, 0) != 0, "cols=0 rejected");
      59            1 :     ASSERT(screen_init(NULL, 10, 40) != 0, "null screen rejected");
      60              : }
      61              : 
      62            1 : static void test_put_str_ascii_lands_in_back(void) {
      63            1 :     Screen s; ASSERT(screen_init(&s, 4, 20) == 0, "init");
      64            1 :     int w = screen_put_str(&s, 1, 2, "hello", SCREEN_ATTR_NORMAL);
      65            1 :     ASSERT(w == 5, "hello uses 5 columns");
      66            1 :     ASSERT(s.back[1 * 20 + 2].cp == 'h', "h at (1,2)");
      67            1 :     ASSERT(s.back[1 * 20 + 6].cp == 'o', "o at (1,6)");
      68            1 :     ASSERT(s.back[1 * 20 + 7].cp == ' ', "untouched cells remain blank");
      69            1 :     screen_free(&s);
      70              : }
      71              : 
      72            1 : static void test_put_str_clips_at_right_edge(void) {
      73            1 :     Screen s; ASSERT(screen_init(&s, 2, 8) == 0, "init");
      74            1 :     int w = screen_put_str(&s, 0, 5, "abcdef", SCREEN_ATTR_NORMAL);
      75            1 :     ASSERT(w == 3, "clipped to 3 columns (5..7)");
      76            1 :     ASSERT(s.back[5].cp == 'a', "a at (0,5)");
      77            1 :     ASSERT(s.back[7].cp == 'c', "c at (0,7)");
      78            1 :     screen_free(&s);
      79              : }
      80              : 
      81            1 : static void test_put_str_wide_char_occupies_two_cells(void) {
      82            1 :     setlocale(LC_CTYPE, "en_US.UTF-8");
      83            1 :     Screen s; ASSERT(screen_init(&s, 1, 10) == 0, "init");
      84              :     /* 日 is U+65E5, encoded in UTF-8 as 0xE6 0x97 0xA5, wcwidth==2. */
      85            1 :     int w = screen_put_str(&s, 0, 0, "\xE6\x97\xA5", SCREEN_ATTR_NORMAL);
      86            1 :     if (w == 0) { screen_free(&s); return; /* locale unsupported on host */ }
      87            1 :     ASSERT(w == 2, "wide char consumes 2 columns");
      88            1 :     ASSERT(s.back[0].cp == 0x65E5, "lead holds codepoint");
      89            1 :     ASSERT(s.back[0].width == 2, "lead width is 2");
      90            1 :     ASSERT(s.back[1].width == 0, "trailer width is 0");
      91            1 :     screen_free(&s);
      92              : }
      93              : 
      94            1 : static void test_put_str_wide_char_clipped_when_one_cell_left(void) {
      95            1 :     setlocale(LC_CTYPE, "en_US.UTF-8");
      96            1 :     Screen s; ASSERT(screen_init(&s, 1, 3) == 0, "init");
      97            1 :     int pre = screen_put_str(&s, 0, 0, "ab", SCREEN_ATTR_NORMAL);
      98            1 :     ASSERT(pre == 2, "pre-fill");
      99            1 :     int w = screen_put_str(&s, 0, 2, "\xE6\x97\xA5", SCREEN_ATTR_NORMAL);
     100            1 :     if (w == 2) {
     101              :         /* Host has no UTF-8 locale — glibc treated cp as narrow. Skip check. */
     102            0 :         screen_free(&s); return;
     103              :     }
     104            1 :     ASSERT(w == 0, "wide char does not fit; nothing written");
     105            1 :     ASSERT(s.back[2].cp == ' ', "cell remains blank");
     106            1 :     screen_free(&s);
     107              : }
     108              : 
     109            1 : static void test_put_str_rejects_out_of_range(void) {
     110            1 :     Screen s; ASSERT(screen_init(&s, 3, 10) == 0, "init");
     111            1 :     ASSERT(screen_put_str(&s, -1, 0, "x", 0) == 0, "negative row");
     112            1 :     ASSERT(screen_put_str(&s, 3, 0, "x", 0) == 0, "row == rows");
     113            1 :     ASSERT(screen_put_str(&s, 0, 10, "x", 0) == 0, "col == cols");
     114            1 :     ASSERT(screen_put_str(&s, 0, -1, "x", 0) == 0, "negative col");
     115            1 :     screen_free(&s);
     116              : }
     117              : 
     118            1 : static void test_clear_back_resets_every_cell(void) {
     119            1 :     Screen s; ASSERT(screen_init(&s, 2, 6) == 0, "init");
     120            1 :     screen_put_str(&s, 0, 0, "hey", SCREEN_ATTR_BOLD);
     121            1 :     screen_clear_back(&s);
     122           13 :     for (int i = 0; i < 12; i++) {
     123           12 :         ASSERT(s.back[i].cp == ' ', "cell blank");
     124           12 :         ASSERT(s.back[i].attrs == 0, "attrs reset");
     125              :     }
     126            1 :     screen_free(&s);
     127              : }
     128              : 
     129            1 : static void test_fill_writes_attrs(void) {
     130            1 :     Screen s; ASSERT(screen_init(&s, 2, 8) == 0, "init");
     131            1 :     screen_fill(&s, 1, 2, 4, SCREEN_ATTR_REVERSE);
     132            1 :     ASSERT(s.back[1 * 8 + 1].attrs == 0, "cell before fill untouched");
     133            1 :     ASSERT(s.back[1 * 8 + 2].attrs == SCREEN_ATTR_REVERSE, "fill cell 0");
     134            1 :     ASSERT(s.back[1 * 8 + 5].attrs == SCREEN_ATTR_REVERSE, "fill cell 3");
     135            1 :     ASSERT(s.back[1 * 8 + 6].attrs == 0, "cell after fill untouched");
     136            1 :     screen_free(&s);
     137              : }
     138              : 
     139            1 : static void test_fill_clips_right_edge(void) {
     140            1 :     Screen s; ASSERT(screen_init(&s, 1, 6) == 0, "init");
     141            1 :     screen_fill(&s, 0, 4, 100, SCREEN_ATTR_BOLD);
     142            1 :     ASSERT(s.back[4].attrs == SCREEN_ATTR_BOLD, "col 4");
     143            1 :     ASSERT(s.back[5].attrs == SCREEN_ATTR_BOLD, "col 5");
     144              :     /* nothing at col 6/7 — no buffer overflow */
     145            1 :     screen_free(&s);
     146              : }
     147              : 
     148            1 : static void test_flip_first_time_emits_everything(void) {
     149            1 :     Sink sink; sink_open(&sink);
     150            1 :     Screen s; ASSERT(screen_init(&s, 2, 10) == 0, "init");
     151            1 :     s.out = sink.stream;
     152            1 :     screen_put_str(&s, 0, 0, "hi", SCREEN_ATTR_NORMAL);
     153            1 :     size_t n = screen_flip(&s);
     154            1 :     sink_flush(&sink);
     155            1 :     ASSERT(n > 0, "first flip emits bytes");
     156            1 :     ASSERT(sink_contains(&sink, "hi"), "output contains 'hi'");
     157            1 :     ASSERT(sink_contains(&sink, "\033[1;1H"), "cursor to (1,1)");
     158            1 :     screen_free(&s);
     159            1 :     sink_close(&sink);
     160              : }
     161              : 
     162            1 : static void test_flip_second_time_identical_emits_nothing(void) {
     163            1 :     Sink sink; sink_open(&sink);
     164            1 :     Screen s; ASSERT(screen_init(&s, 2, 8) == 0, "init");
     165            1 :     s.out = sink.stream;
     166            1 :     screen_put_str(&s, 0, 0, "hi", 0);
     167            1 :     screen_flip(&s);
     168            1 :     sink_flush(&sink);
     169            1 :     size_t baseline = sink.len;
     170            1 :     screen_put_str(&s, 0, 0, "hi", 0);  /* same content */
     171            1 :     size_t n = screen_flip(&s);
     172            1 :     sink_flush(&sink);
     173            1 :     ASSERT(n == 0, "idempotent flip emits 0 bytes");
     174            1 :     ASSERT(sink.len == baseline, "sink unchanged");
     175            1 :     screen_free(&s);
     176            1 :     sink_close(&sink);
     177              : }
     178              : 
     179            1 : static void test_flip_emits_only_changed_cells(void) {
     180            1 :     Sink sink; sink_open(&sink);
     181            1 :     Screen s; ASSERT(screen_init(&s, 2, 10) == 0, "init");
     182            1 :     s.out = sink.stream;
     183            1 :     screen_put_str(&s, 0, 0, "abc", 0);
     184            1 :     screen_put_str(&s, 1, 0, "xyz", 0);
     185            1 :     screen_flip(&s);
     186            1 :     sink_flush(&sink);
     187            1 :     size_t baseline = sink.len;
     188              : 
     189              :     /* Change only row 1, column 1 — 'y' → 'Y'. */
     190            1 :     screen_put_str(&s, 1, 1, "Y", 0);
     191            1 :     screen_flip(&s);
     192            1 :     sink_flush(&sink);
     193              : 
     194            1 :     size_t diff_bytes = sink.len - baseline;
     195            1 :     ASSERT(diff_bytes > 0, "change emits bytes");
     196              :     /* Should not re-emit 'abc' at all. */
     197            1 :     const char *delta = sink.buf + baseline;
     198            1 :     size_t dlen = diff_bytes;
     199            1 :     ASSERT(!memmem(delta, dlen, "abc", 3), "unchanged row not re-emitted");
     200            1 :     ASSERT(memmem(delta, dlen, "Y", 1) != NULL, "new Y emitted");
     201            1 :     ASSERT(memmem(delta, dlen, "\033[2;2H", 6) != NULL, "cursor to (2,2)");
     202            1 :     screen_free(&s);
     203            1 :     sink_close(&sink);
     204              : }
     205              : 
     206            1 : static void test_flip_emits_sgr_for_attrs_and_resets_at_end(void) {
     207            1 :     Sink sink; sink_open(&sink);
     208            1 :     Screen s; ASSERT(screen_init(&s, 1, 6) == 0, "init");
     209            1 :     s.out = sink.stream;
     210            1 :     screen_put_str(&s, 0, 0, "hi", SCREEN_ATTR_BOLD | SCREEN_ATTR_REVERSE);
     211            1 :     screen_flip(&s);
     212            1 :     sink_flush(&sink);
     213            1 :     ASSERT(sink_contains(&sink, "\033[0;1;7m"), "SGR for bold+reverse");
     214              :     /* Trailing reset so subsequent writes aren't styled. */
     215            1 :     ASSERT(sink_contains(&sink, "\033[0m"), "trailing reset emitted");
     216            1 :     screen_free(&s);
     217            1 :     sink_close(&sink);
     218              : }
     219              : 
     220            1 : static void test_invalidate_forces_full_redraw(void) {
     221            1 :     Sink sink; sink_open(&sink);
     222            1 :     Screen s; ASSERT(screen_init(&s, 1, 5) == 0, "init");
     223            1 :     s.out = sink.stream;
     224            1 :     screen_put_str(&s, 0, 0, "abc", 0);
     225            1 :     screen_flip(&s);
     226            1 :     sink_flush(&sink);
     227            1 :     size_t baseline = sink.len;
     228              : 
     229              :     /* Nothing changed in the back buffer but invalidate forces a redraw. */
     230            1 :     screen_invalidate(&s);
     231            1 :     screen_put_str(&s, 0, 0, "abc", 0);  /* must re-stage explicitly */
     232            1 :     size_t n = screen_flip(&s);
     233            1 :     sink_flush(&sink);
     234            1 :     ASSERT(n > 0, "invalidate forces redraw");
     235            1 :     const char *delta = sink.buf + baseline;
     236            1 :     size_t dlen = sink.len - baseline;
     237            1 :     ASSERT(memmem(delta, dlen, "abc", 3) != NULL, "full content re-emitted");
     238            1 :     screen_free(&s);
     239            1 :     sink_close(&sink);
     240              : }
     241              : 
     242            1 : static void test_cursor_writes_cup(void) {
     243            1 :     Sink sink; sink_open(&sink);
     244            1 :     Screen s; ASSERT(screen_init(&s, 5, 20) == 0, "init");
     245            1 :     s.out = sink.stream;
     246            1 :     screen_cursor(&s, 3, 7);
     247            1 :     sink_flush(&sink);
     248            1 :     ASSERT(sink_contains(&sink, "\033[3;7H"), "CUP emitted 1-based");
     249            1 :     screen_free(&s);
     250            1 :     sink_close(&sink);
     251              : }
     252              : 
     253            1 : static void test_cursor_visible_emits_dectcem(void) {
     254            1 :     Sink sink; sink_open(&sink);
     255            1 :     Screen s; ASSERT(screen_init(&s, 1, 10) == 0, "init");
     256            1 :     s.out = sink.stream;
     257            1 :     screen_cursor_visible(&s, 0);
     258            1 :     screen_cursor_visible(&s, 1);
     259            1 :     sink_flush(&sink);
     260            1 :     ASSERT(sink_contains(&sink, "\033[?25l"), "hide sequence");
     261            1 :     ASSERT(sink_contains(&sink, "\033[?25h"), "show sequence");
     262            1 :     screen_free(&s);
     263            1 :     sink_close(&sink);
     264              : }
     265              : 
     266            1 : static void test_flip_skips_wide_char_trailer(void) {
     267            1 :     setlocale(LC_CTYPE, "en_US.UTF-8");
     268            1 :     Sink sink; sink_open(&sink);
     269            1 :     Screen s; ASSERT(screen_init(&s, 1, 4) == 0, "init");
     270            1 :     s.out = sink.stream;
     271            1 :     int w = screen_put_str(&s, 0, 0, "\xE6\x97\xA5", SCREEN_ATTR_NORMAL);
     272            1 :     if (w != 2) {
     273            0 :         screen_free(&s); sink_close(&sink);
     274            0 :         return; /* locale unsupported */
     275              :     }
     276            1 :     screen_flip(&s);
     277            1 :     sink_flush(&sink);
     278              :     /* UTF-8 byte sequence appears exactly once (we skip the trailer). */
     279            1 :     const char needle[] = "\xE6\x97\xA5";
     280            1 :     int count = 0;
     281           14 :     for (size_t i = 0; i + 3 <= sink.len; i++) {
     282           13 :         if (memcmp(sink.buf + i, needle, 3) == 0) count++;
     283              :     }
     284            1 :     ASSERT(count == 1, "wide char emitted once");
     285            1 :     screen_free(&s);
     286            1 :     sink_close(&sink);
     287              : }
     288              : 
     289              : /* --- SEC-01 sanitization tests --- */
     290              : 
     291              : /* U+00B7 MIDDLE DOT encoded as UTF-8: 0xC2 0xB7 */
     292              : static const char MIDDLE_DOT_UTF8[] = "\xC2\xB7";
     293              : 
     294              : /**
     295              :  * @brief SEC-01: ESC (U+001B) in input must not appear in flip output.
     296              :  *
     297              :  * A malicious message containing ESC followed by '[2J' (erase-display)
     298              :  * must have the ESC replaced with U+00B7 MIDDLE DOT so no raw 0x1B byte
     299              :  * reaches the terminal.
     300              :  */
     301            1 : static void test_sec01_esc_replaced_by_placeholder(void) {
     302            1 :     Sink sink; sink_open(&sink);
     303            1 :     Screen s; ASSERT(screen_init(&s, 2, 20) == 0, "init");
     304            1 :     s.out = sink.stream;
     305              :     /* "\033[2J" — erase-display escape sequence embedded in message text */
     306            1 :     screen_put_str(&s, 0, 0, "\033[2J", SCREEN_ATTR_NORMAL);
     307              :     /* ESC should have been replaced; the cell at col 0 must hold U+00B7. */
     308            1 :     ASSERT(s.back[0].cp == 0x00B7, "ESC codepoint replaced with U+00B7");
     309            1 :     screen_flip(&s);
     310            1 :     sink_flush(&sink);
     311              :     /* The raw 0x1B ESC byte must not appear in the terminal output. */
     312            1 :     int found_esc = 0;
     313           58 :     for (size_t i = 0; i < sink.len; i++) {
     314           57 :         if ((unsigned char)sink.buf[i] == 0x1B
     315            3 :                 && i + 1 < sink.len
     316              :                 /* Allow legitimate CUP / SGR sequences emitted by screen_flip
     317              :                  * itself — those follow the pattern 0x1B '[' digit. They are
     318              :                  * fine; what we forbid is 0x1B injected from message content
     319              :                  * at cell positions.  The placeholder U+00B7 emits 0xC2 0xB7
     320              :                  * so it cannot accidentally become 0x1B. */
     321            3 :                 && (unsigned char)sink.buf[i + 1] != '[') {
     322            0 :             found_esc = 1;
     323            0 :             break;
     324              :         }
     325              :     }
     326            1 :     ASSERT(!found_esc, "no raw ESC from message content in terminal output");
     327              :     /* The placeholder byte sequence (UTF-8 for U+00B7) must be present. */
     328            1 :     ASSERT(sink_contains(&sink, MIDDLE_DOT_UTF8), "middle-dot placeholder emitted");
     329            1 :     screen_free(&s);
     330            1 :     sink_close(&sink);
     331              : }
     332              : 
     333              : /**
     334              :  * @brief SEC-01: DEL (U+007F) in input must be replaced with U+00B7.
     335              :  */
     336            1 : static void test_sec01_del_replaced_by_placeholder(void) {
     337            1 :     Screen s; ASSERT(screen_init(&s, 1, 10) == 0, "init");
     338            1 :     screen_put_str(&s, 0, 0, "\x7F", SCREEN_ATTR_NORMAL);
     339            1 :     ASSERT(s.back[0].cp == 0x00B7, "DEL (0x7F) replaced with U+00B7");
     340            1 :     screen_free(&s);
     341              : }
     342              : 
     343              : /**
     344              :  * @brief SEC-01: 8-bit CSI (U+009B, encoded as 0xC2 0x9B in UTF-8) in input
     345              :  * must be replaced with U+00B7.
     346              :  */
     347            1 : static void test_sec01_csi_replaced_by_placeholder(void) {
     348            1 :     Screen s; ASSERT(screen_init(&s, 1, 10) == 0, "init");
     349              :     /* U+009B encoded in UTF-8: 0xC2 0x9B */
     350            1 :     screen_put_str(&s, 0, 0, "\xC2\x9B", SCREEN_ATTR_NORMAL);
     351            1 :     ASSERT(s.back[0].cp == 0x00B7, "8-bit CSI (U+009B) replaced with U+00B7");
     352            1 :     screen_free(&s);
     353              : }
     354              : 
     355              : /**
     356              :  * @brief SEC-01: plain ASCII text must pass through unchanged (no false positives).
     357              :  */
     358            1 : static void test_sec01_plain_text_unchanged(void) {
     359            1 :     Sink sink; sink_open(&sink);
     360            1 :     Screen s; ASSERT(screen_init(&s, 1, 20) == 0, "init");
     361            1 :     s.out = sink.stream;
     362            1 :     screen_put_str(&s, 0, 0, "Hello, world!", SCREEN_ATTR_NORMAL);
     363            1 :     ASSERT(s.back[0].cp == 'H', "H at col 0");
     364            1 :     ASSERT(s.back[4].cp == 'o', "o at col 4");
     365            1 :     screen_flip(&s);
     366            1 :     sink_flush(&sink);
     367            1 :     ASSERT(sink_contains(&sink, "Hello, world!"), "plain text passes through");
     368            1 :     screen_free(&s);
     369            1 :     sink_close(&sink);
     370              : }
     371              : 
     372            1 : void test_tui_screen_run(void) {
     373            1 :     RUN_TEST(test_init_allocates_and_free_releases);
     374            1 :     RUN_TEST(test_init_rejects_bad_dims);
     375            1 :     RUN_TEST(test_put_str_ascii_lands_in_back);
     376            1 :     RUN_TEST(test_put_str_clips_at_right_edge);
     377            1 :     RUN_TEST(test_put_str_wide_char_occupies_two_cells);
     378            1 :     RUN_TEST(test_put_str_wide_char_clipped_when_one_cell_left);
     379            1 :     RUN_TEST(test_put_str_rejects_out_of_range);
     380            1 :     RUN_TEST(test_clear_back_resets_every_cell);
     381            1 :     RUN_TEST(test_fill_writes_attrs);
     382            1 :     RUN_TEST(test_fill_clips_right_edge);
     383            1 :     RUN_TEST(test_flip_first_time_emits_everything);
     384            1 :     RUN_TEST(test_flip_second_time_identical_emits_nothing);
     385            1 :     RUN_TEST(test_flip_emits_only_changed_cells);
     386            1 :     RUN_TEST(test_flip_emits_sgr_for_attrs_and_resets_at_end);
     387            1 :     RUN_TEST(test_invalidate_forces_full_redraw);
     388            1 :     RUN_TEST(test_cursor_writes_cup);
     389            1 :     RUN_TEST(test_cursor_visible_emits_dectcem);
     390            1 :     RUN_TEST(test_flip_skips_wide_char_trailer);
     391            1 :     RUN_TEST(test_sec01_esc_replaced_by_placeholder);
     392            1 :     RUN_TEST(test_sec01_del_replaced_by_placeholder);
     393            1 :     RUN_TEST(test_sec01_csi_replaced_by_placeholder);
     394            1 :     RUN_TEST(test_sec01_plain_text_unchanged);
     395            1 : }
        

Generated by: LCOV version 2.0-1