#include "fgc/TestReport.h" #include #include #include namespace fgc { namespace { // Format a double compactly: integers print without a decimal point, otherwise // up to 4 significant decimals with trailing zeros trimmed. std::string num(double v) { if (std::isfinite(v) && v == std::floor(v) && std::fabs(v) < 1e15) { char b[32]; std::snprintf(b, sizeof(b), "%lld", static_cast(v)); return b; } char b[32]; std::snprintf(b, sizeof(b), "%.4f", v); std::string s(b); auto dot = s.find('.'); if (dot != std::string::npos) { size_t last = s.find_last_not_of('0'); if (last == dot) last--; // drop the dot too s.erase(last + 1); } return s; } } // namespace const TestMetric* TestReport::find(const std::string& section_id, const std::string& metric) const { for (const auto& s : sections) { if (s.id != section_id) continue; for (const auto& m : s.metrics) if (m.name == metric) return &m; } return nullptr; } std::string formatTestReport(const TestReport& r) { std::ostringstream o; o << "# Fire Gimbal Control - hardware test report\n"; o << "ts_ms = " << r.ts_ms << "\n"; o << "profile = " << r.profile << "\n"; o << "host = " << r.host << "\n"; o << "fw = " << r.fw << "\n"; o << "result = " << (r.all_pass ? "PASS" : "FAIL") << "\n"; for (const auto& s : r.sections) { o << "\n[" << s.id << "] " << (s.ran ? (s.pass ? "PASS" : "FAIL") : "SKIP") << " " << num(s.duration_ms) << " ms"; if (!s.title.empty()) o << " # " << s.title; o << "\n"; for (const auto& m : s.metrics) { o << " " << m.name << " = " << num(m.value); if (!m.unit.empty()) o << " " << m.unit; o << " " << (m.pass ? "PASS" : "FAIL"); if (m.has_baseline) o << " base=" << num(m.baseline); if (m.has_prev) o << " prev=" << num(m.prev); if (m.has_baseline || m.has_prev) o << " drift=" << num(m.drift_pct) << "%"; if (m.drift_flag) o << " DRIFT"; o << "\n"; } for (const auto& n : s.notes) o << " ; " << n << "\n"; } return o.str(); } TestReport parseTestReport(const std::string& text) { TestReport r; std::istringstream in(text); std::string line; TestSection* cur = nullptr; while (std::getline(in, line)) { // strip trailing CR if (!line.empty() && line.back() == '\r') line.pop_back(); // find first non-space size_t i = line.find_first_not_of(" \t"); if (i == std::string::npos) continue; if (line[i] == '#' || line[i] == ';') continue; // comment / note if (line[i] == '[') { // section header size_t end = line.find(']', i); if (end == std::string::npos) continue; r.sections.push_back(TestSection{}); cur = &r.sections.back(); cur->id = line.substr(i + 1, end - i - 1); cur->ran = line.find("SKIP", end) == std::string::npos; cur->pass = line.find("FAIL", end) == std::string::npos && cur->ran; continue; } // key = value [unit] [VERDICT] ... std::istringstream ls(line.substr(i)); std::string name, eq, valtok; if (!(ls >> name >> eq >> valtok) || eq != "=") continue; double value = 0.0; try { value = std::stod(valtok); } catch (...) { continue; } if (!cur) { // header block (ts_ms / profile / host / fw / result) if (name == "ts_ms") r.ts_ms = static_cast(value); continue; } std::string rest; ls >> rest; // unit or verdict (we only need name+value for comparison) TestMetric m; m.name = name; m.value = value; if (rest != "PASS" && rest != "FAIL") m.unit = rest; cur->metrics.push_back(m); } // recover header fields that aren't numeric (profile/host/fw/result) { std::istringstream in2(text); std::string l; while (std::getline(in2, l)) { auto eq = l.find('='); if (eq == std::string::npos) continue; auto key = l.substr(0, eq); auto val = l.substr(eq + 1); auto trim = [](std::string s) { size_t a = s.find_first_not_of(" \t\r"); size_t b = s.find_last_not_of(" \t\r"); return a == std::string::npos ? std::string() : s.substr(a, b - a + 1); }; key = trim(key); val = trim(val); if (key == "profile") r.profile = val; else if (key == "host") r.host = val; else if (key == "fw") r.fw = val; else if (key == "result") r.all_pass = (val == "PASS"); } } return r; } void applyComparison(TestReport& cur, const TestReport* baseline, const TestReport* prev, double drift_warn_pct, bool drift_gates_pass) { for (auto& s : cur.sections) { for (auto& m : s.metrics) { const TestMetric* b = baseline ? baseline->find(s.id, m.name) : nullptr; const TestMetric* p = prev ? prev->find(s.id, m.name) : nullptr; if (b) { m.has_baseline = true; m.baseline = b->value; } if (p) { m.has_prev = true; m.prev = p->value; } const double ref = b ? m.baseline : (p ? m.prev : 0.0); if (b || p) { if (std::fabs(ref) > 1e-9) m.drift_pct = 100.0 * (m.value - ref) / std::fabs(ref); else m.drift_pct = (std::fabs(m.value) < 1e-9) ? 0.0 : 100.0; // Only an adverse move counts as drift: worse = larger when // lower_is_better, smaller otherwise. const double adverse = m.lower_is_better ? m.drift_pct : -m.drift_pct; if (drift_warn_pct > 0.0 && adverse > drift_warn_pct) { m.drift_flag = true; if (drift_gates_pass) m.pass = false; } } if (!m.pass) s.pass = false; } if (s.ran && !s.pass) cur.all_pass = false; } } } // namespace fgc