// 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 #include #include using fn_viz::AgentRun; using fn_viz::TimelineFilter; using namespace fn_viz::timeline; static std::vector make_mock() { std::vector 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"); }