summaryrefslogtreecommitdiffstats
path: root/tests/test_input.cpp
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-10-10 20:55:33 +0000
committerEthan Werner <mail@ethanwerner.com>2026-10-10 20:56:50 +0000
commitf8423ebea413605b42de23f5dd06c5479d3fa1dd (patch)
tree2612a5eb026c59e7aae01f10a1cf62f2ee43042c /tests/test_input.cpp
initial commitHEADmaster
Diffstat (limited to 'tests/test_input.cpp')
-rw-r--r--tests/test_input.cpp1263
1 files changed, 1263 insertions, 0 deletions
diff --git a/tests/test_input.cpp b/tests/test_input.cpp
new file mode 100644
index 0000000..99a719f
--- /dev/null
+++ b/tests/test_input.cpp
@@ -0,0 +1,1263 @@
+#include "input.h"
+#include "util.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)
+
+#define CHECK_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (!hay || !strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, \
+ "FAIL %s:%d: %s (got `%s`, want substring `%s`)\n", \
+ __FILE__, __LINE__, (msg), hay ? hay : "(null)", \
+ (needle)); \
+ } \
+ } while (0)
+
+#define CHECK_NOT_CONT(hay, needle, msg) \
+ do { \
+ checks++; \
+ if (hay && strstr(hay, needle)) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (found `%s` in `%s`)\n", \
+ __FILE__, __LINE__, (msg), (needle), hay); \
+ } \
+ } while (0)
+
+#define CHECK_STR(got, want, msg) \
+ do { \
+ checks++; \
+ if (!got || strcmp(got, want) != 0) { \
+ failures++; \
+ fprintf(stderr, "FAIL %s:%d: %s (got `%s`, want `%s`)\n", \
+ __FILE__, __LINE__, (msg), got ? got : "(null)", (want));\
+ } \
+ } while (0)
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+static input_key kkind(int kind) {
+ input_key k;
+ k.kind = kind;
+ k.cp = 0;
+ return k;
+}
+
+static evlist feed(const unsigned char *bytes, size_t n) {
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, bytes, n, &ev);
+ key_parser_flush(&p, &ev);
+ key_parser_free(&p);
+ return ev;
+}
+
+/* Apply a run of parsed keys; every action must be ACT_NONE. */
+static void type_str(editor *e, const char *s) {
+ evlist ev = feed((const unsigned char *)s, strlen(s));
+ for (size_t i = 0; i < ev.len; i++) {
+ input_action a;
+ editor_apply(e, ev.items[i], &a);
+ CHECK(a.kind == ACT_NONE, "type_str: unexpected action while typing");
+ input_action_free(&a);
+ }
+ evlist_free(&ev);
+}
+
+/* Apply a single named key and release its action. */
+static void apply_key(editor *e, int kind) {
+ input_action a;
+ editor_apply(e, kkind(kind), &a);
+ input_action_free(&a);
+}
+
+/* ================================================================== */
+/* Parser tests */
+/* ================================================================== */
+
+static void test_parser_plain_chars(void) {
+ evlist ev = feed((const unsigned char *)"abc", 3);
+ CHECK(ev.len == 3, "three chars");
+ if (ev.len == 3) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a");
+ CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 'b', "b");
+ CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 'c', "c");
+ CHECK(ev.raws[0] && ev.lens[0] == 1 && ev.raws[0][0] == 'a', "raw a");
+ CHECK(ev.raws[2] && ev.lens[2] == 1 && ev.raws[2][0] == 'c', "raw c");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_enter_variants(void) {
+ evlist ev = feed((const unsigned char *)"\r", 1);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CR -> Enter");
+ evlist_free(&ev);
+
+ /* LF (Ctrl+J) is a dead key outside of paste: no event. */
+ ev = feed((const unsigned char *)"\n", 1);
+ CHECK(ev.len == 0, "LF -> no event (dead key)");
+ evlist_free(&ev);
+}
+
+/* Shift-modified Enter and Esc carry no special binding: they behave as the
+ * plain key. */
+static void test_parser_shift_enter_and_esc(void) {
+ evlist ev = feed((const unsigned char *)"\x1b[13;2u", 7);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ENTER, "CSI 13;2 -> Enter");
+ evlist_free(&ev);
+
+ ev = feed((const unsigned char *)"\x1b[27;2u", 7);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "CSI 27;2 -> Esc");
+ evlist_free(&ev);
+}
+
+static void test_parser_lone_escape_is_esc(void) {
+ /* a lone ESC is held until flush */
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, (const unsigned char *)"\x1b", 1, &ev);
+ CHECK(ev.len == 0, "no event before flush");
+ CHECK(key_parser_flush(&p, &ev) == 1, "flush emits one");
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_ESC, "flush -> Esc");
+ evlist_free(&ev);
+ key_parser_free(&p);
+}
+
+static void test_parser_alt_letter_is_the_letter(void) {
+ evlist ev = feed((const unsigned char *)"\x1b" "a", 2);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a',
+ "alt+a -> Char('a')");
+ if (ev.len == 1) {
+ CHECK(ev.raws[0] && ev.lens[0] == 2 && ev.raws[0][0] == '\x1b' &&
+ ev.raws[0][1] == 'a',
+ "raw = ESC a");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_raw_bytes_preserved(void) {
+ evlist ev = feed((const unsigned char *)"\x1b[1;5A", 6);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_UP, "UP");
+ if (ev.len == 1) {
+ CHECK(ev.raws[0] && ev.lens[0] == 6, "raw len 6");
+ if (ev.raws[0] && ev.lens[0] == 6)
+ CHECK(memcmp(ev.raws[0], "\x1b[1;5A", 6) == 0, "raw bytes");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_control_keys(void) {
+ struct {
+ unsigned char b;
+ int want;
+ const char *m;
+ } cases[] = {
+ {0x01, KEY_CTRL_A, "Ctrl-A"},
+ {0x03, KEY_CTRL_C, "Ctrl-C"},
+ {0x04, KEY_CTRL_D, "Ctrl-D"},
+ {0x05, KEY_CTRL_E, "Ctrl-E"},
+ {0x0B, KEY_CTRL_K, "Ctrl-K"},
+ {0x0C, KEY_CTRL_L, "Ctrl-L"},
+ {0x15, KEY_CTRL_U, "Ctrl-U"},
+ {0x17, KEY_CTRL_W, "Ctrl-W"},
+ {0x19, KEY_CTRL_Y, "Ctrl-Y"},
+ {0x1F, KEY_UNDO, "Ctrl-_ (undo)"},
+ };
+ for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
+ evlist ev = feed(&cases[i].b, 1);
+ CHECK(ev.len == 1, cases[i].m);
+ if (ev.len == 1) {
+ checks++;
+ if (ev.items[0].kind != cases[i].want) {
+ failures++;
+ fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__,
+ __LINE__, cases[i].m, ev.items[0].kind, cases[i].want);
+ }
+ }
+ evlist_free(&ev);
+ }
+
+ unsigned char bs[2] = {0x7F, 0x08};
+ for (int i = 0; i < 2; i++) {
+ evlist ev = feed(&bs[i], 1);
+ CHECK(ev.len == 1 && ev.items[0].kind == KEY_BACKSPACE, "backspace");
+ evlist_free(&ev);
+ }
+}
+
+static void test_parser_unmapped_control_bytes(void) {
+ int mapped[256] = {0};
+ mapped[0x09] = mapped[0x0A] = mapped[0x0D] = mapped[0x1B] = 1;
+ for (int b = 0; b < 0x20; b++) {
+ if (mapped[b])
+ continue;
+ unsigned char c = (unsigned char)b;
+ evlist ev = feed(&c, 1);
+ CHECK(ev.len == 1, "exactly one control event");
+ if (ev.len == 1)
+ CHECK(ev.items[0].kind != KEY_CHAR, "not a char");
+ evlist_free(&ev);
+ }
+}
+
+static void test_parser_unknown_sequence_consumed(void) {
+ // Unmapped CSI tilde: empty or a single Unknown (oracle allows either).
+ evlist ev = feed((const unsigned char *)"\x1b[999~", 6);
+ CHECK(ev.len == 0 || (ev.len == 1 && ev.items[0].kind == KEY_UNKNOWN),
+ "unknown CSI ~");
+ evlist_free(&ev);
+
+ // Empty bracketed paste: markers consumed, no events.
+ ev = feed((const unsigned char *)"\x1b[200~\x1b[201~", 12);
+ CHECK(ev.len == 0, "empty paste -> no events");
+ evlist_free(&ev);
+
+ // Mouse reports must not surface as editor keys.
+ ev = feed((const unsigned char *)"\x1b[M", 3);
+ CHECK(ev.len == 0, "mouse SGR -> no events");
+ evlist_free(&ev);
+
+ ev = feed((const unsigned char *)"\x1b[64;10;5M", 10);
+ CHECK(ev.len == 0, "mouse CSI -> no events");
+ evlist_free(&ev);
+
+ // Cursor position reports (DSR responses) are consumed.
+ ev = feed((const unsigned char *)"\x1b[12;40R", 8);
+ CHECK(ev.len == 0, "DSR report -> no events");
+ evlist_free(&ev);
+}
+
+static void test_parser_bracketed_paste(void) {
+ // Paste start marker: no event on its own.
+ evlist ev = feed((const unsigned char *)"\x1b[200~", 6);
+ CHECK(ev.len == 0, "paste start -> no event");
+ evlist_free(&ev);
+
+ // Pasted content is individual char events; a pasted CR is a Newline.
+ ev = feed((const unsigned char *)"\x1b[200~hello\rworld\x1b[201~", 23);
+ CHECK(ev.len == 11, "paste content = 11 events");
+ if (ev.len == 11) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h");
+ CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o");
+ CHECK(ev.items[5].kind == KEY_NEWLINE, "pasted CR -> Newline");
+ CHECK(ev.items[10].kind == KEY_CHAR && ev.items[10].cp == 'd', "d");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_utf8(void) {
+ evlist ev = feed((const unsigned char *)"h\xc3\xa9llo", 6);
+ CHECK(ev.len == 5, "five codepoints");
+ if (ev.len == 5) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'h', "h");
+ CHECK(ev.items[1].kind == KEY_CHAR && ev.items[1].cp == 0xE9, "e-acute");
+ CHECK(ev.items[4].kind == KEY_CHAR && ev.items[4].cp == 'o', "o");
+ }
+ evlist_free(&ev);
+
+ /* split across feed() calls */
+ key_parser p;
+ evlist ev2;
+ key_parser_init(&p);
+ evlist_init(&ev2);
+ key_parser_feed(&p, (const unsigned char *)"h\xc3", 2, &ev2);
+ CHECK(ev2.len == 1 && ev2.items[0].kind == KEY_CHAR &&
+ ev2.items[0].cp == 'h',
+ "h flushed, partial UTF-8 held");
+ key_parser_feed(&p, (const unsigned char *)"\xa9llo", 4, &ev2);
+ key_parser_flush(&p, &ev2);
+ CHECK(ev2.len == 5, "recombined to five codepoints");
+ evlist_free(&ev2);
+ key_parser_free(&p);
+
+ evlist ev3 = feed((const unsigned char *)"\xff", 1);
+ CHECK(ev3.len == 1 && ev3.items[0].kind == KEY_UNKNOWN, "invalid 0xFF");
+ evlist_free(&ev3);
+}
+
+static void test_parser_mixed_stream(void) {
+ unsigned char seq[] = "a\x1b[1;5A\xc3\xa9\x1b[13u";
+ evlist ev = feed(seq, sizeof(seq) - 1);
+ CHECK(ev.len == 4, "four events");
+ if (ev.len == 4) {
+ CHECK(ev.items[0].kind == KEY_CHAR && ev.items[0].cp == 'a', "a");
+ CHECK(ev.items[1].kind == KEY_UP, "UP");
+ CHECK(ev.items[2].kind == KEY_CHAR && ev.items[2].cp == 0xE9, "e-acute");
+ CHECK(ev.items[3].kind == KEY_ENTER, "Enter (CSI 13)");
+ }
+ evlist_free(&ev);
+}
+
+static void test_parser_kitty_and_ss3(void) {
+ struct {
+ const char *seq;
+ int want;
+ const char *m;
+ } cases[] = {
+ {"\x1b[8593;1u", KEY_UP, "kitty up"},
+ {"\x1b[8594;1u", KEY_RIGHT, "kitty right"},
+ {"\x1b[9u", KEY_TAB, "kitty tab"},
+ {"\x1b[9;2u", KEY_BACKTAB, "kitty backtab"},
+ {"\x1b[12u", KEY_BACKSPACE, "kitty backspace"},
+ {"\x1b[127u", KEY_DELETE, "kitty delete"},
+ {"\x1b[13u", KEY_ENTER, "kitty enter"},
+ {"\x1b[27u", KEY_ESC, "kitty esc"},
+ {"\x1bOA", KEY_UP, "ss3 up"},
+ {"\x1bOD", KEY_LEFT, "ss3 left"},
+ {"\x1b[H", KEY_HOME, "csi home"},
+ {"\x1b[3~", KEY_DELETE, "csi delete"},
+ {"\x1b[4~", KEY_END, "csi end"},
+ {"\x1b[5~", KEY_PAGEUP, "csi pageup"},
+ {"\x1b[6~", KEY_PAGEDOWN, "csi pagedown"},
+ };
+ for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
+ const char *seq = cases[i].seq;
+ evlist ev = feed((const unsigned char *)seq, strlen(seq));
+ CHECK(ev.len == 1, cases[i].m);
+ if (ev.len == 1) {
+ checks++;
+ if (ev.items[0].kind != cases[i].want) {
+ failures++;
+ fprintf(stderr, "FAIL %s:%d: %s (got %d want %d)\n", __FILE__,
+ __LINE__, cases[i].m, ev.items[0].kind, cases[i].want);
+ }
+ }
+ evlist_free(&ev);
+ }
+}
+
+/* ================================================================== */
+/* Editor tests */
+/* ================================================================== */
+
+static void test_editor_typing_and_submit(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "text after typing");
+ free(t);
+
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "hello", "submitted text");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+static void test_editor_shift_enter_inserts_newline(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "he");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "llo");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "he\nllo", "NEWLINE key inserts a newline");
+ free(t);
+ CHECK(editor_line_count(&e) == 2, "two lines");
+
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "he\nllo", "submitted multi-line text");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+/* Backslash + Enter at the end of the buffer is a line continuation (a
+ * universal newline trigger for terminals where Shift+Enter is not
+ * distinguishable from Enter); any other Enter still submits. */
+static void test_editor_backslash_enter_continues(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "ls \\");
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_NEWLINE, "trailing backslash + Enter -> continuation");
+ input_action_free(&a);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "ls \n", "backslash became a newline");
+ free(t);
+ CHECK(e.caret == 4, "caret after the newline");
+
+ type_str(&e, "-la");
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "second line's Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "ls \n-la", "multi-line text submitted");
+ input_action_free(&a);
+ editor_free(&e);
+
+ /* No backslash before the caret -> Enter still submits. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "a\\b");
+ input_action b;
+ editor_apply(&f, kkind(KEY_ENTER), &b);
+ CHECK(b.kind == ACT_SUBMIT, "no backslash before caret submits");
+ if (b.kind == ACT_SUBMIT)
+ CHECK_STR(b.text, "a\\b", "text submitted unchanged");
+ input_action_free(&b);
+ editor_free(&f);
+
+ /* A backslash before the caret continues even with text after it. */
+ editor g;
+ editor_init(&g);
+ type_str(&g, "a\\b");
+ apply_key(&g, KEY_LEFT); /* caret sits right after the backslash */
+ input_action c;
+ editor_apply(&g, kkind(KEY_ENTER), &c);
+ CHECK(c.kind == ACT_NEWLINE, "backslash before caret continues");
+ input_action_free(&c);
+ char *gt = editor_text(&g);
+ CHECK_STR(gt, "a\nb", "newline inserted after the backslash");
+ free(gt);
+ editor_free(&g);
+}
+
+/* Regression: after building a multi-line buffer and moving the caret UP to
+ * a middle line, backslash+Enter on that line must split it (insert a
+ * newline), not submit. Previously the continuation only fired when the
+ * caret was at the very end of the whole buffer. */
+static void test_editor_middle_line_continuation(void) {
+ editor e;
+ editor_init(&e);
+ /* Build "alpha \nbeta": line 1 "alpha ", line 2 "beta". */
+ type_str(&e, "alpha ");
+ type_str(&e, "\\");
+ apply_key(&e, KEY_ENTER); /* "alpha \" -> "alpha \n" */
+ type_str(&e, "beta"); /* -> "alpha \nbeta", caret at end */
+ CHECK(editor_line_count(&e) == 2, "two lines before the split");
+ /* Move the caret UP to the middle (first) line. */
+ apply_key(&e, KEY_UP);
+ /* Walk to the end of that line (just before the newline). */
+ while (e.caret < strlen(e.text) && e.text[e.caret] != '\n')
+ apply_key(&e, KEY_RIGHT);
+ CHECK(e.caret == 6, "caret at end of the middle line");
+ /* Type a backslash on the middle line, then Enter -> split it. */
+ type_str(&e, "\\");
+ input_action a;
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_NEWLINE, "middle-line backslash+Enter continues");
+ input_action_free(&a);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "alpha \n\nbeta", "middle line was split");
+ free(t);
+ CHECK(editor_line_count(&e) == 3, "three lines after the split");
+
+ /* And the buffer still submits as a whole from its last line. */
+ apply_key(&e, KEY_DOWN);
+ while (e.caret < strlen(e.text) && e.text[e.caret] != '\n')
+ apply_key(&e, KEY_RIGHT);
+ editor_apply(&e, kkind(KEY_ENTER), &a);
+ CHECK(a.kind == ACT_SUBMIT, "last-line Enter submits");
+ if (a.kind == ACT_SUBMIT)
+ CHECK_STR(a.text, "alpha \n\nbeta", "multi-line submit intact");
+ input_action_free(&a);
+ editor_free(&e);
+}
+
+/* End-to-end regression: Ctrl+J is LF (0x0A). Outside of bracketed paste it
+ * is a dead key — it neither submits nor inserts a newline. Multi-line input
+ * is via backslash+Enter or bracketed paste. */
+static void test_ctrl_j_is_dead_key(void) {
+ key_parser p;
+ evlist ev;
+ key_parser_init(&p);
+ evlist_init(&ev);
+ key_parser_feed(&p, (const unsigned char *)"hi\n", 3, &ev);
+ key_parser_free(&p);
+ CHECK(ev.len == 2, "only the two chars, LF dropped");
+
+ editor e;
+ editor_init(&e);
+ input_action a = {ACT_NONE, NULL};
+ int submitted = 0;
+ for (size_t i = 0; i < ev.len; i++) {
+ editor_apply(&e, ev.items[i], &a);
+ if (a.kind == ACT_SUBMIT)
+ submitted = 1;
+ input_action_free(&a);
+ }
+ CHECK(!submitted, "Ctrl+J (LF) does not submit");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hi", "LF left no newline in the buffer");
+ free(t);
+ evlist_free(&ev);
+ editor_free(&e);
+}
+
+static void test_editor_backspace_and_delete(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "he");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "llo");
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_BACKSPACE);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "backspace removed the newline");
+ CHECK(e.caret == 2, "caret at the join");
+ free(t);
+ editor_free(&e);
+
+ /* A forward-delete needs the caret at the end of the first line: Up gets
+ * there (Left is now clamped to a line), then Delete removes the newline. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "ab");
+ apply_key(&f, KEY_NEWLINE);
+ type_str(&f, "cd");
+ apply_key(&f, KEY_UP);
+ apply_key(&f, KEY_DELETE);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "abcd", "delete removed the newline");
+ CHECK(f.caret == 2, "caret at the join");
+ free(u);
+ editor_free(&f);
+}
+
+static void test_editor_multibyte_backspace(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "h\xc3\xa9llo");
+ apply_key(&e, KEY_END);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ apply_key(&e, KEY_BACKSPACE);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "h", "removed whole codepoints");
+ free(t);
+ editor_free(&e);
+}
+
+static void test_editor_horizontal_movement(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "one");
+ apply_key(&e, KEY_NEWLINE);
+ type_str(&e, "two");
+ CHECK(e.caret == 7, "caret at the end");
+
+ /* Left stays on the current line: three presses reach its start, and a
+ * fourth is a no-op (it no longer crosses into the previous line). */
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ apply_key(&e, KEY_LEFT);
+ CHECK(e.caret == 4, "left moves the caret to the line start");
+ apply_key(&e, KEY_LEFT);
+ CHECK(e.caret == 4, "left is a no-op at the line start");
+ apply_key(&e, KEY_RIGHT);
+ CHECK(e.caret == 5, "right moves the caret");
+
+ /* HOME/END (and their Ctrl aliases) act on the current line, not the
+ * whole buffer. Move to the first line and exercise them there. */
+ apply_key(&e, KEY_UP);
+ CHECK(editor_caret_line(&e) == 0, "on the first line");
+ apply_key(&e, KEY_HOME);
+ CHECK(e.caret == 0, "home is the line start");
+ apply_key(&e, KEY_END);
+ CHECK(e.caret == 3, "end is the line end");
+ apply_key(&e, KEY_CTRL_A);
+ CHECK(e.caret == 0, "ctrl-a is the line start");
+ apply_key(&e, KEY_CTRL_E);
+ CHECK(e.caret == 3, "ctrl-e is the line end");
+ editor_free(&e);
+}
+
+/* Vertical movement walks the lines, keeps the column, clamps it to short
+ * lines on a UTF-8 boundary, and stops at the top/bottom edges. */
+static void test_editor_vertical_movement(void) {
+ editor e;
+ editor_init(&e);
+ editor_set(&e, "one\ntwo\nthree");
+ CHECK(editor_line_count(&e) == 3, "three lines");
+ CHECK(editor_caret_line(&e) == 2, "caret on the last line");
+ CHECK(e.caret == 13, "caret at the end");
+
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 7, "up to the end of line 2");
+ CHECK(editor_caret_line(&e) == 1, "caret on line 2");
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 3, "up to the end of line 1");
+ CHECK(editor_caret_line(&e) == 0, "caret on line 1");
+ apply_key(&e, KEY_UP);
+ CHECK(e.caret == 3, "up is a no-op on the first line");
+
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 7, "down back to line 2");
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 11, "down into line 3, column preserved");
+ CHECK(editor_caret_line(&e) == 2, "caret on line 3");
+ apply_key(&e, KEY_DOWN);
+ CHECK(e.caret == 11, "down is a no-op on the last line");
+ editor_free(&e);
+
+ /* Column clamping: a column longer than the target line clamps to its
+ * end, and the clamped column is what comes back. */
+ editor f;
+ editor_init(&f);
+ editor_set(&f, "abcdef\ngh");
+ CHECK(f.caret == 9, "set caret at the end");
+ apply_key(&f, KEY_UP);
+ CHECK(f.caret == 2, "up keeps the column");
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ CHECK(f.caret == 6, "end of the long line");
+ apply_key(&f, KEY_DOWN);
+ CHECK(f.caret == 9, "down clamps the column to the short line");
+ apply_key(&f, KEY_UP);
+ CHECK(f.caret == 2, "up keeps the clamped column");
+ editor_free(&f);
+
+ /* A single-line buffer: UP/DOWN never move the caret. */
+ editor h;
+ editor_init(&h);
+ editor_set(&h, "hello");
+ apply_key(&h, KEY_HOME);
+ apply_key(&h, KEY_UP);
+ CHECK(h.caret == 0, "UP is a no-op on one line");
+ apply_key(&h, KEY_DOWN);
+ CHECK(h.caret == 0, "DOWN is a no-op on one line");
+ editor_free(&h);
+
+ /* UTF-8: a column landing mid-codepoint snaps to the boundary. */
+ editor u;
+ editor_init(&u);
+ editor_set(&u, "abc\n\xc3\xa9\xc3\xa9"); /* "abc", then two e-acute */
+ CHECK(u.caret == 8, "caret at the end");
+ apply_key(&u, KEY_UP);
+ CHECK(u.caret == 3, "up to the end of line 1");
+ apply_key(&u, KEY_DOWN);
+ CHECK(u.caret == 6, "down clamps between the two codepoints");
+ apply_key(&u, KEY_UP);
+ CHECK(u.caret == 2, "up keeps the clamped column");
+ editor_free(&u);
+}
+
+static void test_editor_insert_at_caret(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "helo");
+ apply_key(&e, KEY_HOME);
+ apply_key(&e, KEY_RIGHT);
+ type_str(&e, "i");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hielo", "inserted before the caret");
+ CHECK(e.caret == 2, "caret after the inserted char");
+ free(t);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ apply_key(&f, KEY_BACKSPACE);
+ CHECK(editor_is_empty(&f), "backspace at 0 is a no-op");
+ apply_key(&f, KEY_DELETE);
+ CHECK(editor_is_empty(&f), "delete at 0 is a no-op");
+ editor_free(&f);
+}
+
+static void test_editor_kill_and_word_kills(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "foo bar");
+ apply_key(&e, KEY_CTRL_K);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "foo bar", "Ctrl-K at end is a no-op");
+ free(t);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ type_str(&f, "foo bar");
+ apply_key(&f, KEY_HOME);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_RIGHT);
+ apply_key(&f, KEY_CTRL_K);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "foo", "Ctrl-K cut the rest of the line");
+ free(u);
+ editor_free(&f);
+
+ editor g;
+ editor_init(&g);
+ type_str(&g, "foo bar");
+ apply_key(&g, KEY_END);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_LEFT);
+ apply_key(&g, KEY_CTRL_U);
+ char *v = editor_text(&g);
+ CHECK_STR(v, "bar", "Ctrl-U cut the front of the line");
+ free(v);
+ editor_free(&g);
+
+ editor h;
+ editor_init(&h);
+ type_str(&h, "hello bar_baz");
+ apply_key(&h, KEY_END);
+ apply_key(&h, KEY_CTRL_W);
+ char *w = editor_text(&h);
+ CHECK_STR(w, "hello", "Ctrl-W removed one word");
+ free(w);
+ editor_free(&h);
+}
+
+static void test_editor_undo(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ char *t = editor_text(&e);
+ CHECK_STR(t, "hello", "typed");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "hell", "undo removed the last char");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "hel", "undo again");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ apply_key(&e, KEY_UNDO);
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "", "undo back to empty");
+ free(t);
+
+ apply_key(&e, KEY_UNDO);
+ t = editor_text(&e);
+ CHECK_STR(t, "", "undo past empty is a no-op");
+ free(t);
+ editor_free(&e);
+}
+
+static void test_editor_yank(void) {
+ /* Ctrl-W kill, then Ctrl-Y yank it back. */
+ editor e;
+ editor_init(&e);
+ type_str(&e, "foo bar");
+ apply_key(&e, KEY_END);
+ apply_key(&e, KEY_CTRL_W);
+ char *t = editor_text(&e);
+ CHECK_STR(t, "foo", "Ctrl-W killed the last word");
+ free(t);
+ apply_key(&e, KEY_CTRL_Y);
+ t = editor_text(&e);
+ CHECK_STR(t, "foo bar", "Ctrl-Y yanked the killed word");
+ free(t);
+ editor_free(&e);
+
+ /* A backspaced char is also yankable. */
+ editor f;
+ editor_init(&f);
+ type_str(&f, "ab");
+ apply_key(&f, KEY_BACKSPACE);
+ char *u = editor_text(&f);
+ CHECK_STR(u, "a", "backspace removed b");
+ free(u);
+ apply_key(&f, KEY_CTRL_Y);
+ u = editor_text(&f);
+ CHECK_STR(u, "ab", "Ctrl-Y yanked the backspaced char");
+ free(u);
+ editor_free(&f);
+}
+
+static void test_editor_ctrl_c_and_ctrl_d(void) {
+ editor e;
+ editor_init(&e);
+ type_str(&e, "hello");
+ input_action a;
+ editor_apply(&e, kkind(KEY_CTRL_C), &a);
+ CHECK(a.kind == ACT_NONE, "Ctrl-C on non-empty is an edit op");
+ CHECK(editor_is_empty(&e), "Ctrl-C clears the buffer");
+ input_action_free(&a);
+
+ input_action q;
+ editor_apply(&e, kkind(KEY_CTRL_C), &q);
+ CHECK(q.kind == ACT_QUIT, "Ctrl-C on empty -> quit");
+ input_action_free(&q);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ input_action d;
+ editor_apply(&f, kkind(KEY_CTRL_D), &d);
+ CHECK(d.kind == ACT_QUIT, "Ctrl-D on empty -> quit");
+ input_action_free(&d);
+
+ type_str(&f, "hello");
+ input_action d2;
+ editor_apply(&f, kkind(KEY_CTRL_D), &d2);
+ CHECK(d2.kind == ACT_NONE, "Ctrl-D on non-empty is an edit op");
+ char *t = editor_text(&f);
+ CHECK_STR(t, "hello", "no-op at end of a single line");
+ free(t);
+ input_action_free(&d2);
+ editor_free(&f);
+}
+
+static void test_editor_backtab_tab_and_esc(void) {
+ editor e;
+ editor_init(&e);
+ input_action a;
+ editor_apply(&e, kkind(KEY_BACKTAB), &a);
+ CHECK(a.kind == ACT_TOGGLE_MODE, "backtab toggles mode");
+ input_action_free(&a);
+ editor_free(&e);
+
+ editor f;
+ editor_init(&f);
+ input_action t;
+ editor_apply(&f, kkind(KEY_TAB), &t);
+ CHECK(t.kind == ACT_NONE, "tab is an insert op");
+ char *ts = editor_text(&f);
+ CHECK_STR(ts, "\t", "tab inserted");
+ free(ts);
+ input_action_free(&t);
+ editor_free(&f);
+
+ editor g;
+ editor_init(&g);
+ input_action s;
+ editor_apply(&g, kkind(KEY_ESC), &s);
+ CHECK(s.kind == ACT_STOP, "esc -> stop");
+ input_action_free(&s);
+ editor_free(&g);
+}
+
+/* ================================================================== */
+/* Display tests */
+/* ================================================================== */
+
+static void test_display_len(void) {
+ CHECK(input_display_len("\x1b[1;36m›\x1b[0m ") == 2, "SGR ignored");
+ CHECK(input_display_len("plain") == 5, "plain ascii");
+ CHECK(input_display_len("") == 0, "empty");
+}
+
+static void test_display_len_wide(void) {
+ CHECK(input_display_len("\xe4\xb8\xad\xe6\x96\x87") == 4, "two CJK = 4");
+ CHECK(input_display_len("a\xe4\xb8\xad" "b") == 4, "a CJK b = 4");
+ CHECK(input_display_len("h\xc3\xa9llo") == 5, "accented = 5");
+}
+
+static void test_display_len_escape_families(void) {
+ CHECK(input_display_len("\x1b]0;llsh\x1b\\hi") == 2, "OSC st");
+ CHECK(input_display_len("\x1b]0;llsh\x07hi") == 2, "OSC BEL");
+ CHECK(input_display_len("\x1bOA") == 0, "SS3");
+ CHECK(input_display_len("\x1b(Bx") == 1, "charset");
+ CHECK(input_display_len("\x1b[c") == 0, "CSI cursor");
+ CHECK(input_display_len("a\x1b") == 1, "trailing lone ESC");
+}
+
+/* History tests */
+/* ================================================================== */
+
+static char *hist_dir(void) {
+ static char buf[512];
+ snprintf(buf, sizeof(buf), "/tmp/llshc_hist_%d", (int)getpid());
+ return buf;
+}
+
+static char *hist_path(const char *name) {
+ static char path[640];
+ snprintf(path, sizeof(path), "%s/%s", hist_dir(), name);
+ return path;
+}
+
+static void hist_cleanup(void) {
+ const char *names[] = {"a.json", "b.json"};
+ for (size_t i = 0; i < 2; i++) {
+ char p[640];
+ snprintf(p, sizeof(p), "%s/%s", hist_dir(), names[i]);
+ remove(p);
+ }
+ rmdir(hist_dir());
+}
+
+static void test_history_capture_submit_cycle(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+
+ input_history *h = input_history_open(path);
+ CHECK(h != NULL, "open history");
+ input_history_push(h, "echo hi");
+ input_history_push(h, "ls -la");
+
+ /* While composing, Up engages only from the first line (caret_line 0);
+ * a mid-buffer Up moves the caret, not history. */
+ CHECK(input_history_walk(h, -1, 1, "") == NULL, "Up mid-buffer -> no recall");
+
+ char *line = input_history_walk(h, -1, 0, "");
+ CHECK_STR(line, "ls -la", "newest first");
+ free(line);
+
+ /* Mid-walk: Up advances toward the oldest regardless of the caret line. */
+ line = input_history_walk(h, -1, 2, "");
+ CHECK_STR(line, "echo hi", "oldest second");
+ free(line);
+
+ /* Oldest already shown: Up hands back to the caret. */
+ CHECK(input_history_walk(h, -1, 0, "") == NULL, "Up at oldest -> NULL");
+
+ /* Down walks back toward the newest entry. */
+ line = input_history_walk(h, +1, 0, "");
+ CHECK_STR(line, "ls -la", "Down back to newest");
+ free(line);
+
+ /* Down past the newest restores the pre-recall line and ends the walk. */
+ line = input_history_walk(h, +1, 0, "");
+ CHECK_STR(line, "", "Down past newest restores pre-recall line");
+ free(line);
+ CHECK(h->active == false, "deactivated after restoring");
+
+ input_history_free(h);
+
+ input_history *h2 = input_history_open(path);
+ CHECK(h2 && h2->entries.len == 2, "reloaded 2 entries");
+ input_history_free(h2);
+ hist_cleanup();
+}
+
+static void test_history_restores_pre_recall_line(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+ input_history *h = input_history_open(path);
+ input_history_push(h, "first");
+ input_history_push(h, "second");
+
+ /* Down while composing never recalls — it moves the caret. */
+ CHECK(input_history_walk(h, +1, 0, "typed but not sent") == NULL,
+ "Down while composing -> no recall");
+
+ char *line = input_history_walk(h, -1, 0, "typed but not sent");
+ CHECK_STR(line, "second", "newest first");
+ free(line);
+
+ line = input_history_walk(h, +1, 0, "typed but not sent");
+ CHECK_STR(line, "typed but not sent", "restored the pre-recall line");
+ free(line);
+ CHECK(h->active == false, "deactivated after restoring");
+ CHECK(h->snapshot == NULL, "snapshot consumed");
+ input_history_free(h);
+ hist_cleanup();
+}
+
+static void test_history_skips_duplicates_and_empty(void) {
+ hist_cleanup();
+ char *path = hist_path("a.json");
+ input_history *h = input_history_open(path);
+ input_history_push(h, "same");
+ input_history_push(h, "same");
+ input_history_push(h, " ");
+ input_history_push(h, " spaced ");
+ input_history_push(h, "spaced");
+
+ CHECK(h->entries.len == 2, "two unique entries");
+ if (h->entries.len == 2) {
+ CHECK_STR(h->entries.items[0], "same", "entry 0");
+ CHECK_STR(h->entries.items[1], "spaced", "entry 1 trimmed");
+ }
+ input_history_free(h);
+ hist_cleanup();
+}
+
+static void test_history_persistence_and_limit(void) {
+ hist_cleanup();
+ char *path = hist_path("b.json");
+
+ input_history *h = input_history_open(path);
+ for (int i = 1; i <= LLSH_HISTORY_LIMIT + 1; i++) {
+ char buf[32];
+ snprintf(buf, sizeof(buf), "cmd%03d", i);
+ input_history_push(h, buf);
+ }
+ size_t live = h->entries.len - h->head;
+ CHECK(live == LLSH_HISTORY_LIMIT, "limit enforced");
+ if (live == LLSH_HISTORY_LIMIT) {
+ CHECK_STR(h->entries.items[h->head], "cmd002", "oldest evicted");
+ CHECK_STR(h->entries.items[h->entries.len - 1], "cmd101",
+ "newest preserved");
+ }
+ input_history_free(h);
+
+ input_history *h2 = input_history_open(path);
+ CHECK(h2 && h2->entries.len - h2->head == LLSH_HISTORY_LIMIT,
+ "reloaded at limit");
+ input_history_free(h2);
+ hist_cleanup();
+}
+
+/* ================================================================== */
+/* Shell shadow tests */
+/* ================================================================== */
+
+static void test_shell_shadow(void) {
+ hist_cleanup();
+ editor shadow;
+ editor_init(&shadow);
+ type_str(&shadow, "echo hi");
+ char *out = shell_shadow_apply(&shadow, kkind(KEY_ENTER));
+ CHECK_STR(out, "echo hi", "submitted line returned");
+ free(out);
+ CHECK(editor_is_empty(&shadow), "shadow cleared after submit");
+ editor_free(&shadow);
+
+ editor s2;
+ editor_init(&s2);
+ type_str(&s2, "ls");
+ char *o2 = shell_shadow_apply(&s2, kkind(KEY_LEFT));
+ CHECK(o2 == NULL, "intermediate key -> NULL");
+ CHECK(!editor_is_empty(&s2), "shadow NOT cleared on intermediate key");
+ editor_free(&s2);
+
+ editor s3;
+ editor_init(&s3);
+ type_str(&s3, " ");
+ char *o3 = shell_shadow_apply(&s3, kkind(KEY_ENTER));
+ CHECK(o3 == NULL, "blank submit -> NULL");
+ CHECK(!editor_is_empty(&s3), "shadow NOT cleared on blank");
+ editor_free(&s3);
+ hist_cleanup();
+}
+
+/* ================================================================== */
+/* Reader queue */
+/* ================================================================== */
+
+/* The reader's event queue must be FIFO: keys typed quickly must reach the
+ * editor in order (regression: a swap-pop scrambled the sequence, dropping
+ * and reordering characters). */
+static void test_reader_queue_fifo(void) {
+ reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx);
+ memset(ctx, 0, sizeof *ctx);
+ input_key_reader *r = input_key_reader_new(ctx);
+
+ const unsigned int cps[] = {'a', 'b', 'c', 'd', 'e', 'f'};
+ for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) {
+ input_key k = {KEY_CHAR, cps[i]};
+ input_key_reader_send(r, &k);
+ }
+ for (size_t i = 0; i < sizeof cps / sizeof *cps; i++) {
+ input_key out;
+ const unsigned char *raw;
+ size_t raw_len;
+ CHECK(input_key_reader_recv(r, &out, &raw, &raw_len),
+ "recv returns queued event");
+ CHECK(out.kind == KEY_CHAR, "queued key kind preserved");
+ CHECK(out.cp == cps[i], "queue is FIFO (order preserved)");
+ input_raw_free(raw);
+ }
+ input_key out;
+ CHECK(!input_key_reader_recv(r, &out, NULL, NULL), "queue drained");
+ input_key_reader_free(r);
+}
+
+/* The raw buffer handed out by recv is owned by the caller: it must stay
+ * valid no matter what the queue does afterwards (regression: recv used to
+ * pop-and-free the very buffer it returned, so callers read freed memory and
+ * shell-mode keystrokes arrived scrambled). */
+static void test_recv_raw_owned(void) {
+ reader_ctx *ctx = (reader_ctx* ) xmalloc(sizeof *ctx);
+ memset(ctx, 0, sizeof *ctx);
+ input_key_reader *r = input_key_reader_new(ctx);
+
+ input_key k = {KEY_CHAR, 'x'};
+ input_key_reader_send_raw(r, &k, "payload");
+
+ input_key out;
+ const unsigned char *raw1 = NULL;
+ size_t raw_len = 0;
+ CHECK(input_key_reader_recv(r, &out, &raw1, &raw_len), "recv raw event");
+ CHECK(raw_len == 7, "raw length preserved");
+
+ /* Fill the ring past one full lap so the queue reuses the first slot;
+ * with a borrowed (freed-on-pop) pointer, `raw1` would now be garbage. */
+ for (int i = 0; i < 16; i++)
+ input_key_reader_send_raw(r, &k, "scratch");
+ for (int i = 0; i < 16; i++) {
+ input_key o2;
+ const unsigned char *r2 = NULL;
+ if (!input_key_reader_recv(r, &o2, &r2, &raw_len))
+ break;
+ input_raw_free(r2);
+ }
+
+ CHECK(raw1 && raw1[0] == 'p' && memcmp(raw1, "payload", 7) == 0,
+ "recv'd raw stays valid after the queue laps it");
+ input_raw_free(raw1);
+ input_key_reader_free(r);
+}
+
+/* ================================================================== */
+
+/* The renderer and these tests share input_wrap_total / input_wrap_caret to
+ * agree on how a buffer breaks into physical rows and where the caret lands on
+ * a terminal of a given width, using the delayed-wrap model real terminals use.
+ * Line 0 carries the 2-column prompt. */
+static void test_wrap_geometry(void) {
+ size_t r, c;
+
+ /* A short first line: the prompt takes the first two columns, nothing
+ * wraps. */
+ CHECK(input_wrap_total("hi", 10) == 1, "short line is one row");
+ input_wrap_caret("hi", 0, 10, &r, &c);
+ CHECK(r == 0 && c == 2, "caret at text start sits after the prompt");
+ input_wrap_caret("hi", 2, 10, &r, &c);
+ CHECK(r == 0 && c == 4, "caret at the text end");
+
+ /* A first line long enough to wrap: prompt + text flow across rows. The
+ * cursor sits on the last typed column, not a spurious next row. */
+ CHECK(input_wrap_total("abcdef", 5) == 2, "wrapped line is two rows");
+ input_wrap_caret("abcdef", 0, 5, &r, &c);
+ CHECK(r == 0 && c == 2, "caret before the first char");
+ input_wrap_caret("abcdef", 3, 5, &r, &c);
+ CHECK(r == 0 && c == 4, "caret after 'abc' sits on the last column");
+ input_wrap_caret("abcdef", 6, 5, &r, &c);
+ CHECK(r == 1 && c == 3, "caret at the wrapped end");
+
+ /* A multi-line buffer: only line 0 carries the prompt. */
+ CHECK(input_wrap_total("ab\ncdef", 5) == 2, "two short lines are two rows");
+ input_wrap_caret("ab\ncdef", 7, 5, &r, &c);
+ CHECK(r == 1 && c == 4, "caret at the buffer end");
+ input_wrap_caret("ab\ncdef", 5, 5, &r, &c);
+ CHECK(r == 1 && c == 2, "caret mid second line");
+
+ /* Regression: a first line that ends exactly at the margin, followed by a
+ * newline, must NOT gain a spurious empty row. '» ab' is exactly 4
+ * columns, so the 'cdefgh' line starts immediately on row 1. */
+ CHECK(input_wrap_total("ab\ncdefgh", 4) == 3,
+ "full first line + newline is three rows, not four");
+ input_wrap_caret("ab\ncdefgh", 9, 4, &r, &c);
+ CHECK(r == 2 && c == 2, "caret at the end of the third row");
+ input_wrap_caret("ab\ncdefgh", 2, 4, &r, &c);
+ CHECK(r == 0 && c == 3, "caret at the end of the full first row");
+ input_wrap_caret("ab\ncdefgh", 3, 4, &r, &c);
+ CHECK(r == 1 && c == 0, "caret at the start of the second line");
+
+ /* A trailing full line keeps the cursor on its last column (no empty row). */
+ CHECK(input_wrap_total("abcdef", 4) == 2, "two full rows is two rows");
+ input_wrap_caret("abcdef", 6, 4, &r, &c);
+ CHECK(r == 1 && c == 3, "caret at the end of a full row sits on it");
+
+ /* The renderer needs the pending flag to tell a cursor sitting on the last
+ * column (delayed wrap) from one merely after the second-to-last column. */
+ CHECK(input_wrap_pending("hi", 2, 10) == false,
+ "short end is not pending");
+ CHECK(input_wrap_pending("abcdef", 3, 5) == true,
+ "caret after the last-column char is pending");
+ CHECK(input_wrap_pending("abcdef", 4, 5) == false,
+ "caret on the wrapped row is not pending");
+ CHECK(input_wrap_pending("abcdefg", 7, 10) == false,
+ "second-to-last column end is not pending");
+ CHECK(input_wrap_pending("abcdefgh", 8, 10) == true,
+ "last-column end is pending");
+ CHECK(input_wrap_pending("a\xE4\xB8\xAD", 4, 10) == false,
+ "three-byte char advances one column");
+ CHECK(input_wrap_pending("a\xF0\x9F\x98\x80", 5, 10) == false,
+ "four-byte char advances one column");
+ CHECK(input_wrap_pending("a", 1, 1) == true,
+ "one-column terminal stays pending");
+ CHECK(input_wrap_pending("a\n", 2, 1) == false,
+ "newline clears pending wrap");
+ CHECK(input_wrap_pending("ab\ncdef\n", 8, 5) == false,
+ "a trailing newline is not pending");
+
+ /* A blank buffer still renders the prompt as one row. */
+ CHECK(input_wrap_total("", 10) == 1, "blank buffer is one row");
+ input_wrap_caret("", 0, 10, &r, &c);
+ CHECK(r == 0 && c == 2, "caret sits after the prompt");
+}
+
+int main(void) {
+ /* parser */
+ test_parser_plain_chars();
+ test_parser_enter_variants();
+ test_parser_shift_enter_and_esc();
+ test_parser_lone_escape_is_esc();
+ test_parser_alt_letter_is_the_letter();
+ test_parser_raw_bytes_preserved();
+ test_parser_control_keys();
+ test_parser_unmapped_control_bytes();
+ test_parser_unknown_sequence_consumed();
+ test_parser_bracketed_paste();
+ test_parser_utf8();
+ test_parser_mixed_stream();
+ test_parser_kitty_and_ss3();
+
+ /* editor */
+ test_editor_typing_and_submit();
+ test_editor_shift_enter_inserts_newline();
+ test_editor_backslash_enter_continues();
+ test_editor_middle_line_continuation();
+ test_ctrl_j_is_dead_key();
+ test_editor_backspace_and_delete();
+ test_editor_multibyte_backspace();
+ test_editor_horizontal_movement();
+ test_editor_vertical_movement();
+ test_editor_insert_at_caret();
+ test_editor_kill_and_word_kills();
+ test_editor_undo();
+ test_editor_yank();
+ test_editor_ctrl_c_and_ctrl_d();
+ test_editor_backtab_tab_and_esc();
+
+ /* display */
+ test_display_len();
+ test_display_len_wide();
+ test_display_len_escape_families();
+
+ /* wrap geometry */
+ test_wrap_geometry();
+
+ /* history */
+ test_history_capture_submit_cycle();
+ test_history_restores_pre_recall_line();
+ test_history_skips_duplicates_and_empty();
+ test_history_persistence_and_limit();
+
+ /* shell shadow */
+ test_shell_shadow();
+
+ /* reader queue */
+ test_reader_queue_fifo();
+ test_recv_raw_owned();
+
+ printf("%d checks, %d failures\n", checks, failures);
+ return failures ? 1 : 0;
+}