merge issue/0117: dod_evidence_panel C++ ImGui + helpers

This commit is contained in:
2026-05-18 18:32:35 +02:00
8 changed files with 640 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
#include "dod_evidence_panel.h"
#include "core/icons_tabler.h"
#include "core/tokens.h"
#include <imgui.h>
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
#ifdef _WIN32
# ifndef WIN32_LEAN_AND_MEAN
# define WIN32_LEAN_AND_MEAN
# endif
# include <windows.h>
# include <shellapi.h>
#endif
namespace fn_viz {
namespace {
// Mapea icon_id (helpers) -> macro TI_*.
const char* ti_for(const std::string& icon_id) {
if (icon_id == "circle-dot") return TI_CIRCLE_DOT;
if (icon_id == "circle-check") return TI_CIRCLE_CHECK;
if (icon_id == "circle-x") return TI_CIRCLE_X;
return TI_CIRCLE_DASHED;
}
// Color por token (0=neutral, 1=info, 2=success, 3=danger).
ImVec4 color_for_token(int tok) {
using namespace fn_tokens;
switch (tok) {
case 1: return colors::info;
case 2: return colors::success;
case 3: return colors::error;
default: return colors::text_muted;
}
}
// Abre una URL en el browser por defecto. Stub en plataformas no soportadas.
void open_url(const std::string& url) {
#ifdef _WIN32
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
#else
// xdg-open con argumento quoteado minimo (sin caracteres especiales no
// se gana mucho con quoting completo). Si el caller pasa una URL
// controlada por usuario, esto deberia validarse antes.
std::string cmd = "xdg-open '" + url + "' >/dev/null 2>&1 &";
int rc = std::system(cmd.c_str());
(void)rc;
#endif
}
// Lee primeras N lineas de un archivo log. Devuelve "" si no se puede abrir.
std::string read_first_lines(const std::string& path, int max_lines) {
std::ifstream f(path);
if (!f.is_open()) return "";
std::ostringstream out;
std::string line;
int n = 0;
while (n < max_lines && std::getline(f, line)) {
out << line << "\n";
++n;
}
return out.str();
}
// Carga entero el archivo. Truncado a 64KiB para popup.
std::string read_full_capped(const std::string& path, size_t cap = 64 * 1024) {
std::ifstream f(path, std::ios::binary);
if (!f.is_open()) return "";
std::string s;
f.seekg(0, std::ios::end);
std::streamsize sz = f.tellg();
if (sz < 0) return "";
size_t n = static_cast<size_t>(sz);
if (n > cap) n = cap;
s.resize(n);
f.seekg(0, std::ios::beg);
f.read(&s[0], static_cast<std::streamsize>(n));
return s;
}
void render_preview_screenshot(const DodEvidence& ev) {
// Sin GL context cacheado ni stb_image load aqui: stub textual. La carga
// real requiere mantener un map<string, GLuint> que el panel no
// posee. Las apps consumidoras pueden override esto sustituyendo la
// funcion (issue futura: callback render_preview).
ImGui::TextDisabled("[screenshot] %s", ev.payload_path.c_str());
}
void render_preview_log(const DodEvidence& ev, int row_idx) {
if (ev.payload_path.empty()) {
ImGui::TextDisabled("(no log path)");
return;
}
std::string head = read_first_lines(ev.payload_path, 5);
if (head.empty()) {
ImGui::TextDisabled("(log not readable)");
} else {
ImGui::TextWrapped("%s", head.c_str());
}
char btn_id[64];
std::snprintf(btn_id, sizeof(btn_id), "Show full##log_%d", row_idx);
if (ImGui::SmallButton(btn_id)) {
ImGui::OpenPopup(btn_id);
}
if (ImGui::BeginPopup(btn_id)) {
std::string full = read_full_capped(ev.payload_path);
ImGui::InputTextMultiline("##log_full", full.data(), full.size() + 1,
ImVec2(600, 400), ImGuiInputTextFlags_ReadOnly);
ImGui::EndPopup();
}
}
void render_preview_url(const DodEvidence& ev) {
ImGui::TextUnformatted(TI_EXTERNAL_LINK);
ImGui::SameLine();
if (ImGui::SmallButton(ev.payload_url.c_str())) {
open_url(ev.payload_url);
}
}
void render_preview_cmd(const DodEvidence& ev, const DodItem& item) {
using namespace fn_tokens;
const bool match = (ev.payload_text == item.expected);
ImVec4 ok = colors::success;
ImVec4 bad = colors::error;
if (ImGui::BeginTable("##cmd_diff", 2, ImGuiTableFlags_SizingStretchSame)) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextDisabled("expected");
ImGui::TextWrapped("%s", item.expected.c_str());
ImGui::TableSetColumnIndex(1);
ImGui::TextDisabled("actual");
ImGui::PushStyleColor(ImGuiCol_Text, match ? ok : bad);
ImGui::TextWrapped("%s", ev.payload_text.c_str());
ImGui::PopStyleColor();
ImGui::EndTable();
}
}
} // namespace
void render_dod_evidence_panel(DodPanelState& state) {
using namespace fn_tokens;
namespace P = ::fn_viz::dod_panel;
P::StatusCounts c = P::count_status(state);
// Header.
ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted);
ImGui::Text("run_id:");
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::TextUnformatted(state.run_id.empty() ? "(none)" : state.run_id.c_str());
ImGui::SameLine();
ImGui::TextDisabled(" | ");
ImGui::SameLine();
ImGui::Text("total %d ", c.total);
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, colors::info);
ImGui::Text("done %d ", c.done);
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, colors::success);
ImGui::Text("validated %d ", c.validated);
ImGui::PopStyleColor();
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, colors::error);
ImGui::Text("failed %d", c.failed);
ImGui::PopStyleColor();
if (c.missing_required > 0) {
ImGui::SameLine();
ImGui::TextDisabled(" | ");
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, colors::error);
ImGui::Text("required missing: %d", c.missing_required);
ImGui::PopStyleColor();
}
ImGui::Separator();
// Tabla principal.
const ImGuiTableFlags flags =
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp;
if (!ImGui::BeginTable("##dod_table", 6, flags)) return;
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f);
ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 120.0f);
ImGui::TableSetupColumn("kind", ImGuiTableColumnFlags_WidthFixed, 80.0f);
ImGui::TableSetupColumn("expected", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("evidence", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("actions", ImGuiTableColumnFlags_WidthFixed, 130.0f);
ImGui::TableHeadersRow();
int row_idx = 0;
for (const auto& item : state.items) {
ImGui::TableNextRow();
ImGui::PushID(row_idx);
// Col 0: status icon.
ImGui::TableSetColumnIndex(0);
const std::string icon_id = P::status_icon_id(item.status);
const int tok = P::status_color_token(item.status);
ImGui::PushStyleColor(ImGuiCol_Text, color_for_token(tok));
ImGui::TextUnformatted(ti_for(icon_id));
ImGui::PopStyleColor();
// Col 1: id (+ required badge si missing).
ImGui::TableSetColumnIndex(1);
ImGui::TextUnformatted(item.id.c_str());
const DodEvidence* ev = P::find_evidence(state, item.id);
const bool resolved = (item.status == "done" || item.status == "validated");
if (item.required && ev == nullptr && !resolved) {
ImGui::PushStyleColor(ImGuiCol_Text, colors::error);
ImGui::TextUnformatted("required, missing");
ImGui::PopStyleColor();
}
// Col 2: kind.
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(item.kind.c_str());
// Col 3: expected.
ImGui::TableSetColumnIndex(3);
ImGui::TextWrapped("%s", item.expected.c_str());
// Col 4: evidence preview.
ImGui::TableSetColumnIndex(4);
if (ev == nullptr) {
ImGui::TextDisabled("(no evidence)");
} else if (item.kind == "screenshot") {
render_preview_screenshot(*ev);
} else if (item.kind == "log") {
render_preview_log(*ev, row_idx);
} else if (item.kind == "url") {
render_preview_url(*ev);
} else if (item.kind == "cmd") {
render_preview_cmd(*ev, item);
} else {
ImGui::TextDisabled("(unknown kind: %s)", item.kind.c_str());
}
// Col 5: actions.
ImGui::TableSetColumnIndex(5);
if (ev != nullptr) {
ImGui::PushStyleColor(ImGuiCol_Text, colors::success);
const bool clicked_v = ImGui::SmallButton(TI_CHECK " Validate");
ImGui::PopStyleColor();
if (clicked_v && state.on_validate) state.on_validate(item.id);
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, colors::error);
const bool clicked_r = ImGui::SmallButton(TI_X " Reject");
ImGui::PopStyleColor();
if (clicked_r && state.on_reject) state.on_reject(item.id);
} else {
ImGui::TextDisabled("");
}
ImGui::PopID();
++row_idx;
}
ImGui::EndTable();
}
} // namespace fn_viz
+31
View File
@@ -0,0 +1,31 @@
#pragma once
// dod_evidence_panel — ImGui panel reutilizable para validar DoD evidence.
//
// Renderiza una tabla con items DoD (Definition of Done) + evidencias
// adjuntas. Cada fila: icono de status, id, kind, expected, preview de
// evidence (screenshot path / log preview / url clickable / cmd output con
// diff vs expected), y botones Validate/Reject que invocan callbacks.
//
// La logica pura (contar status, buscar evidence, mapear icon/color) vive en
// dod_evidence_panel_helpers.{h,cpp} y se testea sin ImGui.
//
// Uso:
// #include "viz/dod_evidence_panel.h"
// fn_viz::DodPanelState state;
// state.run_id = "run_abc123";
// state.items = load_dod_items(...);
// state.evidences = load_dod_evidences(...);
// state.on_validate = [](const std::string& id) { db.mark_validated(id); };
// state.on_reject = [](const std::string& id) { db.mark_failed(id); };
// fn_viz::render_dod_evidence_panel(state);
#include "dod_evidence_panel_helpers.h"
namespace fn_viz {
// Renderiza el panel inline en el current ImGui window.
// Debe llamarse dentro de un frame ImGui activo (entre NewFrame/Render).
void render_dod_evidence_panel(DodPanelState& state);
} // namespace fn_viz
+87
View File
@@ -0,0 +1,87 @@
---
name: dod_evidence_panel
kind: function
lang: cpp
domain: viz
version: "0.1.0"
purity: impure
signature: "void fn_viz::render_dod_evidence_panel(DodPanelState& state)"
description: "Panel ImGui que renderiza items DoD + evidencias (screenshot/log/url/cmd) con botones validate/reject por item y badge required-missing"
tags: [agents, dod, evidence, imgui, viz, panel, cpp-dashboard-viz]
uses_functions: [tokens_cpp_core]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [imgui]
tested: true
tests:
- "count_status: total = items.size"
- "count_status: unknown status counts as pending"
- "count_status: missing_required only when required+no evidence+unresolved"
- "find_evidence: returns matching evidence"
- "find_evidence: empty state returns nullptr"
- "status_icon_id: mapping per status"
- "status_color_token: mapping per status"
test_file_path: "cpp/tests/test_dod_evidence_panel.cpp"
file_path: "cpp/functions/viz/dod_evidence_panel.cpp"
framework: imgui
params:
- name: state
desc: "DodPanelState con items (DodItem[]), evidences (DodEvidence[]), run_id y callbacks on_validate/on_reject (std::function<void(string item_id)>)"
output: "void — renderiza header (run_id + counts) + tabla 6-col (status icon | id | kind | expected | evidence preview | actions) inline en current ImGui window"
---
# dod_evidence_panel
Panel ImGui reutilizable para validar la **Definition of Done** de un agente o run. Compone:
- Header con `run_id` y counts por status (`total / done / validated / failed`) + badge rojo si hay items required sin evidence.
- Tabla con icono de status (TI_CIRCLE_DASHED/DOT/CHECK/X), id, kind, expected, preview de la evidence segun kind, y botones Validate/Reject.
Logica pura (count, find, status mappers) en `dod_evidence_panel_helpers.{h,cpp}` — testeada sin ImGui. El `.cpp` del panel hace render real y se compila SOLO cuando una app lo lista en su CMakeLists.
## Ejemplo
```cpp
#include "viz/dod_evidence_panel.h"
fn_viz::DodPanelState state;
state.run_id = "run_abc123";
fn_viz::DodItem it1;
it1.id = "screenshot_home";
it1.kind = "screenshot";
it1.expected = "Landing page con KPI cards visibles";
it1.required = true;
it1.status = "done";
state.items.push_back(it1);
fn_viz::DodEvidence ev1;
ev1.item_id = "screenshot_home";
ev1.kind = "screenshot";
ev1.payload_path = "/tmp/screenshots/home.png";
state.evidences.push_back(ev1);
state.on_validate = [&](const std::string& id) { db.mark_validated(id); };
state.on_reject = [&](const std::string& id) { db.mark_failed(id); };
// En el render callback de fn::run_app:
ImGui::Begin("DoD evidence");
fn_viz::render_dod_evidence_panel(state);
ImGui::End();
```
## Cuando usarla
Usala cuando una app necesita mostrar al humano los items pendientes de validacion de un run de agente (DoD) con sus evidencias para aprobar/rechazar a mano. Encaja en dashboards de orquestador (`fn-orquestador`), kanban de issues con sub-checks visuales, o paneles "human-in-the-loop" donde se requiere firma manual antes de marcar `done`.
## Gotchas
- **Screenshot preview es stub textual**. La carga real PNG requiere GL context y un cache `map<string, GLuint>` que el panel no posee. Si necesitas thumbnails reales, mantenlos en la app consumidora y pasalos a un callback custom (issue futura).
- **URL open**: en Linux usa `system("xdg-open '<url>' &")`, en Windows `ShellExecuteA`. NO valida la URL antes — si la URL viene de input no confiable, sanitizar antes de poner en `payload_url`.
- **Log preview** lee primeras 5 lineas con `std::ifstream` por frame. Para logs muy grandes o cuando el panel se redibuja a 60fps, considera cachear externamente.
- **Cmd diff** compara strings exactamente (`==`). No hace trimming ni normalizacion de whitespace — el caller decide.
- **Callbacks pueden mutar `state`**: las apps suelen mover el item a `validated`/`failed` desde el `on_validate`/`on_reject`. El panel re-renderiza con el nuevo status en el siguiente frame.
- **`required` + `missing_required`**: solo se cuenta `missing_required` cuando el item es required, NO tiene evidence, y el status NO es `done`/`validated`. Un item required marcado `done` sin evidence no aparece como missing (la app ya lo cerro a mano).
- Llamar dentro de un frame ImGui activo (entre `NewFrame`/`Render`). Fuera, `ImGui::BeginTable` aborta.
@@ -0,0 +1,47 @@
#include "dod_evidence_panel_helpers.h"
namespace fn_viz {
namespace dod_panel {
StatusCounts count_status(const DodPanelState& state) {
StatusCounts c;
c.total = static_cast<int>(state.items.size());
for (const auto& it : state.items) {
if (it.status == "pending") ++c.pending;
else if (it.status == "done") ++c.done;
else if (it.status == "validated") ++c.validated;
else if (it.status == "failed") ++c.failed;
else ++c.pending; // unknown treated as pending
if (it.required) {
const DodEvidence* ev = find_evidence(state, it.id);
const bool resolved = (it.status == "done" || it.status == "validated");
if (ev == nullptr && !resolved) ++c.missing_required;
}
}
return c;
}
const DodEvidence* find_evidence(const DodPanelState& state, const std::string& item_id) {
for (const auto& ev : state.evidences) {
if (ev.item_id == item_id) return &ev;
}
return nullptr;
}
std::string status_icon_id(const std::string& status) {
if (status == "done") return "circle-dot";
if (status == "validated") return "circle-check";
if (status == "failed") return "circle-x";
return "circle-dashed"; // pending / unknown
}
int status_color_token(const std::string& status) {
if (status == "done") return 1;
if (status == "validated") return 2;
if (status == "failed") return 3;
return 0;
}
} // namespace dod_panel
} // namespace fn_viz
@@ -0,0 +1,75 @@
#pragma once
// dod_evidence_panel_helpers — logica pura, sin ImGui.
// Tests (cpp/tests/test_dod_evidence_panel.cpp) linkan SOLO este archivo
// para validar conteos, lookup y mapeo de status -> icon/color.
#include <string>
#include <vector>
#include <functional>
#include <cstdint>
namespace fn_viz {
struct DodItem {
std::string id;
std::string kind; // screenshot|log|url|cmd
std::string expected;
bool required = true;
std::string status; // pending|done|validated|failed
};
struct DodEvidence {
std::string item_id;
std::string kind;
std::string payload_path; // screenshot/log
std::string payload_url; // url
std::string payload_text; // cmd output
int64_t attached_at = 0;
bool validated = false;
std::string validated_by;
};
struct DodPanelState {
std::vector<DodItem> items;
std::vector<DodEvidence> evidences;
std::string run_id;
std::function<void(const std::string& item_id)> on_validate;
std::function<void(const std::string& item_id)> on_reject;
};
namespace dod_panel {
struct StatusCounts {
int total = 0;
int pending = 0;
int done = 0;
int validated = 0;
int failed = 0;
int missing_required = 0; // required + no evidence + status != done/validated
};
// Cuenta items por status. Si un item required NO tiene evidence y su status
// no es done/validated, suma missing_required.
StatusCounts count_status(const DodPanelState& state);
// Busca la primera evidence cuyo item_id == item_id. nullptr si no existe.
const DodEvidence* find_evidence(const DodPanelState& state, const std::string& item_id);
// Status -> icon key (uso interno; en render se mapea a TI_*).
// pending -> "circle-dashed"
// done -> "circle-dot"
// validated -> "circle-check"
// failed -> "circle-x"
// otro -> "circle-dashed"
std::string status_icon_id(const std::string& status);
// Status -> color token.
// 0 = neutral (pending / desconocido)
// 1 = info (done)
// 2 = success (validated)
// 3 = danger (failed)
int status_color_token(const std::string& status);
} // namespace dod_panel
} // namespace fn_viz
+7
View File
@@ -297,3 +297,10 @@ target_compile_definitions(test_visual PRIVATE
"FN_TEST_REPO_ROOT=\"${CMAKE_SOURCE_DIR}/..\"")
# Asegura que primitives_gallery existe antes de correr el test.
add_dependencies(test_visual primitives_gallery)
# --- Issue 0117 — dod_evidence_panel helpers: logica pura para panel DoD ----
# Solo helpers (count_status, find_evidence, status_icon_id, status_color_token).
# El render con ImGui (dod_evidence_panel.cpp) NO se compila aqui: requiere
# imgui + tokens + icons_tabler — cubierto en builds de apps consumidoras.
add_fn_test(test_dod_evidence_panel test_dod_evidence_panel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/dod_evidence_panel_helpers.cpp)
+119
View File
@@ -0,0 +1,119 @@
// Tests para la logica pura de dod_evidence_panel (issue 0117).
//
// El render real con ImGui no se testea aqui (requiere context). Cubrimos:
// - count_status: tally por status + missing_required.
// - find_evidence: lookup por item_id.
// - status_icon_id: mapeo a icon key.
// - status_color_token: mapeo a token semantico.
#define CATCH_CONFIG_MAIN
#include "catch_amalgamated.hpp"
#include "viz/dod_evidence_panel_helpers.h"
using namespace fn_viz;
using namespace fn_viz::dod_panel;
namespace {
DodItem mk_item(std::string id, std::string kind, std::string status, bool required = true) {
DodItem it;
it.id = std::move(id);
it.kind = std::move(kind);
it.status = std::move(status);
it.required = required;
it.expected = "expected_" + it.id;
return it;
}
DodEvidence mk_evidence(std::string item_id, std::string kind = "screenshot") {
DodEvidence ev;
ev.item_id = std::move(item_id);
ev.kind = std::move(kind);
ev.payload_path = "/tmp/" + ev.item_id + ".png";
return ev;
}
} // namespace
TEST_CASE("count_status: total = items.size", "[dod_panel]") {
DodPanelState s;
s.items.push_back(mk_item("a", "screenshot", "pending"));
s.items.push_back(mk_item("b", "log", "done"));
s.items.push_back(mk_item("c", "url", "validated"));
s.items.push_back(mk_item("d", "cmd", "failed"));
auto c = count_status(s);
REQUIRE(c.total == 4);
REQUIRE(c.pending == 1);
REQUIRE(c.done == 1);
REQUIRE(c.validated == 1);
REQUIRE(c.failed == 1);
}
TEST_CASE("count_status: unknown status counts as pending", "[dod_panel]") {
DodPanelState s;
s.items.push_back(mk_item("a", "log", "weird"));
s.items.push_back(mk_item("b", "log", "pending"));
auto c = count_status(s);
REQUIRE(c.total == 2);
REQUIRE(c.pending == 2);
}
TEST_CASE("count_status: missing_required only when required+no evidence+unresolved", "[dod_panel]") {
DodPanelState s;
// required + no evidence + pending -> missing
s.items.push_back(mk_item("missing_one", "screenshot", "pending", /*required*/ true));
// required + no evidence + done -> resolved, no missing
s.items.push_back(mk_item("done_no_ev", "log", "done", true));
// required + evidence + pending -> no missing
s.items.push_back(mk_item("has_ev", "url", "pending", true));
s.evidences.push_back(mk_evidence("has_ev", "url"));
// optional + no evidence + pending -> no missing
s.items.push_back(mk_item("optional", "log", "pending", /*required*/ false));
auto c = count_status(s);
REQUIRE(c.missing_required == 1);
}
TEST_CASE("find_evidence: returns matching evidence", "[dod_panel]") {
DodPanelState s;
s.items.push_back(mk_item("a", "log", "done"));
s.evidences.push_back(mk_evidence("a", "log"));
s.evidences.push_back(mk_evidence("b", "screenshot"));
const DodEvidence* a = find_evidence(s, "a");
REQUIRE(a != nullptr);
REQUIRE(a->item_id == "a");
REQUIRE(a->kind == "log");
const DodEvidence* b = find_evidence(s, "b");
REQUIRE(b != nullptr);
REQUIRE(b->kind == "screenshot");
REQUIRE(find_evidence(s, "missing") == nullptr);
}
TEST_CASE("find_evidence: empty state returns nullptr", "[dod_panel]") {
DodPanelState s;
REQUIRE(find_evidence(s, "anything") == nullptr);
}
TEST_CASE("status_icon_id: mapping per status", "[dod_panel]") {
REQUIRE(status_icon_id("pending") == "circle-dashed");
REQUIRE(status_icon_id("done") == "circle-dot");
REQUIRE(status_icon_id("validated") == "circle-check");
REQUIRE(status_icon_id("failed") == "circle-x");
REQUIRE(status_icon_id("") == "circle-dashed");
REQUIRE(status_icon_id("garbage") == "circle-dashed");
}
TEST_CASE("status_color_token: mapping per status", "[dod_panel]") {
REQUIRE(status_color_token("pending") == 0);
REQUIRE(status_color_token("done") == 1);
REQUIRE(status_color_token("validated") == 2);
REQUIRE(status_color_token("failed") == 3);
REQUIRE(status_color_token("") == 0);
REQUIRE(status_color_token("garbage") == 0);
}