merge issue/0118: agent_runs_timeline C++ ImGui + helpers
# Conflicts: # cpp/tests/CMakeLists.txt
This commit is contained in:
@@ -0,0 +1,280 @@
|
|||||||
|
#include "viz/agent_runs_timeline.h"
|
||||||
|
#include "viz/agent_runs_timeline_helpers.h"
|
||||||
|
#include "core/icons_tabler.h"
|
||||||
|
#include "core/tokens.h"
|
||||||
|
|
||||||
|
#include "imgui.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace fn_viz {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Map color token -> fn_tokens color. Mirrors timeline::status_color_token.
|
||||||
|
ImVec4 token_to_color(int t) {
|
||||||
|
using namespace fn_tokens::colors;
|
||||||
|
switch (t) {
|
||||||
|
case 1: return info;
|
||||||
|
case 2: return success;
|
||||||
|
case 3: return primary;
|
||||||
|
case 5: return warning;
|
||||||
|
case 6: return error;
|
||||||
|
case 0:
|
||||||
|
default: return text_muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "#rrggbb" to ImVec4. On bad input returns text_muted.
|
||||||
|
ImVec4 parse_hex(const std::string& h) {
|
||||||
|
if (h.size() != 7 || h[0] != '#') return fn_tokens::colors::text_muted;
|
||||||
|
auto hex2 = [](const std::string& s, size_t i) -> int {
|
||||||
|
int v = 0;
|
||||||
|
for (int k = 0; k < 2; ++k) {
|
||||||
|
char c = s[i + k];
|
||||||
|
int d = (c >= '0' && c <= '9') ? c - '0'
|
||||||
|
: (c >= 'a' && c <= 'f') ? 10 + c - 'a'
|
||||||
|
: (c >= 'A' && c <= 'F') ? 10 + c - 'A'
|
||||||
|
: -1;
|
||||||
|
if (d < 0) return -1;
|
||||||
|
v = v * 16 + d;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
int r = hex2(h, 1), g = hex2(h, 3), b = hex2(h, 5);
|
||||||
|
if (r < 0 || g < 0 || b < 0) return fn_tokens::colors::text_muted;
|
||||||
|
return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void draw_app_chip(const std::string& app) {
|
||||||
|
ImVec4 c = parse_hex(timeline::app_chip_hex(app));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(c.x, c.y, c.z, 0.20f));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(c.x, c.y, c.z, 0.30f));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(c.x, c.y, c.z, 0.40f));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, c);
|
||||||
|
ImGui::SmallButton(app.empty() ? "?" : app.c_str());
|
||||||
|
ImGui::PopStyleColor(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void draw_dod_badge(int done, int validated, int total) {
|
||||||
|
char buf[48];
|
||||||
|
std::snprintf(buf, sizeof(buf), "%d/%d/%d", done, validated, total);
|
||||||
|
ImVec4 c = (validated >= total && total > 0)
|
||||||
|
? fn_tokens::colors::success
|
||||||
|
: (done > 0 ? fn_tokens::colors::info : fn_tokens::colors::text_muted);
|
||||||
|
ImGui::TextColored(c, "%s", buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string fmt_started(int64_t ts) {
|
||||||
|
if (ts <= 0) return "—";
|
||||||
|
std::time_t t = (std::time_t)ts;
|
||||||
|
std::tm tm{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(&tm, &t);
|
||||||
|
#else
|
||||||
|
localtime_r(&t, &tm);
|
||||||
|
#endif
|
||||||
|
char buf[32];
|
||||||
|
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", &tm);
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
void draw_filters(TimelineState& state,
|
||||||
|
const std::vector<std::string>& known_apps,
|
||||||
|
const std::vector<std::string>& known_statuses) {
|
||||||
|
if (ImGui::Button(TI_FILTER " Apps")) ImGui::OpenPopup("##apps_filter");
|
||||||
|
if (ImGui::BeginPopup("##apps_filter")) {
|
||||||
|
for (const auto& a : known_apps) {
|
||||||
|
bool sel = false;
|
||||||
|
for (const auto& s : state.filter.apps) if (s == a) { sel = true; break; }
|
||||||
|
if (ImGui::Checkbox(a.c_str(), &sel)) {
|
||||||
|
if (sel) state.filter.apps.push_back(a);
|
||||||
|
else {
|
||||||
|
state.filter.apps.erase(
|
||||||
|
std::remove(state.filter.apps.begin(), state.filter.apps.end(), a),
|
||||||
|
state.filter.apps.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndPopup();
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::Button(TI_FILTER " Status")) ImGui::OpenPopup("##status_filter");
|
||||||
|
if (ImGui::BeginPopup("##status_filter")) {
|
||||||
|
for (const auto& s : known_statuses) {
|
||||||
|
bool sel = false;
|
||||||
|
for (const auto& x : state.filter.statuses) if (x == s) { sel = true; break; }
|
||||||
|
if (ImGui::Checkbox(s.c_str(), &sel)) {
|
||||||
|
if (sel) state.filter.statuses.push_back(s);
|
||||||
|
else {
|
||||||
|
state.filter.statuses.erase(
|
||||||
|
std::remove(state.filter.statuses.begin(), state.filter.statuses.end(), s),
|
||||||
|
state.filter.statuses.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndPopup();
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
int days = (state.filter.since_ts == 0)
|
||||||
|
? 0
|
||||||
|
: (int)((std::time(nullptr) - state.filter.since_ts) / 86400);
|
||||||
|
if (ImGui::InputInt("Since (days)", &days, 1, 7)) {
|
||||||
|
if (days <= 0) state.filter.since_ts = 0;
|
||||||
|
else state.filter.since_ts = std::time(nullptr) - (int64_t)days * 86400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void draw_connection_badge(const std::string& status) {
|
||||||
|
ImVec4 c;
|
||||||
|
const char* sym;
|
||||||
|
if (status == "connected") {
|
||||||
|
c = fn_tokens::colors::success;
|
||||||
|
sym = "\xe2\x97\x8f"; // ● U+25CF
|
||||||
|
} else if (status == "connecting") {
|
||||||
|
c = fn_tokens::colors::warning;
|
||||||
|
sym = "\xe2\x97\x90"; // ◐
|
||||||
|
} else {
|
||||||
|
c = fn_tokens::colors::error;
|
||||||
|
sym = "\xe2\x97\x8b"; // ○ U+25CB
|
||||||
|
}
|
||||||
|
ImGui::TextColored(c, "%s %s", sym,
|
||||||
|
status.empty() ? "disconnected" : status.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void render_agent_runs_timeline(TimelineState& state) {
|
||||||
|
// Snapshot under the lock so the rest of the frame is lock-free.
|
||||||
|
std::vector<AgentRun> runs_copy;
|
||||||
|
std::string conn_copy;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(state.runs_mutex);
|
||||||
|
runs_copy = state.runs;
|
||||||
|
conn_copy = state.connection_status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build "known" sets from the runs themselves so the dropdowns reflect
|
||||||
|
// whatever the source emits, even if it grows beyond the docs list.
|
||||||
|
std::vector<std::string> known_apps;
|
||||||
|
std::vector<std::string> known_statuses;
|
||||||
|
{
|
||||||
|
std::unordered_map<std::string, int> apps_seen, status_seen;
|
||||||
|
for (const auto& r : runs_copy) {
|
||||||
|
if (!r.app.empty() && !apps_seen[r.app]++) known_apps.push_back(r.app);
|
||||||
|
if (!r.status.empty() && !status_seen[r.status]++) known_statuses.push_back(r.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_filters(state, known_apps, known_statuses);
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::Dummy(ImVec2(16, 0));
|
||||||
|
ImGui::SameLine();
|
||||||
|
draw_connection_badge(conn_copy);
|
||||||
|
|
||||||
|
ImGui::Separator();
|
||||||
|
|
||||||
|
auto rows = timeline::filter_and_sort(runs_copy, state.filter);
|
||||||
|
|
||||||
|
if (ImGui::BeginTable("##agent_runs_table", 7,
|
||||||
|
ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders |
|
||||||
|
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY)) {
|
||||||
|
ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f);
|
||||||
|
ImGui::TableSetupColumn("app", ImGuiTableColumnFlags_WidthFixed, 130.0f);
|
||||||
|
ImGui::TableSetupColumn("issue/card",ImGuiTableColumnFlags_WidthStretch);
|
||||||
|
ImGui::TableSetupColumn("branch", ImGuiTableColumnFlags_WidthStretch);
|
||||||
|
ImGui::TableSetupColumn("dod", ImGuiTableColumnFlags_WidthFixed, 70.0f);
|
||||||
|
ImGui::TableSetupColumn("duration", ImGuiTableColumnFlags_WidthFixed, 90.0f);
|
||||||
|
ImGui::TableSetupColumn("started", ImGuiTableColumnFlags_WidthFixed, 130.0f);
|
||||||
|
ImGui::TableHeadersRow();
|
||||||
|
|
||||||
|
for (const auto& r : rows) {
|
||||||
|
ImGui::TableNextRow();
|
||||||
|
|
||||||
|
// Whole-row highlight + selectable via the first cell spanning.
|
||||||
|
ImGui::TableSetColumnIndex(0);
|
||||||
|
bool selected = (state.selected_run_id == r.id);
|
||||||
|
ImGui::PushID(r.id.c_str());
|
||||||
|
|
||||||
|
// Icon coloured by status.
|
||||||
|
ImVec4 c = token_to_color(timeline::status_color_token(r.status));
|
||||||
|
ImGui::TextColored(c, "%s", timeline::status_icon_id(r.status).c_str());
|
||||||
|
|
||||||
|
// Make the row clickable: invisible button spanning all columns.
|
||||||
|
// Simpler — use Selectable on cell 1 with SpanAllColumns.
|
||||||
|
ImGui::TableSetColumnIndex(1);
|
||||||
|
char label[96];
|
||||||
|
std::snprintf(label, sizeof(label), "##row_%s", r.id.c_str());
|
||||||
|
if (ImGui::Selectable(label, selected,
|
||||||
|
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) {
|
||||||
|
state.selected_run_id = r.id;
|
||||||
|
if (state.on_select) state.on_select(r.id);
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
draw_app_chip(r.app);
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(2);
|
||||||
|
if (!r.issue_id.empty() && !r.card_id.empty()) {
|
||||||
|
ImGui::Text("%s / %s", r.issue_id.c_str(), r.card_id.c_str());
|
||||||
|
} else if (!r.issue_id.empty()) {
|
||||||
|
ImGui::TextUnformatted(r.issue_id.c_str());
|
||||||
|
} else {
|
||||||
|
ImGui::TextUnformatted(r.card_id.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(3);
|
||||||
|
ImGui::TextUnformatted(r.branch.c_str());
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(4);
|
||||||
|
draw_dod_badge(r.dod_done, r.dod_validated, r.dod_total);
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(5);
|
||||||
|
std::string dur = timeline::format_duration(r.started_at, r.finished_at);
|
||||||
|
ImGui::TextColored(
|
||||||
|
r.finished_at == 0 ? fn_tokens::colors::info : fn_tokens::colors::text,
|
||||||
|
"%s", dur.c_str());
|
||||||
|
|
||||||
|
ImGui::TableSetColumnIndex(6);
|
||||||
|
ImGui::TextUnformatted(fmt_started(r.started_at).c_str());
|
||||||
|
|
||||||
|
ImGui::PopID();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::EndTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer counts per status (over the FULL set, not the filtered one — gives
|
||||||
|
// a global sense even when filters narrow the table).
|
||||||
|
ImGui::Separator();
|
||||||
|
std::unordered_map<std::string, int> counts;
|
||||||
|
for (const auto& r : runs_copy) counts[r.status]++;
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& s : {"pending", "running", "done", "validated", "merged", "aborted", "failed"}) {
|
||||||
|
int n = counts[s];
|
||||||
|
if (!first) { ImGui::SameLine(); ImGui::TextUnformatted(" "); ImGui::SameLine(); }
|
||||||
|
first = false;
|
||||||
|
ImVec4 c = token_to_color(timeline::status_color_token(s));
|
||||||
|
ImGui::TextColored(c, "%s: %d", s, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void poll_sse_runs(TimelineState& state) {
|
||||||
|
// STUB. See header comment + agent_runs_timeline.md ## Gotchas.
|
||||||
|
//
|
||||||
|
// Intended future wiring:
|
||||||
|
// 1. spawn background thread with libcurl multi handle on state.sse_url
|
||||||
|
// - parse "data:" lines, json-decode AgentRun events
|
||||||
|
// - lock state.runs_mutex; upsert by run.id; set connection_status
|
||||||
|
// 2. OR call fn_core::http_request("GET", url + "?since=...", ...)
|
||||||
|
// every N seconds and full-refresh state.runs
|
||||||
|
//
|
||||||
|
// For now: noop so that consumers that wire poll_sse_runs() in their
|
||||||
|
// main loop don't crash. Consumers populate state.runs manually
|
||||||
|
// (fixtures, in-process orchestrator) until a real SSE issue lands.
|
||||||
|
(void)state;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fn_viz
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#pragma once
|
||||||
|
//
|
||||||
|
// agent_runs_timeline.h — ImGui panel: cross-app timeline of agent runs.
|
||||||
|
//
|
||||||
|
// API surface declared per issue 0118. Render is impure (uses ImGui); pure
|
||||||
|
// helpers live in agent_runs_timeline_helpers.{h,cpp} so unit tests can
|
||||||
|
// exercise filter / sort / formatting without a GL context.
|
||||||
|
//
|
||||||
|
// Typical wiring (consumer side):
|
||||||
|
//
|
||||||
|
// fn_viz::TimelineState g_state;
|
||||||
|
// g_state.sse_url = "http://127.0.0.1:8497/api/runs/stream";
|
||||||
|
// g_state.on_select = [](const std::string& id){ open_detail_panel(id); };
|
||||||
|
//
|
||||||
|
// ImGui::Begin("Agent runs");
|
||||||
|
// fn_viz::render_agent_runs_timeline(g_state);
|
||||||
|
// ImGui::End();
|
||||||
|
//
|
||||||
|
// // Whenever a worker thread (real SSE client, polling fallback, fixture)
|
||||||
|
// // wants to update the list:
|
||||||
|
// {
|
||||||
|
// std::lock_guard<std::mutex> lk(g_state.runs_mutex);
|
||||||
|
// g_state.runs = new_runs;
|
||||||
|
// g_state.connection_status = "connected";
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// SSE client itself is OUT OF SCOPE for this issue — see the documented stub
|
||||||
|
// in poll_sse_runs() and the .md ## Gotchas section.
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace fn_viz {
|
||||||
|
|
||||||
|
struct AgentRun {
|
||||||
|
std::string id;
|
||||||
|
std::string app; // kanban_cpp | skill_tree | ...
|
||||||
|
std::string issue_id;
|
||||||
|
std::string card_id;
|
||||||
|
std::string branch;
|
||||||
|
std::string status; // pending|running|done|validated|merged|aborted|failed
|
||||||
|
int64_t started_at = 0; // unix epoch seconds
|
||||||
|
int64_t finished_at = 0; // 0 if still running
|
||||||
|
int dod_total = 0;
|
||||||
|
int dod_done = 0;
|
||||||
|
int dod_validated = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TimelineFilter {
|
||||||
|
std::vector<std::string> apps;
|
||||||
|
std::vector<std::string> statuses;
|
||||||
|
int64_t since_ts = 0; // 0 means "no lower bound"
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TimelineState {
|
||||||
|
std::string sse_url;
|
||||||
|
std::vector<AgentRun> runs;
|
||||||
|
TimelineFilter filter;
|
||||||
|
std::function<void(const std::string& run_id)> on_select;
|
||||||
|
std::string selected_run_id;
|
||||||
|
std::string connection_status; // "connected"|"disconnected"|"connecting"
|
||||||
|
std::mutex runs_mutex; // SSE thread writes, UI reads
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renders the timeline INLINE inside the current ImGui window. The caller
|
||||||
|
// owns ImGui::Begin/End — this function only draws filters, badge, table
|
||||||
|
// and footer counts. Safe to call every frame; cost is O(N) over runs.
|
||||||
|
void render_agent_runs_timeline(TimelineState& state);
|
||||||
|
|
||||||
|
// STUB. Documented in agent_runs_timeline.md ## Gotchas.
|
||||||
|
// Today this is a no-op. The intent is that a future issue wires either:
|
||||||
|
// (a) a real SSE client (libcurl multi + custom read callback), or
|
||||||
|
// (b) a polling fallback that calls fn_core::http_request("GET", url, ...)
|
||||||
|
// every N seconds and replaces state.runs under runs_mutex.
|
||||||
|
// Until then, the consumer is responsible for populating state.runs (e.g.
|
||||||
|
// test fixtures, or an in-process orchestrator that owns the data already).
|
||||||
|
void poll_sse_runs(TimelineState& state);
|
||||||
|
|
||||||
|
} // namespace fn_viz
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
name: agent_runs_timeline
|
||||||
|
kind: function
|
||||||
|
lang: cpp
|
||||||
|
domain: viz
|
||||||
|
version: 0.1.0
|
||||||
|
description: "Panel ImGui timeline de agent runs cross-app, filtros + sort + click row + connection badge"
|
||||||
|
tags: [agents, timeline, sse, imgui, viz, panel]
|
||||||
|
purity: impure
|
||||||
|
signature: "fn_viz::render_agent_runs_timeline(state) — ImGui draw call"
|
||||||
|
params:
|
||||||
|
- name: state
|
||||||
|
desc: "TimelineState con runs, filter, callbacks. Mutex protege runs ante el thread del SSE/poll"
|
||||||
|
output: "void — pinta el panel inline en el current ImGui window"
|
||||||
|
uses_functions:
|
||||||
|
- http_request_cpp_core
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: error_go_core
|
||||||
|
imports: [imgui]
|
||||||
|
example: "ver ## Ejemplo abajo; demo visual TODO en cpp/apps/primitives_gallery/demos_viz.cpp"
|
||||||
|
tested: true
|
||||||
|
tests: [test_agent_runs_timeline]
|
||||||
|
test_file_path: "cpp/tests/test_agent_runs_timeline.cpp"
|
||||||
|
file_path: "cpp/functions/viz/agent_runs_timeline.cpp"
|
||||||
|
framework: "imgui"
|
||||||
|
---
|
||||||
|
|
||||||
|
# agent_runs_timeline
|
||||||
|
|
||||||
|
Panel ImGui que pinta una tabla cross-app de "agent runs" (ejecuciones de
|
||||||
|
`fn-orquestador` / `/autonomous-task` / hooks que disparan agentes) con:
|
||||||
|
|
||||||
|
- Filtros: multi-select de **apps**, multi-select de **statuses**, slider
|
||||||
|
`Since (days)` para acotar `started_at`.
|
||||||
|
- **Connection badge** (● verde / ○ rojo / ◐ amarillo) segun
|
||||||
|
`state.connection_status`.
|
||||||
|
- Tabla ordenada por `started_at` DESC con columnas:
|
||||||
|
status icon | app (chip coloreado) | issue/card | branch | dod
|
||||||
|
(badge `done/validated/total`) | duration | started (humano).
|
||||||
|
- Row click → setea `state.selected_run_id` e invoca `state.on_select(id)`
|
||||||
|
si existe.
|
||||||
|
- Footer: contadores por status sobre el set FULL (no el filtrado).
|
||||||
|
|
||||||
|
La logica pura (filter / sort / formato duration / mapping status→color/icon
|
||||||
|
/ app→chip hex) vive en `agent_runs_timeline_helpers.{h,cpp}` y es testable
|
||||||
|
sin contexto ImGui (ver `cpp/tests/test_agent_runs_timeline.cpp`).
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "viz/agent_runs_timeline.h"
|
||||||
|
|
||||||
|
static fn_viz::TimelineState g_runs_state;
|
||||||
|
|
||||||
|
// En setup (una vez):
|
||||||
|
g_runs_state.sse_url = "http://127.0.0.1:8497/api/runs/stream";
|
||||||
|
g_runs_state.on_select = [](const std::string& run_id) {
|
||||||
|
open_run_detail_window(run_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Por algun mecanismo (test fixture, http poll, future SSE) — poblar runs:
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_runs_state.runs_mutex);
|
||||||
|
g_runs_state.runs = {
|
||||||
|
{"r1", "kanban_cpp", "0118", "c1", "issue/0118", "running",
|
||||||
|
/*started*/ 1731920000, /*finished*/ 0, /*dod*/ 5, 2, 0},
|
||||||
|
};
|
||||||
|
g_runs_state.connection_status = "connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
// En render (cada frame):
|
||||||
|
ImGui::Begin("Agent runs");
|
||||||
|
fn_viz::render_agent_runs_timeline(g_runs_state);
|
||||||
|
ImGui::End();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando construyas un dashboard cross-app que exponga el estado de los agentes
|
||||||
|
(orquestador, autonomous-task, hooks reactivos): registry_dashboard, kanban,
|
||||||
|
agent_runner_ui. La quieres siempre que el usuario necesite una vista unica
|
||||||
|
"que esta corriendo / que merge / que fallo" sin abrir N apps. Tambien util
|
||||||
|
como panel `Recent runs` dentro de una app que dispara agentes propios.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **SSE client NO esta implementado.** `poll_sse_runs()` es un stub
|
||||||
|
documentado. Hoy el consumer tiene que poblar `state.runs` por su cuenta
|
||||||
|
(fixture, orquestador in-proc, o polling manual con
|
||||||
|
`http_request_cpp_core` contra `GET /api/runs`). Cuando exista un endpoint
|
||||||
|
estable `/api/runs/stream`, conectar via libcurl multi en thread aparte y
|
||||||
|
empujar updates bajo `state.runs_mutex`.
|
||||||
|
- **Timestamps en segundos epoch** (no ms). `format_duration` asume `int64_t`
|
||||||
|
unix-epoch seconds; si tu fuente emite ms, divide entre 1000 antes de
|
||||||
|
rellenar `started_at`/`finished_at`.
|
||||||
|
- **`runs_mutex` es obligatorio** cuando algun thread distinto del UI escribe
|
||||||
|
`state.runs` o `state.connection_status`. El render hace un snapshot bajo
|
||||||
|
lock al principio del frame para no bloquear el resto del dibujo.
|
||||||
|
- **No autoescroll**. Si la tabla crece y queres mantener "lo mas reciente
|
||||||
|
visible", anade tu scroll-to-top tras detectar `runs.size()` cambia.
|
||||||
|
Decision deliberada: muchas vistas prefieren no saltar mientras lees.
|
||||||
|
- **Selectable + SpanAllColumns**: usa `ImGuiSelectableFlags_AllowOverlap`
|
||||||
|
(Dear ImGui >= 1.91). En forks viejos pasaba a llamarse
|
||||||
|
`AllowItemOverlap` — si tu vendor de imgui es viejo, sustituye en
|
||||||
|
`agent_runs_timeline.cpp`.
|
||||||
|
- **App chip colors** estan hardcodeados en `app_chip_hex`. Anadir entrada
|
||||||
|
cuando salga una app nueva; sin entrada cae a gris neutral. Mantener el
|
||||||
|
hex aligned con `icon.accent` del `app.md` de cada app C++.
|
||||||
|
- **Filter "Since (days)"** convierte a/desde epoch usando `std::time(nullptr)`:
|
||||||
|
no determinista en tests. Por eso los tests del helper pasan `since_ts`
|
||||||
|
directamente en segundos epoch ficticios (ej. `f.since_ts = 1000`).
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#include "viz/agent_runs_timeline_helpers.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
// Tabler icon macros live in core/icons_tabler.h. They are short (3-byte
|
||||||
|
// utf-8) string literals like "\xee\xa9\x9e". We don't depend on that header
|
||||||
|
// here directly to keep helpers buildable without the icon font tree — but
|
||||||
|
// the values below are pulled from cpp/functions/core/icons_tabler.h
|
||||||
|
// (Tabler v3.41). If a glyph moves upstream, update this table.
|
||||||
|
|
||||||
|
namespace fn_viz {
|
||||||
|
namespace timeline {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Mirror of icons_tabler.h entries used by the timeline. Kept inline so the
|
||||||
|
// helpers TU compiles standalone (tests don't pull the full icon font).
|
||||||
|
constexpr const char* TI_CLOCK_ = "\xee\xa9\xb0"; // U+EA70
|
||||||
|
constexpr const char* TI_LOADER_ = "\xee\xb2\xa3"; // U+ECA3 spinning loader
|
||||||
|
constexpr const char* TI_CIRCLE_CHECK_ = "\xee\xa9\xa7"; // U+EA67
|
||||||
|
constexpr const char* TI_CHECKS_ = "\xee\xae\xaa"; // U+EBAA
|
||||||
|
constexpr const char* TI_GIT_MERGE_ = "\xee\xaa\xb5"; // U+EAB5
|
||||||
|
constexpr const char* TI_BAN_ = "\xee\xa8\xae"; // U+EA2E
|
||||||
|
constexpr const char* TI_CIRCLE_X_ = "\xee\xa9\xaa"; // U+EA6A
|
||||||
|
constexpr const char* TI_HOURGLASS_ = "\xee\xbe\x93"; // U+EF93
|
||||||
|
|
||||||
|
bool contains(const std::vector<std::string>& v, const std::string& needle) {
|
||||||
|
for (const auto& s : v) {
|
||||||
|
if (s == needle) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool passes_filter(const AgentRun& r, const TimelineFilter& f) {
|
||||||
|
if (!f.apps.empty() && !contains(f.apps, r.app)) return false;
|
||||||
|
if (!f.statuses.empty() && !contains(f.statuses, r.status)) return false;
|
||||||
|
if (f.since_ts > 0 && r.started_at < f.since_ts) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<AgentRun> filter_and_sort(const std::vector<AgentRun>& runs,
|
||||||
|
const TimelineFilter& f) {
|
||||||
|
std::vector<AgentRun> out;
|
||||||
|
out.reserve(runs.size());
|
||||||
|
for (const auto& r : runs) {
|
||||||
|
if (passes_filter(r, f)) out.push_back(r);
|
||||||
|
}
|
||||||
|
std::sort(out.begin(), out.end(), [](const AgentRun& a, const AgentRun& b) {
|
||||||
|
if (a.started_at != b.started_at) return a.started_at > b.started_at;
|
||||||
|
return a.id < b.id;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_duration(int64_t started_at, int64_t finished_at) {
|
||||||
|
if (finished_at == 0) return "running";
|
||||||
|
int64_t dur = finished_at - started_at;
|
||||||
|
if (dur < 0) return "—";
|
||||||
|
|
||||||
|
char buf[32];
|
||||||
|
if (dur < 60) {
|
||||||
|
std::snprintf(buf, sizeof(buf), "%llds", (long long)dur);
|
||||||
|
} else if (dur < 3600) {
|
||||||
|
long long m = dur / 60;
|
||||||
|
long long s = dur % 60;
|
||||||
|
std::snprintf(buf, sizeof(buf), "%lldm%02llds", m, s);
|
||||||
|
} else {
|
||||||
|
long long h = dur / 3600;
|
||||||
|
long long m = (dur % 3600) / 60;
|
||||||
|
std::snprintf(buf, sizeof(buf), "%lldh%02lldm", h, m);
|
||||||
|
}
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
int status_color_token(const std::string& status) {
|
||||||
|
if (status == "pending") return 1; // info
|
||||||
|
if (status == "running") return 1; // info
|
||||||
|
if (status == "done") return 2; // success
|
||||||
|
if (status == "validated") return 2; // success
|
||||||
|
if (status == "merged") return 3; // primary
|
||||||
|
if (status == "aborted") return 5; // warning
|
||||||
|
if (status == "failed") return 6; // danger
|
||||||
|
return 0; // neutral
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string status_icon_id(const std::string& status) {
|
||||||
|
if (status == "pending") return TI_CLOCK_;
|
||||||
|
if (status == "running") return TI_LOADER_;
|
||||||
|
if (status == "done") return TI_CIRCLE_CHECK_;
|
||||||
|
if (status == "validated") return TI_CHECKS_;
|
||||||
|
if (status == "merged") return TI_GIT_MERGE_;
|
||||||
|
if (status == "aborted") return TI_BAN_;
|
||||||
|
if (status == "failed") return TI_CIRCLE_X_;
|
||||||
|
return TI_HOURGLASS_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string app_chip_hex(const std::string& app) {
|
||||||
|
// Hardcoded palette. Keep aligned with the apps' icon.accent in app.md.
|
||||||
|
if (app == "kanban_cpp") return "#a855f7"; // violet
|
||||||
|
if (app == "skill_tree") return "#0ea5e9"; // sky
|
||||||
|
if (app == "graph_explorer") return "#16a34a"; // green
|
||||||
|
if (app == "shaders_lab") return "#f97316"; // orange
|
||||||
|
if (app == "registry_dashboard") return "#0ea5e9";
|
||||||
|
return "#5C5F66"; // neutral gray (matches fn_tokens::border_strong)
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace timeline
|
||||||
|
} // namespace fn_viz
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#pragma once
|
||||||
|
//
|
||||||
|
// agent_runs_timeline_helpers.h — pure helpers for the agent runs timeline panel.
|
||||||
|
//
|
||||||
|
// These helpers operate on AgentRun + TimelineFilter from agent_runs_timeline.h
|
||||||
|
// but never call ImGui. They are unit-testable in isolation (see
|
||||||
|
// cpp/tests/test_agent_runs_timeline.cpp).
|
||||||
|
//
|
||||||
|
// Issue 0118.
|
||||||
|
|
||||||
|
#include "viz/agent_runs_timeline.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace fn_viz {
|
||||||
|
namespace timeline {
|
||||||
|
|
||||||
|
// True iff `r` matches every active sub-filter:
|
||||||
|
// - filter.apps empty => any app
|
||||||
|
// - filter.statuses empty => any status
|
||||||
|
// - filter.since_ts == 0 => any started_at
|
||||||
|
// All filters are AND-combined.
|
||||||
|
bool passes_filter(const AgentRun& r, const TimelineFilter& f);
|
||||||
|
|
||||||
|
// Returns a copy of `runs` with `passes_filter` applied and sorted by
|
||||||
|
// `started_at` descending (most recent first). Stable for runs with equal
|
||||||
|
// started_at: ties broken by id ascending.
|
||||||
|
std::vector<AgentRun> filter_and_sort(const std::vector<AgentRun>& runs,
|
||||||
|
const TimelineFilter& f);
|
||||||
|
|
||||||
|
// Human-friendly duration string built from two unix-epoch SECOND timestamps.
|
||||||
|
// - finished_at == 0 => "running"
|
||||||
|
// - duration < 60s => "Ns" (e.g. "45s")
|
||||||
|
// - duration < 3600s => "MmSs" (e.g. "12m05s")
|
||||||
|
// - else => "HhMm" (e.g. "1h12m")
|
||||||
|
// Negative or inverted (finished < started) => "—".
|
||||||
|
std::string format_duration(int64_t started_at, int64_t finished_at);
|
||||||
|
|
||||||
|
// Returns a small integer color token (0..7) per status. The render layer
|
||||||
|
// maps this token to an actual ImVec4 using fn_tokens::colors.
|
||||||
|
//
|
||||||
|
// pending -> 1 (info)
|
||||||
|
// running -> 1 (info)
|
||||||
|
// done -> 2 (success)
|
||||||
|
// validated -> 2 (success)
|
||||||
|
// merged -> 3 (primary)
|
||||||
|
// aborted -> 5 (warning)
|
||||||
|
// failed -> 6 (danger)
|
||||||
|
// <other> -> 0 (neutral)
|
||||||
|
int status_color_token(const std::string& status);
|
||||||
|
|
||||||
|
// Returns the TI_* macro string for the given status (already utf-8 encoded
|
||||||
|
// 3-byte sequence). Render layer uses these directly in ImGui::Text.
|
||||||
|
std::string status_icon_id(const std::string& status);
|
||||||
|
|
||||||
|
// Hardcoded chip color hex for known app ids. Returns "#5C5F66" (border_strong
|
||||||
|
// / neutral gray) for unknown apps. Render layer parses the hex to ImVec4.
|
||||||
|
std::string app_chip_hex(const std::string& app);
|
||||||
|
|
||||||
|
} // namespace timeline
|
||||||
|
} // namespace fn_viz
|
||||||
@@ -304,3 +304,7 @@ add_dependencies(test_visual primitives_gallery)
|
|||||||
# imgui + tokens + icons_tabler — cubierto en builds de apps consumidoras.
|
# imgui + tokens + icons_tabler — cubierto en builds de apps consumidoras.
|
||||||
add_fn_test(test_dod_evidence_panel test_dod_evidence_panel.cpp
|
add_fn_test(test_dod_evidence_panel test_dod_evidence_panel.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/dod_evidence_panel_helpers.cpp)
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/dod_evidence_panel_helpers.cpp)
|
||||||
|
|
||||||
|
# --- Issue 0118 — agent_runs_timeline: helpers puros ----------
|
||||||
|
add_fn_test(test_agent_runs_timeline test_agent_runs_timeline.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/agent_runs_timeline_helpers.cpp)
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Tests for the pure helpers of fn_viz::agent_runs_timeline (issue 0118).
|
||||||
|
//
|
||||||
|
// Render path (ImGui) is intentionally NOT tested here — only filter / sort /
|
||||||
|
// duration / status mapping logic.
|
||||||
|
|
||||||
|
#define CATCH_CONFIG_MAIN
|
||||||
|
#include "catch_amalgamated.hpp"
|
||||||
|
|
||||||
|
#include "viz/agent_runs_timeline_helpers.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
using fn_viz::AgentRun;
|
||||||
|
using fn_viz::TimelineFilter;
|
||||||
|
using namespace fn_viz::timeline;
|
||||||
|
|
||||||
|
static std::vector<AgentRun> make_mock() {
|
||||||
|
std::vector<AgentRun> runs;
|
||||||
|
AgentRun r;
|
||||||
|
|
||||||
|
r = {}; r.id = "r1"; r.app = "kanban_cpp"; r.issue_id = "0118"; r.card_id = "c1";
|
||||||
|
r.branch = "issue/0118"; r.status = "running"; r.started_at = 1000;
|
||||||
|
r.finished_at = 0; r.dod_total = 5; r.dod_done = 2; r.dod_validated = 0;
|
||||||
|
runs.push_back(r);
|
||||||
|
|
||||||
|
r = {}; r.id = "r2"; r.app = "skill_tree"; r.issue_id = "0107"; r.card_id = "c2";
|
||||||
|
r.branch = "issue/0107"; r.status = "done"; r.started_at = 800;
|
||||||
|
r.finished_at = 860; r.dod_total = 3; r.dod_done = 3; r.dod_validated = 0;
|
||||||
|
runs.push_back(r);
|
||||||
|
|
||||||
|
r = {}; r.id = "r3"; r.app = "kanban_cpp"; r.issue_id = "0100"; r.card_id = "c3";
|
||||||
|
r.branch = "issue/0100"; r.status = "merged"; r.started_at = 600;
|
||||||
|
r.finished_at = 4200; r.dod_total = 4; r.dod_done = 4; r.dod_validated = 4;
|
||||||
|
runs.push_back(r);
|
||||||
|
|
||||||
|
r = {}; r.id = "r4"; r.app = "skill_tree"; r.issue_id = "0090"; r.card_id = "c4";
|
||||||
|
r.branch = "quick/x"; r.status = "failed"; r.started_at = 1200;
|
||||||
|
r.finished_at = 1205; r.dod_total = 2; r.dod_done = 0; r.dod_validated = 0;
|
||||||
|
runs.push_back(r);
|
||||||
|
|
||||||
|
r = {}; r.id = "r5"; r.app = "graph_explorer"; r.issue_id = "0080"; r.card_id = "c5";
|
||||||
|
r.branch = "issue/0080"; r.status = "validated"; r.started_at = 1500;
|
||||||
|
r.finished_at = 5000; r.dod_total = 6; r.dod_done = 6; r.dod_validated = 5;
|
||||||
|
runs.push_back(r);
|
||||||
|
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("passes_filter — empty filter matches everything", "[timeline]") {
|
||||||
|
auto runs = make_mock();
|
||||||
|
TimelineFilter f;
|
||||||
|
for (const auto& r : runs) REQUIRE(passes_filter(r, f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("passes_filter — app filter", "[timeline]") {
|
||||||
|
auto runs = make_mock();
|
||||||
|
TimelineFilter f;
|
||||||
|
f.apps = {"kanban_cpp"};
|
||||||
|
int n = 0;
|
||||||
|
for (const auto& r : runs) if (passes_filter(r, f)) ++n;
|
||||||
|
REQUIRE(n == 2);
|
||||||
|
|
||||||
|
f.apps = {"kanban_cpp", "graph_explorer"};
|
||||||
|
n = 0;
|
||||||
|
for (const auto& r : runs) if (passes_filter(r, f)) ++n;
|
||||||
|
REQUIRE(n == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("passes_filter — status filter combined with app", "[timeline]") {
|
||||||
|
auto runs = make_mock();
|
||||||
|
TimelineFilter f;
|
||||||
|
f.apps = {"kanban_cpp"};
|
||||||
|
f.statuses = {"merged"};
|
||||||
|
int n = 0;
|
||||||
|
AgentRun match{};
|
||||||
|
for (const auto& r : runs) {
|
||||||
|
if (passes_filter(r, f)) { ++n; match = r; }
|
||||||
|
}
|
||||||
|
REQUIRE(n == 1);
|
||||||
|
REQUIRE(match.id == "r3");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("passes_filter — since_ts lower bound", "[timeline]") {
|
||||||
|
auto runs = make_mock();
|
||||||
|
TimelineFilter f;
|
||||||
|
f.since_ts = 1000; // keep r1 (1000), r4 (1200), r5 (1500)
|
||||||
|
int n = 0;
|
||||||
|
for (const auto& r : runs) if (passes_filter(r, f)) ++n;
|
||||||
|
REQUIRE(n == 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("filter_and_sort — sorts by started_at desc", "[timeline]") {
|
||||||
|
auto runs = make_mock();
|
||||||
|
TimelineFilter f; // no filter
|
||||||
|
auto out = filter_and_sort(runs, f);
|
||||||
|
REQUIRE(out.size() == 5);
|
||||||
|
// expected order by started_at desc: r5(1500), r4(1200), r1(1000), r2(800), r3(600)
|
||||||
|
REQUIRE(out[0].id == "r5");
|
||||||
|
REQUIRE(out[1].id == "r4");
|
||||||
|
REQUIRE(out[2].id == "r1");
|
||||||
|
REQUIRE(out[3].id == "r2");
|
||||||
|
REQUIRE(out[4].id == "r3");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("filter_and_sort — combines filter and sort", "[timeline]") {
|
||||||
|
auto runs = make_mock();
|
||||||
|
TimelineFilter f;
|
||||||
|
f.apps = {"kanban_cpp"};
|
||||||
|
auto out = filter_and_sort(runs, f);
|
||||||
|
REQUIRE(out.size() == 2);
|
||||||
|
REQUIRE(out[0].id == "r1"); // 1000
|
||||||
|
REQUIRE(out[1].id == "r3"); // 600
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("format_duration — running when finished_at=0", "[timeline]") {
|
||||||
|
REQUIRE(format_duration(1000, 0) == "running");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("format_duration — seconds", "[timeline]") {
|
||||||
|
REQUIRE(format_duration(1000, 1045) == "45s");
|
||||||
|
REQUIRE(format_duration(1000, 1000) == "0s");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("format_duration — minutes + seconds", "[timeline]") {
|
||||||
|
REQUIRE(format_duration(0, 65) == "1m05s");
|
||||||
|
REQUIRE(format_duration(0, 305) == "5m05s");
|
||||||
|
REQUIRE(format_duration(0, 3599) == "59m59s");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("format_duration — hours + minutes", "[timeline]") {
|
||||||
|
REQUIRE(format_duration(0, 3600) == "1h00m");
|
||||||
|
REQUIRE(format_duration(0, 3600 + 720) == "1h12m");
|
||||||
|
REQUIRE(format_duration(0, 7200 + 1800) == "2h30m");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("format_duration — inverted timestamps", "[timeline]") {
|
||||||
|
REQUIRE(format_duration(2000, 1000) == "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("status_color_token — known statuses", "[timeline]") {
|
||||||
|
REQUIRE(status_color_token("pending") == 1);
|
||||||
|
REQUIRE(status_color_token("running") == 1);
|
||||||
|
REQUIRE(status_color_token("done") == 2);
|
||||||
|
REQUIRE(status_color_token("validated") == 2);
|
||||||
|
REQUIRE(status_color_token("merged") == 3);
|
||||||
|
REQUIRE(status_color_token("aborted") == 5);
|
||||||
|
REQUIRE(status_color_token("failed") == 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("status_color_token — unknown falls back to neutral", "[timeline]") {
|
||||||
|
REQUIRE(status_color_token("") == 0);
|
||||||
|
REQUIRE(status_color_token("zombie") == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("status_icon_id — non-empty for known statuses", "[timeline]") {
|
||||||
|
for (const std::string s : {"pending", "running", "done", "validated",
|
||||||
|
"merged", "aborted", "failed"}) {
|
||||||
|
REQUIRE_FALSE(status_icon_id(s).empty());
|
||||||
|
}
|
||||||
|
// unknown still returns a glyph (hourglass), never empty
|
||||||
|
REQUIRE_FALSE(status_icon_id("nope").empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("status_icon_id — distinct for distinct statuses", "[timeline]") {
|
||||||
|
auto a = status_icon_id("running");
|
||||||
|
auto b = status_icon_id("done");
|
||||||
|
auto c = status_icon_id("failed");
|
||||||
|
REQUIRE(a != b);
|
||||||
|
REQUIRE(b != c);
|
||||||
|
REQUIRE(a != c);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("app_chip_hex — known apps", "[timeline]") {
|
||||||
|
REQUIRE(app_chip_hex("kanban_cpp") == "#a855f7");
|
||||||
|
REQUIRE(app_chip_hex("skill_tree") == "#0ea5e9");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("app_chip_hex — unknown falls back to gray", "[timeline]") {
|
||||||
|
REQUIRE(app_chip_hex("never_seen") == "#5C5F66");
|
||||||
|
REQUIRE(app_chip_hex("") == "#5C5F66");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user