171 lines
6.2 KiB
C++
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
|