// 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 #include #include #include using namespace fn_md; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- static std::string str(const YamlValue& v) { if (auto* s = std::get_if(&v)) return *s; return ""; } static std::vector lst(const YamlValue& v) { if (auto* l = std::get_if>(&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 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 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); }