feat(0132): cpp terminal_panel module + ansi_parser
Nuevo modulo reutilizable terminal_panel (fn_term) para ImGui: Sub-fn ansi_parser_cpp_core (cpp/functions/core/): - Parser ANSI/VT100 byte-a-byte sin heap allocs por evento - SGR colores FG/BG 16-color + bold + reset - Cursor moves CUU/CUD/CUF/CUB + CUP absoluto - Erase ED(2)/EL(2), CR/LF/BS - Statemachine 4 estados, thread-unsafe por diseno - 21 tests unitarios (57 assertions), todos pasan terminal_panel_cpp_viz (cpp/functions/viz/terminal_panel/): - terminal_panel.cpp: render ImGui + process_output con list clipper - terminal_panel_linux.cpp: forkpty + reader thread no-blocking - terminal_panel_windows.cpp: ConPTY CreatePseudoConsole (SDK >= 17763) - Scrollback circular configurable (default 5000 lineas) - Toolbar: clear, copy, reset, scroll-lock + status indicator - readonly mode: sin input box, send() es no-op - uses_functions: ansi_parser_cpp_core, logger_cpp_core Tests: - test_ansi_parser.cpp: 21 test cases, 57 assertions (PASS) - test_terminal_panel_smoke.cpp: 3 test cases (PASS: spawn echo hello, process exits cleanly, readonly ignores send) CMake: - cpp/tests/CMakeLists.txt: add test_ansi_parser + test_terminal_panel_smoke - primitives_gallery (sub-repo): ver commit separado en apps/primitives_gallery Pendiente (anti-scope v1): - Windows ConPTY: stub funcional que compila; join() del reader thread via std::thread no implementado (usa CreateThread detached) - ANSI 256/24-bit color, italics, Unicode wide - Curses pesados (vim, htop, top) — cursor visible basic solo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -316,3 +316,20 @@ add_fn_test(test_agent_runs_timeline test_agent_runs_timeline.cpp
|
||||
add_fn_test(test_sse_client test_sse_client.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/sse_client.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp)
|
||||
|
||||
# --- Issue 0132 — ansi_parser: logica pura, sin ImGui ---
|
||||
add_fn_test(test_ansi_parser test_ansi_parser.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp)
|
||||
|
||||
# --- Issue 0132 — terminal_panel smoke: spawn real PTY (Linux only) ---
|
||||
# En Windows: todos los casos se skipean via SKIP(). En Linux necesita -lutil.
|
||||
# Linkamos fn_framework para obtener logger.cpp (fn_log) + imgui + implot.
|
||||
add_fn_test(test_terminal_panel_smoke test_terminal_panel_smoke.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/ansi_parser.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel_linux.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/terminal_panel/terminal_panel_windows.cpp)
|
||||
target_link_libraries(test_terminal_panel_smoke PRIVATE fn_framework)
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(test_terminal_panel_smoke PRIVATE util)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// test_ansi_parser.cpp — tests unitarios para fn_term::AnsiParser.
|
||||
//
|
||||
// Logica pura: no requiere ImGui ni contexto GL. Cubre:
|
||||
// - SGR: reset, FG color, BG color, bright colors, bold
|
||||
// - Cursor moves: CUU/CUD/CUF/CUB, CUP
|
||||
// - ED(2) erase display, EL(2) erase line
|
||||
// - Texto normal + secuencias mixtas
|
||||
// - CR, LF, BS
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/ansi_parser.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace fn_term;
|
||||
|
||||
// Helper: parsea una cadena y colecta los eventos.
|
||||
static std::vector<AnsiEvent> parse(const std::string& s) {
|
||||
AnsiParser p;
|
||||
std::vector<AnsiEvent> evs;
|
||||
p.feed(s.c_str(), s.size(), [&](const AnsiEvent& ev) {
|
||||
evs.push_back(ev);
|
||||
});
|
||||
return evs;
|
||||
}
|
||||
|
||||
// Helper: obtiene estados SGR después de parsear (sin eventos de salida).
|
||||
struct SgrState { uint8_t fg; uint8_t bg; uint8_t bold; };
|
||||
static SgrState parse_sgr(const std::string& s) {
|
||||
AnsiParser p;
|
||||
p.feed(s.c_str(), s.size(), [](const AnsiEvent&) {});
|
||||
return {p.current_fg(), p.current_bg(), p.current_bold()};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SGR tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("SGR reset sets default colors", "[ansi_parser][sgr]") {
|
||||
// Primero ponemos FG rojo, luego reset.
|
||||
auto st = parse_sgr("\x1b[31m\x1b[0m");
|
||||
REQUIRE(st.fg == kColorDefault);
|
||||
REQUIRE(st.bg == kColorDefault);
|
||||
REQUIRE(st.bold == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("SGR fg color 31 sets red", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[31m");
|
||||
REQUIRE(st.fg == 1); // rojo = index 1
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bg color 44 sets blue background", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[44m");
|
||||
REQUIRE(st.bg == 4); // azul = index 4
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bright fg 91 sets bright red", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[91m");
|
||||
REQUIRE(st.fg == 9); // bright red = index 8+1 = 9
|
||||
}
|
||||
|
||||
TEST_CASE("SGR bold sets bold flag", "[ansi_parser][sgr]") {
|
||||
auto st = parse_sgr("\x1b[1m");
|
||||
REQUIRE(st.bold == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("SGR reset via bare ESC[m", "[ansi_parser][sgr]") {
|
||||
// ESC [ m sin parametro = reset
|
||||
auto st = parse_sgr("\x1b[31m\x1b[m");
|
||||
REQUIRE(st.fg == kColorDefault);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor move tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("cursor CUU moves up N", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[3A");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Up);
|
||||
REQUIRE(evs[0].cursor_rel.n == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUF moves forward N", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[5C");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Forward);
|
||||
REQUIRE(evs[0].cursor_rel.n == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUB moves back 1 when no param", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[D");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorMove);
|
||||
REQUIRE(evs[0].cursor_rel.dir == CursorDir::Back);
|
||||
REQUIRE(evs[0].cursor_rel.n == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUP absolute position", "[ansi_parser][cursor]") {
|
||||
// ESC[5;10H → row=4, col=9 (0-based)
|
||||
auto evs = parse("\x1b[5;10H");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorAbsolute);
|
||||
REQUIRE(evs[0].cursor_abs.row == 4);
|
||||
REQUIRE(evs[0].cursor_abs.col == 9);
|
||||
}
|
||||
|
||||
TEST_CASE("cursor CUP default params (ESC[H) = origin", "[ansi_parser][cursor]") {
|
||||
auto evs = parse("\x1b[H");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CursorAbsolute);
|
||||
REQUIRE(evs[0].cursor_abs.row == 0);
|
||||
REQUIRE(evs[0].cursor_abs.col == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Erase tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("erase display ED 2", "[ansi_parser][erase]") {
|
||||
auto evs = parse("\x1b[2J");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::EraseDisplay);
|
||||
}
|
||||
|
||||
TEST_CASE("erase line EL 2", "[ansi_parser][erase]") {
|
||||
auto evs = parse("\x1b[2K");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::EraseLine);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control chars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("newline and carriage return", "[ansi_parser][control]") {
|
||||
auto evs = parse("\r\n");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::CarriageReturn);
|
||||
REQUIRE(evs[1].type == AnsiEventType::Newline);
|
||||
}
|
||||
|
||||
TEST_CASE("backspace emits Backspace event", "[ansi_parser][control]") {
|
||||
auto evs = parse("\x08");
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Backspace);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text + mixed sequences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("plain text emits Char events", "[ansi_parser][text]") {
|
||||
auto evs = parse("hi");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[0].cell.ch == U'h');
|
||||
REQUIRE(evs[1].cell.ch == U'i');
|
||||
}
|
||||
|
||||
TEST_CASE("mixed text and SGR sequence", "[ansi_parser][mixed]") {
|
||||
// "A" con FG rojo, luego reset, luego "B".
|
||||
auto evs = parse("\x1b[31mA\x1b[0mB");
|
||||
// Debemos tener exactamente 2 eventos Char: A (fg=1) y B (fg=default).
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[0].cell.ch == U'A');
|
||||
REQUIRE(evs[0].cell.fg == 1); // rojo
|
||||
REQUIRE(evs[1].type == AnsiEventType::Char);
|
||||
REQUIRE(evs[1].cell.ch == U'B');
|
||||
REQUIRE(evs[1].cell.fg == kColorDefault);
|
||||
}
|
||||
|
||||
TEST_CASE("char inherits current SGR attrs", "[ansi_parser][sgr]") {
|
||||
AnsiParser p;
|
||||
std::vector<AnsiEvent> evs;
|
||||
// Poner BG azul, luego emitir texto.
|
||||
std::string s = "\x1b[44mX";
|
||||
p.feed(s.c_str(), s.size(), [&](const AnsiEvent& ev) { evs.push_back(ev); });
|
||||
REQUIRE(evs.size() == 1);
|
||||
REQUIRE(evs[0].cell.ch == U'X');
|
||||
REQUIRE(evs[0].cell.bg == 4); // azul
|
||||
}
|
||||
|
||||
TEST_CASE("unknown CSI final byte ignored silently", "[ansi_parser][robustness]") {
|
||||
// ESC [ Z es desconocido — no debe emitir nada ni crashear.
|
||||
auto evs = parse("a\x1b[Zb");
|
||||
REQUIRE(evs.size() == 2);
|
||||
REQUIRE(evs[0].cell.ch == U'a');
|
||||
REQUIRE(evs[1].cell.ch == U'b');
|
||||
}
|
||||
|
||||
TEST_CASE("incomplete escape at end of buffer", "[ansi_parser][robustness]") {
|
||||
// Buffer termina a mitad de una secuencia — no debe crashear.
|
||||
AnsiParser p;
|
||||
std::string s1 = "\x1b[3";
|
||||
std::string s2 = "1m";
|
||||
p.feed(s1.c_str(), s1.size(), [](const AnsiEvent&) {});
|
||||
p.feed(s2.c_str(), s2.size(), [](const AnsiEvent&) {});
|
||||
REQUIRE(p.current_fg() == 1); // FG rojo aplicado correctamente
|
||||
}
|
||||
|
||||
TEST_CASE("reset() clears state", "[ansi_parser][reset]") {
|
||||
AnsiParser p;
|
||||
std::string s = "\x1b[31m"; // FG rojo
|
||||
p.feed(s.c_str(), s.size(), [](const AnsiEvent&) {});
|
||||
REQUIRE(p.current_fg() == 1);
|
||||
p.reset();
|
||||
REQUIRE(p.current_fg() == kColorDefault);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// test_terminal_panel_smoke.cpp — smoke test para terminal_panel.
|
||||
//
|
||||
// Prueba real del PTY en Linux: spawn "echo hello && exit 0",
|
||||
// espera output, verifica que el scrollback contiene "hello".
|
||||
//
|
||||
// En Windows: test skipped (ConPTY require DISPLAY y proceso vivo — CI).
|
||||
// En Linux sin forkpty: verifica que el build es correcto al menos.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "viz/terminal_panel/terminal_panel.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifdef _WIN32
|
||||
// En Windows en CI, skipeamos el smoke del proceso real.
|
||||
TEST_CASE("smoke: spawn echo hello and exit, scrollback contains hello", "[terminal_panel][smoke]") {
|
||||
SKIP("Smoke PTY test skipped on Windows CI");
|
||||
}
|
||||
#else
|
||||
|
||||
// Helper: concatena todas las celdas del scrollback como texto plano.
|
||||
static std::string scrollback_text(fn_term::TerminalPanel& p) {
|
||||
std::lock_guard<std::mutex> lk(p.buf_mutex);
|
||||
std::string result;
|
||||
for (const auto& line : p.lines) {
|
||||
for (const auto& cell : line) {
|
||||
if (cell.ch >= 0x20 && cell.ch < 0x7F)
|
||||
result += static_cast<char>(cell.ch);
|
||||
}
|
||||
result += '\n';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: spawn echo hello and exit, scrollback contains hello", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.scrollback_lines = 100;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
// Enviar el comando y esperar a que el proceso salga.
|
||||
fn_term::send(term, "echo hello && exit 0\n");
|
||||
|
||||
// Esperar máximo 2 segundos a que el proceso termine.
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (!term.process_exited.load()
|
||||
&& std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
// Dar 100ms adicionales para que el reader thread procese el último output.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
std::string text = scrollback_text(term);
|
||||
fn_term::close(term);
|
||||
|
||||
INFO("scrollback: " << text);
|
||||
REQUIRE(text.find("hello") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: process exits cleanly", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.scrollback_lines = 50;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
fn_term::send(term, "exit 0\n");
|
||||
|
||||
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
|
||||
while (!term.process_exited.load()
|
||||
&& std::chrono::steady_clock::now() < deadline) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
REQUIRE(term.process_exited.load());
|
||||
REQUIRE(term.exit_code == 0);
|
||||
|
||||
fn_term::close(term);
|
||||
}
|
||||
|
||||
TEST_CASE("smoke: readonly panel ignores send", "[terminal_panel][smoke]") {
|
||||
fn_term::TerminalPanel term;
|
||||
term.shell = "/bin/bash";
|
||||
term.readonly = true;
|
||||
term.scrollback_lines = 50;
|
||||
|
||||
fn_term::open(term);
|
||||
REQUIRE(term.is_open());
|
||||
|
||||
// send() no debe hacer nada (readonly).
|
||||
fn_term::send(term, "echo should_not_appear\n");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
|
||||
std::string text = scrollback_text(term);
|
||||
fn_term::close(term);
|
||||
|
||||
// "should_not_appear" no debería estar en el scrollback porque send es no-op.
|
||||
INFO("scrollback: " << text);
|
||||
REQUIRE(text.find("should_not_appear") == std::string::npos);
|
||||
}
|
||||
|
||||
#endif // !_WIN32
|
||||
Reference in New Issue
Block a user