//! JSON report writer. One file per workload run, under `report_dir`
//! (`./runs/` by default). Schema v2: run metadata, workload verdict,
//! and the perf score. No sensor fields — aldermon owns sensors; this
//! report only records what the workload itself produced.
//!
//! Incremental flush: the whole file is rewritten on each call. A v2
//! report is small (no per-tick samples), so we only flush once at
//! `finish()` — partial runs leave a JSON file with verdict=null +
//! score=null, which is parseable.
//!
//! Zero-dep: hand-rolled JSON. The schema is flat enough that serde_json
//! would be one dep for little gain; revisit if the schema nests deeper.
#![allow(dead_code)] // consumed by run.rs + main.rs — not yet wired
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::workload::{Score, Verdict};
/// The data captured for one workload run. The run loop owns this,
/// feeds it the verdict + score when the workload completes, and calls
/// `finish()` to flush.
pub struct Report {
pub schema_version: u32,
pub workload: String,
pub params: String,
/// Unix timestamp (seconds) when the run started.
pub started: u64,
/// ISA class — "integer", "sse", "avx2", "mixed", or "other".
/// Inferred from the workload name (see `isa_class`).
pub isa_class: &'static str,
/// Set by `finish()`. None until the workload completes.
pub verdict: Option<Verdict>,
/// Set by `finish()`. None for stability-only workloads (stress-ng),
/// or until the workload completes.
pub score: Option<Score>,
/// Path the report will be written to (set at construction).
path: PathBuf,
}
impl Report {
/// Construct a new report for a workload. Creates `report_dir` if
/// missing. The filename is `<unix-seconds>-<workload>.json`.
pub fn new(report_dir: &Path, workload: &str, params: &str) -> io::Result<Self> {
fs::create_dir_all(report_dir)?;
let started = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Sanitize the workload name for the filename (replace chars that
// are hostile to filesystems).
let safe = workload.replace(['/', ' ', ':'], "_");
let path = report_dir.join(format!("{started}-{safe}.json"));
Ok(Report {
schema_version: 2,
workload: workload.to_string(),
params: params.to_string(),
started,
isa_class: isa_class(workload),
verdict: None,
score: None,
path,
})
}
/// Mark the run complete + flush the final state with the verdict +
/// score. Called once, after the workload has exited and its score
/// has been parsed. The score is stored verbatim — `Score::None` is
/// kept (so `human_summary` can distinguish "stability-only workload"
/// from "not finished"), and `json_score` renders it as `null`.
pub fn finish(&mut self, verdict: Verdict, score: Score) -> io::Result<()> {
self.verdict = Some(verdict);
self.score = Some(score);
self.flush()
}
/// Where the report is being written. The run loop may want this for
/// the TUI / stdout summary.
pub fn path(&self) -> &Path {
&self.path
}
fn flush(&self) -> io::Result<()> {
let mut f = File::create(&self.path)?;
f.write_all(self.to_json().as_bytes())?;
f.sync_all().ok(); // best-effort; don't fail the run on sync error
Ok(())
}
/// Serialize to a JSON string. Used by `flush()` and by the
/// `--report <path>` printer in main.rs.
pub fn to_json(&self) -> String {
let mut out = String::with_capacity(1024);
out.push('{');
out.push_str(&format!("\"schema_version\":{},", self.schema_version));
out.push_str(&format!("\"workload\":{},", json_str(&self.workload)));
out.push_str(&format!("\"params\":{},", json_str(&self.params)));
out.push_str(&format!("\"started\":{},", self.started));
out.push_str(&format!("\"isa_class\":{},", json_str(self.isa_class)));
out.push_str(&format!("\"verdict\":{},", json_verdict(&self.verdict)));
out.push_str(&format!("\"score\":{}", json_score(&self.score)));
out.push('}');
out
}
}
/// JSON-encode a string. Escapes the minimal set (quote, backslash,
/// control chars). Assumes UTF-8 input (Rust strings are).
fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
/// JSON-encode the verdict. None (run not finished) → `null`; otherwise
/// an object with `kind` + optional `detail`.
fn json_verdict(v: &Option<Verdict>) -> String {
match v {
None => "null".to_string(),
Some(Verdict::Clean) => "{\"kind\":\"clean\"}".to_string(),
Some(Verdict::Failed(msg)) => {
format!("{{\"kind\":\"failed\",\"detail\":{}}}", json_str(msg))
}
Some(Verdict::Stopped) => "{\"kind\":\"stopped\"}".to_string(),
Some(Verdict::Error(msg)) => format!("{{\"kind\":\"error\",\"detail\":{}}}", json_str(msg)),
}
}
/// JSON-encode a Score. None (no score / not finished) → `null`.
/// Otherwise an object with `kind` + the per-kind fields.
fn json_score(s: &Option<Score>) -> String {
match s {
None => "null".to_string(),
Some(Score::None) => "null".to_string(),
Some(Score::SevenZip {
rating_mips,
r_u_mips,
usage_pct,
compress_mips,
decompress_mips,
}) => format!(
"{{\"kind\":\"7zip\",\"rating_mips\":{rating_mips},\"r_u_mips\":{r_u_mips},\
\"usage_pct\":{usage_pct},\"compress_mips\":{compress_mips},\
\"decompress_mips\":{decompress_mips}}}"
),
Some(Score::YCruncher { tests }) => {
let mut out = String::from("{\"kind\":\"y-cruncher\",\"tests\":[");
for (i, t) in tests.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"tag\":{},\"passed\":{},\"bits_per_sec\":{}}}",
json_str(&t.tag),
t.passed,
t.bits_per_sec
));
}
out.push_str("]}");
out
}
Some(Score::C2c { pairs }) => {
let mut out = String::from("{\"kind\":\"c2c\",\"pairs\":[");
for (i, p) in pairs.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"a\":{},\"b\":{},\"ns\":{}}}",
p.cpu_a, p.cpu_b, p.ns_per_hop
));
}
out.push_str("]}");
out
}
Some(Score::Dram { levels }) => {
let mut out = String::from("{\"kind\":\"dram\",\"levels\":[");
for (i, l) in levels.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&format!(
"{{\"kb\":{},\"ns\":{}}}",
l.size_kb, l.ns_per_access
));
}
out.push_str("]}");
out
}
}
}
/// Infer the ISA class from the workload name. Used for the report's
/// `isa_class` field so sweeps can be grouped/diffed by rail.
pub fn isa_class(workload: &str) -> &'static str {
match workload {
"stress-ng-cpu" => "integer",
"y-cruncher-sse" => "sse",
"y-cruncher-avx2" => "avx2",
"7zip-bench" => "mixed",
"c2c-latency" | "dram-latency" => "other",
_ => "other",
}
}
/// Human-readable one-line summary of a finished report. Printed to
/// stdout after the TUI exits, one per workload in the sweep.
pub fn human_summary(r: &Report) -> String {
let verdict = match &r.verdict {
Some(Verdict::Clean) => "CLEAN".to_string(),
Some(Verdict::Failed(msg)) => format!("FAILED ({msg})"),
Some(Verdict::Stopped) => "STOPPED".to_string(),
Some(Verdict::Error(msg)) => format!("ERROR ({msg})"),
None => "(not finished)".to_string(),
};
let score = match &r.score {
None => "(no score)".to_string(),
Some(Score::None) => "(stability only)".to_string(),
Some(Score::SevenZip { rating_mips, .. }) => format!("rating={rating_mips} MIPS"),
Some(Score::YCruncher { tests }) => {
if tests.len() == 1 {
let t = &tests[0];
let gbps = t.bits_per_sec / 1e9;
format!("{} {:.2} Gb/s", t.tag, gbps)
} else {
format!("{} tests", tests.len())
}
}
Some(Score::C2c { pairs }) => format!("{} pairs", pairs.len()),
Some(Score::Dram { levels }) => levels
.iter()
.map(|l| format!("{}K {:.0}ns", l.size_kb, l.ns_per_access))
.collect::<Vec<_>>()
.join(" "),
};
format!(
"{:<18} {:<8} {:<24} verdict={}",
r.workload, r.isa_class, score, verdict
)
}
// ---------------------------------------------------------------------------
// Report reader (`--report <path>`): parse back a JSON file written above
// and print a human summary. Zero-dep: we only ever read our own output,
// so a minimal depth-tracking object scanner replaces a JSON crate.
// ---------------------------------------------------------------------------
/// Split a JSON object body (`{...}`) into (key, raw value slice) pairs at
/// depth 0. Handles nested objects/arrays and string escapes. Returns None
/// if `obj` isn't a well-formed-enough `{...}` object.
fn object_entries(obj: &str) -> Option<Vec<(&str, &str)>> {
let bytes = obj.as_bytes();
if bytes.len() < 2 || bytes[0] != b'{' || *bytes.last()? != b'}' {
return None;
}
let mut entries = Vec::new();
let mut depth = 0i32;
let mut in_str = false;
let mut esc = false;
let mut entry_start = 1usize;
for i in 1..bytes.len() - 1 {
let c = bytes[i];
if in_str {
if esc {
esc = false;
} else if c == b'\\' {
esc = true;
} else if c == b'"' {
in_str = false;
}
continue;
}
match c {
b'"' => in_str = true,
b'{' | b'[' => depth += 1,
b'}' | b']' => depth -= 1,
b',' if depth == 0 => {
entries.push(entry_pair(&obj[entry_start..i])?);
entry_start = i + 1;
}
_ => {}
}
}
let last = &obj[entry_start..obj.len() - 1];
if !last.trim().is_empty() {
entries.push(entry_pair(last)?);
}
Some(entries)
}
/// Turn one `"key": value` entry slice into a (key, value) pair. Our
/// writer's keys never contain a colon, so the first `:` is the split.
fn entry_pair(raw: &str) -> Option<(&str, &str)> {
let (key, val) = raw.split_once(':')?;
let key = key.trim();
let key = key.strip_prefix('"')?.strip_suffix('"')?;
Some((key, val.trim()))
}
/// Split a JSON array (`[...]`) into its element slices at depth 0.
fn array_items(arr: &str) -> Option<Vec<&str>> {
let bytes = arr.as_bytes();
if bytes.len() < 2 || bytes[0] != b'[' || *bytes.last()? != b']' {
return None;
}
let mut items = Vec::new();
let mut depth = 0i32;
let mut in_str = false;
let mut esc = false;
let mut start = 1usize;
for i in 1..bytes.len() - 1 {
let c = bytes[i];
if in_str {
if esc {
esc = false;
} else if c == b'\\' {
esc = true;
} else if c == b'"' {
in_str = false;
}
continue;
}
match c {
b'"' => in_str = true,
b'{' | b'[' => depth += 1,
b'}' | b']' => depth -= 1,
b',' if depth == 0 => {
items.push(arr[start..i].trim());
start = i + 1;
}
_ => {}
}
}
let last = arr[start..arr.len() - 1].trim();
if !last.is_empty() {
items.push(last);
}
Some(items)
}
/// Unquote a JSON string value (our writer only emits the basic escapes).
fn unquote(s: &str) -> Option<String> {
let inner = s.strip_prefix('"')?.strip_suffix('"')?;
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next()? {
'"' => out.push('"'),
'\\' => out.push('\\'),
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
other => out.push(other),
}
}
Some(out)
}
fn get_str<'a>(entries: &[(&'a str, &'a str)], key: &str) -> Option<String> {
entries
.iter()
.find(|(k, _)| *k == key)
.and_then(|(_, v)| unquote(v))
}
fn get_raw<'a>(entries: &[(&'a str, &'a str)], key: &str) -> Option<&'a str> {
entries.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}
/// Human-readable one-line summary from raw report JSON (the `--report`
/// mode). Mirrors `human_summary`'s layout, reconstructed from the file.
pub fn summary_from_json(json: &str) -> Option<String> {
let top = object_entries(json)?;
let workload = get_str(&top, "workload")?;
let isa = get_str(&top, "isa_class").unwrap_or_else(|| "other".to_string());
let verdict = match get_raw(&top, "verdict") {
None | Some("null") => "(not finished)".to_string(),
Some(v) => {
let e = object_entries(v)?;
match get_str(&e, "kind")?.as_str() {
"clean" => "CLEAN".to_string(),
"stopped" => "STOPPED".to_string(),
"failed" => format!("FAILED ({})", get_str(&e, "detail").unwrap_or_default()),
"error" => format!("ERROR ({})", get_str(&e, "detail").unwrap_or_default()),
other => format!("?{other}"),
}
}
};
let score = match get_raw(&top, "score") {
None | Some("null") => {
if workload == "stress-ng-cpu" {
"(stability only)".to_string()
} else {
"(no score)".to_string()
}
}
Some(s) => {
let e = object_entries(s)?;
match get_str(&e, "kind")?.as_str() {
"7zip" => format!("rating={} MIPS", get_raw(&e, "rating_mips").unwrap_or("?")),
"y-cruncher" => {
let tests = get_raw(&e, "tests").unwrap_or("[]");
let mut parts = Vec::new();
for item in array_items(tests).unwrap_or_default() {
let t = object_entries(item)?;
let tag = get_str(&t, "tag").unwrap_or_default();
let passed = get_raw(&t, "passed") == Some("true");
let bps: f64 = get_raw(&t, "bits_per_sec")
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
parts.push(format!(
"{tag}={} {:.2} Gb/s",
if passed { "pass" } else { "FAIL" },
bps / 1e9
));
}
parts.join(" ")
}
"c2c" => {
let pairs = get_raw(&e, "pairs").unwrap_or("[]");
let items = array_items(pairs).unwrap_or_default();
let mut ns_vals = Vec::new();
for item in &items {
let p = object_entries(item)?;
ns_vals.push(
get_raw(&p, "ns")
.and_then(|v| v.parse::<f64>().ok())
.unwrap_or(0.0),
);
}
if ns_vals.is_empty() {
"0 pairs".to_string()
} else {
let lo = ns_vals.iter().cloned().fold(f64::INFINITY, f64::min);
let hi = ns_vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
format!("{} pairs min={:.1} max={:.1} ns", ns_vals.len(), lo, hi)
}
}
"dram" => {
let levels = get_raw(&e, "levels").unwrap_or("[]");
let items = array_items(levels).unwrap_or_default();
let mut parts = Vec::new();
for item in &items {
let l = object_entries(item)?;
let kb = get_raw(&l, "kb").unwrap_or("?");
let ns: f64 = get_raw(&l, "ns")
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
parts.push(format!("{kb}K={ns:.1} ns"));
}
parts.join(" ")
}
other => format!("?{other}"),
}
}
};
Some(format!(
"{:<18} {:<8} {:<24} verdict={}",
workload, isa, score, verdict
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workload::YCruncherTest;
/// Unique dir per test (by test name) so tests don't race on a shared
/// directory when one test's `remove_dir_all` wipes another's parent.
fn test_dir(name: &str) -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/opencode/test-reports").join(name)
}
fn sample_report() -> Report {
let mut r = Report::new(
&test_dir("sample_report"),
"y-cruncher-avx2",
"60s cores=0-15 FFTv4",
)
.unwrap();
r.finish(
Verdict::Clean,
Score::YCruncher {
tests: vec![YCruncherTest {
tag: "FFTv4".to_string(),
passed: true,
bits_per_sec: 8.94e8,
}],
},
)
.unwrap();
r
}
#[test]
fn isa_class_infers_from_name() {
assert_eq!(isa_class("stress-ng-cpu"), "integer");
assert_eq!(isa_class("y-cruncher-sse"), "sse");
assert_eq!(isa_class("y-cruncher-avx2"), "avx2");
assert_eq!(isa_class("7zip-bench"), "mixed");
assert_eq!(isa_class("c2c-latency"), "other");
assert_eq!(isa_class("unknown-future"), "other");
}
#[test]
fn json_escapes_strings() {
assert_eq!(json_str("hello"), "\"hello\"");
assert_eq!(json_str("a\"b"), "\"a\\\"b\"");
assert_eq!(json_str("a\\b"), "\"a\\\\b\"");
assert_eq!(json_str("a\nb"), "\"a\\nb\"");
assert_eq!(json_str(""), "\"\"");
}
#[test]
fn json_verdict_variants() {
assert_eq!(json_verdict(&None), "null");
assert_eq!(json_verdict(&Some(Verdict::Clean)), "{\"kind\":\"clean\"}");
assert_eq!(
json_verdict(&Some(Verdict::Stopped)),
"{\"kind\":\"stopped\"}"
);
assert!(json_verdict(&Some(Verdict::Failed("oops".to_string())))
.contains("\"kind\":\"failed\""));
assert!(json_verdict(&Some(Verdict::Failed("oops".to_string())))
.contains("\"detail\":\"oops\""));
}
#[test]
fn json_score_null_for_none() {
assert_eq!(json_score(&None), "null");
assert_eq!(json_score(&Some(Score::None)), "null");
}
#[test]
fn json_score_7zip_round_trips() {
let s = Score::SevenZip {
rating_mips: 66259,
r_u_mips: 4653,
usage_pct: 1431,
compress_mips: 5346,
decompress_mips: 3961,
};
let j = json_score(&Some(s));
assert!(j.contains("\"kind\":\"7zip\""));
assert!(j.contains("\"rating_mips\":66259"));
assert!(j.contains("\"r_u_mips\":4653"));
assert!(j.contains("\"usage_pct\":1431"));
assert!(j.contains("\"compress_mips\":5346"));
assert!(j.contains("\"decompress_mips\":3961"));
}
#[test]
fn json_score_ycruncher_round_trips() {
let s = Score::YCruncher {
tests: vec![
YCruncherTest {
tag: "FFTv4".to_string(),
passed: true,
bits_per_sec: 8.94e8,
},
YCruncherTest {
tag: "BKT".to_string(),
passed: true,
bits_per_sec: 9.49e9,
},
],
};
let j = json_score(&Some(s));
assert!(j.contains("\"kind\":\"y-cruncher\""));
assert!(j.contains("\"tag\":\"FFTv4\""));
assert!(j.contains("\"passed\":true"));
assert!(j.contains("\"bits_per_sec\":894000000"));
assert!(j.contains("\"tag\":\"BKT\""));
}
#[test]
fn finish_stores_score_and_verdict() {
let mut r =
Report::new(&test_dir("finish_stores_score_and_verdict"), "test", "test").unwrap();
r.finish(
Verdict::Clean,
Score::SevenZip {
rating_mips: 1000,
r_u_mips: 500,
usage_pct: 1500,
compress_mips: 700,
decompress_mips: 300,
},
)
.unwrap();
assert_eq!(r.verdict, Some(Verdict::Clean));
assert!(matches!(r.score, Some(Score::SevenZip { .. })));
}
#[test]
fn finish_keeps_none_score_for_summary() {
let mut r = Report::new(
&test_dir("finish_keeps_none_score_for_summary"),
"test",
"test",
)
.unwrap();
r.finish(Verdict::Clean, Score::None).unwrap();
// Stored as Some(Score::None) so human_summary can distinguish
// "stability only" from "not finished". JSON still renders null.
assert_eq!(r.score, Some(Score::None));
assert!(r.to_json().contains("\"score\":null"));
}
#[test]
fn to_json_has_schema_v2() {
let r = sample_report();
let j = r.to_json();
assert!(j.contains("\"schema_version\":2"));
assert!(!j.contains("\"samples\"")); // v1 field, gone
assert!(!j.contains("\"peak_clock_khz\"")); // v1 field, gone
}
#[test]
fn to_json_is_valid_structure() {
let r = sample_report();
let j = r.to_json();
assert!(j.starts_with('{'));
assert!(j.ends_with('}'));
assert!(j.contains("\"workload\":\"y-cruncher-avx2\""));
assert!(j.contains("\"isa_class\":\"avx2\""));
assert!(j.contains("\"verdict\":{\"kind\":\"clean\"}"));
assert!(j.contains("\"kind\":\"y-cruncher\""));
assert!(j.contains("\"tag\":\"FFTv4\""));
assert!(j.contains("\"bits_per_sec\":894000000"));
}
#[test]
fn flush_writes_readable_file() {
let dir = test_dir("flush_writes_readable_file");
let _ = fs::remove_dir_all(&dir);
let mut r = Report::new(&dir, "flush-test", "30s cores=0-15").unwrap();
r.finish(Verdict::Clean, Score::None).unwrap();
let written = fs::read_to_string(r.path()).unwrap();
assert!(written.contains("\"workload\":\"flush-test\""));
assert!(written.contains("\"verdict\":{\"kind\":\"clean\"}"));
assert!(written.contains("\"score\":null"));
assert!(r
.path()
.file_name()
.unwrap()
.to_str()
.unwrap()
.contains("flush-test.json"));
}
#[test]
fn human_summary_formats_one_line() {
let r = sample_report();
let s = human_summary(&r);
assert!(s.contains("y-cruncher-avx2"));
assert!(s.contains("avx2"));
assert!(s.contains("CLEAN"));
assert!(s.contains("FFTv4"));
assert!(s.contains("Gb/s"));
}
#[test]
fn human_summary_for_7zip() {
let mut r = Report::new(&test_dir("human_summary_for_7zip"), "7zip-bench", "60s").unwrap();
r.finish(
Verdict::Clean,
Score::SevenZip {
rating_mips: 66259,
r_u_mips: 4653,
usage_pct: 1431,
compress_mips: 5346,
decompress_mips: 3961,
},
)
.unwrap();
let s = human_summary(&r);
assert!(s.contains("66259 MIPS"));
assert!(s.contains("mixed"));
}
#[test]
fn human_summary_for_stability_only() {
let mut r = Report::new(
&test_dir("human_summary_for_stability_only"),
"stress-ng-cpu",
"60s",
)
.unwrap();
r.finish(Verdict::Clean, Score::None).unwrap();
let s = human_summary(&r);
assert!(s.contains("(stability only)"));
assert!(s.contains("integer"));
assert!(s.contains("CLEAN"));
}
#[test]
fn filename_sanitizes_workload_name() {
let dir = test_dir("filename_sanitizes_workload_name");
let _ = fs::remove_dir_all(&dir);
let r = Report::new(&dir, "workload/with:spaces", "test").unwrap();
let name = r.path().file_name().unwrap().to_str().unwrap();
assert!(!name.contains('/'));
assert!(!name.contains(':'));
assert!(!name.contains(' '));
}
#[test]
fn summary_from_json_round_trips_ycruncher() {
let r = sample_report();
let s = summary_from_json(&r.to_json()).unwrap();
assert!(s.contains("y-cruncher-avx2"), "{s}");
assert!(s.contains("avx2"), "{s}");
assert!(s.contains("CLEAN"), "{s}");
assert!(s.contains("FFTv4=pass 0.89 Gb/s"), "{s}");
}
#[test]
fn summary_from_json_round_trips_7zip() {
let mut r = Report::new(&test_dir("summary_7zip"), "7zip-bench", "60s").unwrap();
r.finish(
Verdict::Clean,
Score::SevenZip {
rating_mips: 66259,
r_u_mips: 4653,
usage_pct: 1431,
compress_mips: 5346,
decompress_mips: 3961,
},
)
.unwrap();
let s = summary_from_json(&r.to_json()).unwrap();
assert!(s.contains("rating=66259 MIPS"), "{s}");
assert!(s.contains("mixed"), "{s}");
}
#[test]
fn summary_from_json_not_finished_and_stability_only() {
let r = Report::new(&test_dir("summary_not_finished"), "test", "60s").unwrap();
let s = summary_from_json(&r.to_json()).unwrap();
assert!(s.contains("(not finished)"), "{s}");
assert!(s.contains("(no score)"), "{s}");
let mut r = Report::new(&test_dir("summary_stability"), "stress-ng-cpu", "60s").unwrap();
r.finish(Verdict::Clean, Score::None).unwrap();
let s = summary_from_json(&r.to_json()).unwrap();
assert!(s.contains("(stability only)"), "{s}");
assert!(s.contains("CLEAN"), "{s}");
}
#[test]
fn summary_from_json_survives_special_chars_in_detail() {
let mut r = Report::new(&test_dir("summary_special"), "test", "60s").unwrap();
r.finish(
Verdict::Failed("bad: exit {1}, \"quoted\"".to_string()),
Score::None,
)
.unwrap();
let s = summary_from_json(&r.to_json()).unwrap();
assert!(s.contains("FAILED (bad: exit {1}, \"quoted\")"), "{s}");
}
#[test]
fn summary_from_json_rejects_junk() {
assert!(summary_from_json("not json").is_none());
assert!(summary_from_json("{}").is_none()); // no workload key
}
#[test]
fn summary_from_json_round_trips_c2c() {
let mut r = Report::new(&test_dir("summary_c2c"), "c2c-latency", "30s").unwrap();
r.finish(
Verdict::Clean,
Score::C2c {
pairs: vec![
crate::workload::C2cPair {
cpu_a: 0,
cpu_b: 1,
ns_per_hop: 28.02,
},
crate::workload::C2cPair {
cpu_a: 0,
cpu_b: 2,
ns_per_hop: 43.83,
},
],
},
)
.unwrap();
let s = summary_from_json(&r.to_json()).unwrap();
assert!(s.contains("2 pairs min=28.0 max=43.8 ns"), "{s}");
assert!(s.contains("CLEAN"), "{s}");
}
#[test]
fn summary_from_json_round_trips_dram() {
let mut r = Report::new(&test_dir("summary_dram"), "dram-latency", "30s").unwrap();
r.finish(
Verdict::Clean,
Score::Dram {
levels: vec![
crate::workload::DramLevel {
size_kb: 32,
ns_per_access: 4.536,
},
crate::workload::DramLevel {
size_kb: 65536,
ns_per_access: 67.51,
},
],
},
)
.unwrap();
let s = summary_from_json(&r.to_json()).unwrap();
assert!(s.contains("32K=4.5 ns"), "{s}");
assert!(s.contains("65536K=67.5 ns"), "{s}");
}
}