summaryrefslogtreecommitdiffstats
path: root/tests/test_clio.c
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test_clio.c')
-rw-r--r--tests/test_clio.c884
1 files changed, 884 insertions, 0 deletions
diff --git a/tests/test_clio.c b/tests/test_clio.c
new file mode 100644
index 0000000..535df1d
--- /dev/null
+++ b/tests/test_clio.c
@@ -0,0 +1,884 @@
+/* test_clio.c — standalone test suite for clio.h (+ escape.h).
+ * Zero dependencies: single TU, no linked libraries (defines
+ * ESCAPE_IMPLEMENTATION to pull in the esc_* symbols). */
+#define ESCAPE_IMPLEMENTATION
+#include "clio.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static int failures = 0;
+static int checks = 0;
+
+#define CHECK(cond, msg) \
+ do { \
+ checks++; \
+ if (!(cond)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \
+ } \
+ } while (0)
+
+/* Capture stdout (fd 1) into a malloc'd buffer across the test body. */
+typedef struct cap {
+ int pfd[2];
+ int saved;
+} cap_t;
+
+/* dup2() returns the target fd (STDOUT_FILENO == 1) on success, -1 on
+ * failure — compare against -1, not 0. */
+static int cap_begin(cap_t *c) {
+ if (pipe(c->pfd) != 0)
+ return -1;
+ c->saved = dup(STDOUT_FILENO);
+ if (c->saved < 0)
+ return -1;
+ if (dup2(c->pfd[1], STDOUT_FILENO) == -1) {
+ close(c->pfd[0]);
+ close(c->pfd[1]);
+ close(c->saved);
+ return -1;
+ }
+ close(c->pfd[1]);
+ return 0;
+}
+
+static char *cap_end(cap_t *c, size_t *len) {
+ fflush(stdout);
+ if (dup2(c->saved, STDOUT_FILENO) == -1) {
+ close(c->saved);
+ return NULL;
+ }
+ close(c->saved);
+ size_t cap = 256;
+ size_t n = 0;
+ char *buf = (char *) malloc(cap);
+ char tmp[1024];
+ ssize_t r;
+ while ((r = read(c->pfd[0], tmp, sizeof tmp)) > 0) {
+ if (n + (size_t) r > cap) {
+ cap = (cap + (size_t) r) * 2;
+ buf = (char *) realloc(buf, cap);
+ }
+ memcpy(buf + n, tmp, (size_t) r);
+ n += (size_t) r;
+ }
+ close(c->pfd[0]);
+ buf[n] = '\0'; /* callers use both strlen() and len */
+ *len = n;
+ return buf;
+}
+
+static int contains(const char *hay, size_t len, const char *needle) {
+ size_t nl = strlen(needle);
+ if (nl > len)
+ return 0;
+ for (size_t i = 0; i + nl <= len; i++)
+ if (memcmp(hay + i, needle, nl) == 0)
+ return 1;
+ return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* Text: UTF-8 + width */
+/* ------------------------------------------------------------------ */
+
+static void test_utf8(void) {
+ u32 cp;
+ char out[8];
+ CHECK(clio_utf8_next("a", &cp) == 1 && cp == 'a', "ascii decode");
+ CHECK(clio_utf8_next("\xC3\xA9", &cp) == 2 && cp == 0xE9, "latin1 decode");
+ CHECK(clio_utf8_next("\xE4\xB8\xAD", &cp) == 3 && cp == 0x4E2D,
+ "cjk decode");
+ CHECK(clio_utf8_next("\xF0\x9F\x98\x80", &cp) == 4 && cp == 0x1F600,
+ "emoji decode");
+ /* truncated sequence: consumes what is left */
+ CHECK(clio_utf8_next("\xE4\xB8", &cp) == 2, "truncated decode");
+ CHECK(clio_utf8_enc(0x4E2D, out) == 3 && memcmp(out, "\xE4\xB8\xAD", 3) == 0,
+ "cjk encode");
+ CHECK(clio_utf8_enc(0x1F600, out) == 4, "emoji encode");
+ CHECK(clio_utf8_enc(0xD800, out) == -1, "surrogate rejected");
+ CHECK(clio_utf8_enc(0x110000, out) == -1, "out of range rejected");
+
+ CHECK(clio_text_width("abc") == 3, "ascii width");
+ CHECK(clio_text_width("h\xC3\xA9llo") == 5, "latin width");
+ CHECK(clio_text_width("\xE4\xB8\xAD\xE6\x96\x87") == 4, "cjk width");
+ CHECK(clio_text_width("\xF0\x9F\x98\x80") == 2, "emoji width");
+ CHECK(clio_cp_width('\n') == 0, "newline zero width");
+
+ const char *s = "ab\xE4\xB8\xAD" "c"; /* a b 中 c */
+ CHECK(clio_char_start(s, 4) == 2, "char_start cjk");
+ CHECK(clio_char_start(s, 2) == 2, "char_start at boundary");
+ CHECK(clio_char_start(s, 1) == 1, "char_start ascii");
+ CHECK(clio_char_end(s, 6, 2) == 5, "char_end cjk");
+ CHECK(clio_char_end(s, 6, 5) == 6, "char_end last");
+
+ /* semantics verified against bash/readline Ctrl+W via pty:
+ * "foo bar baz"@12 -> 7, "foo bar"@8 -> 3, "a_b c"@5 -> 3 */
+ CHECK(clio_word_back("foo bar baz", 12) == 7, "word_back word");
+ CHECK(clio_word_back("foo bar", 8) == 3, "word_back across spaces");
+ CHECK(clio_word_back("a_b c", 5) == 3, "word_back underscore");
+}
+
+/* ------------------------------------------------------------------ */
+/* Canvas */
+/* ------------------------------------------------------------------ */
+
+static void test_buf(void) {
+ clio_buf b;
+ clio_style s = clio_style_default();
+ s.fg = 248;
+ s.bg = 238;
+ s.attr = CLIO_BOLD | CLIO_REVERSE;
+
+ CHECK(clio_buf_init(&b, 3, 8) == 0, "buf init");
+ CHECK(b.rows == 3 && b.cols == 8, "buf dims");
+
+ /* default cell is blank + default style */
+ clio_cell *c0 = b.cells;
+ CHECK(c0->ch == 0 && c0->fg == 0xFFFF && c0->bg == 0xFFFF && c0->attr == 0,
+ "default cell");
+
+ CHECK(clio_buf_put(&b, 0, 0, "hi", s, 0) == 2, "put ascii");
+ CHECK(b.cells[0].ch == 'h' && b.cells[1].ch == 'i', "put stored");
+ CHECK(b.cells[0].fg == 248 && b.cells[0].bg == 238, "put style");
+ CHECK(b.cells[0].attr == (CLIO_BOLD | CLIO_REVERSE), "put attr");
+ CHECK(b.cells[2].ch == 0, "put bounds");
+
+ /* clipping: no wrap */
+ CHECK(clio_buf_put(&b, 0, 6, "abc", s, 0) == 2, "clip no wrap");
+ CHECK(b.cells[7].ch == 'b', "clip stored edge");
+ clio_buf_clear(&b);
+
+ /* wrapping onto next row */
+ CHECK(clio_buf_put(&b, 0, 6, "abcd", s, 1) == 4, "wrap put");
+ CHECK(b.cells[7].ch == 'b', "wrap first row edge");
+ CHECK(b.cells[8].ch == 'c' && b.cells[9].ch == 'd', "wrap second row");
+
+ clio_buf_clear(&b);
+
+ /* newline moves to next row at col 0 */
+ clio_buf_put(&b, 0, 0, "a\nb", s, 1);
+ CHECK(b.cells[0].ch == 'a' && b.cells[8].ch == 'b', "newline put");
+
+ clio_buf_clear(&b);
+
+ /* wide char occupies two cells */
+ clio_buf_put(&b, 0, 0, "\xE4\xB8\xAD\xE6\x96\x87", s, 1);
+ CHECK(b.cells[0].ch == 0x4E2D && b.cells[1].ch == 0, "wide first cell");
+ CHECK(b.cells[2].ch == 0x6587 && b.cells[3].ch == 0, "wide second cell");
+
+ clio_buf_clear(&b);
+
+ /* zero-width chars consume no cell */
+ clio_buf_put(&b, 0, 0, "\xE2\x80\x8Bx", s, 1); /* ZWSP + x */
+ CHECK(b.cells[0].ch == 'x', "zero width skips");
+
+ clio_buf_clear(&b);
+
+ /* fill + clear region */
+ clio_buf_fill_region(&b, 0, 0, 1, 3, '*', s);
+ CHECK(b.cells[0].ch == '*' && b.cells[3].ch == '*', "fill row 0");
+ CHECK(b.cells[8].ch == '*' && b.cells[11].ch == '*', "fill row 1");
+ CHECK(b.cells[4].ch == 0, "fill bounds");
+ clio_buf_clear_region(&b, 0, 1, 1, 2);
+ CHECK(b.cells[0].ch == '*' && b.cells[1].ch == 0 && b.cells[2].ch == 0,
+ "clear region");
+ CHECK(b.cells[10].ch == 0 && b.cells[11].ch == '*', "clear region row 1");
+ clio_buf_clear(&b);
+
+ /* box 2x4 in top-left */
+ clio_buf_box(&b, 0, 0, 4, 2, '#', s);
+ CHECK(b.cells[0].ch == '#' && b.cells[3].ch == '#', "box top");
+ CHECK(b.cells[8].ch == '#' && b.cells[11].ch == '#', "box bottom");
+ clio_buf_clear(&b);
+
+ /* scroll up: rows 0..2 shift, bottom becomes blank */
+ clio_buf_put(&b, 0, 0, "aa", s, 0);
+ clio_buf_put(&b, 1, 0, "bb", s, 0);
+ clio_buf_put(&b, 2, 0, "cc", s, 0);
+ clio_buf_scroll_up(&b, 0, 0, 2, 1);
+ CHECK(b.cells[0].ch == 'b' && b.cells[8].ch == 'c', "scroll moved");
+ CHECK(b.cells[16].ch == 0, "scroll bottom blank");
+
+ clio_buf_free(&b);
+ CHECK(b.cells == NULL, "buf free");
+
+ /* init with zero dims clamps to 1x1 */
+ CHECK(clio_buf_init(&b, 0, 0) == 0 && b.rows == 1 && b.cols == 1,
+ "buf init clamps");
+ clio_buf_free(&b);
+}
+
+/* ------------------------------------------------------------------ */
+/* Key decoder */
+/* ------------------------------------------------------------------ */
+
+static size_t decode(clio_keydec *d, const char *in, clio_key *k, size_t cap) {
+ clio_keydec_init(d);
+ return clio_keydec_feed(d, (const unsigned char *) in, strlen(in), k, cap);
+}
+
+static void test_keydec(void) {
+ clio_keydec d;
+ clio_key k[16];
+ size_t n;
+
+ n = decode(&d, "\x1B[C", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "csi right");
+ n = decode(&d, "\x1B[D", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_LEFT, "csi left");
+ n = decode(&d, "\x1B[A", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UP, "csi up");
+ n = decode(&d, "\x1B[B", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DOWN, "csi down");
+ n = decode(&d, "\x1B[H", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi home");
+ n = decode(&d, "\x1B[F", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "csi end");
+ n = decode(&d, "\x1B[Z", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_BACKTAB, "csi backtab");
+ n = decode(&d, "\x1B[1~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 1~ home");
+ n = decode(&d, "\x1B[7~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_HOME, "csi 7~ home");
+ n = decode(&d, "\x1B[3~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "csi 3~ delete");
+ n = decode(&d, "\x1B[4~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "csi 4~ end");
+ n = decode(&d, "\x1B[5~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_PAGEUP, "csi 5~ pageup");
+ n = decode(&d, "\x1B[6~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_PAGEDOWN, "csi 6~ pagedown");
+
+ n = decode(&d, "\x1BOA", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UP, "ss3 up");
+ n = decode(&d, "\x1BOF", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_END, "ss3 end");
+
+ n = decode(&d, "\t\r\x7F\x08", k, 16);
+ CHECK(n == 4 && k[0].kind == CLIOK_TAB && k[1].kind == CLIOK_ENTER &&
+ k[2].kind == CLIOK_BACKSPACE && k[3].kind == CLIOK_BACKSPACE,
+ "tab enter backspace del8");
+
+ n = decode(&d, "\x01\x03\x04\x05\x0B\x0C\x15\x17\x19\x1A", k, 16);
+ CHECK(n == 10 && k[0].kind == CLIOK_CTRL_A && k[1].kind == CLIOK_CTRL_C &&
+ k[2].kind == CLIOK_CTRL_D && k[3].kind == CLIOK_CTRL_E &&
+ k[4].kind == CLIOK_CTRL_K && k[5].kind == CLIOK_CTRL_L &&
+ k[6].kind == CLIOK_CTRL_U && k[7].kind == CLIOK_CTRL_W &&
+ k[8].kind == CLIOK_CTRL_Y && k[9].kind == CLIOK_CTRL_Z,
+ "ctrl letters");
+
+ n = decode(&d, "\x07", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_CTRL_G, "ctrl g");
+ n = decode(&d, "\x1B", k, 16);
+ CHECK(n == 0, "bare esc not emitted yet");
+ CHECK(clio_keydec_flush(&d, &k[0]) == 1 && k[0].kind == CLIOK_ESC,
+ "esc flush");
+
+ n = decode(&d, "h\xC3\xA9\xE4\xB8\xAD", k, 16);
+ CHECK(n == 3 && k[0].cp == 'h' && k[1].cp == 0xE9 && k[2].cp == 0x4E2D,
+ "utf8 keys");
+
+ /* kitty protocol */
+ n = decode(&d, "\x1B[13u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_ENTER, "kitty enter");
+ n = decode(&d, "\x1B[127u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_DELETE, "kitty delete");
+ n = decode(&d, "\x1B[9u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_TAB, "kitty tab");
+ n = decode(&d, "\x1B[27u", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_ESC, "kitty esc");
+
+ /* bracketed paste: LF inside paste is a char; start/end emit no key */
+ n = decode(&d, "\x1B[200~a\nb\x1B[201~", k, 16);
+ CHECK(n == 3 && k[0].cp == 'a' && k[1].cp == '\n' && k[2].cp == 'b',
+ "bracketed paste");
+
+ /* bare LF outside paste: dead key */
+ n = decode(&d, "\n", k, 16);
+ CHECK(n == 0, "bare lf dead");
+
+ /* split feeds: escape sequence across chunk boundaries */
+ clio_keydec_init(&d);
+ n = clio_keydec_feed(&d, (const unsigned char *) "\x1B", 1, k, 16);
+ CHECK(n == 0, "split esc");
+ n = clio_keydec_feed(&d, (const unsigned char *) "[", 1, k, 16);
+ CHECK(n == 0, "split [");
+ n = clio_keydec_feed(&d, (const unsigned char *) "C", 1, k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_RIGHT, "split C");
+
+ /* split UTF-8 across chunk boundaries */
+ clio_keydec_init(&d);
+ n = clio_keydec_feed(&d, (const unsigned char *) "\xC3", 1, k, 16);
+ CHECK(n == 0, "split utf8 lead");
+ n = clio_keydec_feed(&d, (const unsigned char *) "\xA9", 1, k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_CHAR && k[0].cp == 0xE9, "split utf8");
+
+ /* unknown CSI emits UNKNOWN so the app can react (e.g. beep) */
+ n = decode(&d, "\x1B[15~", k, 16);
+ CHECK(n == 1 && k[0].kind == CLIOK_UNKNOWN, "unknown csi -> UNKNOWN");
+ /* cursor position report swallowed */
+ n = decode(&d, "\x1B[12;40R", k, 16);
+ CHECK(n == 0, "cpr swallowed");
+}
+
+/* ------------------------------------------------------------------ */
+/* Color policy */
+/* ------------------------------------------------------------------ */
+
+static void setenv0(const char *k, const char *v) {
+ if (v)
+ setenv(k, v, 1);
+ else
+ unsetenv(k);
+}
+
+static void test_color_policy(void) {
+ setenv0("CLICOLOR_FORCE", NULL);
+ setenv0("NO_COLOR", NULL);
+ setenv0("CI", NULL);
+ setenv0("TERM", NULL);
+ CHECK(clio_color_policy(1) == 1, "tty on");
+ CHECK(clio_color_policy(0) == 0, "notty off");
+ setenv0("NO_COLOR", "1");
+ CHECK(clio_color_policy(1) == 0, "NO_COLOR off");
+ setenv0("NO_COLOR", NULL);
+ setenv0("TERM", "dumb");
+ CHECK(clio_color_policy(1) == 0, "TERM=dumb off");
+ setenv0("TERM", NULL);
+ setenv0("CI", "true");
+ CHECK(clio_color_policy(1) == 0, "CI off");
+ setenv0("CI", NULL);
+ setenv0("CLICOLOR_FORCE", "1");
+ CHECK(clio_color_policy(0) == 1, "force on, notty");
+ setenv0("NO_COLOR", "1");
+ CHECK(clio_color_policy(1) == 1, "force beats NO_COLOR");
+ setenv0("CLICOLOR_FORCE", "0");
+ CHECK(clio_color_policy(1) == 0, "force 0 off");
+ setenv0("CLICOLOR_FORCE", NULL);
+}
+
+/* ------------------------------------------------------------------ */
+/* Style emission */
+/* ------------------------------------------------------------------ */
+
+static void test_fstyle(void) {
+ cap_t c;
+ clio_style s = clio_style_default();
+ s.fg = 38;
+ s.bg = 48;
+ s.attr = CLIO_BOLD | CLIO_UNDERLINE;
+
+ CHECK(cap_begin(&c) == 0, "cap begin 1");
+ clio_fstyle(stdout, s);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(out != NULL && len > 0, "fstyle wrote");
+ if (out) {
+ /* exact sequence: reset, bold, underline, fg, bg (in that order) */
+ CHECK(contains(out, len, ESC_RESET) == 1, "fstyle reset");
+ CHECK(contains(out, len, ESC_BOLD) == 1, "fstyle bold");
+ CHECK(contains(out, len, ESC_UNDERLINE) == 1, "fstyle underline");
+ CHECK(contains(out, len, ESC_COLOR_FG_256(38)) == 1, "fstyle fg256");
+ CHECK(contains(out, len, ESC_COLOR_BG_256(48)) == 1, "fstyle bg256");
+ CHECK(contains(out, len, "48;5;48m") == 1, "bg uses 48 not 38");
+ CHECK(contains(out, len, ESC_ITALIC) == 0, "no italic");
+ /* reset first, fg before bg */
+ const char *rp = strstr(out, ESC_RESET);
+ const char *fp = strstr(out, ESC_COLOR_FG_256(38));
+ const char *bp = strstr(out, ESC_COLOR_BG_256(48));
+ CHECK(rp && fp && bp && rp < fp && fp < bp, "fstyle order");
+ free(out);
+ }
+
+ /* default style: only reset */
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_fstyle(stdout, clio_style_default());
+ out = cap_end(&c, &len);
+ CHECK(out != NULL && strcmp(out, ESC_RESET) == 0, "default = reset only");
+ free(out);
+}
+
+/* ------------------------------------------------------------------ */
+/* Flush (non-tty plain + tty diff) */
+/* ------------------------------------------------------------------ */
+
+static void test_flush_plain(void) {
+ clio_term t;
+ clio_term_init(&t);
+ clio_buf b;
+ clio_buf_init(&b, 2, 8);
+ clio_style s = clio_style_default();
+ s.fg = 248;
+ clio_buf_put(&b, 0, 0, "hi", s, 0);
+ clio_buf_put(&b, 1, 0, "yo", s, 0);
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin");
+ clio_flush(&t, &b);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(out != NULL, "cap end");
+ CHECK(len > 0, "plain wrote");
+ CHECK(contains(out, len, "hi"), "plain row 0");
+ CHECK(contains(out, len, "yo"), "plain row 1");
+ CHECK(contains(out, len, "\x1B") == 0, "plain has no escapes");
+ CHECK(contains(out, len, "hi\nyo\n") == 1, "plain exact lines");
+ free(out);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+}
+
+static void test_flush_diff(void) {
+ clio_term t;
+ clio_term_init(&t);
+ /* force tty mode + fixed size for a deterministic test */
+ t.is_tty = 1;
+ t.rows = 3;
+ t.cols = 8;
+
+ clio_buf a;
+ clio_buf_init(&a, 3, 8);
+ clio_style s = clio_style_default();
+ clio_buf_put(&a, 0, 0, "row0", s, 0);
+ clio_buf_put(&a, 1, 0, "row1", s, 0);
+ clio_buf_put(&a, 2, 0, "row2", s, 0);
+
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin 1");
+ clio_flush(&t, &a);
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "first flush clears");
+ CHECK(contains(out, len, "\x1B[1;1H") == 1, "first flush row 1 pos");
+ CHECK(contains(out, len, "\x1B[3;1H") == 1, "first flush row 3 pos");
+ CHECK(contains(out, len, "row0") == 1, "first flush content");
+ free(out);
+
+ /* change only row 2 */
+ clio_buf b;
+ clio_buf_init(&b, 3, 8);
+ clio_buf_put(&b, 0, 0, "row0", s, 0);
+ clio_buf_put(&b, 1, 0, "row1", s, 0);
+ clio_buf_put(&b, 2, 0, "CHG2", s, 0);
+
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_flush(&t, &b);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, "\x1B[3;1H") == 1, "diff row 3 pos");
+ CHECK(contains(out, len, "CHG2") == 1, "diff row 2 content");
+ CHECK(contains(out, len, "\x1B[1;1H") == 0, "diff skips row 1");
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 0, "diff no clear");
+ CHECK(contains(out, len, "row0") == 0, "diff skips row 0 content");
+ free(out);
+
+ /* size change forces full repaint */
+ clio_buf d2;
+ clio_buf_init(&d2, 2, 4);
+ clio_buf_put(&d2, 0, 0, "xy", s, 0);
+ clio_buf_put(&d2, 1, 0, "zw", s, 0);
+ clio_buf_free(&t.last);
+ t.has_last = 0;
+ t.rows = 2;
+ t.cols = 4;
+ CHECK(cap_begin(&c) == 0, "cap begin 3");
+ clio_flush(&t, &d2);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_CLEAR_SCREEN) == 1, "resize full repaint");
+ CHECK(contains(out, len, "\x1B[1;1H") == 1, "resize row 1");
+ free(out);
+
+ clio_buf_free(&a);
+ clio_buf_free(&b);
+ clio_buf_free(&d2);
+ clio_term_free(&t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Term toggles emit the right codes */
+/* ------------------------------------------------------------------ */
+
+static void test_term_toggles(void) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ cap_t c;
+ CHECK(cap_begin(&c) == 0, "cap begin");
+ clio_term_alt_on(&t);
+ clio_term_cursor_hide(&t);
+ clio_term_paste_on(&t);
+ clio_term_kitty_on(&t);
+ clio_term_bell(&t);
+ clio_term_alt_on(&t); /* idempotent: no second emission */
+ size_t len;
+ char *out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on");
+ CHECK(contains(out, len, ESC_ALT_SCREEN_ON) == 1, "alt on once");
+ CHECK(contains(out, len, ESC_CURSOR_HIDE) == 1, "cursor hide");
+ CHECK(contains(out, len, ESC_BRACKETED_PASTE_ON) == 1, "paste on");
+ CHECK(contains(out, len, ESC_KITTY_ON) == 1, "kitty on");
+ CHECK(contains(out, len, ESC_BELL) == 1, "bell");
+ free(out);
+
+ /* shutdown restores everything, in reverse, once each */
+ CHECK(cap_begin(&c) == 0, "cap begin 2");
+ clio_term_shutdown(&t);
+ out = cap_end(&c, &len);
+ CHECK(contains(out, len, ESC_KITTY_OFF) == 1, "kitty off");
+ CHECK(contains(out, len, ESC_BRACKETED_PASTE_OFF) == 1, "paste off");
+ CHECK(contains(out, len, ESC_CURSOR_SHOW) == 1, "cursor show");
+ CHECK(contains(out, len, ESC_ALT_SCREEN_OFF) == 1, "alt off");
+ /* order: kitty first, alt last */
+ if (out) {
+ const char *kp = strstr(out, ESC_KITTY_OFF);
+ const char *ap = strstr(out, ESC_ALT_SCREEN_OFF);
+ CHECK(kp && ap && kp < ap, "shutdown order");
+ }
+ free(out);
+ clio_term_free(&t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Readline (scripted key source) */
+/* ------------------------------------------------------------------ */
+
+typedef struct keys {
+ clio_key *ks;
+ size_t n;
+ size_t i;
+} keys_t;
+
+static int keys_next(void *ud, clio_key *k, int timeout_ms) {
+ (void) timeout_ms;
+ keys_t *s = (keys_t *) ud;
+ if (s->i >= s->n)
+ return 0;
+ *k = s->ks[s->i++];
+ return 1;
+}
+
+static void push(keys_t *s, int kind, u32 cp) {
+ s->ks[s->n].kind = kind;
+ s->ks[s->n].cp = cp;
+ s->n++;
+}
+
+static char *run_readline(keys_t *s, int echo, int rows, int cols) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = (size_t) rows;
+ t.cols = (size_t) cols;
+ clio_buf b;
+ clio_buf_init(&b, (size_t) rows, (size_t) cols);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ s->i = 0;
+ char *r = clio_readline(&t, &b, 0, "> ", p, st, echo, keys_next, s, 0);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+ return r;
+}
+
+static void test_readline(void) {
+ keys_t s;
+ clio_key ks[64];
+ s.ks = ks;
+ s.n = 0;
+ char *r;
+
+ /* type "ab", backspace, "c" -> "ac" */
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r != NULL && strcmp(r, "ac") == 0, "insert backspace");
+ free(r);
+
+ /* left arrow + insert: caret between a and c, x goes there -> "axc" */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CHAR, 'x');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "axc") == 0, "left insert");
+ free(r);
+
+ /* ctrl+a, 'z' -> "zaxc" */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'x');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_CTRL_A, 0);
+ push(&s, CLIOK_CHAR, 'z');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "zaxc") == 0, "ctrl+a insert");
+ free(r);
+
+ /* ctrl+u clears line -> "" (submit empty) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CTRL_U, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+u clears all");
+
+ /* ctrl+a then ctrl+k kills whole line -> empty submit */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_CTRL_A, 0);
+ push(&s, CLIOK_CTRL_K, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+k empty after");
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CTRL_K, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "ab") == 0, "ctrl+k kills rest");
+ free(r);
+
+ /* ctrl+w deletes a word */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'h');
+ push(&s, CLIOK_CHAR, 'e');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'o');
+ push(&s, CLIOK_CHAR, ' ');
+ push(&s, CLIOK_CHAR, 'w');
+ push(&s, CLIOK_CHAR, 'o');
+ push(&s, CLIOK_CHAR, 'r');
+ push(&s, CLIOK_CHAR, 'l');
+ push(&s, CLIOK_CHAR, 'd');
+ push(&s, CLIOK_CTRL_W, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ /* matches bash/readline: the preceding space is killed too */
+ CHECK(r && strcmp(r, "hello") == 0, "ctrl+w word");
+ free(r);
+
+ /* ctrl+c cancels */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CTRL_C, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+c cancels");
+
+ /* escape cancels */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_ESC, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "esc cancels");
+
+ /* ctrl+d on empty submits */
+ s.n = 0;
+ push(&s, CLIOK_CTRL_D, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r == NULL, "ctrl+d empty submits");
+
+ /* ctrl+d deletes the char after the caret (left first) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_CTRL_D, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "a") == 0, "ctrl+d delete");
+ free(r);
+
+ /* delete key removes forward char (caret in the middle) */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_DELETE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "a") == 0, "delete forward");
+ free(r);
+
+ /* utf8 input round trip */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 0x4E2D); /* 中 */
+ push(&s, CLIOK_CHAR, 0x6587); /* 文 */
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 24, 80);
+ CHECK(r && strcmp(r, "\xE4\xB8\xAD\xE6\x96\x87") == 0, "utf8 input");
+ free(r);
+
+ /* echo=0: stars rendered, but value is real text */
+ s.n = 0;
+ push(&s, CLIOK_CHAR, 'a');
+ push(&s, CLIOK_CHAR, 'b');
+ push(&s, CLIOK_CHAR, 'c');
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 0, 24, 80);
+ CHECK(r && strcmp(r, "abc") == 0, "echo off value");
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Long lines: no wrap, window scrolls, backspace spans the "wrap" */
+/* ------------------------------------------------------------------ */
+
+static void test_readline_long(void) {
+ /* 10 cols, prompt "> " => 8-cell text area. */
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = 3;
+ t.cols = 10;
+ clio_buf b;
+ clio_buf_init(&b, 3, 10);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ clio_str txt;
+ clio_str_init(&txt);
+ clio_str_ins(&txt, 0, "abcdefghijkl", 12);
+ size_t col;
+
+ /* caret at end: caret pinned to last col, tail visible, head scrolled */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 1);
+ CHECK(col == 9, "caret pinned last col");
+ CHECK(b.cells[2].ch == 'f' && b.cells[8].ch == 'l', "tail window");
+ CHECK(b.cells[9].ch == 0, "nothing past caret");
+
+ /* one backspace of scroll: window slides left by one */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 11, st, 1);
+ CHECK(col == 9, "caret stays pinned");
+ CHECK(b.cells[2].ch == 'e' && b.cells[9].ch == 'l', "window slid");
+
+ /* caret in the middle: anchored at 0, right edge clipped */
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 3, st, 1);
+ CHECK(col == 5, "caret middle");
+ CHECK(b.cells[2].ch == 'a' && b.cells[9].ch == 'h', "head window clipped");
+
+ /* wide chars: 8 CJK = 16 cells; window must start on a char
+ * boundary and the caret clamp inside the row */
+ clio_str_free(&txt);
+ clio_str_init(&txt);
+ static const char cjk[] = "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD"
+ "\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD\xE4\xB8\xAD";
+ clio_str_ins(&txt, 0, cjk, sizeof cjk - 1);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, sizeof cjk - 1, st, 1);
+ CHECK(col == 9, "wide caret clamped");
+ CHECK(b.cells[2].ch == 0x4E2D && b.cells[8].ch == 0x4E2D,
+ "wide window on boundary");
+
+ /* noecho: same windowing over star count */
+ clio_str_free(&txt);
+ clio_str_init(&txt);
+ clio_str_ins(&txt, 0, "abcdefghijkl", 12);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 12, st, 0);
+ CHECK(col == 9, "star caret pinned");
+ CHECK(b.cells[2].ch == '*' && b.cells[8].ch == '*' && b.cells[9].ch == 0,
+ "star window");
+
+ clio_str_free(&txt);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+
+ /* integration: type past the edge, backspace across the scroll
+ * boundary — the logical result must be exact */
+ keys_t s;
+ clio_key ks[64];
+ s.ks = ks;
+ s.n = 0;
+ for (char ch = 'a'; ch <= 'l'; ch++)
+ push(&s, CLIOK_CHAR, (u32) ch);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ char *r = run_readline(&s, 1, 3, 10);
+ CHECK(r && strcmp(r, "abcdefghi") == 0, "backspace across wrap");
+ free(r);
+
+ /* left-arrow into the scrolled region, then backspace */
+ s.n = 0;
+ for (char ch = 'a'; ch <= 'l'; ch++)
+ push(&s, CLIOK_CHAR, (u32) ch);
+ push(&s, CLIOK_LEFT, 0);
+ push(&s, CLIOK_BACKSPACE, 0);
+ push(&s, CLIOK_ENTER, 0);
+ r = run_readline(&s, 1, 3, 10);
+ CHECK(r && strcmp(r, "abcdefghijl") == 0, "left+backspace across wrap");
+ free(r);
+}
+
+/* ------------------------------------------------------------------ */
+/* Readline canvas paint */
+/* ------------------------------------------------------------------ */
+
+static void test_readline_paint(void) {
+ clio_term t;
+ clio_term_init(&t);
+ t.is_tty = 1;
+ t.rows = 4;
+ t.cols = 20;
+ clio_buf b;
+ clio_buf_init(&b, 4, 20);
+ clio_style p = clio_style_default();
+ clio_style st = clio_style_default();
+ clio_str txt;
+ clio_str_init(&txt);
+
+ /* type "ab", caret after 'b' (col 4) */
+ clio_str_ins(&txt, 0, "ab", 2);
+ size_t col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1);
+ CHECK(col == 4, "caret col echo");
+ CHECK(b.cells[0].ch == '>' && b.cells[1].ch == ' ', "prompt painted");
+ CHECK(b.cells[2].ch == 'a' && b.cells[3].ch == 'b', "text painted");
+
+ /* echo off: stars */
+ clio_buf_clear(&b);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 0);
+ CHECK(col == 4, "caret col noecho");
+ CHECK(b.cells[2].ch == '*' && b.cells[3].ch == '*', "stars painted");
+
+ /* caret in the middle */
+ clio_str_ins(&txt, 0, "ab", 2);
+ clio_str_ins(&txt, 1, "X", 1);
+ clio_str_ins(&txt, 2, "c", 1);
+ col = clio_readline_paint(&b, 0, "> ", p, &txt, 2, st, 1);
+ CHECK(col == 4, "caret middle");
+
+ clio_str_free(&txt);
+ clio_buf_free(&b);
+ clio_term_free(&t);
+}
+
+int main(void) {
+ test_utf8();
+ test_buf();
+ test_keydec();
+ test_color_policy();
+ test_fstyle();
+ test_flush_plain();
+ test_flush_diff();
+ test_term_toggles();
+ test_readline();
+ test_readline_long();
+ test_readline_paint();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures == 0 ? 0 : 1;
+}