chore: snapshot WIP previo + flow 0008 + 7 sub-issues (0112-0119)
Snapshot de WIP acumulado de sesiones previas antes de merge wave 1 del flow 0008 (kanban_cpp + agent_runner_api + DoD schema). Incluye: - dev/flows/0008-kanban-cpp-and-agent-workflows.md - dev/issues/0112-0119*.md (7 sub-issues) - WIP previo en cmd/fn/doctor.go, registry/*, modules/, cpp/, etc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -256,6 +256,16 @@ add_fn_test(test_tql_to_sql test_tql_to_sql.cpp
|
||||
add_fn_test(test_llm_anthropic test_llm_anthropic.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/llm_anthropic.cpp)
|
||||
|
||||
# --- Issue 0109a — parse_md_frontmatter: pure YAML-subset frontmatter parser --
|
||||
add_fn_test(test_parse_md_frontmatter test_parse_md_frontmatter.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/parse_md_frontmatter.cpp)
|
||||
target_compile_definitions(test_parse_md_frontmatter PRIVATE
|
||||
FN_TEST_REPO_ROOT="${CMAKE_CURRENT_SOURCE_DIR}/../..")
|
||||
|
||||
# --- Issue 0109b — compute_ring_layout: geometria pura para skill_tree -------
|
||||
add_fn_test(test_compute_ring_layout test_compute_ring_layout.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/compute_ring_layout.cpp)
|
||||
|
||||
# --- Visual golden-image diff (issue 0048) ---------------------------------
|
||||
# El binario primitives_gallery se compila con --capture; el test compara los
|
||||
# PNGs generados con los goldens en cpp/tests/golden/. Si no hay goldens o el
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// Tests para fn_ring::compute_ring_layout (cpp/functions/core/compute_ring_layout).
|
||||
// Pure: sin ImGui context, sin I/O.
|
||||
// Issue 0109b — skill_tree ring layout.
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/compute_ring_layout.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace fn_ring;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static const LayoutConfig kDefault; // default 5 rings, 18 sectors
|
||||
|
||||
static LayoutOutput find_by_id(const std::vector<LayoutOutput>& out,
|
||||
const std::string& id) {
|
||||
for (auto& o : out) {
|
||||
if (o.id == id) return o;
|
||||
}
|
||||
// not found — return sentinel with ring=-99
|
||||
LayoutOutput bad;
|
||||
bad.id = id;
|
||||
bad.ring = -99;
|
||||
return bad;
|
||||
}
|
||||
|
||||
static float dist2(float x, float y) {
|
||||
return std::sqrt(x * x + y * y);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: empty input → empty output
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("empty_input_empty_output") {
|
||||
auto out = compute_ring_layout({}, kDefault);
|
||||
REQUIRE(out.empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: un solo nodo cae en centro de su banda radial + centro de su sector
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("single_node_centered_in_bin") {
|
||||
std::vector<LayoutInput> nodes = {{"n1", "completado", "core", 1.0f}};
|
||||
|
||||
// "completado" → ring 0, ring_radii default: [0, 150)
|
||||
// r_inner efectivo = 30 (ring0 avoidance), r_outer = 150
|
||||
// r_lo = 30 + 14 = 44, r_hi = 150 - 14 = 136, center = (44+136)/2 = 90
|
||||
|
||||
auto out = compute_ring_layout(nodes, kDefault);
|
||||
REQUIRE(out.size() == 1);
|
||||
|
||||
auto o = out[0];
|
||||
REQUIRE(o.ring == 0);
|
||||
REQUIRE(o.sector == 17); // "core" no esta en DomainOrder vacia → sector n_sectors-1 = 17
|
||||
|
||||
float r = dist2(o.x, o.y);
|
||||
// Centro de banda: [44, 136] -> 90.0
|
||||
REQUIRE(std::abs(r - 90.0f) < 0.01f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: dos nodos en mismo bin → radios distintos, uniformemente distribuidos
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("two_nodes_same_bin_radial_distribution") {
|
||||
std::vector<LayoutInput> nodes = {
|
||||
{"a", "completado", "alpha", 0.5f},
|
||||
{"b", "completado", "alpha", 0.5f},
|
||||
};
|
||||
|
||||
DomainOrder order = {"alpha"};
|
||||
auto out = compute_ring_layout(nodes, kDefault, {}, order);
|
||||
REQUIRE(out.size() == 2);
|
||||
|
||||
auto oa = find_by_id(out, "a");
|
||||
auto ob = find_by_id(out, "b");
|
||||
|
||||
REQUIRE(oa.ring == 0);
|
||||
REQUIRE(ob.ring == 0);
|
||||
REQUIRE(oa.sector == 0);
|
||||
REQUIRE(ob.sector == 0);
|
||||
|
||||
float ra = dist2(oa.x, oa.y);
|
||||
float rb = dist2(ob.x, ob.y);
|
||||
|
||||
// Radios deben ser distintos
|
||||
REQUIRE(std::abs(ra - rb) > 0.01f);
|
||||
|
||||
// Ambos deben estar en la banda [44, 136]
|
||||
float r_lo = 44.0f; // 30 + 14
|
||||
float r_hi = 136.0f; // 150 - 14
|
||||
REQUIRE(ra >= r_lo - 0.01f);
|
||||
REQUIRE(ra <= r_hi + 0.01f);
|
||||
REQUIRE(rb >= r_lo - 0.01f);
|
||||
REQUIRE(rb <= r_hi + 0.01f);
|
||||
|
||||
// N=2: r_0 = 44 + 0.5*(136-44)/2 = 44+23 = 67; r_1 = 44+1.5*46 = 113
|
||||
// (aprox, sin jitter porque bin tiene 2 nodos y capacidad radial = band/18 ~ 5)
|
||||
// No verificamos valores exactos porque el jitter angular puede activarse,
|
||||
// pero la diferencia de radios debe ser aprox (r_hi-r_lo)/N = 46
|
||||
float expected_step = (r_hi - r_lo) / 2.0f; // 46
|
||||
REQUIRE(std::abs(std::abs(ra - rb) - expected_step) < 0.5f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 4: default status map mapea correctamente
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("default_status_map") {
|
||||
std::vector<LayoutInput> nodes = {
|
||||
{"c1", "completado", "d", 0.0f},
|
||||
{"c2", "completed", "d", 0.0f},
|
||||
{"c3", "in-progress", "d", 0.0f},
|
||||
{"c4", "pendiente", "d", 0.0f},
|
||||
{"c5", "deferred", "d", 0.0f},
|
||||
{"c6", "locked", "d", 0.0f},
|
||||
{"c7", "unlocked", "d", 0.0f},
|
||||
{"c8", "bloqueado", "d", 0.0f},
|
||||
};
|
||||
|
||||
auto out = compute_ring_layout(nodes, kDefault);
|
||||
|
||||
REQUIRE(find_by_id(out, "c1").ring == 0);
|
||||
REQUIRE(find_by_id(out, "c2").ring == 0);
|
||||
REQUIRE(find_by_id(out, "c3").ring == 1);
|
||||
REQUIRE(find_by_id(out, "c4").ring == 3);
|
||||
REQUIRE(find_by_id(out, "c5").ring == 4);
|
||||
REQUIRE(find_by_id(out, "c6").ring == 3);
|
||||
REQUIRE(find_by_id(out, "c7").ring == 2);
|
||||
REQUIRE(find_by_id(out, "c8").ring == 4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5: status no mapeado → ring == -1, x/y == 0
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("unmapped_status_returns_ring_minus_one") {
|
||||
std::vector<LayoutInput> nodes = {{"x1", "unknown_status", "core", 0.5f}};
|
||||
|
||||
auto out = compute_ring_layout(nodes, kDefault);
|
||||
REQUIRE(out.size() == 1);
|
||||
|
||||
auto o = out[0];
|
||||
REQUIRE(o.ring == -1);
|
||||
REQUIRE(o.x == 0.0f);
|
||||
REQUIRE(o.y == 0.0f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6: domain fuera del orden → sector n_sectors-1
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("domain_not_in_order_falls_back_to_last_sector") {
|
||||
DomainOrder order = {"alpha", "beta", "gamma"};
|
||||
std::vector<LayoutInput> nodes = {{"n1", "completado", "unknown_domain", 0.5f}};
|
||||
|
||||
LayoutConfig cfg = kDefault;
|
||||
cfg.n_sectors = 5;
|
||||
|
||||
auto out = compute_ring_layout(nodes, cfg, {}, order);
|
||||
REQUIRE(out.size() == 1);
|
||||
REQUIRE(out[0].sector == 4); // n_sectors-1 = 4
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 7: determinismo — dos llamadas identicas producen el mismo output
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("deterministic_repeated_call") {
|
||||
std::vector<LayoutInput> nodes = {
|
||||
{"0001", "completado", "core", 1.0f},
|
||||
{"0002", "pendiente", "infra", 0.5f},
|
||||
{"0003", "in-progress", "finance", 0.8f},
|
||||
{"0004", "deferred", "core", 0.1f},
|
||||
{"0005", "completado", "infra", 0.9f},
|
||||
};
|
||||
|
||||
auto out1 = compute_ring_layout(nodes, kDefault);
|
||||
auto out2 = compute_ring_layout(nodes, kDefault);
|
||||
|
||||
REQUIRE(out1.size() == out2.size());
|
||||
for (size_t i = 0; i < out1.size(); ++i) {
|
||||
REQUIRE(out1[i].id == out2[i].id);
|
||||
REQUIRE(out1[i].x == out2[i].x);
|
||||
REQUIRE(out1[i].y == out2[i].y);
|
||||
REQUIRE(out1[i].ring == out2[i].ring);
|
||||
REQUIRE(out1[i].sector == out2[i].sector);
|
||||
REQUIRE(out1[i].rank_in_bin == out2[i].rank_in_bin);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 8: ring 0 con radio interno 0 → nodos colocados con r >= 30
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("ring_zero_avoids_origin") {
|
||||
// ring_radii[0] == 0 por defecto
|
||||
std::vector<LayoutInput> nodes = {
|
||||
{"n1", "completado", "d1", 1.0f},
|
||||
{"n2", "completed", "d2", 0.9f},
|
||||
};
|
||||
|
||||
auto out = compute_ring_layout(nodes, kDefault);
|
||||
for (auto& o : out) {
|
||||
if (o.ring == 0) {
|
||||
float r = dist2(o.x, o.y);
|
||||
REQUIRE(r >= 30.0f - 0.01f); // kRing0InnerMin = 30
|
||||
REQUIRE(r > 0.5f); // definitivamente no en el origen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 9: sector wrap-around — sector 17 (ultimo) con 18 sectores
|
||||
// theta ≈ 2*PI*(17+0.5)/18 = 2*PI*17.5/18 ≈ 6.109 rad
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("sector_wrap_around_last_sector") {
|
||||
// Forzamos que el nodo caiga en sector 17 pasando domain_order sin "misc"
|
||||
std::vector<LayoutInput> nodes = {{"n1", "completado", "misc_domain", 1.0f}};
|
||||
|
||||
LayoutConfig cfg;
|
||||
cfg.n_sectors = 18;
|
||||
cfg.ring_radii = {0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f};
|
||||
cfg.start_angle = 0.0f;
|
||||
cfg.bin_padding = 14.0f;
|
||||
|
||||
DomainOrder order; // vacia → "misc_domain" cae en sector n_sectors-1 = 17
|
||||
|
||||
auto out = compute_ring_layout(nodes, cfg, {}, order);
|
||||
REQUIRE(out.size() == 1);
|
||||
REQUIRE(out[0].sector == 17);
|
||||
|
||||
// theta = start_angle + (17 + 0.5) * (2*PI / 18) = 17.5 / 18 * 2*PI
|
||||
const float pi = 3.14159265358979323846f;
|
||||
const float theta = (17.5f / 18.0f) * 2.0f * pi; // ≈ 6.1087 rad
|
||||
|
||||
// Radio del nodo: ring 0, r_inner=30, r_outer=150, padding=14 → [44,136] → 90
|
||||
const float r = 90.0f;
|
||||
|
||||
float expected_x = r * std::cos(theta);
|
||||
float expected_y = r * std::sin(theta);
|
||||
|
||||
REQUIRE(std::abs(out[0].x - expected_x) < 0.05f);
|
||||
REQUIRE(std::abs(out[0].y - expected_y) < 0.05f);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 10: golden snapshot — 30 nodos, mezcla de status/domain
|
||||
// Verifica primer y ultimo nodo con tolerancia 0.001
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("golden_snapshot_30_nodes") {
|
||||
// Input fijo: 30 nodos con mix de status/domain/recency
|
||||
const std::vector<LayoutInput> nodes = {
|
||||
{"0001", "completado", "core", 1.00f},
|
||||
{"0002", "completado", "infra", 0.95f},
|
||||
{"0003", "in-progress", "finance", 0.90f},
|
||||
{"0004", "pendiente", "core", 0.85f},
|
||||
{"0005", "deferred", "datascience", 0.80f},
|
||||
{"0006", "completado", "core", 0.75f},
|
||||
{"0007", "locked", "infra", 0.70f},
|
||||
{"0008", "unlocked", "finance", 0.65f},
|
||||
{"0009", "pendiente", "core", 0.60f},
|
||||
{"0010", "in-progress", "datascience", 0.55f},
|
||||
{"0011", "completado", "infra", 0.50f},
|
||||
{"0012", "bloqueado", "core", 0.45f},
|
||||
{"0013", "deferred", "finance", 0.40f},
|
||||
{"0014", "pendiente", "infra", 0.35f},
|
||||
{"0015", "completado", "datascience", 0.30f},
|
||||
{"0016", "unknown_status", "core", 0.25f}, // descartado
|
||||
{"0017", "in-progress", "infra", 0.20f},
|
||||
{"0018", "pendiente_unlocked", "finance", 0.15f},
|
||||
{"0019", "completed", "datascience", 0.10f},
|
||||
{"0020", "locked", "core", 0.05f},
|
||||
{"0021", "completado", "core", 1.00f},
|
||||
{"0022", "completado", "infra", 0.92f},
|
||||
{"0023", "pendiente", "finance", 0.88f},
|
||||
{"0024", "in-progress", "core", 0.84f},
|
||||
{"0025", "deferred", "infra", 0.80f},
|
||||
{"0026", "unlocked", "datascience", 0.76f},
|
||||
{"0027", "completado", "finance", 0.72f},
|
||||
{"0028", "locked", "datascience", 0.68f},
|
||||
{"0029", "bloqueado", "core", 0.64f},
|
||||
{"0030", "pendiente", "finance", 0.60f},
|
||||
};
|
||||
|
||||
DomainOrder order = {"core", "infra", "finance", "datascience"};
|
||||
LayoutConfig cfg;
|
||||
cfg.n_sectors = 18;
|
||||
cfg.ring_radii = {0.0f, 150.0f, 280.0f, 450.0f, 650.0f, 850.0f};
|
||||
cfg.start_angle = 0.0f;
|
||||
cfg.bin_padding = 14.0f;
|
||||
cfg.center_x = 0.0f;
|
||||
cfg.center_y = 0.0f;
|
||||
|
||||
auto out = compute_ring_layout(nodes, cfg, {}, order);
|
||||
REQUIRE(out.size() == 30);
|
||||
|
||||
// Nodo "0016" tiene status desconocido → debe ser descartado
|
||||
auto o16 = find_by_id(out, "0016");
|
||||
REQUIRE(o16.ring == -1);
|
||||
REQUIRE(o16.x == 0.0f);
|
||||
REQUIRE(o16.y == 0.0f);
|
||||
|
||||
// Verificar "0001": completado→ring0, core→sector0
|
||||
auto o1 = find_by_id(out, "0001");
|
||||
REQUIRE(o1.ring == 0);
|
||||
REQUIRE(o1.sector == 0);
|
||||
// theta = 0 + (0 + 0.5) * (2*PI / 18) = PI/18 ≈ 0.17453 rad
|
||||
// En el bin (ring=0, sector=0) hay "0001", "0006", "0021", "0024"
|
||||
// ordenados por (recency desc): recency 1.00, 0.75, 1.00 (0021), 0.84
|
||||
// -> "0001"(1.0), "0021"(1.0, id mayor), "0024"(0.84), "0006"(0.75)
|
||||
// rank "0001" = 0 si su id < "0021": "0001" < "0021" → rank 0
|
||||
REQUIRE(o1.rank_in_bin == 0);
|
||||
// El radio y angulo exactos dependen del jitter deterministico.
|
||||
// Verificamos que esta dentro de la banda del ring 0: [44, 136]
|
||||
float r1 = dist2(o1.x, o1.y);
|
||||
REQUIRE(r1 >= 44.0f - 0.01f);
|
||||
REQUIRE(r1 <= 136.0f + 0.01f);
|
||||
|
||||
// Verificar "0030": pendiente→ring3, finance→sector2
|
||||
auto o30 = find_by_id(out, "0030");
|
||||
REQUIRE(o30.ring == 3);
|
||||
REQUIRE(o30.sector == 2);
|
||||
// ring 3: [450, 650), r_lo = 450+14 = 464, r_hi = 650-14 = 636
|
||||
float r30 = dist2(o30.x, o30.y);
|
||||
REQUIRE(r30 >= 464.0f - 0.01f);
|
||||
REQUIRE(r30 <= 636.0f + 0.01f);
|
||||
|
||||
// El output es deterministico: dos llamadas dan resultado identico
|
||||
auto out2 = compute_ring_layout(nodes, cfg, {}, order);
|
||||
for (size_t i = 0; i < out.size(); ++i) {
|
||||
REQUIRE(out[i].x == out2[i].x);
|
||||
REQUIRE(out[i].y == out2[i].y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Tests for parse_md_frontmatter (cpp/functions/core/parse_md_frontmatter).
|
||||
// Pure function — no ImGui context, no I/O (except the golden-run test which
|
||||
// reads dev/issues/ using FN_TEST_REPO_ROOT defined at compile time).
|
||||
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch_amalgamated.hpp"
|
||||
|
||||
#include "core/parse_md_frontmatter.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace fn_md;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::string str(const YamlValue& v) {
|
||||
if (auto* s = std::get_if<std::string>(&v)) return *s;
|
||||
return "";
|
||||
}
|
||||
|
||||
static std::vector<std::string> lst(const YamlValue& v) {
|
||||
if (auto* l = std::get_if<std::vector<std::string>>(&v)) return *l;
|
||||
return {};
|
||||
}
|
||||
|
||||
static bool has(const Frontmatter& fm, const std::string& key) {
|
||||
return fm.fields.count(key) > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 1 — No frontmatter
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_no_frontmatter") {
|
||||
const std::string content = "# Just a markdown file\n\nSome body text.\n";
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE_FALSE(fm.has_frontmatter);
|
||||
REQUIRE(fm.body == content);
|
||||
REQUIRE(fm.fields.empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2 — Simple key:value
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_simple_key_value") {
|
||||
const std::string content =
|
||||
"---\n"
|
||||
"title: Hello\n"
|
||||
"status: pending\n"
|
||||
"---\n"
|
||||
"Body here.\n";
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
REQUIRE(str(fm.fields.at("title")) == "Hello");
|
||||
REQUIRE(str(fm.fields.at("status")) == "pending");
|
||||
REQUIRE(fm.body == "Body here.\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 3 — Quoted string (double and single quotes)
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_quoted_strings") {
|
||||
const std::string content =
|
||||
"---\n"
|
||||
"description: \"foo bar\"\n"
|
||||
"note: 'baz qux'\n"
|
||||
"id: \"0109\"\n"
|
||||
"---\n";
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
REQUIRE(str(fm.fields.at("description")) == "foo bar");
|
||||
REQUIRE(str(fm.fields.at("note")) == "baz qux");
|
||||
REQUIRE(str(fm.fields.at("id")) == "0109");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 4 — Inline list
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_inline_list") {
|
||||
const std::string content =
|
||||
"---\n"
|
||||
"tags: [meta, cpp, imgui]\n"
|
||||
"---\n";
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
const auto v = lst(fm.fields.at("tags"));
|
||||
REQUIRE(v.size() == 3);
|
||||
REQUIRE(v[0] == "meta");
|
||||
REQUIRE(v[1] == "cpp");
|
||||
REQUIRE(v[2] == "imgui");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 5 — Multiline list
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_multiline_list") {
|
||||
const std::string content =
|
||||
"---\n"
|
||||
"domain:\n"
|
||||
" - meta\n"
|
||||
" - cpp-stack\n"
|
||||
"---\n";
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
const auto v = lst(fm.fields.at("domain"));
|
||||
REQUIRE(v.size() == 2);
|
||||
REQUIRE(v[0] == "meta");
|
||||
REQUIRE(v[1] == "cpp-stack");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 6 — Body after frontmatter
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_body_after_frontmatter") {
|
||||
const std::string content =
|
||||
"---\n"
|
||||
"key: v\n"
|
||||
"---\n"
|
||||
"body text\n";
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
REQUIRE(str(fm.fields.at("key")) == "v");
|
||||
REQUIRE(fm.body == "body text\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 7 — Empty list
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_empty_inline_list") {
|
||||
const std::string content =
|
||||
"---\n"
|
||||
"blocks: []\n"
|
||||
"depends: []\n"
|
||||
"---\n";
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
REQUIRE(lst(fm.fields.at("blocks")).empty());
|
||||
REQUIRE(lst(fm.fields.at("depends")).empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 8 — Trailing comments stripped
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_strips_trailing_comment") {
|
||||
const std::string content =
|
||||
"---\n"
|
||||
"key: value # this is a comment\n"
|
||||
"other: hello # another\n"
|
||||
"---\n";
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
REQUIRE(str(fm.fields.at("key")) == "value");
|
||||
REQUIRE(str(fm.fields.at("other")) == "hello");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 9 — Real issue sample (issue 0109)
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("parses_real_issue_0109") {
|
||||
const std::string content =
|
||||
"---\n"
|
||||
"id: \"0109\"\n"
|
||||
"title: \"App skill_tree: mapa interactivo de issues+flows en anillos concentricos por estado (roadmap)\"\n"
|
||||
"status: in-progress\n"
|
||||
"type: epic\n"
|
||||
"domain:\n"
|
||||
" - meta\n"
|
||||
" - cpp-stack\n"
|
||||
"scope: cross-stack\n"
|
||||
"priority: media\n"
|
||||
"depends: []\n"
|
||||
"blocks: []\n"
|
||||
"related:\n"
|
||||
" - \"0069\"\n"
|
||||
" - \"0085\"\n"
|
||||
"created: 2026-05-17\n"
|
||||
"updated: 2026-05-17\n"
|
||||
"tags:\n"
|
||||
" - skill-tree\n"
|
||||
" - roadmap\n"
|
||||
" - meta\n"
|
||||
" - cpp\n"
|
||||
" - imgui\n"
|
||||
" - gamification\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Body\n";
|
||||
|
||||
auto fm = parse_md_frontmatter(content);
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
REQUIRE_FALSE(fm.fields.empty());
|
||||
|
||||
REQUIRE(str(fm.fields.at("id")) == "0109");
|
||||
REQUIRE(str(fm.fields.at("status")) == "in-progress");
|
||||
|
||||
const auto domain = lst(fm.fields.at("domain"));
|
||||
REQUIRE(domain.size() == 2);
|
||||
REQUIRE(domain[0] == "meta");
|
||||
REQUIRE(domain[1] == "cpp-stack");
|
||||
|
||||
const auto tags = lst(fm.fields.at("tags"));
|
||||
REQUIRE(tags.size() == 6);
|
||||
REQUIRE(tags[0] == "skill-tree");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 10 — Golden run: parse ALL dev/issues/*.md and dev/flows/*.md
|
||||
//
|
||||
// Requires the compile-time definition FN_TEST_REPO_ROOT pointing to the
|
||||
// root of the fn_registry repo (set by CMakeLists.txt via
|
||||
// target_compile_definitions(... PRIVATE FN_TEST_REPO_ROOT="...") ).
|
||||
//
|
||||
// The test reads every .md file it finds, parses it, and verifies:
|
||||
// 1. No crash / no exception.
|
||||
// 2. If the file starts with `---`, has_frontmatter == true.
|
||||
// 3. fields is not empty.
|
||||
// 4. Cumulative parse_errors == 0.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#ifndef FN_TEST_REPO_ROOT
|
||||
#define FN_TEST_REPO_ROOT ""
|
||||
#endif
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
TEST_CASE("parses_real_issues_golden") {
|
||||
const std::string repo_root = FN_TEST_REPO_ROOT;
|
||||
if (repo_root.empty()) {
|
||||
WARN("FN_TEST_REPO_ROOT not set — skipping golden run");
|
||||
return;
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> md_files;
|
||||
for (const auto& dir : {"dev/issues", "dev/flows"}) {
|
||||
const auto p = std::filesystem::path(repo_root) / dir;
|
||||
if (!std::filesystem::is_directory(p)) continue;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(p)) {
|
||||
if (entry.path().extension() == ".md")
|
||||
md_files.push_back(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
REQUIRE_FALSE(md_files.empty());
|
||||
|
||||
int parse_errors = 0;
|
||||
for (const auto& path : md_files) {
|
||||
std::ifstream f(path);
|
||||
REQUIRE(f.is_open());
|
||||
std::ostringstream ss;
|
||||
ss << f.rdbuf();
|
||||
const std::string content = ss.str();
|
||||
|
||||
Frontmatter fm;
|
||||
// Must not throw
|
||||
REQUIRE_NOTHROW(fm = parse_md_frontmatter(content));
|
||||
|
||||
if (content.rfind("---", 0) == 0) {
|
||||
// File starts with `---`: expect frontmatter was found
|
||||
INFO("File: " << path.string());
|
||||
REQUIRE(fm.has_frontmatter);
|
||||
REQUIRE_FALSE(fm.fields.empty());
|
||||
}
|
||||
}
|
||||
|
||||
REQUIRE(parse_errors == 0);
|
||||
}
|
||||
Reference in New Issue
Block a user