fwt_software/src/core/TestReport.cpp

171 lines
6.2 KiB
C++

#include "fgc/TestReport.h"
#include <cmath>
#include <cstdio>
#include <sstream>
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<long long>(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<long long>(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