diff --git a/frontier-cli/Makefile b/frontier-cli/Makefile index 8b978e246..c79116d86 100644 --- a/frontier-cli/Makefile +++ b/frontier-cli/Makefile @@ -69,12 +69,19 @@ TB2_IMPL_SRC = $(TB2_DIR)/termbox2_impl.c # unused statics get __attribute__((unused)) if the compiler complains. BOXEN_DIR = boxen INCLUDES += -I$(BOXEN_DIR) +# +# 2026-06-29 JES #809 Phase C M1: input_decoder.c added. At M1 it is a stub +# (create / destroy / inject-test-seam / poll-returns-timeout). Compiled into +# the production binary so the M2-M6 sub-agents land their work without a +# separate build-system change. It is NOT yet wired into backend_tb2.c -- +# tb2_poll_event continues to call tb_peek_event() until M6 cutover. BOXEN_SOURCES = \ $(BOXEN_DIR)/boxen_log.c \ $(BOXEN_DIR)/boxen_wcwidth.c \ $(BOXEN_DIR)/backend_tb2.c \ $(BOXEN_DIR)/backend_mock.c \ - $(BOXEN_DIR)/boxen.c + $(BOXEN_DIR)/boxen.c \ + $(BOXEN_DIR)/input_decoder.c # Strings compiler integration (shared with tests) STRINGS_COMPILER_DIR = ../tools/strings_compiler diff --git a/frontier-cli/boxen/input_decoder.c b/frontier-cli/boxen/input_decoder.c new file mode 100644 index 000000000..8d12eba19 --- /dev/null +++ b/frontier-cli/boxen/input_decoder.c @@ -0,0 +1,242 @@ +/* + * input_decoder.c -- private terminal input decoder for the boxen tb2 backend. + * + * 2026-06-29 JES #809 M1: PTY-replay harness + decoder stub. + * + * Scope of this file at M1: + * - Allocate / free the input_decoder_t struct. + * - Provide a ring buffer that input_decoder_inject_bytes() fills (test seam). + * - input_decoder_poll() always returns BOXEN_ERR_TIMEOUT (the state machine + * that drains the ring buffer into boxen_event_t lands in M2). + * - Mouse-enable and Kitty-enable record the request but emit nothing. + * + * Out of scope for M1: + * - The escape-sequence state machine (M2 -- GROUND / ESC_RECEIVED / + * CSI_COLLECTING / SS3_RECEIVED states; see INPUT_DECODER_PLAN.md s4). + * - SGR mouse / X10 mouse parsing (M3). + * - Bracketed paste (M4) including the BOXEN_EV_PASTE ABI addition. + * - Kitty CSI-u decode (M5). + * - Wiring into backend_tb2.c (M6). + * - The /mouse on/off slash command (M7). + * + * The M1 deliverable is the harness, not the decoder. This file exists so + * the M2 sub-agent has a place to grow the state machine and so that the + * tests/input_decoder_tests.c harness has a real linkable surface that + * exercises the API shape (create / inject / poll / destroy). + * + * Threading: see the contract block in input_decoder.h. Summary: single + * owner thread; GIL-held at every API entry/exit; the GIL is dropped only + * inside the M6 blocking read inside input_decoder_poll, and the + * single-owner invariant means no other thread can touch the decoder + * during that window. No locks, no atomics -- by design. + * + * See planning/phase_c/INPUT_DECODER_PLAN.md sections 2, 6, 7 for the full + * design. + */ + +#include "input_decoder.h" + +#include +#include +#include + +/* 2026-06-29 JES #809 M1: ring-buffer sizing. + * + * 4 KiB is comfortably larger than any non-paste protocol burst we expect + * (a fully-modified CSI sequence tops out at ~12 bytes; SGR mouse reports + * are ~16; a worst-case burst of "every key on the keyboard pressed at once" + * fits in well under 1 KiB). Bracketed paste (M4) has its own dedicated + * buffer with a 256 KiB cap -- it does NOT share this ring. + * + * The size is a compile-time constant rather than runtime-tunable because + * the decoder is single-purpose and the M2 state machine will reason about + * worst-case fill levels at audit time. Power-of-two simplifies any future + * masked-index arithmetic if we move from memmove-on-drain to a true ring. */ +#define INPUT_DECODER_BUFFER_SIZE 4096 + +struct input_decoder { + /* TTY fd (or -1 for the test harness). M1: unused. M2+ uses select(2) + * + read(2) on this fd inside input_decoder_poll. */ + int tty_fd; + + /* Mouse-enable bit. Records the request from input_decoder_set_mouse; + * M3 will write \e[?1006h / \e[?1006l + \e[?2004h / \e[?2004l to tty_fd. */ + bool mouse_enabled; + + /* Kitty enable bit. Records the request from input_decoder_kitty_enable; + * M5 will write \e[=1u to tty_fd and probe for a response. */ + bool kitty_enabled; + + /* Linear scratch buffer used as a simple drain-from-head queue. + * fill_len is the number of bytes currently held, starting at buf[0] + * (the "fill level" -- not an index, despite the future plan to grow + * into a head/tail ring). + * + * Future-tense: the M2 state machine will consume from the head and + * compact (memmove) on drain, or be promoted to a head/tail ring if + * profiling shows the memmove is hot. For M1 the buffer is filled by + * inject_bytes (test seam) and never drained because poll always returns + * BOXEN_ERR_TIMEOUT. + * + * Invariant: fill_len <= INPUT_DECODER_BUFFER_SIZE. Enforced by clamp + * in inject_bytes; asserted at top of inject_bytes so a future drain + * regression cannot violate it silently. */ + uint8_t buf[INPUT_DECODER_BUFFER_SIZE]; + size_t fill_len; + + /* Cumulative count of bytes dropped because the ring was full when + * inject_bytes (or future M2 / M3 read(2) loop) tried to deposit them. + * Exposed via input_decoder_dropped_bytes() so tests and (post-M2) + * production callers can detect silent desync. Monotonic; never + * decreases. This is the security-relevant back-pressure signal + * called out by plan section 3.12 (burst-read robustness). */ + size_t dropped_bytes; +}; + +/* ------------------------------------------------------------------------- + * Lifecycle + * ---------------------------------------------------------------------- */ + +input_decoder_t *input_decoder_create(int tty_fd) { + input_decoder_t *dec = (input_decoder_t *)calloc(1, sizeof(*dec)); + if (dec == NULL) { + return NULL; + } + dec->tty_fd = tty_fd; + dec->mouse_enabled = false; + dec->kitty_enabled = false; + dec->fill_len = 0; + dec->dropped_bytes = 0; + return dec; +} + +void input_decoder_destroy(input_decoder_t *dec) { + if (dec == NULL) { + return; + } + /* M1: no resources beyond the struct itself. M3 / M4 will own paste + * buffers and possibly an escape-write retry queue; both will be freed + * here. */ + free(dec); +} + +/* ------------------------------------------------------------------------- + * Mouse-mode policy seam + * ---------------------------------------------------------------------- */ + +void input_decoder_set_mouse(input_decoder_t *dec, bool enable) { + if (dec == NULL) { + return; + } + dec->mouse_enabled = enable; + /* M3 / M4: write \e[?1006h / \e[?1006l and \e[?2004h / \e[?2004l to + * dec->tty_fd. M1 stub records the bit only. */ +} + +bool input_decoder_mouse_enabled(const input_decoder_t *dec) { + if (dec == NULL) { + return false; + } + return dec->mouse_enabled; +} + +/* ------------------------------------------------------------------------- + * Poll (M1 stub) + * ---------------------------------------------------------------------- */ + +int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) { + (void)timeout_ms; /* M1: ignored; M2+ uses it in select(2). */ + + if (dec == NULL || out == NULL) { + return BOXEN_ERR_INVALID; + } + + /* Always-zeroed out parameter on timeout, per the plan section 2.2: + * "On timeout returns BOXEN_ERR_TIMEOUT (*out is zeroed)." */ + memset(out, 0, sizeof(*out)); + + /* M1 stub: the state machine that consumes the ring buffer is M2's + * deliverable. Until then we always report timeout so the only event + * a caller can observe is "nothing happened". The buffered bytes + * remain in place for the M2 implementation to drain. */ + return BOXEN_ERR_TIMEOUT; +} + +/* ------------------------------------------------------------------------- + * Kitty enable (M1 stub; full impl in M5) + * ---------------------------------------------------------------------- */ + +void input_decoder_kitty_enable(input_decoder_t *dec) { + if (dec == NULL) { + return; + } + dec->kitty_enabled = true; + /* M5: write "\e[=1u" to dec->tty_fd; subsequent polls decode the + * \e[CODE;MOD u replies. M1 stub records the bit only. */ +} + +bool input_decoder_kitty_enabled(const input_decoder_t *dec) { + if (dec == NULL) { + return false; + } + return dec->kitty_enabled; +} + +/* ------------------------------------------------------------------------- + * Test seam (compile-guarded) + * ---------------------------------------------------------------------- */ + +#ifdef INPUT_DECODER_TEST_SEAM + +size_t input_decoder_inject_bytes(input_decoder_t *dec, + const uint8_t *bytes, size_t len) { + if (dec == NULL || len == 0) { + return 0; + } + /* bytes == NULL with len > 0 is a programmer error; the test seam is + * exactly where we want such bugs to surface (rather than silently + * no-op'ing and looking like the inject succeeded). */ + assert(bytes != NULL); + + /* Load-bearing invariant: fill_len never exceeds buffer size. Cheap + * to assert here; protects M2 / M3 future drain code from a partial- + * compaction bug that would otherwise produce an underflow on the + * available-space calculation below. */ + assert(dec->fill_len <= INPUT_DECODER_BUFFER_SIZE); + + size_t available = INPUT_DECODER_BUFFER_SIZE - dec->fill_len; + size_t to_copy = (len < available) ? len : available; + + if (to_copy > 0) { + memcpy(dec->buf + dec->fill_len, bytes, to_copy); + dec->fill_len += to_copy; + } + + /* Surface overflow rather than swallow it. Plan section 3.12 calls + * for burst-read robustness; the dropped_bytes counter is the test- + * observable signal that M2 / M3 / M4 will use to assert their drain + * cadences are correct. See input_decoder.h for the back-pressure + * contract. */ + size_t dropped = len - to_copy; + if (dropped > 0) { + dec->dropped_bytes += dropped; + } + + return to_copy; +} + +size_t input_decoder_buffered_bytes(const input_decoder_t *dec) { + if (dec == NULL) { + return 0; + } + return dec->fill_len; +} + +size_t input_decoder_dropped_bytes(const input_decoder_t *dec) { + if (dec == NULL) { + return 0; + } + return dec->dropped_bytes; +} + +#endif /* INPUT_DECODER_TEST_SEAM */ diff --git a/frontier-cli/boxen/input_decoder.h b/frontier-cli/boxen/input_decoder.h new file mode 100644 index 000000000..68bf1415d --- /dev/null +++ b/frontier-cli/boxen/input_decoder.h @@ -0,0 +1,183 @@ +/* + * input_decoder.h -- private terminal input decoder for the boxen tb2 backend. + * + * Owns: TTY fd read loop, byte buffering, escape-sequence state machine, + * Unicode (UTF-8) decoding, mouse-mode enable/disable. + * + * Does NOT own: rendering, alternate screen, SIGWINCH, cell buffer, + * color, cursor positioning. termbox2 retains all of those. SIGWINCH + * does not touch any decoder field; termbox2 handles screen resize via + * its own signal pipe, separate from the decoder's tty_fd, so no + * async-signal coordination is needed here. + * + * Threading contract -- READ THIS BEFORE EDITING: + * + * Single-owner invariant: there is exactly one input_decoder_t per + * process (the tb2 backend's static g_decoder, post-M6). No other + * Frontier thread holds a pointer to it. Safety relies on that + * invariant -- the decoder has no internal locks and no atomics. + * + * The owner thread is the GIL holder when entering and leaving any + * decoder API. input_decoder_poll is the one function that releases + * the GIL around its blocking select(2) / read(2) (M6 wires this); + * the decoder's fields are mutated inside that GIL-dropped window. + * This is safe because the single-owner invariant means no other + * thread can call into the decoder during that window. + * + * set_mouse / kitty_enable must be called only from GIL-held context + * and only when no poll is in progress -- i.e., from slash-command + * dispatch, init, or shutdown, NEVER from inside the GIL-drop window + * around boxen_poll_event. Future writes to tty_fd from set_mouse + * (M3+) would race the in-flight read(2) otherwise. The REPL event + * loop satisfies this naturally: slash commands run between polls, + * not during them. + * + * Memory ordering: the GIL release/reacquire pair around poll + * provides the happens-before edge for any cross-call observation + * of decoder state (e.g., the REPL reading mouse_enabled after a + * prior set_mouse call). No additional barriers are required as + * long as the single-owner invariant holds. + * + * This header is PRIVATE to the boxen subsystem (sibling to boxen_internal.h). + * Only boxen.h is the public boxen header; do NOT include input_decoder.h + * from outside frontier-cli/boxen/. + * + * 2026-06-29 JES #809 M1: PTY-replay harness + decoder stub. + * See planning/phase_c/INPUT_DECODER_PLAN.md sections 2 and 7. + */ + +#ifndef INPUT_DECODER_H +#define INPUT_DECODER_H + +#include "boxen.h" /* for boxen_event_t, boxen_result_t */ +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Opaque decoder state. Allocated by input_decoder_create(). */ +typedef struct input_decoder input_decoder_t; + +/* 2026-06-29 JES #809 M1: lifecycle. + * + * Create a decoder attached to the given file descriptor (usually the TTY fd + * obtained from tb_get_fds() or /dev/tty). A tty_fd of -1 is permitted and + * is the convention used by the PTY-replay test harness -- the decoder will + * never call read(2) in that case; bytes are supplied exclusively through + * input_decoder_inject_bytes(). + * + * Ownership: the decoder does NOT take ownership of tty_fd, does NOT close + * it on destroy, and does NOT validate it at create time. The caller is + * responsible for the fd's lifecycle (tb2 init/shutdown in M6). A garbage + * non-negative integer will not be caught here; the future read(2) call + * will surface it as a normal I/O error returned from input_decoder_poll. + * + * Returns NULL on allocation failure (ENOMEM). Mouse reporting starts + * DISABLED; call input_decoder_set_mouse(true) to enable SGR + bracketed + * paste (M3 / M4 scope -- the M1 stub records the bit but writes nothing + * to the fd). */ +input_decoder_t *input_decoder_create(int tty_fd); + +/* Free all resources. Does NOT close tty_fd. Safe to pass NULL. */ +void input_decoder_destroy(input_decoder_t *dec); + +/* 2026-06-29 JES #809 M1: mouse-mode policy seam. + * + * Enable or disable SGR mouse reporting (mode 1006) and bracketed paste + * (mode 2004). When mouse is disabled the terminal's native selection is + * active. When mouse is enabled, selections require Shift-click + * (Terminal.app) or the configured pass-through modifier (iTerm2 / Alacritty). + * + * M1 stub: records the requested state and returns; no write(2) to the TTY. + * M3 / M4 will wire the actual escape-sequence writes once the parser side + * is in place. */ +void input_decoder_set_mouse(input_decoder_t *dec, bool enable); + +/* Query current mouse-enable state. Safe to call on a freshly-created + * decoder (returns false). */ +bool input_decoder_mouse_enabled(const input_decoder_t *dec); + +/* 2026-06-29 JES #809 M1: poll contract. + * + * Block until one event is decoded or timeout_ms elapses. + * On success: writes *out and returns BOXEN_OK. + * On timeout: returns BOXEN_ERR_TIMEOUT; *out is zeroed. + * On read error: returns BOXEN_ERR_IO. + * + * M1 stub: always returns BOXEN_ERR_TIMEOUT with *out zeroed. The state + * machine that consumes the ring buffer lands in M2. The buffer itself is + * already wired so M2 tests can split sequences across multiple inject_bytes + * calls (see section 3.12 of the plan: burst-read robustness). */ +int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms); + +/* 2026-06-29 JES #809 M1: Kitty keyboard protocol enable (deferred to M5). + * + * Sends the enable sequence (\e[=1u) and records that progressive + * enhancement was requested. Responses arrive via normal + * input_decoder_poll. Idempotent. No-op if already enabled or if the + * terminal does not respond to the probe. + * + * M1 stub: records the request but emits nothing. M5 wires the write + * and decodes CSI-u replies. */ +void input_decoder_kitty_enable(input_decoder_t *dec); + +/* Query whether kitty_enable has been called on this decoder. Symmetric + * to input_decoder_mouse_enabled. Safe on a freshly-created or NULL + * decoder (returns false). Reflects the request, not the terminal's + * confirmed support -- M5 will not flip the bit back if the probe times + * out, because the decoder remains a valid no-op in that case. */ +bool input_decoder_kitty_enabled(const input_decoder_t *dec); + +/* 2026-06-29 JES #809 M1: test seam. + * + * Inject raw bytes into the decoder's ring buffer. Compile-guarded by + * INPUT_DECODER_TEST_SEAM so the symbol is invisible to production builds + * (and any well-meaning future caller cannot accidentally smuggle bytes + * past the TTY read loop). Normal production code never calls this -- + * the real read(2) path lands in M2. + * + * Returns the number of bytes actually accepted into the buffer. This + * is normally equal to `len`, but on overflow it is clamped to the + * available space and the remainder is counted in + * input_decoder_dropped_bytes() (which the M2 / M3 read loop will also + * increment when a real read(2) burst overflows the ring). Tests that + * inject > INPUT_DECODER_BUFFER_SIZE bytes can therefore detect the + * drop without relying on stderr inspection. + * + * Safe to call with len == 0 (no-op, returns 0). bytes == NULL with + * len > 0 is a programmer error and triggers an assert -- a test seam + * is exactly where you want such bugs to surface. + * + * The dropped_bytes counter and the inject return value together define + * the M2 back-pressure contract: the read loop must either keep up (no + * drops) or surface drops to logging / metrics so silent desync is + * impossible. See plan section 3.12 (burst-read robustness). */ +#ifdef INPUT_DECODER_TEST_SEAM +size_t input_decoder_inject_bytes(input_decoder_t *dec, + const uint8_t *bytes, size_t len); + +/* Test-only accessor: number of bytes currently held in the ring buffer. + * Returns 0 for a NULL decoder. Used by M1 harness tests to assert the + * inject path actually deposited bytes (the M1 poll stub does not drain + * them, so the count after inject equals the number injected up to buffer + * capacity). */ +size_t input_decoder_buffered_bytes(const input_decoder_t *dec); + +/* Test-only accessor: cumulative count of bytes dropped due to ring + * overflow since decoder creation. Returns 0 for a NULL decoder. M2 / + * M3 also bump this counter when a real read(2) cannot fit into the + * remaining ring space. Always-zero return is the M1 / M2 happy path; + * non-zero indicates the test deliberately exceeded buffer capacity OR + * (in production) a runaway terminal burst that the state machine + * failed to drain. */ +size_t input_decoder_dropped_bytes(const input_decoder_t *dec); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* INPUT_DECODER_H */ diff --git a/tests/Makefile b/tests/Makefile index 23d9abef6..37a0a9c41 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -223,7 +223,7 @@ USERTALK_OBJECT_TESTS = \ test_usertalk_runner # Buildable test executables on this branch -RUN_BUILDABLE = core_tests db_format_tests runtime_tests parser_tests save_migration_tests test_efp_augmentation file_portable_tests file_readline_tests file_verb_tests handle_tests cli_runtime_tests paige_text_tests oppack_tests hashpack_modern_tests hash_corruption_tests langvalue_64_tests table_operations_integration refcon_tests refcon_phase2_tests op_context_tests table_context_tests time_portability_test test_sys_shell_command_verbs headless_selection_tests opattributes_stub_tests headless_thread_registry_tests test_callback_infrastructure tcp_phase1a_unit_tests menu_v7_refcon_tests menudata_headless_tests menu_list_describe_tests meuserselected_headless_tests test_stringerrorlist pane_compositor_tests mouse_parser_tests terminal_control_tests terminal_control_dsr_tests palette_state_tests palette_render_snapshot_tests palette_render_backend_tests palette_arg_inject_tests palette_arg_inject_concurrency_tests repl_palette_source_tests repl_verbs_tests repl_slash_resolver_tests scrollback_pane_tests lang_causedby_snapshot_tests test_getsystemtablescript test_window_bridge copyctopstring_tests ut_sync_canonicalize_tests ut_sync_pathmap_tests ut_sync_pctcodec_tests ut_sync_export_tests ut_sync_decanonicalize_tests ut_sync_import_tests boxen_backend_tests boxen_core_tests boxen_input_tests boxen_resize_tests boxen_scroll_tests boxen_chrome_tests boxen_cursor_tests debugger_tui_tests boxen_repl_tests boxen_outline_tests +RUN_BUILDABLE = core_tests db_format_tests runtime_tests parser_tests save_migration_tests test_efp_augmentation file_portable_tests file_readline_tests file_verb_tests handle_tests cli_runtime_tests paige_text_tests oppack_tests hashpack_modern_tests hash_corruption_tests langvalue_64_tests table_operations_integration refcon_tests refcon_phase2_tests op_context_tests table_context_tests time_portability_test test_sys_shell_command_verbs headless_selection_tests opattributes_stub_tests headless_thread_registry_tests test_callback_infrastructure tcp_phase1a_unit_tests menu_v7_refcon_tests menudata_headless_tests menu_list_describe_tests meuserselected_headless_tests test_stringerrorlist pane_compositor_tests mouse_parser_tests terminal_control_tests terminal_control_dsr_tests palette_state_tests palette_render_snapshot_tests palette_render_backend_tests palette_arg_inject_tests palette_arg_inject_concurrency_tests repl_palette_source_tests repl_verbs_tests repl_slash_resolver_tests scrollback_pane_tests lang_causedby_snapshot_tests test_getsystemtablescript test_window_bridge copyctopstring_tests ut_sync_canonicalize_tests ut_sync_pathmap_tests ut_sync_pctcodec_tests ut_sync_export_tests ut_sync_decanonicalize_tests ut_sync_import_tests boxen_backend_tests boxen_core_tests boxen_input_tests boxen_resize_tests boxen_scroll_tests boxen_chrome_tests boxen_cursor_tests debugger_tui_tests boxen_repl_tests boxen_outline_tests input_decoder_tests # Note: table_verb_tests skipped - pre-existing macOS path detection issue # Note: test_error_messages skipped - needs full runtime linking (tracked in separate task) # Note: test_table_sorting skipped - script execution errors cause segfault (Issue #449) @@ -638,6 +638,19 @@ boxen_outline_tests: boxen_outline_tests.c $(BOXEN_OUTLINE_TEST_SRCS) boxen_outline_tests.c $(BOXEN_OUTLINE_TEST_SRCS) \ -o $@ $(LINK_LIBS) +# 2026-06-29 JES #809 Phase C M1: PTY-replay harness for the boxen input +# decoder. Links input_decoder.c only -- no termbox2, no Frontier runtime, +# no boxen core (the decoder is intentionally a leaf module). The +# INPUT_DECODER_TEST_SEAM define exposes input_decoder_inject_bytes() and +# input_decoder_buffered_bytes() so the harness can drive bytes without a +# real PTY. See planning/phase_c/INPUT_DECODER_PLAN.md section 6.1. +input_decoder_tests: input_decoder_tests.c $(BOXEN_DIR)/input_decoder.c $(BOXEN_DIR)/input_decoder.h + $(CC) $(CFLAGS) \ + -I$(BOXEN_DIR) \ + -DINPUT_DECODER_TEST_SEAM \ + input_decoder_tests.c $(BOXEN_DIR)/input_decoder.c \ + -o $@ $(LINK_LIBS) + # Pure helper for arg-injection — no Frontier runtime / handle deps, so it # compiles with palette_arg_inject.c standalone. The test exercises escape # rules and the script-source synthesis algorithm. diff --git a/tests/input_decoder_tests.c b/tests/input_decoder_tests.c new file mode 100644 index 000000000..f2beee9cf --- /dev/null +++ b/tests/input_decoder_tests.c @@ -0,0 +1,597 @@ +/* + * input_decoder_tests.c -- PTY-replay harness for the boxen input decoder. + * + * 2026-06-29 JES #809 M1: harness infrastructure + create/destroy/inject smoke + * tests. Protocol coverage tests (cursor keys, mouse, paste, Kitty) are listed + * here as SKIP stubs that will be un-skipped one milestone at a time + * (M2 through M5; see planning/phase_c/INPUT_DECODER_PLAN.md section 6.2). + * + * SKIP mechanism note (no TR_SKIP exists in test_report.h): + * The TR_RUN macro records a test as "passed" if its body returns without + * firing assert(). Each protocol stub below prints a SKIP line to stderr + * and returns; TR_RUN records a green pass. Two accounting signals keep + * the deferral visible despite the green tally: + * (a) Every SKIP-stub function is named "test_skip_*" so a grep over + * tests/tmp/unit/input_decoder_tests.json discloses the deferred + * set without parsing stderr. + * (b) main() prints "[SUMMARY] N stub tests deferred to M2-M5" before + * the TR_SUMMARY line, sourced from the g_skip_count counter that + * tr_skip() increments. + * Each SKIP test will be rewritten as a behavioral assert when its + * milestone lands; that's the un-skip. At that point the test_skip_ + * prefix should be dropped along with the tr_skip() call. + * + * The harness uses the INPUT_DECODER_TEST_SEAM compile flag to expose + * input_decoder_inject_bytes() and input_decoder_buffered_bytes(), so the + * tests can drive bytes into the decoder without opening a real PTY. This + * is the "PTY-replay" pattern from plan section 6.1: a recorded byte + * sequence (xxd -i'd into a C array) is replayed through inject_bytes; + * poll() then advances the state machine and emits a boxen_event_t. + * + * Pattern mirrors other boxen unit-test binaries (boxen_backend_tests.c, + * boxen_repl_tests.c): TR_INIT / TR_RUN / TR_SUMMARY / TR_EXIT_CODE. + * + * Link slice: input_decoder.c only (no termbox2, no Frontier runtime). See + * tests/Makefile. + */ + +#include +#include +#include +#include +#include +#include + +#include "../frontier-cli/boxen/boxen.h" +#include "../frontier-cli/boxen/input_decoder.h" + +/* Test harness. */ +#include "test_report.h" + +/* ------------------------------------------------------------------------- + * SKIP helper -- documented in the file header. + * + * Prints "[SKIP] " to stderr and returns. TR_RUN then records the + * surrounding test function as passing. Caller is expected to invoke this + * as the first statement of the test body and then return immediately + * (early-return pattern, not setjmp) so the stub does NOT touch any decoder + * state that the M2+ implementation has not yet built. + * + * Accounting: g_skip_count is bumped on every tr_skip() call so the + * end-of-run summary can report "N of M tests were stubs deferred to + * milestones M2-M5" rather than a flat "M/M green" that conceals + * unimplemented behavior. Naming convention: every skip-stub test + * function is named test_skip_* -- a grep over the JSON tally for + * "test_skip_" produces the deferred set without parsing stderr. + * + * 2026-06-29 JES #809 M1. */ +static int g_skip_count = 0; +static void tr_skip(const char *reason) { + g_skip_count++; + fprintf(stderr, "[SKIP] %s\n", reason); +} + +/* ------------------------------------------------------------------------- + * M1 harness smoke tests (PASS today) + * + * These exercise the API shape we landed in M1. They do NOT depend on the + * state machine and must keep passing through every subsequent milestone -- + * a regression here means the lifecycle / inject contract broke. + * ---------------------------------------------------------------------- */ + +/* 2026-06-29 JES #809 M1: create/destroy round trip with a -1 fd. + * + * The PTY-replay harness uses tty_fd=-1 to signal "do not open a real + * descriptor". The decoder must accept that and not attempt any I/O. */ +static void test_create_destroy_smoke(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #809 M1: destroying NULL is a no-op (defensive contract). */ +static void test_destroy_null_is_noop(void) { + input_decoder_destroy(NULL); + /* Reaching this line without crashing is the assertion. */ +} + +/* 2026-06-29 JES #809 M1: mouse-enable bit starts false and round-trips. + * + * Per plan section 5.1: "Mouse mode is disabled on startup." The M1 stub + * records the requested state but writes nothing; M3 / M4 wire the real + * escape-sequence writes. */ +static void test_mouse_disabled_by_default(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + assert(input_decoder_mouse_enabled(dec) == false); + + input_decoder_set_mouse(dec, true); + assert(input_decoder_mouse_enabled(dec) == true); + + input_decoder_set_mouse(dec, false); + assert(input_decoder_mouse_enabled(dec) == false); + + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #809 M1: mouse-enabled query on NULL returns false. */ +static void test_mouse_enabled_null_safe(void) { + assert(input_decoder_mouse_enabled(NULL) == false); +} + +/* 2026-06-29 JES #809 M1: poll on a freshly-created decoder returns TIMEOUT + * and zeroes the out parameter. + * + * This is the M1 contract from plan section 7: "poll returns + * BOXEN_ERR_TIMEOUT" always, until M2 lands the state machine. */ +static void test_poll_empty_buffer_returns_timeout(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + boxen_event_t ev; + /* Pre-fill with junk so we can verify poll zeroed it. */ + memset(&ev, 0x7f, sizeof(ev)); + + int r = input_decoder_poll(dec, &ev, 0); + assert(r == BOXEN_ERR_TIMEOUT); + assert(ev.type == BOXEN_EV_NONE); + + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #809 M1: poll with NULL decoder is INVALID. */ +static void test_poll_null_decoder_is_invalid(void) { + boxen_event_t ev; + int r = input_decoder_poll(NULL, &ev, 0); + assert(r == BOXEN_ERR_INVALID); +} + +/* 2026-06-29 JES #809 M1: poll with NULL out param is INVALID. */ +static void test_poll_null_out_is_invalid(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + int r = input_decoder_poll(dec, NULL, 0); + assert(r == BOXEN_ERR_INVALID); + + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #809 M1: inject_bytes deposits exactly the requested bytes + * into the ring buffer. Verified via the test-only buffered_bytes accessor. + * + * This is the contract M2 will build the state machine against: a sequence + * injected by the test must end up in the buffer in the order it was given, + * with no implicit drain. */ +static void test_inject_bytes_appends_to_buffer(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + assert(input_decoder_buffered_bytes(dec) == 0); + assert(input_decoder_dropped_bytes(dec) == 0); + + static const uint8_t seq[] = {0x1b, '[', '1', ';', '3', 'D'}; + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + assert(input_decoder_buffered_bytes(dec) == sizeof(seq)); + assert(input_decoder_dropped_bytes(dec) == 0); + + /* Inject more: count accumulates, no drops on a non-full buffer. */ + static const uint8_t more[] = {'a', 'b', 'c'}; + accepted = input_decoder_inject_bytes(dec, more, sizeof(more)); + assert(accepted == sizeof(more)); + assert(input_decoder_buffered_bytes(dec) == sizeof(seq) + sizeof(more)); + assert(input_decoder_dropped_bytes(dec) == 0); + + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #809 M1: inject_bytes with len=0 or NULL bytes is a no-op, + * not a crash. Defensive against future callers that compute (ptr, len) + * from a parsed buffer slice. */ +static void test_inject_bytes_noop_on_empty(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + assert(input_decoder_inject_bytes(dec, NULL, 0) == 0); + assert(input_decoder_buffered_bytes(dec) == 0); + + static const uint8_t nothing[1] = {0}; + assert(input_decoder_inject_bytes(dec, nothing, 0) == 0); + assert(input_decoder_buffered_bytes(dec) == 0); + + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #809 M1: inject_bytes on a NULL decoder is a no-op and + * returns 0 accepted. */ +static void test_inject_bytes_null_decoder_safe(void) { + static const uint8_t seq[] = {0x1b}; + assert(input_decoder_inject_bytes(NULL, seq, sizeof(seq)) == 0); +} + +/* 2026-06-29 JES #809 M1: buffered_bytes / dropped_bytes on NULL return 0. */ +static void test_buffered_bytes_null_safe(void) { + assert(input_decoder_buffered_bytes(NULL) == 0); + assert(input_decoder_dropped_bytes(NULL) == 0); +} + +/* 2026-06-29 JES #809 M1: kitty_enable round-trip via the symmetric query + * (mirrors mouse_enabled round-trip). Verifies the bit flip is observable, + * the buffer is unaffected (no loop-back via inject), and NULL safety. */ +static void test_kitty_enable_round_trip(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + assert(input_decoder_kitty_enabled(dec) == false); + + input_decoder_kitty_enable(dec); + assert(input_decoder_kitty_enabled(dec) == true); + assert(input_decoder_buffered_bytes(dec) == 0); + + /* NULL-safety on both sides. */ + input_decoder_kitty_enable(NULL); + assert(input_decoder_kitty_enabled(NULL) == false); + + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #809 M1: inject overflow surfaces via dropped_bytes. + * + * The M1 back-pressure contract: inject_bytes returns the number of bytes + * accepted, and any remainder is counted in input_decoder_dropped_bytes(). + * This regression-guards the test seam (and the future M2/M3 read(2) loop + * that will share the counter) against silent overflow desync. See plan + * section 3.12 (burst-read robustness). */ +static void test_inject_overflow_increments_dropped_counter(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + assert(input_decoder_dropped_bytes(dec) == 0); + + /* The ring is 4 KiB. Inject 5 KiB: 4 KiB lands, 1 KiB drops. */ + enum { ATTEMPT = 5 * 1024 }; + static uint8_t flood[ATTEMPT]; + memset(flood, 'X', sizeof(flood)); + + size_t accepted = input_decoder_inject_bytes(dec, flood, sizeof(flood)); + assert(accepted <= sizeof(flood)); + assert(accepted == input_decoder_buffered_bytes(dec)); + size_t dropped = input_decoder_dropped_bytes(dec); + assert(dropped > 0); + assert(accepted + dropped == sizeof(flood)); + + /* Subsequent inject when buffer is full: every byte drops, counter + * accumulates monotonically. */ + static const uint8_t more[] = {'Y', 'Z'}; + size_t accepted2 = input_decoder_inject_bytes(dec, more, sizeof(more)); + assert(accepted2 == 0); + assert(input_decoder_dropped_bytes(dec) == dropped + sizeof(more)); + + input_decoder_destroy(dec); +} + +/* ------------------------------------------------------------------------- + * M2 SKIP stubs -- cursor keys + modifiers + ESC disambiguation. + * + * Per plan section 7, M2 un-skips and implements these. The names below + * mirror the sequences in section 3 (Protocol Coverage Matrix) so the M2 + * sub-agent can grep "M2 SKIP" to find its TDD entry points. + * ---------------------------------------------------------------------- */ + +static void test_skip_csi_plain_arrow_up(void) { + tr_skip("M2: \\e[A -> KEY UP, mod=NONE (plan section 3.1)"); +} + +static void test_skip_csi_plain_arrow_down(void) { + tr_skip("M2: \\e[B -> KEY DOWN, mod=NONE (plan section 3.1)"); +} + +static void test_skip_csi_plain_arrow_right(void) { + tr_skip("M2: \\e[C -> KEY RIGHT, mod=NONE (plan section 3.1)"); +} + +static void test_skip_csi_plain_arrow_left(void) { + tr_skip("M2: \\e[D -> KEY LEFT, mod=NONE (plan section 3.1)"); +} + +static void test_skip_ss3_arrow_up(void) { + tr_skip("M2: \\eOA -> KEY UP, mod=NONE (plan section 3.1)"); +} + +static void test_skip_csi_modifier_alt_left_issue_805(void) { + tr_skip("M2: \\e[1;3D -> KEY LEFT, mod=ALT -- the #805 root-cause case " + "(plan section 3.2)"); +} + +static void test_skip_csi_modifier_alt_right_issue_805(void) { + tr_skip("M2: \\e[1;3C -> KEY RIGHT, mod=ALT -- the #805 root-cause case " + "(plan section 3.2)"); +} + +static void test_skip_csi_modifier_shift_up(void) { + tr_skip("M2: \\e[1;2A -> KEY UP, mod=SHIFT (plan section 3.2)"); +} + +static void test_skip_csi_modifier_ctrl_down(void) { + tr_skip("M2: \\e[1;5B -> KEY DOWN, mod=CTRL (plan section 3.2)"); +} + +static void test_skip_csi_modifier_alt_shift_left(void) { + tr_skip("M2: \\e[1;4D -> KEY LEFT, mod=ALT|SHIFT (plan section 3.2)"); +} + +static void test_skip_csi_modifier_meta_up(void) { + tr_skip("M2: \\e[1;9A -> KEY UP, mod=META (plan section 3.2)"); +} + +static void test_skip_csi_nav_home(void) { + tr_skip("M2: \\e[H -> KEY HOME (plan section 3.3)"); +} + +static void test_skip_csi_nav_end(void) { + tr_skip("M2: \\e[F -> KEY END (plan section 3.3)"); +} + +static void test_skip_csi_nav_pgup(void) { + tr_skip("M2: \\e[5~ -> KEY PGUP (plan section 3.3)"); +} + +static void test_skip_csi_nav_pgdn(void) { + tr_skip("M2: \\e[6~ -> KEY PGDN (plan section 3.3)"); +} + +static void test_skip_csi_nav_delete(void) { + tr_skip("M2: \\e[3~ -> KEY DELETE (plan section 3.3)"); +} + +static void test_skip_function_key_f1_ss3(void) { + tr_skip("M2: \\eOP -> KEY F1 (plan section 3.4)"); +} + +static void test_skip_function_key_f5_csi(void) { + tr_skip("M2: \\e[15~ -> KEY F5 (plan section 3.4)"); +} + +static void test_skip_function_key_f1_shift(void) { + tr_skip("M2: \\e[1;2P -> KEY F1, mod=SHIFT (plan section 3.4)"); +} + +static void test_skip_meta_prefix_alt_a(void) { + tr_skip("M2: \\ea -> ch='a', mod=ALT (plan section 3.5)"); +} + +static void test_skip_meta_prefix_alt_b(void) { + tr_skip("M2: \\eb -> ch='b', mod=ALT (plan section 3.5 policy: emit as " + "letter+ALT, not KEY LEFT)"); +} + +static void test_skip_esc_alone_emits_escape(void) { + tr_skip("M2: lone 0x1B with no follow-up -> KEY ESCAPE (plan section 4.4)"); +} + +static void test_skip_partial_sequence_across_inject_boundary(void) { + tr_skip("M2: inject \\e[1; then poll (timeout, no event), inject 3D then " + "poll (LEFT+ALT) -- burst-read robustness (plan section 3.12 + 6.1)"); +} + +static void test_skip_control_char_ctrl_a(void) { + tr_skip("M2: 0x01 -> KEY CTRL_A (plan section 3.11)"); +} + +static void test_skip_control_char_tab(void) { + tr_skip("M2: 0x09 -> KEY TAB (plan section 3.11)"); +} + +static void test_skip_control_char_enter(void) { + tr_skip("M2: 0x0D -> KEY ENTER (plan section 3.11)"); +} + +static void test_skip_control_char_backspace_del(void) { + tr_skip("M2: 0x7F -> KEY BACKSPACE (plan section 3.11)"); +} + +/* ------------------------------------------------------------------------- + * M3 SKIP stubs -- SGR mouse + X10 fallback + burst-read. + * ---------------------------------------------------------------------- */ + +static void test_skip_sgr_mouse_left_press(void) { + tr_skip("M3: \\e[<0;10;5M -> MOUSE button=1 pressed=true x=9 y=4 " + "(plan section 3.6)"); +} + +static void test_skip_sgr_mouse_left_release(void) { + tr_skip("M3: \\e[<0;10;5m -> MOUSE button=1 pressed=false (plan section 3.6)"); +} + +static void test_skip_sgr_mouse_wheel_up(void) { + tr_skip("M3: \\e[<64;10;5M -> MOUSE button=4 (wheel-up) -- the #805 " + "mousewheel case (plan section 3.6)"); +} + +static void test_skip_sgr_mouse_wheel_down(void) { + tr_skip("M3: \\e[<65;10;5M -> MOUSE button=5 (wheel-down) (plan section 3.6)"); +} + +static void test_skip_sgr_mouse_motion(void) { + tr_skip("M3: \\e[<32;10;5M -> MOUSE motion (plan section 3.6)"); +} + +static void test_skip_sgr_mouse_shift_modifier(void) { + tr_skip("M3: SGR button bit 4 -> mod=SHIFT (plan section 3.6)"); +} + +static void test_skip_x10_mouse_left_press(void) { + tr_skip("M3: \\e[M\\x20\\x2a\\x19 -> MOUSE left press (plan section 3.7)"); +} + +static void test_skip_x10_mouse_wheel_up(void) { + tr_skip("M3: \\e[M\\x60\\x2a\\x19 -> MOUSE wheel-up (plan section 3.7)"); +} + +static void test_skip_burst_read_multiple_events_one_inject(void) { + tr_skip("M3: inject 3 complete events in one inject_bytes call; poll() " + "dequeues all three in order (plan section 7 M3)"); +} + +static void test_skip_double_click_synthesis(void) { + tr_skip("M3: two SGR left-press within window -> BOXEN_MOUSE_DOUBLE_CLICK " + "flag set on second (plan section 7 M3)"); +} + +/* ------------------------------------------------------------------------- + * M4 SKIP stubs -- bracketed paste + BOXEN_EV_PASTE. + * ---------------------------------------------------------------------- */ + +static void test_skip_bracketed_paste_simple(void) { + tr_skip("M4: \\e[200~hello\\e[201~ -> BOXEN_EV_PASTE data=\"hello\" " + "(plan section 3.8); requires boxen.h ABI add for BOXEN_EV_PASTE"); +} + +static void test_skip_bracketed_paste_with_newlines(void) { + tr_skip("M4: paste with embedded \\n / \\r\\n -> single PASTE event " + "(plan section 7 M4)"); +} + +static void test_skip_bracketed_paste_size_cap_truncation(void) { + tr_skip("M4: paste > 256 KiB -> truncated + BOXEN_LOG_W " + "(plan section 3.8, R5)"); +} + +static void test_skip_bracketed_paste_embedded_esc_is_literal(void) { + tr_skip("M4: ESC sequences inside paste markers -> treated as literal " + "text, not parsed (plan section 7 M4)"); +} + +/* ------------------------------------------------------------------------- + * M5 SKIP stubs -- Kitty keyboard protocol. + * ---------------------------------------------------------------------- */ + +static void test_skip_kitty_csi_u_letter(void) { + tr_skip("M5: \\e[65u -> ch='A', mod=NONE (plan section 3.9)"); +} + +static void test_skip_kitty_csi_u_letter_with_alt(void) { + tr_skip("M5: \\e[65;3u -> ch='A', mod=ALT (plan section 3.9)"); +} + +static void test_skip_kitty_csi_u_enter_with_ctrl(void) { + tr_skip("M5: \\e[13;5u -> KEY_ENTER, mod=CTRL (plan section 3.9)"); +} + +static void test_skip_kitty_csi_u_escape_with_alt(void) { + tr_skip("M5: \\e[27;3u -> KEY_ESCAPE, mod=ALT (plan section 3.9)"); +} + +/* ------------------------------------------------------------------------- + * UTF-8 SKIP stubs -- multi-byte decode. Slated for M2 (the codepoint + * accumulator is part of the GROUND-state path). + * ---------------------------------------------------------------------- */ + +static void test_skip_utf8_two_byte_codepoint(void) { + tr_skip("M2: 2-byte UTF-8 (U+00A9 copyright) -> ev.key.ch=0xA9 " + "(plan section 3.10)"); +} + +static void test_skip_utf8_three_byte_codepoint(void) { + tr_skip("M2: 3-byte UTF-8 (U+2014 em-dash) -> ev.key.ch=0x2014 " + "(plan section 3.10)"); +} + +static void test_skip_utf8_four_byte_codepoint(void) { + tr_skip("M2: 4-byte UTF-8 (U+1F600 emoji) -> ev.key.ch=0x1F600 " + "(plan section 3.10)"); +} + +static void test_skip_utf8_continuation_split_across_inject(void) { + tr_skip("M2: UTF-8 lead byte + first cont in inject 1, remaining cont " + "in inject 2 -> single emit on completion (plan section 3.10)"); +} + +/* ------------------------------------------------------------------------- + * main + * ---------------------------------------------------------------------- */ + +int main(void) { + TR_INIT("input_decoder_tests"); + + /* M1 smoke -- must pass today and stay passing through every milestone. */ + TR_RUN(test_create_destroy_smoke); + TR_RUN(test_destroy_null_is_noop); + TR_RUN(test_mouse_disabled_by_default); + TR_RUN(test_mouse_enabled_null_safe); + TR_RUN(test_poll_empty_buffer_returns_timeout); + TR_RUN(test_poll_null_decoder_is_invalid); + TR_RUN(test_poll_null_out_is_invalid); + TR_RUN(test_inject_bytes_appends_to_buffer); + TR_RUN(test_inject_bytes_noop_on_empty); + TR_RUN(test_inject_bytes_null_decoder_safe); + TR_RUN(test_buffered_bytes_null_safe); + TR_RUN(test_kitty_enable_round_trip); + TR_RUN(test_inject_overflow_increments_dropped_counter); + + /* M2 SKIP -- cursor keys, modifiers, ESC, control chars, UTF-8. */ + TR_RUN(test_skip_csi_plain_arrow_up); + TR_RUN(test_skip_csi_plain_arrow_down); + TR_RUN(test_skip_csi_plain_arrow_right); + TR_RUN(test_skip_csi_plain_arrow_left); + TR_RUN(test_skip_ss3_arrow_up); + TR_RUN(test_skip_csi_modifier_alt_left_issue_805); + TR_RUN(test_skip_csi_modifier_alt_right_issue_805); + TR_RUN(test_skip_csi_modifier_shift_up); + TR_RUN(test_skip_csi_modifier_ctrl_down); + TR_RUN(test_skip_csi_modifier_alt_shift_left); + TR_RUN(test_skip_csi_modifier_meta_up); + TR_RUN(test_skip_csi_nav_home); + TR_RUN(test_skip_csi_nav_end); + TR_RUN(test_skip_csi_nav_pgup); + TR_RUN(test_skip_csi_nav_pgdn); + TR_RUN(test_skip_csi_nav_delete); + TR_RUN(test_skip_function_key_f1_ss3); + TR_RUN(test_skip_function_key_f5_csi); + TR_RUN(test_skip_function_key_f1_shift); + TR_RUN(test_skip_meta_prefix_alt_a); + TR_RUN(test_skip_meta_prefix_alt_b); + TR_RUN(test_skip_esc_alone_emits_escape); + TR_RUN(test_skip_partial_sequence_across_inject_boundary); + TR_RUN(test_skip_control_char_ctrl_a); + TR_RUN(test_skip_control_char_tab); + TR_RUN(test_skip_control_char_enter); + TR_RUN(test_skip_control_char_backspace_del); + TR_RUN(test_skip_utf8_two_byte_codepoint); + TR_RUN(test_skip_utf8_three_byte_codepoint); + TR_RUN(test_skip_utf8_four_byte_codepoint); + TR_RUN(test_skip_utf8_continuation_split_across_inject); + + /* M3 SKIP -- SGR mouse, X10 fallback, burst-read. */ + TR_RUN(test_skip_sgr_mouse_left_press); + TR_RUN(test_skip_sgr_mouse_left_release); + TR_RUN(test_skip_sgr_mouse_wheel_up); + TR_RUN(test_skip_sgr_mouse_wheel_down); + TR_RUN(test_skip_sgr_mouse_motion); + TR_RUN(test_skip_sgr_mouse_shift_modifier); + TR_RUN(test_skip_x10_mouse_left_press); + TR_RUN(test_skip_x10_mouse_wheel_up); + TR_RUN(test_skip_burst_read_multiple_events_one_inject); + TR_RUN(test_skip_double_click_synthesis); + + /* M4 SKIP -- bracketed paste. */ + TR_RUN(test_skip_bracketed_paste_simple); + TR_RUN(test_skip_bracketed_paste_with_newlines); + TR_RUN(test_skip_bracketed_paste_size_cap_truncation); + TR_RUN(test_skip_bracketed_paste_embedded_esc_is_literal); + + /* M5 SKIP -- Kitty keyboard protocol. */ + TR_RUN(test_skip_kitty_csi_u_letter); + TR_RUN(test_skip_kitty_csi_u_letter_with_alt); + TR_RUN(test_skip_kitty_csi_u_enter_with_ctrl); + TR_RUN(test_skip_kitty_csi_u_escape_with_alt); + + /* 2026-06-29 JES #809 M1: skip-stub visibility (see file header). + * Printed BEFORE TR_SUMMARY so the count appears alongside the green + * tally in the test log without polluting the JSON tally itself. + * tr_count was updated by TR_RUN as each test ran; g_skip_count tracks + * the subset that called tr_skip() instead of asserting behavior. */ + printf("[skip-summary] %d of %d tests are SKIP stubs deferred to M2-M5\n", + g_skip_count, tr_count); + + TR_SUMMARY(); + return TR_EXIT_CODE(); +}