//! 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, /// Set by `finish()`. None for stability-only workloads (stress-ng), /// or until the workload completes. pub score: Option, /// 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 `-.json`. pub fn new(report_dir: &Path, workload: &str, params: &str) -> io::Result { 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 ` 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) -> 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) -> 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::>() .join(" "), }; format!( "{:<18} {:<8} {:<24} verdict={}", r.workload, r.isa_class, score, verdict ) } // --------------------------------------------------------------------------- // Report reader (`--report `): 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> { 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> { 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 { 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 { 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 { 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::().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}"); } }