[ Guides ]
Headless Snapshot and Input Testing
Write deterministic, TTY-free unit tests that render widgets into an in-memory Frame, assert on plain-text snapshots, cells, and hit-buffer tags, and simulate keyboard, mouse, and paste events through EventLoop and App.
Complete working example
Section titled “Complete working example”#ifdef NDEBUG#undef NDEBUG#endif#include <koto/koto.hpp>#include <cassert>#include <string>
using namespace koto;
int main(){ // 1. Standalone headless Frame snapshot test { Frame frame(5, 24); Box() .title("Files") .title_align(TitleAlign::Left) .border_style(BorderStyle::Rounded) .id(Id("files_box")) .draw(frame, frame.area());
assert(frame.to_plain_text_row(0).starts_with("\xE2\x95\xAD\xE2\x94\x80 Files \xE2\x94\x80")); assert(frame.hit_at(2, 2) == Id("files_box")); assert(frame.bounds_of(Id("files_box")) == Area(0, 0, 24, 5)); }
// 2. Full App + FocusManager headless integration test without a TTY { App app; FocusManager fm; TextInputState input("init"); bool clicked = false;
fm.add(input); app.use_focus(fm);
app.on_render([&](Frame& frame) { auto [top_area, bottom_area] = Layout::vertical(frame.area(), { Constraint::fixed(3), Constraint::fixed(3), }); TextInput(input).draw(frame, top_area); Button("Run") .id(Id("btn_run")) .focus(fm) .on_click([&]() { clicked = true; }) .draw(frame, bottom_area); });
// Resize headless frame and render with flush = false app.frame().handle_resize(10, 30); app.draw_frame(false);
// Parse synthetic Kitty CSI-u key and dispatch Event tab_ev = EventLoop::parse("\033[9u"); app.dispatch_event(tab_ev); assert(fm.is_focused(Id("btn_run")));
// Parse SGR mouse click at row 4, col 5 (1-based: [<0;6;5M) Event click_ev = EventLoop::parse("\033[<0;6;5M"); app.dispatch_event(click_ev); assert(clicked); }
return 0;}Step-by-step explanation
Section titled “Step-by-step explanation”- In-memory
Frame(rows, cols): ConstructingFrame(rows, cols)setsm_term = nullptr(src/frame.cpp:28-35). You can draw any widget into it, inspectframe.to_plain_text_row(r),frame.to_plain_text(),frame.get_cell(r, c),frame.hit_at(r, c),frame.bounds_of(id),frame.cursor_row(), andframe.cursor_col(), and callframe.render(false)to inspect the generated ANSI sequence viaframe.raw_output(). - Driving
Appwithoutapp.run(): Becauseapp.run()requiresm_term.is_tty() == true(src/app.cpp:201), headless tests driveAppdirectly using:app.frame().handle_resize(rows, cols)(orapp.frame() = Frame(rows, cols)) to set deterministic screen dimensions.app.draw_frame(false)to clear the back buffer, invokeon_render, callfm.sync_geometry(m_frame), and runm_frame.render(false)without writing toSTDOUT_FILENO(src/app.cpp:184-197).app.dispatch_event(ev)to route syntheticKeyEvent,MouseEvent,PasteEvent, orResizeEventobjects throughFocusManagerandAppcallbacks (src/app.cpp:84-182).
- Testing streaming input with
EventLoop::feedandpoll(0): To test split escape sequences or partial pastes, callloop.feed(chunk1), checkloop.has_pending()andloop.has_pending_event(), then callloop.feed(chunk2)andloop.poll(0)(include/koto/event.hpp:173-184).
Common mistakes
Section titled “Common mistakes”- Calling
app.run()in a CI test runner: In CI (ctest),stdinandstdoutare pipes (isatty() == 0), soapp.run()immediately returnsstd::unexpected(TerminalErrorInfo{TerminalError::NotATty, ENOTTY}). Always useapp.draw_frame(false)andapp.dispatch_event(...)in automated tests. - Dispatching spatial arrow keys or mouse clicks before calling
app.draw_frame(false): BothFocusManager::move(Direction)andFocusManager::handle_mouse(...)rely on bounding boxes and hit tags stamped intoFrameduringon_renderand harvested bysync_geometry(frame). Callapp.draw_frame(false)once before dispatching mouse clicks or 2D spatial navigation keys.