From aa8edf048de94f6cb67a0ebdd2951a1847399bba Mon Sep 17 00:00:00 2001 From: Jake Savin Date: Mon, 29 Jun 2026 18:42:35 -0700 Subject: [PATCH 1/2] feat(boxen): C M1 #809 -- input_decoder PTY-replay harness + stub Phase C input-decoder milestone M1. Pure infrastructure: a stub decoder module and a 61-test harness that exercises the M1 API surface (create / destroy / inject-bytes / poll / mouse-enable / kitty-enable). What this adds: - frontier-cli/boxen/input_decoder.h -- private API (typedef, lifecycle, poll, mouse / kitty enable, INPUT_DECODER_TEST_SEAM-guarded inject / buffered-bytes accessor). - frontier-cli/boxen/input_decoder.c -- minimal skeleton: 4 KiB linear scratch buffer, NULL-safe everywhere. poll() always returns BOXEN_ERR_TIMEOUT (the state machine lands in M2). Wired into the frontier-cli production build so the CLI continues to link, but NOT yet wired into backend_tb2.c (the M6 cutover). - tests/input_decoder_tests.c -- TR_RUN harness. 12 M1 behavioral tests (create / destroy round-trip, NULL safety, mouse-enable round-trip, inject deposits bytes, poll-on-empty returns TIMEOUT, etc.). 49 SKIP stubs ranged by milestone (M2: cursor keys + UTF-8, M3: mouse, M4: paste, M5: Kitty). Each SKIP stub prints "[SKIP] " to stderr so deferrals are visible in the test log; the M2-M5 sub-agents rewrite each stub as a behavioral assert when its milestone lands. - tests/Makefile -- new input_decoder_tests target (link slice: input_decoder.c only, no termbox2, no Frontier runtime; uses -DINPUT_DECODER_TEST_SEAM to expose the inject seam). - frontier-cli/Makefile -- input_decoder.c added to BOXEN_SOURCES. SKIP mechanism note (no TR_SKIP exists in test_report.h): the SKIP stubs return without firing assert(), so TR_RUN records them as passing. This keeps the 61/61 green count accurate (we are not claiming protocol behavior we have not implemented). The early-return + stderr SKIP line makes the deferral visible in the log without polluting the JSON tally. Done criterion (per plan section 7, M1): make -C tests input_decoder_tests && ./tests/input_decoder_tests --> compiles, passes (61/61 green, of which 49 print SKIP). Full unit suite: 882/882 (was 821 pre-existing, +61 new). Out of scope for M1 (per plan section 7): the state machine (M2), SGR mouse (M3), bracketed paste with BOXEN_EV_PASTE ABI add (M4), Kitty keyboard protocol (M5), backend_tb2 cutover (M6), /mouse toggle (M7). No production input-path behavior changes; backend_tb2.c is untouched. Plan: planning/phase_c/INPUT_DECODER_PLAN.md Closes #809 --- frontier-cli/Makefile | 9 +- frontier-cli/boxen/input_decoder.c | 199 +++++++++++ frontier-cli/boxen/input_decoder.h | 124 +++++++ tests/Makefile | 15 +- tests/input_decoder_tests.c | 531 +++++++++++++++++++++++++++++ 5 files changed, 876 insertions(+), 2 deletions(-) create mode 100644 frontier-cli/boxen/input_decoder.c create mode 100644 frontier-cli/boxen/input_decoder.h create mode 100644 tests/input_decoder_tests.c 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..64c40c17e --- /dev/null +++ b/frontier-cli/boxen/input_decoder.c @@ -0,0 +1,199 @@ +/* + * 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: single-threaded; the caller (M6: tb2_poll_event) is responsible + * for GIL release/reacquire around any future blocking read. The M1 stub + * never blocks because it never reads. + * + * See planning/phase_c/INPUT_DECODER_PLAN.md sections 2, 6, 7 for the full + * design. + */ + +#include "input_decoder.h" + +#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. + * head_len is the number of unread bytes starting at buf[0]. + * + * 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. */ + uint8_t buf[INPUT_DECODER_BUFFER_SIZE]; + size_t head_len; +}; + +/* ------------------------------------------------------------------------- + * 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->head_len = 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. */ +} + +/* ------------------------------------------------------------------------- + * Test seam (compile-guarded) + * ---------------------------------------------------------------------- */ + +#ifdef INPUT_DECODER_TEST_SEAM + +void input_decoder_inject_bytes(input_decoder_t *dec, + const uint8_t *bytes, size_t len) { + if (dec == NULL || len == 0) { + return; + } + if (bytes == NULL) { + return; + } + + /* M1: drop bytes silently on overflow. M2 will refine the contract -- + * once the state machine is draining the buffer, the realistic failure + * mode is "a paste larger than 256 KiB" (handled by the paste-specific + * cap in M4) rather than "the ring filled because nobody read it". + * + * For the M1 harness, the smoke tests inject a handful of bytes; a + * future test that floods the buffer past INPUT_DECODER_BUFFER_SIZE + * is documenting an unwanted behavior, not an expected one. */ + size_t available = INPUT_DECODER_BUFFER_SIZE - dec->head_len; + size_t to_copy = (len < available) ? len : available; + + if (to_copy > 0) { + memcpy(dec->buf + dec->head_len, bytes, to_copy); + dec->head_len += to_copy; + } +} + +size_t input_decoder_buffered_bytes(const input_decoder_t *dec) { + if (dec == NULL) { + return 0; + } + return dec->head_len; +} + +#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..422671378 --- /dev/null +++ b/frontier-cli/boxen/input_decoder.h @@ -0,0 +1,124 @@ +/* + * 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. + * + * Threading: same contract as the rest of boxen -- single-threaded, GIL-held. + * The caller (tb2_poll_event) releases the GIL before entering the read loop + * and reacquires before calling back into Frontier. + * + * 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(). + * + * 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); + +/* 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. + * + * On overflow, the excess bytes are dropped silently for now -- M2 will + * formalize the back-pressure contract. The M1 buffer is sized for the + * largest single protocol burst we expect (worst case: bracketed paste, + * which M4 caps at 256 KB but for the M1 stub a much smaller value is + * adequate to exercise the state machine that M2 lands). + * + * Safe to call with len == 0 (no-op) or bytes == NULL when len == 0. */ +#ifdef INPUT_DECODER_TEST_SEAM +void 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); +#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..41325e141 --- /dev/null +++ b/tests/input_decoder_tests.c @@ -0,0 +1,531 @@ +/* + * 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 -- so TR_RUN records a green pass, but the SKIP output makes + * the deferral visible in the test log. The total / passed counts in the + * tests/tmp/unit/input_decoder_tests.json output then correctly reflect + * "all tests green" without claiming protocol behavior we have not yet + * implemented. Each SKIP test will be rewritten as a behavioral assert + * when its milestone lands; that's the un-skip. + * + * 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. + * + * 2026-06-29 JES #809 M1. */ +static void tr_skip(const char *reason) { + 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); + + static const uint8_t seq[] = {0x1b, '[', '1', ';', '3', 'D'}; + input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(input_decoder_buffered_bytes(dec) == sizeof(seq)); + + /* Inject more: count accumulates. */ + static const uint8_t more[] = {'a', 'b', 'c'}; + input_decoder_inject_bytes(dec, more, sizeof(more)); + assert(input_decoder_buffered_bytes(dec) == sizeof(seq) + sizeof(more)); + + 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); + + input_decoder_inject_bytes(dec, NULL, 0); + assert(input_decoder_buffered_bytes(dec) == 0); + + static const uint8_t nothing[1] = {0}; + input_decoder_inject_bytes(dec, nothing, 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. */ +static void test_inject_bytes_null_decoder_safe(void) { + static const uint8_t seq[] = {0x1b}; + input_decoder_inject_bytes(NULL, seq, sizeof(seq)); + /* Survival is the assertion. */ +} + +/* 2026-06-29 JES #809 M1: buffered_bytes on a NULL decoder returns 0. */ +static void test_buffered_bytes_null_safe(void) { + assert(input_decoder_buffered_bytes(NULL) == 0); +} + +/* 2026-06-29 JES #809 M1: kitty_enable on a fresh decoder is a no-op the + * test can observe -- the decoder must not crash and the buffer must + * remain empty (no bytes loop back to us via inject). */ +static void test_kitty_enable_does_not_crash(void) { + input_decoder_t *dec = input_decoder_create(-1); + assert(dec != NULL); + + input_decoder_kitty_enable(dec); + assert(input_decoder_buffered_bytes(dec) == 0); + + /* NULL-safety. */ + input_decoder_kitty_enable(NULL); + + 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_csi_plain_arrow_up(void) { + tr_skip("M2: \\e[A -> KEY UP, mod=NONE (plan section 3.1)"); +} + +static void test_csi_plain_arrow_down(void) { + tr_skip("M2: \\e[B -> KEY DOWN, mod=NONE (plan section 3.1)"); +} + +static void test_csi_plain_arrow_right(void) { + tr_skip("M2: \\e[C -> KEY RIGHT, mod=NONE (plan section 3.1)"); +} + +static void test_csi_plain_arrow_left(void) { + tr_skip("M2: \\e[D -> KEY LEFT, mod=NONE (plan section 3.1)"); +} + +static void test_ss3_arrow_up(void) { + tr_skip("M2: \\eOA -> KEY UP, mod=NONE (plan section 3.1)"); +} + +static void test_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_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_csi_modifier_shift_up(void) { + tr_skip("M2: \\e[1;2A -> KEY UP, mod=SHIFT (plan section 3.2)"); +} + +static void test_csi_modifier_ctrl_down(void) { + tr_skip("M2: \\e[1;5B -> KEY DOWN, mod=CTRL (plan section 3.2)"); +} + +static void test_csi_modifier_alt_shift_left(void) { + tr_skip("M2: \\e[1;4D -> KEY LEFT, mod=ALT|SHIFT (plan section 3.2)"); +} + +static void test_csi_modifier_meta_up(void) { + tr_skip("M2: \\e[1;9A -> KEY UP, mod=META (plan section 3.2)"); +} + +static void test_csi_nav_home(void) { + tr_skip("M2: \\e[H -> KEY HOME (plan section 3.3)"); +} + +static void test_csi_nav_end(void) { + tr_skip("M2: \\e[F -> KEY END (plan section 3.3)"); +} + +static void test_csi_nav_pgup(void) { + tr_skip("M2: \\e[5~ -> KEY PGUP (plan section 3.3)"); +} + +static void test_csi_nav_pgdn(void) { + tr_skip("M2: \\e[6~ -> KEY PGDN (plan section 3.3)"); +} + +static void test_csi_nav_delete(void) { + tr_skip("M2: \\e[3~ -> KEY DELETE (plan section 3.3)"); +} + +static void test_function_key_f1_ss3(void) { + tr_skip("M2: \\eOP -> KEY F1 (plan section 3.4)"); +} + +static void test_function_key_f5_csi(void) { + tr_skip("M2: \\e[15~ -> KEY F5 (plan section 3.4)"); +} + +static void test_function_key_f1_shift(void) { + tr_skip("M2: \\e[1;2P -> KEY F1, mod=SHIFT (plan section 3.4)"); +} + +static void test_meta_prefix_alt_a(void) { + tr_skip("M2: \\ea -> ch='a', mod=ALT (plan section 3.5)"); +} + +static void test_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_esc_alone_emits_escape(void) { + tr_skip("M2: lone 0x1B with no follow-up -> KEY ESCAPE (plan section 4.4)"); +} + +static void test_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_control_char_ctrl_a(void) { + tr_skip("M2: 0x01 -> KEY CTRL_A (plan section 3.11)"); +} + +static void test_control_char_tab(void) { + tr_skip("M2: 0x09 -> KEY TAB (plan section 3.11)"); +} + +static void test_control_char_enter(void) { + tr_skip("M2: 0x0D -> KEY ENTER (plan section 3.11)"); +} + +static void test_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_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_sgr_mouse_left_release(void) { + tr_skip("M3: \\e[<0;10;5m -> MOUSE button=1 pressed=false (plan section 3.6)"); +} + +static void test_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_sgr_mouse_wheel_down(void) { + tr_skip("M3: \\e[<65;10;5M -> MOUSE button=5 (wheel-down) (plan section 3.6)"); +} + +static void test_sgr_mouse_motion(void) { + tr_skip("M3: \\e[<32;10;5M -> MOUSE motion (plan section 3.6)"); +} + +static void test_sgr_mouse_shift_modifier(void) { + tr_skip("M3: SGR button bit 4 -> mod=SHIFT (plan section 3.6)"); +} + +static void test_x10_mouse_left_press(void) { + tr_skip("M3: \\e[M\\x20\\x2a\\x19 -> MOUSE left press (plan section 3.7)"); +} + +static void test_x10_mouse_wheel_up(void) { + tr_skip("M3: \\e[M\\x60\\x2a\\x19 -> MOUSE wheel-up (plan section 3.7)"); +} + +static void test_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_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_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_bracketed_paste_with_newlines(void) { + tr_skip("M4: paste with embedded \\n / \\r\\n -> single PASTE event " + "(plan section 7 M4)"); +} + +static void test_bracketed_paste_size_cap_truncation(void) { + tr_skip("M4: paste > 256 KiB -> truncated + BOXEN_LOG_W " + "(plan section 3.8, R5)"); +} + +static void test_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_kitty_csi_u_letter(void) { + tr_skip("M5: \\e[65u -> ch='A', mod=NONE (plan section 3.9)"); +} + +static void test_kitty_csi_u_letter_with_alt(void) { + tr_skip("M5: \\e[65;3u -> ch='A', mod=ALT (plan section 3.9)"); +} + +static void test_kitty_csi_u_enter_with_ctrl(void) { + tr_skip("M5: \\e[13;5u -> KEY_ENTER, mod=CTRL (plan section 3.9)"); +} + +static void test_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_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_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_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_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_does_not_crash); + + /* M2 SKIP -- cursor keys, modifiers, ESC, control chars, UTF-8. */ + TR_RUN(test_csi_plain_arrow_up); + TR_RUN(test_csi_plain_arrow_down); + TR_RUN(test_csi_plain_arrow_right); + TR_RUN(test_csi_plain_arrow_left); + TR_RUN(test_ss3_arrow_up); + TR_RUN(test_csi_modifier_alt_left_issue_805); + TR_RUN(test_csi_modifier_alt_right_issue_805); + TR_RUN(test_csi_modifier_shift_up); + TR_RUN(test_csi_modifier_ctrl_down); + TR_RUN(test_csi_modifier_alt_shift_left); + TR_RUN(test_csi_modifier_meta_up); + TR_RUN(test_csi_nav_home); + TR_RUN(test_csi_nav_end); + TR_RUN(test_csi_nav_pgup); + TR_RUN(test_csi_nav_pgdn); + TR_RUN(test_csi_nav_delete); + TR_RUN(test_function_key_f1_ss3); + TR_RUN(test_function_key_f5_csi); + TR_RUN(test_function_key_f1_shift); + TR_RUN(test_meta_prefix_alt_a); + TR_RUN(test_meta_prefix_alt_b); + TR_RUN(test_esc_alone_emits_escape); + TR_RUN(test_partial_sequence_across_inject_boundary); + TR_RUN(test_control_char_ctrl_a); + TR_RUN(test_control_char_tab); + TR_RUN(test_control_char_enter); + TR_RUN(test_control_char_backspace_del); + TR_RUN(test_utf8_two_byte_codepoint); + TR_RUN(test_utf8_three_byte_codepoint); + TR_RUN(test_utf8_four_byte_codepoint); + TR_RUN(test_utf8_continuation_split_across_inject); + + /* M3 SKIP -- SGR mouse, X10 fallback, burst-read. */ + TR_RUN(test_sgr_mouse_left_press); + TR_RUN(test_sgr_mouse_left_release); + TR_RUN(test_sgr_mouse_wheel_up); + TR_RUN(test_sgr_mouse_wheel_down); + TR_RUN(test_sgr_mouse_motion); + TR_RUN(test_sgr_mouse_shift_modifier); + TR_RUN(test_x10_mouse_left_press); + TR_RUN(test_x10_mouse_wheel_up); + TR_RUN(test_burst_read_multiple_events_one_inject); + TR_RUN(test_double_click_synthesis); + + /* M4 SKIP -- bracketed paste. */ + TR_RUN(test_bracketed_paste_simple); + TR_RUN(test_bracketed_paste_with_newlines); + TR_RUN(test_bracketed_paste_size_cap_truncation); + TR_RUN(test_bracketed_paste_embedded_esc_is_literal); + + /* M5 SKIP -- Kitty keyboard protocol. */ + TR_RUN(test_kitty_csi_u_letter); + TR_RUN(test_kitty_csi_u_letter_with_alt); + TR_RUN(test_kitty_csi_u_enter_with_ctrl); + TR_RUN(test_kitty_csi_u_escape_with_alt); + + TR_SUMMARY(); + return TR_EXIT_CODE(); +} From c886b52ebd695bb28d5550a85db2a7244e54b44c Mon Sep 17 00:00:00 2001 From: Jake Savin Date: Mon, 29 Jun 2026 19:06:42 -0700 Subject: [PATCH 2/2] fix(boxen): C M1 #809 -- address /gate P1 findings (3) + cheap P2s Round 2 of M1: fold in the three P1 findings from the local /gate review (security + concurrency reviewers) plus the cheap P2s that are part of the same edits. No behavior change to the existing 12 M1 tests; one new test exercises the back-pressure surface. Concurrency P1 -- input_decoder.h threading contract was wrong: - Previous text said "single-threaded, GIL-held" then "releases the GIL before entering the read loop", which contradict each other and would have misled the M6 sub-agent. - Rewrite the threading block to state the single-owner invariant explicitly: exactly one decoder per process, GIL held at every API entry/exit, GIL dropped ONLY inside the M6 blocking read inside input_decoder_poll, mutations during that window are safe because no other thread holds a pointer to the decoder. Concurrency P1 -- set_mouse / kitty_enable owner-thread invariant was implicit: - Document that these must be called from GIL-held context AND only when no poll is in progress. The REPL event loop satisfies this naturally (slash commands dispatch between polls), but it needs to be a documented invariant rather than an accident, because M3+ will turn these into TTY writes that would race the in-flight read(2) otherwise. Security P1 -- silent drop-on-overflow contract: - Previous inject_bytes returned void and silently truncated. M2/M3 read-loop overflow would have been an invisible parser-desync surface. - Change inject_bytes to return the number of bytes accepted (size_t). - Add a monotonic dropped_bytes counter on the struct, exposed via input_decoder_dropped_bytes() (test seam, mirrors buffered_bytes). - Add behavioral test test_inject_overflow_increments_dropped_counter that floods 5 KiB into the 4 KiB ring and asserts accepted+dropped = attempted, then verifies a second inject when full drops everything. Cheap P2s folded in: - Rename head_len -> fill_len (bar-raiser P2 #6: "head" implied an index, it's actually a fill level). Will keep the M2 state-machine commit honest about what's an index vs a count. - assert(fill_len <= INPUT_DECODER_BUFFER_SIZE) at top of inject_bytes (bar-raiser P2 #4): cheap insurance for the M2 drain refactor. - assert(bytes != NULL) when len > 0 (security P2): test seam should catch caller bugs, not silently no-op. - Add input_decoder_kitty_enabled() symmetric query (bar-raiser P2 #7): the field was write-only with no observer; tests can now assert the bit flipped, and M5 has the seam pre-built. - Document tty_fd ownership: caller owns the fd, decoder does not validate or close it (bar-raiser P2 #5/8). - Document SIGWINCH non-interaction (concurrency P2 #12). - Unify the threading comment between header and .c (concurrency P2 #11). Test-harness P2 (bar-raiser #5): - Rename all 49 SKIP-stub functions test_* -> test_skip_* so a grep over tests/tmp/unit/input_decoder_tests.json discloses the deferred set without parsing stderr. - Add g_skip_count counter bumped by tr_skip(); main() prints "[skip-summary] 49 of 62 tests are SKIP stubs deferred to M2-M5" alongside the green tally so the 62/62 doesn't conceal unimplemented behavior. Test count: Before: 61/61 (12 real + 49 SKIP). After: 62/62 (13 real + 49 SKIP). Net suite: 882 -> 883 (no other tests touched). Deferred to follow-up issues (P2s not folded in here): - Forward-compat note about future M2 signed/unsigned hazard in drain arithmetic (security P2 #10) -- belongs in the M2 PR review checklist. - "Document the implementation's actual behavior of bytes==NULL" -- resolved by switching to assert (no separate doc needed now). Plan: planning/phase_c/INPUT_DECODER_PLAN.md PR: #816 --- frontier-cli/boxen/input_decoder.c | 95 ++++++--- frontier-cli/boxen/input_decoder.h | 91 +++++++-- tests/input_decoder_tests.c | 304 ++++++++++++++++++----------- 3 files changed, 329 insertions(+), 161 deletions(-) diff --git a/frontier-cli/boxen/input_decoder.c b/frontier-cli/boxen/input_decoder.c index 64c40c17e..8d12eba19 100644 --- a/frontier-cli/boxen/input_decoder.c +++ b/frontier-cli/boxen/input_decoder.c @@ -24,9 +24,11 @@ * tests/input_decoder_tests.c harness has a real linkable surface that * exercises the API shape (create / inject / poll / destroy). * - * Threading: single-threaded; the caller (M6: tb2_poll_event) is responsible - * for GIL release/reacquire around any future blocking read. The M1 stub - * never blocks because it never reads. + * 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. @@ -34,6 +36,7 @@ #include "input_decoder.h" +#include #include #include @@ -65,15 +68,29 @@ struct input_decoder { bool kitty_enabled; /* Linear scratch buffer used as a simple drain-from-head queue. - * head_len is the number of unread bytes starting at buf[0]. + * 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. */ + * 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 head_len; + 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; }; /* ------------------------------------------------------------------------- @@ -88,7 +105,8 @@ input_decoder_t *input_decoder_create(int tty_fd) { dec->tty_fd = tty_fd; dec->mouse_enabled = false; dec->kitty_enabled = false; - dec->head_len = 0; + dec->fill_len = 0; + dec->dropped_bytes = 0; return dec; } @@ -157,43 +175,68 @@ void input_decoder_kitty_enable(input_decoder_t *dec) { * \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 -void input_decoder_inject_bytes(input_decoder_t *dec, - const uint8_t *bytes, size_t len) { +size_t input_decoder_inject_bytes(input_decoder_t *dec, + const uint8_t *bytes, size_t len) { if (dec == NULL || len == 0) { - return; - } - if (bytes == NULL) { - return; + return 0; } - - /* M1: drop bytes silently on overflow. M2 will refine the contract -- - * once the state machine is draining the buffer, the realistic failure - * mode is "a paste larger than 256 KiB" (handled by the paste-specific - * cap in M4) rather than "the ring filled because nobody read it". - * - * For the M1 harness, the smoke tests inject a handful of bytes; a - * future test that floods the buffer past INPUT_DECODER_BUFFER_SIZE - * is documenting an unwanted behavior, not an expected one. */ - size_t available = INPUT_DECODER_BUFFER_SIZE - dec->head_len; + /* 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->head_len, bytes, to_copy); - dec->head_len += to_copy; + 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->head_len; + 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 index 422671378..68bf1415d 100644 --- a/frontier-cli/boxen/input_decoder.h +++ b/frontier-cli/boxen/input_decoder.h @@ -5,11 +5,38 @@ * 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. - * - * Threading: same contract as the rest of boxen -- single-threaded, GIL-held. - * The caller (tb2_poll_event) releases the GIL before entering the read loop - * and reacquires before calling back into Frontier. + * 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 @@ -42,6 +69,12 @@ typedef struct input_decoder input_decoder_t; * 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 @@ -91,23 +124,40 @@ int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms * 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. - * - * On overflow, the excess bytes are dropped silently for now -- M2 will - * formalize the back-pressure contract. The M1 buffer is sized for the - * largest single protocol burst we expect (worst case: bracketed paste, - * which M4 caps at 256 KB but for the M1 stub a much smaller value is - * adequate to exercise the state machine that M2 lands). - * - * Safe to call with len == 0 (no-op) or bytes == NULL when len == 0. */ + * 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 -void input_decoder_inject_bytes(input_decoder_t *dec, - const uint8_t *bytes, size_t len); +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 @@ -115,6 +165,15 @@ void input_decoder_inject_bytes(input_decoder_t *dec, * 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 diff --git a/tests/input_decoder_tests.c b/tests/input_decoder_tests.c index 41325e141..f2beee9cf 100644 --- a/tests/input_decoder_tests.c +++ b/tests/input_decoder_tests.c @@ -9,12 +9,17 @@ * 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 -- so TR_RUN records a green pass, but the SKIP output makes - * the deferral visible in the test log. The total / passed counts in the - * tests/tmp/unit/input_decoder_tests.json output then correctly reflect - * "all tests green" without claiming protocol behavior we have not yet - * implemented. Each SKIP test will be rewritten as a behavioral assert - * when its milestone lands; that's the un-skip. + * 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 @@ -52,8 +57,17 @@ * (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); } @@ -153,15 +167,20 @@ 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'}; - input_decoder_inject_bytes(dec, seq, sizeof(seq)); + 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. */ + /* Inject more: count accumulates, no drops on a non-full buffer. */ static const uint8_t more[] = {'a', 'b', 'c'}; - input_decoder_inject_bytes(dec, more, sizeof(more)); + 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); } @@ -173,40 +192,78 @@ static void test_inject_bytes_noop_on_empty(void) { input_decoder_t *dec = input_decoder_create(-1); assert(dec != NULL); - input_decoder_inject_bytes(dec, NULL, 0); + assert(input_decoder_inject_bytes(dec, NULL, 0) == 0); assert(input_decoder_buffered_bytes(dec) == 0); static const uint8_t nothing[1] = {0}; - input_decoder_inject_bytes(dec, nothing, 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. */ +/* 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}; - input_decoder_inject_bytes(NULL, seq, sizeof(seq)); - /* Survival is the assertion. */ + assert(input_decoder_inject_bytes(NULL, seq, sizeof(seq)) == 0); } -/* 2026-06-29 JES #809 M1: buffered_bytes on a NULL decoder returns 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 on a fresh decoder is a no-op the - * test can observe -- the decoder must not crash and the buffer must - * remain empty (no bytes loop back to us via inject). */ -static void test_kitty_enable_does_not_crash(void) { +/* 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. */ + /* 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); } @@ -219,115 +276,115 @@ static void test_kitty_enable_does_not_crash(void) { * sub-agent can grep "M2 SKIP" to find its TDD entry points. * ---------------------------------------------------------------------- */ -static void test_csi_plain_arrow_up(void) { +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_csi_plain_arrow_down(void) { +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_csi_plain_arrow_right(void) { +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_csi_plain_arrow_left(void) { +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_ss3_arrow_up(void) { +static void test_skip_ss3_arrow_up(void) { tr_skip("M2: \\eOA -> KEY UP, mod=NONE (plan section 3.1)"); } -static void test_csi_modifier_alt_left_issue_805(void) { +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_csi_modifier_alt_right_issue_805(void) { +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_csi_modifier_shift_up(void) { +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_csi_modifier_ctrl_down(void) { +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_csi_modifier_alt_shift_left(void) { +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_csi_modifier_meta_up(void) { +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_csi_nav_home(void) { +static void test_skip_csi_nav_home(void) { tr_skip("M2: \\e[H -> KEY HOME (plan section 3.3)"); } -static void test_csi_nav_end(void) { +static void test_skip_csi_nav_end(void) { tr_skip("M2: \\e[F -> KEY END (plan section 3.3)"); } -static void test_csi_nav_pgup(void) { +static void test_skip_csi_nav_pgup(void) { tr_skip("M2: \\e[5~ -> KEY PGUP (plan section 3.3)"); } -static void test_csi_nav_pgdn(void) { +static void test_skip_csi_nav_pgdn(void) { tr_skip("M2: \\e[6~ -> KEY PGDN (plan section 3.3)"); } -static void test_csi_nav_delete(void) { +static void test_skip_csi_nav_delete(void) { tr_skip("M2: \\e[3~ -> KEY DELETE (plan section 3.3)"); } -static void test_function_key_f1_ss3(void) { +static void test_skip_function_key_f1_ss3(void) { tr_skip("M2: \\eOP -> KEY F1 (plan section 3.4)"); } -static void test_function_key_f5_csi(void) { +static void test_skip_function_key_f5_csi(void) { tr_skip("M2: \\e[15~ -> KEY F5 (plan section 3.4)"); } -static void test_function_key_f1_shift(void) { +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_meta_prefix_alt_a(void) { +static void test_skip_meta_prefix_alt_a(void) { tr_skip("M2: \\ea -> ch='a', mod=ALT (plan section 3.5)"); } -static void test_meta_prefix_alt_b(void) { +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_esc_alone_emits_escape(void) { +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_partial_sequence_across_inject_boundary(void) { +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_control_char_ctrl_a(void) { +static void test_skip_control_char_ctrl_a(void) { tr_skip("M2: 0x01 -> KEY CTRL_A (plan section 3.11)"); } -static void test_control_char_tab(void) { +static void test_skip_control_char_tab(void) { tr_skip("M2: 0x09 -> KEY TAB (plan section 3.11)"); } -static void test_control_char_enter(void) { +static void test_skip_control_char_enter(void) { tr_skip("M2: 0x0D -> KEY ENTER (plan section 3.11)"); } -static void test_control_char_backspace_del(void) { +static void test_skip_control_char_backspace_del(void) { tr_skip("M2: 0x7F -> KEY BACKSPACE (plan section 3.11)"); } @@ -335,46 +392,46 @@ static void test_control_char_backspace_del(void) { * M3 SKIP stubs -- SGR mouse + X10 fallback + burst-read. * ---------------------------------------------------------------------- */ -static void test_sgr_mouse_left_press(void) { +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_sgr_mouse_left_release(void) { +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_sgr_mouse_wheel_up(void) { +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_sgr_mouse_wheel_down(void) { +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_sgr_mouse_motion(void) { +static void test_skip_sgr_mouse_motion(void) { tr_skip("M3: \\e[<32;10;5M -> MOUSE motion (plan section 3.6)"); } -static void test_sgr_mouse_shift_modifier(void) { +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_x10_mouse_left_press(void) { +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_x10_mouse_wheel_up(void) { +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_burst_read_multiple_events_one_inject(void) { +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_double_click_synthesis(void) { +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)"); } @@ -383,22 +440,22 @@ static void test_double_click_synthesis(void) { * M4 SKIP stubs -- bracketed paste + BOXEN_EV_PASTE. * ---------------------------------------------------------------------- */ -static void test_bracketed_paste_simple(void) { +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_bracketed_paste_with_newlines(void) { +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_bracketed_paste_size_cap_truncation(void) { +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_bracketed_paste_embedded_esc_is_literal(void) { +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)"); } @@ -407,19 +464,19 @@ static void test_bracketed_paste_embedded_esc_is_literal(void) { * M5 SKIP stubs -- Kitty keyboard protocol. * ---------------------------------------------------------------------- */ -static void test_kitty_csi_u_letter(void) { +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_kitty_csi_u_letter_with_alt(void) { +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_kitty_csi_u_enter_with_ctrl(void) { +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_kitty_csi_u_escape_with_alt(void) { +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)"); } @@ -428,22 +485,22 @@ static void test_kitty_csi_u_escape_with_alt(void) { * accumulator is part of the GROUND-state path). * ---------------------------------------------------------------------- */ -static void test_utf8_two_byte_codepoint(void) { +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_utf8_three_byte_codepoint(void) { +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_utf8_four_byte_codepoint(void) { +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_utf8_continuation_split_across_inject(void) { +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)"); } @@ -467,64 +524,73 @@ int main(void) { 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_does_not_crash); + 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_csi_plain_arrow_up); - TR_RUN(test_csi_plain_arrow_down); - TR_RUN(test_csi_plain_arrow_right); - TR_RUN(test_csi_plain_arrow_left); - TR_RUN(test_ss3_arrow_up); - TR_RUN(test_csi_modifier_alt_left_issue_805); - TR_RUN(test_csi_modifier_alt_right_issue_805); - TR_RUN(test_csi_modifier_shift_up); - TR_RUN(test_csi_modifier_ctrl_down); - TR_RUN(test_csi_modifier_alt_shift_left); - TR_RUN(test_csi_modifier_meta_up); - TR_RUN(test_csi_nav_home); - TR_RUN(test_csi_nav_end); - TR_RUN(test_csi_nav_pgup); - TR_RUN(test_csi_nav_pgdn); - TR_RUN(test_csi_nav_delete); - TR_RUN(test_function_key_f1_ss3); - TR_RUN(test_function_key_f5_csi); - TR_RUN(test_function_key_f1_shift); - TR_RUN(test_meta_prefix_alt_a); - TR_RUN(test_meta_prefix_alt_b); - TR_RUN(test_esc_alone_emits_escape); - TR_RUN(test_partial_sequence_across_inject_boundary); - TR_RUN(test_control_char_ctrl_a); - TR_RUN(test_control_char_tab); - TR_RUN(test_control_char_enter); - TR_RUN(test_control_char_backspace_del); - TR_RUN(test_utf8_two_byte_codepoint); - TR_RUN(test_utf8_three_byte_codepoint); - TR_RUN(test_utf8_four_byte_codepoint); - TR_RUN(test_utf8_continuation_split_across_inject); + 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_sgr_mouse_left_press); - TR_RUN(test_sgr_mouse_left_release); - TR_RUN(test_sgr_mouse_wheel_up); - TR_RUN(test_sgr_mouse_wheel_down); - TR_RUN(test_sgr_mouse_motion); - TR_RUN(test_sgr_mouse_shift_modifier); - TR_RUN(test_x10_mouse_left_press); - TR_RUN(test_x10_mouse_wheel_up); - TR_RUN(test_burst_read_multiple_events_one_inject); - TR_RUN(test_double_click_synthesis); + 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_bracketed_paste_simple); - TR_RUN(test_bracketed_paste_with_newlines); - TR_RUN(test_bracketed_paste_size_cap_truncation); - TR_RUN(test_bracketed_paste_embedded_esc_is_literal); + 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_kitty_csi_u_letter); - TR_RUN(test_kitty_csi_u_letter_with_alt); - TR_RUN(test_kitty_csi_u_enter_with_ctrl); - TR_RUN(test_kitty_csi_u_escape_with_alt); + 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();