use super::compare::{
CORRECT_COLUMN, MATCH, NO_BASELINE, NO_CANDIDATE, RESULT_COLUMN, TREND_COLUMN,
};
use super::flow::Header;
use super::model::{OutputColumn, ReportResult, Trend, Verdict};
pub trait ReportWriter {
fn write(&self, result: &ReportResult, header: &Header) -> Result<Vec<u8>, String>;
}
pub fn writer_for_extension(ext: &str) -> Option<Box<dyn ReportWriter>> {
match ext.to_ascii_lowercase().as_str() {
"csv" => Some(Box::new(CsvWriter)),
"json" => Some(Box::new(JsonWriter)),
"html" | "htm" => Some(Box::new(HtmlWriter)),
"xlsx" => Some(Box::new(XlsxWriter)),
"pdf" => Some(Box::new(super::pdf::PdfWriter)),
_ => None,
}
}
pub const OUTPUT_EXTENSIONS: [&str; 5] = ["csv", "json", "html", "xlsx", "pdf"];
pub fn report_output_extension(report: &crate::report::Report) -> String {
report
.flow()
.ok()
.and_then(|f| f.header.output().map(|o| o.trim().to_ascii_lowercase()))
.filter(|ext| writer_for_extension(ext).is_some())
.unwrap_or_else(|| "csv".to_string())
}
pub fn export_path(report: &crate::report::Report, ext: &str) -> std::path::PathBuf {
if let Some(p) = tokened_output_path(report, ext) {
return p;
}
if let Some(path) = &report.path {
return path.with_extension(ext);
}
std::path::PathBuf::from(format!("{}.{ext}", sanitize_file_stem(&report.name)))
}
fn tokened_output_path(report: &crate::report::Report, ext: &str) -> Option<std::path::PathBuf> {
if !crate::report::name_has_output_token(&report.name) {
return None;
}
let stem = sanitize_file_stem(&crate::report::expand_output_tokens(&report.name));
let file = format!("{stem}.{ext}");
match report.path.as_ref().and_then(|p| p.parent()) {
Some(d) => Some(d.join(file)),
None => Some(std::path::PathBuf::from(file)),
}
}
fn sanitize_file_stem(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' {
c
} else {
'_'
}
})
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"report".to_string()
} else {
trimmed.to_string()
}
}
pub struct CsvWriter;
impl ReportWriter for CsvWriter {
fn write(&self, result: &ReportResult, header: &Header) -> Result<Vec<u8>, String> {
let columns = result.resolved_columns(header);
let mut out = String::new();
push_record(&mut out, columns.iter().map(|c| c.header.as_str()));
for row in &result.rows {
let cells: Vec<String> = columns
.iter()
.map(|c| c.value(row, &result.no_match_marker))
.collect();
push_record(&mut out, cells.iter().map(String::as_str));
}
for srow in result.footer_rows(&columns, header) {
let cells: Vec<String> = (0..columns.len()).map(|c| srow.text_cell(c)).collect();
push_record(&mut out, cells.iter().map(String::as_str));
}
Ok(out.into_bytes())
}
}
pub struct JsonWriter;
impl ReportWriter for JsonWriter {
fn write(&self, result: &ReportResult, header: &Header) -> Result<Vec<u8>, String> {
let columns = result.resolved_columns(header);
let headers: Vec<&str> = columns.iter().map(|c| c.header.as_str()).collect();
let rows: Vec<serde_json::Value> = result
.rows
.iter()
.map(|row| {
let mut obj = serde_json::Map::new();
for c in &columns {
obj.insert(
c.header.clone(),
serde_json::Value::String(c.value(row, &result.no_match_marker)),
);
}
serde_json::Value::Object(obj)
})
.collect();
let doc = serde_json::json!({ "columns": headers, "rows": rows });
let mut doc = doc;
let summary: Vec<serde_json::Value> = result
.footer_rows(&columns, header)
.iter()
.map(|srow| {
let mut obj = serde_json::Map::new();
for (ci, c) in columns.iter().enumerate() {
obj.insert(
c.header.clone(),
serde_json::Value::String(srow.text_cell(ci)),
);
}
serde_json::Value::Object(obj)
})
.collect();
if !summary.is_empty() {
doc.as_object_mut()
.unwrap()
.insert("summary".to_string(), serde_json::Value::Array(summary));
}
if let Some(metrics) = result.metrics(&columns, header) {
doc.as_object_mut()
.unwrap()
.insert("metrics".to_string(), metrics_json(&metrics));
}
serde_json::to_vec_pretty(&doc).map_err(|e| e.to_string())
}
}
fn metrics_json(metrics: &super::metrics::Metrics) -> serde_json::Value {
let column = |m: &super::metrics::ColumnMetrics| {
let mut obj = serde_json::json!({
"column": m.header,
"total": m.total,
"compared": m.compared,
"correct": m.correct,
"incorrect": m.incorrect,
"accuracy": m.accuracy(),
});
if let Some(matrix) = &m.matrix {
obj.as_object_mut().unwrap().insert(
"confusion".to_string(),
serde_json::json!({
"axis": matrix.axis,
"counts": matrix.counts,
}),
);
}
obj
};
let mut doc = serde_json::json!({
"columns": metrics.columns.iter().map(column).collect::<Vec<_>>(),
});
if let Some(overall) = &metrics.overall {
doc.as_object_mut()
.unwrap()
.insert("overall".to_string(), column(overall));
}
if let Some(mv) = &metrics.movement {
doc.as_object_mut().unwrap().insert(
"movement".to_string(),
serde_json::json!({
"fixed": mv.fixed,
"regressed": mv.regressed,
"still_wrong": mv.still_wrong,
"unchanged": mv.unchanged,
}),
);
}
doc
}
pub struct HtmlWriter;
const HTML_HEAD: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>PaperTrail report</title>
<style>
:root{
--bg:#fff; --fg:#222; --muted:#666; --faint:#777;
--line:#ccc; --line-soft:#ddd;
--head-bg:#333; --head-fg:#fff;
--alt-bg:#f7f7f7; --hover-bg:#eaf1fb; --det-bg:#fbfbfd; --pre-bg:#fff;
--panel-bg:#fafafa; --btn-line:#bbb; --on-bg:#333; --on-fg:#fff;
--foot-bg:#ececec; --foot-line:#999; --fdiff-head-bg:#eee;
--pass-bg:#c6efce; --fail-bg:#ffc7ce; --warn-bg:#ffeb9c; --tint-fg:#222;
--cell-fg:#12305a; --pick-line:#12305a;
}
@media (prefers-color-scheme: dark){
:root:not([data-pb-theme="light"]){
--bg:#14161a; --fg:#e6e6e6; --muted:#9aa0a6; --faint:#8a9099;
--line:#3a3f47; --line-soft:#2b3038;
--head-bg:#2a2f37; --head-fg:#f0f0f0;
--alt-bg:#1a1d22; --hover-bg:#232a35; --det-bg:#171a1f; --pre-bg:#0f1115;
--panel-bg:#1b1f25; --btn-line:#454b54; --on-bg:#dfe3e8; --on-fg:#14161a;
--foot-bg:#22262c; --foot-line:#555c66; --fdiff-head-bg:#22262c;
--pass-bg:#1e4620; --fail-bg:#5b1f26; --warn-bg:#5c4a12; --tint-fg:#f2f2f2;
--cell-fg:#cfe0ff; --pick-line:#7aa7ff;
}
}
:root[data-pb-theme="dark"]{
--bg:#14161a; --fg:#e6e6e6; --muted:#9aa0a6; --faint:#8a9099;
--line:#3a3f47; --line-soft:#2b3038;
--head-bg:#2a2f37; --head-fg:#f0f0f0;
--alt-bg:#1a1d22; --hover-bg:#232a35; --det-bg:#171a1f; --pre-bg:#0f1115;
--panel-bg:#1b1f25; --btn-line:#454b54; --on-bg:#dfe3e8; --on-fg:#14161a;
--foot-bg:#22262c; --foot-line:#555c66; --fdiff-head-bg:#22262c;
--pass-bg:#1e4620; --fail-bg:#5b1f26; --warn-bg:#5c4a12; --tint-fg:#f2f2f2;
--cell-fg:#cfe0ff; --pick-line:#7aa7ff;
}
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;margin:1rem;
background:var(--bg);color:var(--fg)}
table{border-collapse:collapse;table-layout:fixed;width:max-content;min-width:100%;font-size:14px}
th,td{border:1px solid var(--line);padding:6px 8px;text-align:left;vertical-align:top;
white-space:pre-wrap;overflow-wrap:anywhere}
thead th{position:sticky;top:0;background:var(--head-bg);color:var(--head-fg);
white-space:nowrap;overflow-wrap:normal}
tbody tr.sum.alt{background:var(--alt-bg)}
tbody tr.sum.has{cursor:pointer}
tbody tr.sum.has:hover{background:var(--hover-bg)}
tbody tr.sum.has>td:first-child::before{content:'\25b8 ';color:var(--faint)}
tbody tr.sum.has[aria-expanded='true']>td:first-child::before{content:'\25be '}
tr.det{display:none}
tr.det.open{display:table-row}
tr.det>td{background:var(--det-bg);border-top:none}
.panel{display:flex;flex-wrap:wrap;align-items:flex-start;gap:.7rem 1rem}
/* Sections hug their content instead of each claiming an equal share of the
row: stretched to a third of a wide screen apiece, a picture and a short
JSON blob ended up at opposite ends of the panel with a void between them.
They still wrap, and still cannot grow past a comfortable reading width. */
.panel section{flex:0 1 auto;min-width:0;max-width:min(100%,40rem)}
.panel h3{font-size:12px;text-transform:uppercase;letter-spacing:.04em;
color:var(--muted);margin:0 0 .35rem;font-weight:600}
.panel pre{margin:0;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;
background:var(--pre-bg);border:1px solid var(--line-soft);border-radius:4px;padding:.4rem .5rem;
max-height:24rem;overflow:auto}
.panel img{display:block;max-width:100%;height:auto;border:1px solid var(--line-soft);border-radius:4px}
table.fdiff{width:100%;font-size:12px}
table.fdiff th{background:var(--fdiff-head-bg);color:var(--fg);position:static;font-weight:600}
table.fdiff tr.chg td{background:var(--warn-bg);color:var(--tint-fg)}
/* A DETAIL column carrying a TRUTH says whether it is right, the way its grid
cell would have: the panel is where the value is actually read, so a full
value shown without its verdict reads as one nobody checked. */
.panel h3 .verdict{margin-left:.4rem;font-size:11px;padding:.05rem .35rem;border-radius:3px;
color:var(--tint-fg);text-transform:none;letter-spacing:0}
.panel h3 .verdict.pass{background:var(--pass-bg)}
.panel h3 .verdict.fail{background:var(--fail-bg)}
.toolbar{display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin:0 0 .6rem}
.toolbar button{font:inherit;font-size:13px;padding:.25rem .7rem;border:1px solid var(--btn-line);
border-radius:5px;background:var(--panel-bg);color:var(--fg);cursor:pointer}
.toolbar button.on{background:var(--on-bg);color:var(--on-fg);border-color:var(--on-bg)}
.toolbar button#pb-theme{margin-left:auto}
.toolbar input{font:inherit;font-size:13px;padding:.25rem .5rem;border:1px solid var(--btn-line);
border-radius:5px;background:var(--pre-bg);color:var(--fg)}
.toolbar .count{font-size:12px;color:var(--muted)}
tfoot td{font-weight:bold;background:var(--foot-bg);border-top:2px solid var(--foot-line)}
td.pass{background:var(--pass-bg);color:var(--tint-fg)}
td.fail{background:var(--fail-bg);color:var(--tint-fg)}
td.warn{background:var(--warn-bg);color:var(--tint-fg)}
.metrics{display:flex;flex-wrap:wrap;gap:.75rem;margin:0 0 1rem}
.card{border:1px solid var(--line);border-radius:6px;padding:.5rem .9rem;background:var(--panel-bg)}
.card .k{display:block;font-size:12px;color:var(--muted);text-transform:uppercase;
letter-spacing:.04em}
.card .v{display:block;font-size:20px;font-weight:600}
.matrix{margin:0 0 1.25rem}
.matrix h2{font-size:17px;font-weight:600;margin:0 0 .45rem}
.matrix table{width:auto;min-width:0;font-size:20px}
.matrix th,.matrix td{text-align:center;white-space:nowrap;padding:12px 18px}
.matrix thead th{position:static;background:var(--panel-bg);color:var(--fg);font-weight:600}
.matrix th.axis{background:var(--panel-bg);color:var(--fg);text-align:right;font-weight:600}
.matrix td.cell{color:var(--cell-fg);background:var(--heat,transparent)}
.matrix td.pick{cursor:pointer}
.matrix td.pick:hover{outline:2px solid var(--pick-line);outline-offset:-2px}
.matrix td.hot{color:#fff}
.matrix caption{caption-side:bottom;font-size:13px;color:var(--muted);padding-top:.4rem;
text-align:left}
/* The heat ramp is mixed with white by construction, so on a dark page every
cell would be a bright block. Mixing it back toward the page keeps the
ramp's shape without the glare. `color-mix` is a progressive enhancement:
a browser that doesn't know it keeps the light ramp, which is legible
either way because the cell text stays dark on it. */
@media (prefers-color-scheme: dark){
:root:not([data-pb-theme="light"]) .matrix td.cell{
background:color-mix(in srgb, var(--heat) 62%, #0b0d10);color:#eaf1ff}
}
:root[data-pb-theme="dark"] .matrix td.cell{
background:color-mix(in srgb, var(--heat) 62%, #0b0d10);color:#eaf1ff}
</style>
<noscript><style>tr.det{display:table-row}.toolbar{display:none}</style></noscript>
</head>
<body>
"##;
impl ReportWriter for HtmlWriter {
fn write(&self, result: &ReportResult, header: &Header) -> Result<Vec<u8>, String> {
let all_columns = result.resolved_columns(header);
let (columns, detail_columns) = super::detail::split_columns(&all_columns);
let labels = super::labels::LabelMap::parse(&header.labels());
let mut out = String::new();
out.push_str(HTML_HEAD);
let metrics = result.metrics(&all_columns, header);
let (filters, buttons) = super::filter::all_filters(result, metrics.as_ref());
push_filter_toolbar(&mut out, &filters[..buttons]);
if let Some(metrics) = &metrics {
push_metric_cards(&mut out, metrics);
for m in &metrics.columns {
if let Some(matrix) = &m.matrix {
push_confusion_matrix(&mut out, &m.header, matrix, &filters);
}
}
}
out.push_str("<table>\n");
out.push_str("<colgroup>");
for w in html_column_widths(&columns, result) {
out.push_str(&format!("<col style=\"width:{w}ch\">"));
}
out.push_str("</colgroup>\n<thead>\n<tr>");
for c in &columns {
out.push_str("<th>");
push_escaped(&mut out, &c.header);
out.push_str("</th>");
}
out.push_str("</tr>\n</thead>\n<tbody>\n");
for (r, row) in result.rows.iter().enumerate() {
let passes: Vec<String> = filters
.iter()
.enumerate()
.filter(|(_, f)| f.matches(result, &all_columns, &labels, r))
.map(|(i, _)| i.to_string())
.collect();
let searchable = columns
.iter()
.map(|c| c.value(row, &result.no_match_marker))
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
out.push_str("<tr class=\"sum");
if r % 2 == 1 {
out.push_str(" alt");
}
out.push_str("\" data-f=\"");
out.push_str(&passes.join(" "));
out.push_str("\" data-t=\"");
push_escaped(&mut out, &searchable);
out.push_str("\">");
for (ci, c) in columns.iter().enumerate() {
let value = c.value(row, &result.no_match_marker);
let class = match run_cell_tint(result, r, &c.header, &value) {
Some(Tint::Green) => " class=\"pass\"",
Some(Tint::Red) => " class=\"fail\"",
Some(Tint::Amber) => " class=\"warn\"",
None => "",
};
out.push_str("<td");
out.push_str(class);
out.push('>');
match result.images.get(&(r, c.header.clone())) {
Some(img) => push_html_image(&mut out, img, c.image, &value, Some(ci)),
None => push_escaped(&mut out, &value),
}
out.push_str("</td>");
}
out.push_str("</tr>\n");
push_detail_row(
&mut out,
result,
r,
&all_columns,
&detail_columns,
&columns.iter().collect::<Vec<_>>(),
columns.len(),
);
}
out.push_str("</tbody>\n");
let summary = result.summary_rows(&columns);
if !summary.is_empty() {
out.push_str("<tfoot>\n");
for srow in &summary {
out.push_str("<tr>");
for ci in 0..columns.len() {
out.push_str("<td>");
push_escaped(&mut out, &srow.text_cell(ci));
out.push_str("</td>");
}
out.push_str("</tr>\n");
}
out.push_str("</tfoot>\n");
}
out.push_str("</table>\n");
out.push_str(INTERACTIVE_SCRIPT);
out.push_str("</body>\n</html>\n");
Ok(out.into_bytes())
}
}
fn push_filter_toolbar(out: &mut String, filters: &[super::filter::RowFilter]) {
out.push_str("<div class=\"toolbar\" role=\"group\" aria-label=\"Filter rows\">");
if filters.len() > 1 {
for (i, f) in filters.iter().enumerate() {
out.push_str(&format!(
"<button type=\"button\" data-i=\"{i}\"{}>",
if i == 0 { " class=\"on\"" } else { "" }
));
push_escaped(out, &f.label());
out.push_str("</button>");
}
}
out.push_str(
"<input type=\"search\" id=\"pb-find\" placeholder=\"Find\u{2026}\" \
aria-label=\"Find in rows\">\
<span class=\"count\" id=\"pb-count\" aria-live=\"polite\"></span>\
<button type=\"button\" id=\"pb-theme\" aria-pressed=\"false\" \
title=\"Switch between the light and dark palette\">Dark</button></div>\n",
);
}
fn push_detail_row(
out: &mut String,
result: &ReportResult,
r: usize,
all_columns: &[OutputColumn],
detail_columns: &[&OutputColumn],
summary_columns: &[&OutputColumn],
span: usize,
) {
use super::detail::DetailSection;
let sections = super::detail::sections(result, r, all_columns, detail_columns);
if sections.is_empty() {
return;
}
out.push_str(&format!(
"<tr class=\"det\"><td colspan=\"{span}\"><div class=\"panel\">"
));
for section in §ions {
match section {
DetailSection::Image {
header,
image,
value,
} => {
out.push_str("<section><h3>");
push_escaped(out, header);
out.push_str("</h3>");
let cell = summary_columns.iter().position(|s| &s.header == header);
push_panel_image(out, image, value, cell);
out.push_str("</section>");
}
DetailSection::Text {
header,
value,
verdict,
} => {
out.push_str("<section><h3>");
push_escaped(out, header);
if let Some((v, truth)) = verdict {
let cls = if *v == Verdict::Correct {
"pass"
} else {
"fail"
};
out.push_str(&format!("<span class=\"verdict {cls}\">"));
push_escaped(out, &super::detail::verdict_label(*v, truth));
out.push_str("</span>");
}
out.push_str("</h3><pre>");
push_escaped(out, value);
out.push_str("</pre></section>");
}
DetailSection::Diff { header, fields } => {
out.push_str("<section><h3>");
push_escaped(out, &format!("{header} \u{2014} changed fields"));
out.push_str(
"</h3><table class=\"fdiff\"><thead><tr><th>Field</th><th>Baseline</th>\
<th>This run</th></tr></thead><tbody>",
);
for f in fields {
out.push_str(if f.differs() {
"<tr class=\"chg\"><td>"
} else {
"<tr><td>"
});
push_escaped(out, &f.path);
out.push_str("</td><td>");
push_escaped(out, f.baseline.as_deref().unwrap_or("\u{2014}"));
out.push_str("</td><td>");
push_escaped(out, f.candidate.as_deref().unwrap_or("\u{2014}"));
out.push_str("</td></tr>");
}
out.push_str("</tbody></table></section>");
}
}
}
out.push_str("</div></td></tr>\n");
}
const INTERACTIVE_SCRIPT: &str = r#"<script>
(function () {
var rows = Array.prototype.slice.call(document.querySelectorAll('tr.sum'));
var panelOf = function (tr) {
var n = tr.nextElementSibling;
return n && n.classList.contains('det') ? n : null;
};
rows.forEach(function (tr) {
var p = panelOf(tr);
if (!p) return;
tr.classList.add('has');
tr.tabIndex = 0;
tr.setAttribute('role', 'button');
tr.setAttribute('aria-expanded', 'false');
// The panel's pictures carry no `src` of their own -- they borrow the
// bytes already in the row's cells, so the file holds each picture once
// instead of twice. Done on first expand rather than up front so a report
// with a thousand rows doesn't decode a thousand images nobody opened.
var hydrate = function () {
var pending = p.querySelectorAll('img.full[data-from]');
for (var i = 0; i < pending.length; i++) {
var want = pending[i].getAttribute('data-from');
var src = tr.querySelector('img[data-c="' + want + '"]');
if (src) {
pending[i].src = src.src;
pending[i].removeAttribute('data-from');
}
}
};
var toggle = function () {
var open = p.classList.toggle('open');
if (open) hydrate();
tr.setAttribute('aria-expanded', open ? 'true' : 'false');
};
tr.addEventListener('click', function (e) {
if (e.target.closest('a, img, input, button')) return;
toggle();
});
tr.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
});
});
var buttons = Array.prototype.slice.call(document.querySelectorAll('.toolbar button'));
var picks = Array.prototype.slice.call(document.querySelectorAll('.matrix td.pick'));
var find = document.getElementById('pb-find');
var count = document.getElementById('pb-count');
var active = 0;
var apply = function () {
var needle = find ? find.value.trim().toLowerCase() : '';
var shown = 0;
rows.forEach(function (tr) {
var f = (tr.getAttribute('data-f') || '').split(' ');
var ok = f.indexOf(String(active)) >= 0 &&
(!needle || (tr.getAttribute('data-t') || '').indexOf(needle) >= 0);
tr.style.display = ok ? '' : 'none';
var p = panelOf(tr);
if (p) p.style.display = ok ? '' : 'none';
if (ok) shown++;
});
buttons.forEach(function (b) {
b.classList.toggle('on', Number(b.getAttribute('data-i')) === active);
});
picks.forEach(function (c) {
c.classList.toggle('on', Number(c.getAttribute('data-i')) === active);
});
if (count) {
count.textContent = shown === rows.length
? rows.length + ' rows'
: shown + ' of ' + rows.length + ' rows';
}
};
buttons.forEach(function (b) {
b.addEventListener('click', function () {
active = Number(b.getAttribute('data-i'));
apply();
});
});
picks.forEach(function (c) {
// A second click on the same cell returns to everything, so a reader who
// drilled in by accident is never stuck with a filter they can't name.
c.addEventListener('click', function () {
var i = Number(c.getAttribute('data-i'));
active = active === i ? 0 : i;
apply();
});
});
if (find) find.addEventListener('input', apply);
apply();
// Dark mode. The page already follows the reader's system setting on its own
// (a `prefers-color-scheme` block in the stylesheet); this is the override
// for when the two disagree -- a dark desktop and a report going on a
// projector, say. The choice is remembered per file:// origin where the
// browser allows it, and simply doesn't stick where it doesn't.
var root = document.documentElement;
var themeBtn = document.getElementById('pb-theme');
var store = function (k, v) {
try { if (v === null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch (e) {}
};
var stored = null;
try { stored = localStorage.getItem('pb-theme'); } catch (e) {}
var systemDark = function () {
return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
};
var paint = function () {
var dark = root.getAttribute('data-pb-theme') === 'dark' ||
(!root.hasAttribute('data-pb-theme') && systemDark());
if (themeBtn) {
// The button names the palette you would get by pressing it, which is
// the question someone reaching for it is actually asking.
themeBtn.textContent = dark ? 'Light' : 'Dark';
themeBtn.setAttribute('aria-pressed', dark ? 'true' : 'false');
}
};
if (stored === 'dark' || stored === 'light') root.setAttribute('data-pb-theme', stored);
paint();
if (themeBtn) {
themeBtn.addEventListener('click', function () {
var dark = root.getAttribute('data-pb-theme') === 'dark' ||
(!root.hasAttribute('data-pb-theme') && systemDark());
var next = dark ? 'light' : 'dark';
root.setAttribute('data-pb-theme', next);
store('pb-theme', next);
paint();
});
}
if (window.matchMedia) {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
var follow = function () { if (!root.hasAttribute('data-pb-theme')) paint(); };
if (mq.addEventListener) mq.addEventListener('change', follow);
else if (mq.addListener) mq.addListener(follow);
}
})();
</script>
"#;
fn push_metric_cards(out: &mut String, metrics: &super::metrics::Metrics) {
use super::metrics::{
ACCURACY_LABEL, COMPARED_LABEL, FIXED_LABEL, INCORRECT_LABEL, MOVEMENT_LABEL,
REGRESSED_LABEL, STILL_WRONG_LABEL,
};
fn card(out: &mut String, k: &str, v: &str) {
out.push_str("<div class=\"card\"><span class=\"k\">");
push_escaped(out, k);
out.push_str("</span><span class=\"v\">");
push_escaped(out, v);
out.push_str("</span></div>");
}
if let Some(mv) = &metrics.movement {
out.push_str("<div class=\"metrics\">");
if mv.is_still() {
card(out, MOVEMENT_LABEL, "Nothing moved");
} else {
card(out, FIXED_LABEL, &mv.fixed.to_string());
card(out, REGRESSED_LABEL, &mv.regressed.to_string());
}
if mv.still_wrong > 0 {
card(out, STILL_WRONG_LABEL, &mv.still_wrong.to_string());
}
out.push_str("</div>\n");
}
let groups = metrics
.overall
.iter()
.chain(metrics.columns.iter())
.collect::<Vec<_>>();
for m in groups {
out.push_str("<div class=\"metrics\">");
card(
out,
&format!("{} — {COMPARED_LABEL}", m.header),
&format!("{} of {}", m.compared, m.total),
);
card(out, INCORRECT_LABEL, &m.incorrect.to_string());
card(
out,
ACCURACY_LABEL,
m.accuracy_text().as_deref().unwrap_or("\u{2014}"),
);
out.push_str("</div>\n");
}
}
fn push_confusion_matrix(
out: &mut String,
column: &str,
matrix: &super::metrics::ConfusionMatrix,
filters: &[super::filter::RowFilter],
) {
let max = matrix.max();
out.push_str("<div class=\"matrix\"><h2>");
push_escaped(out, column);
out.push_str("</h2>\n<table><caption>");
let clean = if matrix.is_diagonal() {
" Every scored row matched its ground truth."
} else {
""
};
push_escaped(
out,
&format!(
"Rows: ground truth. Columns: reported value. {} scored row(s).{clean}",
matrix.total()
),
);
out.push_str("</caption>\n<thead><tr><th class=\"axis\"></th>");
for label in &matrix.axis {
out.push_str("<th>");
push_escaped(out, label);
out.push_str("</th>");
}
out.push_str("</tr></thead>\n<tbody>\n");
for (t, label) in matrix.axis.iter().enumerate() {
out.push_str("<tr><th class=\"axis\">");
push_escaped(out, label);
out.push_str("</th>");
for (p, answer) in matrix.axis.iter().enumerate() {
let n = matrix.counts[t][p];
let (bg, hot) = heat_shade(n, max);
let pick = filters
.iter()
.position(|f| {
matches!(f, super::filter::RowFilter::MatrixCell { column: c, truth: tr, answer: a }
if c == column && tr == label && a == answer)
})
.map(|i| format!(" pick\" data-i=\"{i}\" title=\"Show these rows"))
.unwrap_or_default();
out.push_str(&format!(
"<td class=\"cell{}{pick}\" style=\"--heat:{bg}\">{n}</td>",
if hot { " hot" } else { "" }
));
}
out.push_str("</tr>\n");
}
out.push_str("</tbody></table></div>\n");
}
fn heat_shade(n: usize, max: usize) -> (String, bool) {
let ([r, g, b], hot) = super::metrics::heat_rgb(n, max);
(format!("#{r:02x}{g:02x}{b:02x}"), hot)
}
fn push_html_image(
out: &mut String,
img: &super::model::ImageData,
spec: Option<crate::report::flow::ImageSpec>,
value: &str,
cell: Option<usize>,
) {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&img.bytes);
let style = match spec.and_then(|s| s.scaled_size(img.natural)) {
Some((w, h)) => format!("width:{}px;height:{}px", w.round(), h.round()),
None => "max-width:100%;height:auto".to_string(),
};
out.push_str("<img style=\"");
out.push_str(&style);
out.push_str("\"");
if let Some(ci) = cell {
out.push_str(&format!(" data-c=\"{ci}\""));
}
out.push_str(" alt=\"");
push_escaped(out, value);
out.push_str("\" title=\"");
push_escaped(out, value);
out.push_str("\" src=\"data:");
out.push_str(&img.mime);
out.push_str(";base64,");
out.push_str(&b64);
out.push_str("\">");
}
fn push_panel_image(
out: &mut String,
img: &super::model::ImageData,
value: &str,
cell: Option<usize>,
) {
let Some(ci) = cell else {
push_html_image(out, img, None, value, None);
return;
};
out.push_str(&format!(
"<img class=\"full\" data-from=\"{ci}\" style=\"max-width:100%;height:auto\" alt=\""
));
push_escaped(out, value);
out.push_str("\" title=\"");
push_escaped(out, value);
out.push_str("\">");
}
fn push_escaped(out: &mut String, text: &str) {
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
}
pub struct XlsxWriter;
impl ReportWriter for XlsxWriter {
fn write(&self, result: &ReportResult, header: &Header) -> Result<Vec<u8>, String> {
use rust_xlsxwriter::{Color, Format, FormatAlign, Workbook};
let resolved = result.resolved_columns(header);
let summary_count = resolved.iter().filter(|c| !c.detail).count();
let columns: Vec<OutputColumn> = resolved
.iter()
.filter(|c| !c.detail)
.chain(resolved.iter().filter(|c| c.detail))
.cloned()
.collect();
let boxes = xlsx_image_boxes(&columns, result);
let mut workbook = Workbook::new();
let sheet = workbook.add_worksheet();
let header_fmt = Format::new()
.set_bold()
.set_background_color(Color::RGB(0x33_3333))
.set_font_color(Color::White)
.set_align(FormatAlign::Left);
let body_fmt = Format::new().set_text_wrap().set_align(FormatAlign::Top);
let make_status = |rgb: u32| {
Format::new()
.set_text_wrap()
.set_align(FormatAlign::Top)
.set_background_color(Color::RGB(rgb))
};
let green = make_status(0xC6_EFCE);
let red = make_status(0xFF_C7CE);
let amber = make_status(0xFF_EB9C);
for (col, c) in columns.iter().enumerate() {
sheet
.write_string_with_format(0, col as u16, &c.header, &header_fmt)
.map_err(|e| e.to_string())?;
}
let widths = xlsx_column_widths(&columns, result);
for (col, width) in widths.into_iter().enumerate() {
sheet
.set_column_width(col as u16, width)
.map_err(|e| e.to_string())?;
}
if summary_count > 0 && summary_count < columns.len() {
sheet
.group_columns_collapsed(summary_count as u16, (columns.len() - 1) as u16)
.map_err(|e| e.to_string())?;
}
if !columns.is_empty() {
sheet.set_freeze_panes(1, 0).map_err(|e| e.to_string())?;
let last_col = (columns.len() - 1) as u16;
let last_row = result.rows.len() as u32;
sheet
.autofilter(0, 0, last_row, last_col)
.map_err(|e| e.to_string())?;
}
let numeric: Vec<bool> = columns
.iter()
.map(|c| column_is_numeric(c, result))
.collect();
for (r, row) in result.rows.iter().enumerate() {
let excel_row = (r + 1) as u32;
let tallest = (0..columns.len())
.filter_map(|col| boxes.get(&(r, col)))
.fold(0.0f64, |acc, (_, h)| acc.max(*h));
if tallest > 0.0 {
sheet
.set_row_height_pixels(excel_row, tallest.ceil() as u32)
.map_err(|e| e.to_string())?;
}
for (col, c) in columns.iter().enumerate() {
let value = c.value(row, &result.no_match_marker);
let fmt = match run_cell_tint(result, r, &c.header, &value) {
Some(Tint::Green) => &green,
Some(Tint::Red) => &red,
Some(Tint::Amber) => &amber,
None => &body_fmt,
};
if let Some(img) = result.images.get(&(r, c.header.clone())) {
let mut image = rust_xlsxwriter::Image::new_from_buffer(&img.bytes)
.map_err(|e| e.to_string())?
.set_alt_text(&value);
if c.image.is_some_and(|i| i.fit) {
sheet
.insert_image_fit_to_cell(excel_row, col as u16, &image, true)
.map_err(|e| e.to_string())?;
} else {
if let Some((w, h)) = boxes.get(&(r, col)) {
image = image.set_scale_to_size(*w, *h, false);
}
sheet
.insert_image(excel_row, col as u16, &image)
.map_err(|e| e.to_string())?;
}
continue;
}
match parse_report_number(&value) {
Some(n) if numeric[col] => sheet
.write_number_with_format(excel_row, col as u16, n, fmt)
.map_err(|e| e.to_string())?,
_ => sheet
.write_string_with_format(excel_row, col as u16, &value, fmt)
.map_err(|e| e.to_string())?,
};
}
}
let nrows = result.rows.len();
let summary = result.summary_rows(&columns);
if !summary.is_empty() {
let summary_fmt = Format::new()
.set_bold()
.set_text_wrap()
.set_align(FormatAlign::Top)
.set_background_color(Color::RGB(0xEC_ECEC));
for (si, srow) in summary.iter().enumerate() {
let excel_row = (nrows + 1 + si) as u32;
for col in 0..columns.len() {
match srow.cells.get(col).and_then(|c| c.as_ref()) {
Some(v) => match xlsx_stat_formula(v, col, nrows) {
Some(formula) => {
sheet
.write_formula_with_format(
excel_row,
col as u16,
formula.as_str(),
&summary_fmt,
)
.map_err(|e| e.to_string())?;
}
None => {
sheet
.write_string_with_format(
excel_row,
col as u16,
&v.text,
&summary_fmt,
)
.map_err(|e| e.to_string())?;
}
},
None => {
let text = srow.text_cell(col);
if !text.is_empty() {
sheet
.write_string_with_format(
excel_row,
col as u16,
&text,
&summary_fmt,
)
.map_err(|e| e.to_string())?;
}
}
}
}
}
}
if let Some(metrics) = result.metrics(&columns, header) {
write_metrics_sheet(&mut workbook, &metrics)?;
}
workbook.save_to_buffer().map_err(|e| e.to_string())
}
}
fn write_metrics_sheet(
workbook: &mut rust_xlsxwriter::Workbook,
metrics: &super::metrics::Metrics,
) -> Result<(), String> {
use super::metrics::{
ACCURACY_LABEL, COMPARED_LABEL, FIXED_LABEL, INCORRECT_LABEL, MOVEMENT_LABEL,
REGRESSED_LABEL, STILL_WRONG_LABEL, UNCHANGED_LABEL,
};
use rust_xlsxwriter::{Color, Format, FormatAlign};
let head = Format::new()
.set_bold()
.set_background_color(Color::RGB(0x33_3333))
.set_font_color(Color::White);
let label = Format::new().set_bold();
let axis = Format::new().set_bold().set_align(FormatAlign::Right);
let sheet = workbook.add_worksheet();
sheet.set_name("Metrics").map_err(|e| e.to_string())?;
sheet.set_column_width(0, 28.0).map_err(|e| e.to_string())?;
let mut r: u32 = 0;
let put = |sheet: &mut rust_xlsxwriter::Worksheet,
row: u32,
col: u16,
text: &str,
fmt: &Format|
-> Result<(), String> {
sheet
.write_string_with_format(row, col, text, fmt)
.map(|_| ())
.map_err(|e| e.to_string())
};
for (c, h) in ["Column", COMPARED_LABEL, INCORRECT_LABEL, ACCURACY_LABEL]
.iter()
.enumerate()
{
put(sheet, r, c as u16, h, &head)?;
}
r += 1;
for m in metrics.overall.iter().chain(metrics.columns.iter()) {
put(sheet, r, 0, &m.header, &label)?;
put(
sheet,
r,
1,
&format!("{} of {}", m.compared, m.total),
&Format::new(),
)?;
sheet
.write_number(r, 2, m.incorrect as f64)
.map_err(|e| e.to_string())?;
if let Some(a) = m.accuracy() {
sheet
.write_number_with_format(r, 3, a, &Format::new().set_num_format("0.0%"))
.map_err(|e| e.to_string())?;
}
r += 1;
}
if let Some(mv) = &metrics.movement {
r += 2;
put(sheet, r, 0, MOVEMENT_LABEL, &head)?;
r += 1;
for (name, n) in [
(FIXED_LABEL, mv.fixed),
(REGRESSED_LABEL, mv.regressed),
(STILL_WRONG_LABEL, mv.still_wrong),
(UNCHANGED_LABEL, mv.unchanged),
] {
put(sheet, r, 0, name, &label)?;
sheet
.write_number(r, 1, n as f64)
.map_err(|e| e.to_string())?;
r += 1;
}
}
for m in &metrics.columns {
let Some(matrix) = &m.matrix else { continue };
r += 2;
put(
sheet,
r,
0,
&format!("{} — truth (down) by reported value (across)", m.header),
&label,
)?;
r += 1;
for (c, a) in matrix.axis.iter().enumerate() {
put(sheet, r, c as u16 + 1, a, &head)?;
}
r += 1;
for (t, a) in matrix.axis.iter().enumerate() {
put(sheet, r, 0, a, &axis)?;
for (p, n) in matrix.counts[t].iter().enumerate() {
sheet
.write_number(r, p as u16 + 1, *n as f64)
.map_err(|e| e.to_string())?;
}
r += 1;
}
}
Ok(())
}
#[derive(Clone, Copy)]
pub(super) enum Tint {
Green,
Red,
Amber,
}
const XLSX_MIN_COL_WIDTH: f64 = 9.0;
const XLSX_MAX_COL_WIDTH: f64 = 60.0;
const XLSX_HEADER_PADDING: usize = 5;
const XLSX_CELL_PADDING: usize = 2;
fn text_display_width(text: &str) -> usize {
text.lines().map(|l| l.chars().count()).max().unwrap_or(0)
}
fn clamp_xlsx_width(measured: usize) -> f64 {
(measured as f64).clamp(XLSX_MIN_COL_WIDTH, XLSX_MAX_COL_WIDTH)
}
fn px_to_char_width(px: f64) -> usize {
(((px - 5.0).max(0.0)) / 7.0).ceil() as usize
}
fn xlsx_image_boxes(
columns: &[OutputColumn],
result: &ReportResult,
) -> std::collections::HashMap<(usize, usize), (f64, f64)> {
let mut out = std::collections::HashMap::new();
for (col, c) in columns.iter().enumerate() {
let Some(spec) = c.image else { continue };
if spec.fit {
continue;
}
for r in 0..result.rows.len() {
if let Some(img) = result.images.get(&(r, c.header.clone()))
&& let Some(size) = spec.scaled_size(img.natural)
{
out.insert((r, col), size);
}
}
}
out
}
fn xlsx_column_widths(columns: &[OutputColumn], result: &ReportResult) -> Vec<f64> {
let mut widths: Vec<f64> = measured_column_widths(columns, result)
.into_iter()
.map(clamp_xlsx_width)
.collect();
for (col, c) in columns.iter().enumerate() {
if let Some(w) = xlsx_image_column_width(c, result) {
widths[col] = w;
}
}
widths
}
fn xlsx_image_column_width(column: &OutputColumn, result: &ReportResult) -> Option<f64> {
let spec = column.image?;
let widest = result
.images
.iter()
.filter(|((_, header), _)| header == &column.header)
.filter_map(|(_, img)| spec.scaled_size(img.natural).map(|(w, _)| w))
.fold(0.0_f64, f64::max);
if widest > 0.0 {
return Some(clamp_xlsx_width(px_to_char_width(widest)));
}
result
.images
.keys()
.any(|(_, header)| header == &column.header)
.then_some(XLSX_FIT_IMAGE_WIDTH)
}
const XLSX_FIT_IMAGE_WIDTH: f64 = 18.0;
pub(super) fn measured_column_widths(
columns: &[OutputColumn],
result: &ReportResult,
) -> Vec<usize> {
let mut widths: Vec<usize> = columns
.iter()
.map(|c| text_display_width(&c.header) + XLSX_HEADER_PADDING)
.collect();
for row in &result.rows {
for (col, c) in columns.iter().enumerate() {
let value = c.value(row, &result.no_match_marker);
let want = text_display_width(&value) + XLSX_CELL_PADDING;
if want > widths[col] {
widths[col] = want;
}
}
}
for srow in result.summary_rows(columns) {
for (col, width) in widths.iter_mut().enumerate() {
let want = text_display_width(&srow.text_cell(col)) + XLSX_HEADER_PADDING;
if want > *width {
*width = want;
}
}
}
widths
}
const HTML_MAX_COL_WIDTH: usize = 70;
const HTML_MIN_COL_WIDTH: usize = 6;
const HTML_PX_PER_CH: f64 = 8.0;
const HTML_FIT_IMAGE_WIDTH: usize = 24;
const HTML_TOTAL_WIDTH_BUDGET: usize = 240;
const HTML_SHRINK_FLOOR: usize = 16;
fn html_column_widths(columns: &[OutputColumn], result: &ReportResult) -> Vec<usize> {
let mut widths: Vec<usize> = measured_column_widths(columns, result)
.into_iter()
.map(|w| w.clamp(HTML_MIN_COL_WIDTH, HTML_MAX_COL_WIDTH))
.collect();
for (ci, c) in columns.iter().enumerate() {
if let Some(w) = image_column_width(c, result) {
widths[ci] = w;
}
}
fit_to_budget(widths)
}
fn image_column_width(column: &OutputColumn, result: &ReportResult) -> Option<usize> {
let spec = column.image?;
let widest = result
.images
.iter()
.filter(|((_, header), _)| header == &column.header)
.filter_map(|(_, img)| spec.scaled_size(img.natural).map(|(w, _)| w))
.fold(0.0_f64, f64::max);
if widest <= 0.0 {
return result
.images
.keys()
.any(|(_, header)| header == &column.header)
.then_some(HTML_FIT_IMAGE_WIDTH);
}
let ch = (widest / HTML_PX_PER_CH).ceil() as usize + 2;
Some(ch.clamp(HTML_MIN_COL_WIDTH, HTML_MAX_COL_WIDTH))
}
fn fit_to_budget(mut widths: Vec<usize>) -> Vec<usize> {
let total: usize = widths.iter().sum();
if total <= HTML_TOTAL_WIDTH_BUDGET || widths.is_empty() {
return widths;
}
let mut low = HTML_SHRINK_FLOOR;
let mut high = widths.iter().copied().max().unwrap_or(low).max(low);
while low < high {
let mid = low + (high - low).div_ceil(2);
let sum: usize = widths.iter().map(|w| (*w).min(mid)).sum();
if sum <= HTML_TOTAL_WIDTH_BUDGET {
low = mid;
} else {
high = mid - 1;
}
}
for w in widths.iter_mut() {
*w = (*w).min(low);
}
widths
}
pub(super) fn run_cell_tint(
result: &ReportResult,
r: usize,
header: &str,
value: &str,
) -> Option<Tint> {
if let Some(v) = result.verdicts.get(&(r, header.to_string())) {
return match v {
Verdict::Correct => Some(Tint::Green),
Verdict::Incorrect => Some(Tint::Red),
Verdict::Untested => None,
};
}
if header == TREND_COLUMN {
return match result.row_trend(r) {
Some(Trend::Fixed) => Some(Tint::Green),
Some(Trend::Regressed) => Some(Tint::Red),
Some(Trend::Unchanged) | Some(Trend::StillWrong) | None => None,
};
}
if header == CORRECT_COLUMN {
return match value.trim() {
v if v == Verdict::Correct.as_str() => Some(Tint::Green),
v if v == Verdict::Incorrect.as_str() => Some(Tint::Red),
_ => None,
};
}
cell_tint(header, value)
}
fn cell_tint(header: &str, value: &str) -> Option<Tint> {
let v = value.trim();
if v.is_empty() {
return None;
}
if header == RESULT_COLUMN {
return match v {
MATCH => None,
NO_BASELINE => Some(Tint::Amber),
NO_CANDIDATE => Some(Tint::Red),
_ => Some(Tint::Amber),
};
}
let lower = v.to_ascii_lowercase();
match lower.as_str() {
"ok" | "success" | "succeeded" | "pass" | "passed" | "match" | "matched" | "true"
| "done" | "complete" | "completed" => return Some(Tint::Green),
"error" | "fail" | "failed" | "failure" | "false" | "no candidate" => {
return Some(Tint::Red);
}
"changed" | "change" | "warning" | "warn" | "diff" | "different" | "mismatch" => {
return Some(Tint::Amber);
}
_ => {}
}
if v.len() == 3
&& let Ok(code) = v.parse::<u16>()
&& (100..600).contains(&code)
{
return Some(match code / 100 {
2 => Tint::Green,
3 => Tint::Amber,
_ => Tint::Red,
});
}
None
}
fn col_letter(mut n: usize) -> String {
let mut s = String::new();
loop {
s.insert(0, (b'A' + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
s
}
fn xlsx_stat_formula(
v: &crate::report::model::StatValue,
col: usize,
nrows: usize,
) -> Option<String> {
use crate::report::model::StatKind;
if nrows == 0 {
return None;
}
let letter = col_letter(col);
let range = format!("{letter}2:{letter}{}", nrows + 1);
if v.stat == Some(StatKind::Distribution) {
let crit = v.match_value.as_deref().unwrap_or("").replace('"', "\"\"");
return Some(format!("=COUNTIF({range},\"{crit}\")"));
}
if !v.numeric {
return None;
}
let f = match v.stat? {
StatKind::Mean => format!("=AVERAGE({range})"),
StatKind::Median => format!("=MEDIAN({range})"),
StatKind::Sum => format!("=SUM({range})"),
StatKind::Min => format!("=MIN({range})"),
StatKind::Max => format!("=MAX({range})"),
StatKind::StdDev => format!("=STDEVP({range})"),
StatKind::Mode => format!("=MODE({range})"),
StatKind::Count => format!("=COUNT({range})"),
StatKind::Distribution => unreachable!(),
};
Some(f)
}
pub(crate) fn parse_report_number(value: &str) -> Option<f64> {
let v = value.trim();
if v.is_empty() {
return None;
}
let digits = v.strip_prefix(['+', '-']).unwrap_or(v);
let mut chars = digits.chars();
if chars.next() == Some('0') && chars.next().is_some_and(|c| c.is_ascii_digit()) {
return None;
}
let n: f64 = v.parse().ok()?;
n.is_finite().then_some(n)
}
fn column_is_numeric(column: &OutputColumn, result: &ReportResult) -> bool {
let mut saw_value = false;
for row in &result.rows {
let v = column.value(row, &result.no_match_marker);
let t = v.trim();
if t.is_empty() || v == result.no_match_marker {
continue;
}
if parse_report_number(&v).is_none() {
return false;
}
saw_value = true;
}
saw_value
}
fn push_record<'a>(out: &mut String, fields: impl Iterator<Item = &'a str>) {
let mut first = true;
for field in fields {
if !first {
out.push(',');
}
first = false;
out.push_str(&escape_field(field));
}
out.push_str("\r\n");
}
fn escape_field(field: &str) -> String {
let neutralised;
let field = if field.starts_with(['=', '+', '@', '\t', '\r']) {
neutralised = format!("'{field}");
neutralised.as_str()
} else {
field
};
if field.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::model::{ReportResult, ReportRow};
use std::collections::HashMap;
#[test]
fn the_output_directive_chooses_the_export_extension() {
let r = crate::report::Report::from_text("s", "# output: XLSX\n# collection: c.hurl\n");
assert_eq!(report_output_extension(&r), "xlsx");
}
#[test]
fn an_unwritable_or_absent_output_directive_falls_back_to_csv() {
let r = crate::report::Report::from_text("s", "# output: docx\n# collection: c.hurl\n");
assert_eq!(report_output_extension(&r), "csv");
let r = crate::report::Report::from_text("s", "# collection: c.hurl\n");
assert_eq!(report_output_extension(&r), "csv");
let r = crate::report::Report::from_text("s", "this is not a report at all {{{\n");
assert_eq!(report_output_extension(&r), "csv");
}
#[test]
fn an_export_lands_beside_a_saved_report() {
let mut r = crate::report::Report::from_text("sample", "# collection: c.hurl\n");
r.path = Some(std::path::PathBuf::from("/tmp/reports/sample.trail"));
assert_eq!(
export_path(&r, "xlsx"),
std::path::PathBuf::from("/tmp/reports/sample.xlsx")
);
assert_eq!(
export_path(&r, "baseline"),
std::path::PathBuf::from("/tmp/reports/sample.baseline")
);
}
#[test]
fn a_scratch_reports_name_is_sanitised_into_the_stem() {
let r = crate::report::Report::from_text("s", "# name: ../../etc/passwd\n");
let p = export_path(&r, "csv");
assert_eq!(p, std::path::PathBuf::from("______etc_passwd.csv"));
assert_eq!(p.components().count(), 1, "must stay a single segment");
}
fn row(cells: &[(&str, &str)]) -> ReportRow {
ReportRow {
cells: cells
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
vars: HashMap::new(),
key: vec![],
path: Vec::new(),
target: None,
}
}
fn csv(result: &ReportResult) -> String {
String::from_utf8(CsvWriter.write(result, &Header::default()).unwrap()).unwrap()
}
#[test]
fn default_columns_follow_first_seen_order() {
let res = ReportResult {
column_order: vec!["p.HttpStatus".into(), "p.status".into()],
rows: vec![row(&[("p.HttpStatus", "200"), ("p.status", "ok")])],
..Default::default()
};
assert_eq!(csv(&res), "p.HttpStatus,p.status\r\n200,ok\r\n");
}
#[test]
fn columns_directive_renames_reorders_and_marks_missing() {
let res = ReportResult {
no_match_marker: "-".into(),
column_order: vec!["FILE".into(), "p.status".into()],
rows: vec![row(&[("FILE", "a.jpg")])], ..Default::default()
};
let header = Header {
lines: vec![super::super::flow::HeaderLine::Directive {
key: "columns".into(),
value: "FILE as Name, p.status as Status".into(),
}],
};
let text = String::from_utf8(CsvWriter.write(&res, &header).unwrap()).unwrap();
assert_eq!(text, "Name,Status\r\na.jpg,-\r\n");
}
#[test]
fn fields_with_commas_quotes_and_newlines_are_escaped() {
let res = ReportResult {
column_order: vec!["resp".into()],
rows: vec![row(&[("resp", "a,\"b\"\nc")])],
..Default::default()
};
assert_eq!(csv(&res), "resp\r\n\"a,\"\"b\"\"\nc\"\r\n");
}
#[test]
fn formula_leading_fields_are_neutralised_against_injection() {
let mut res = ReportResult {
column_order: vec!["body".into()],
rows: vec![row(&[("body", "=1+SUM(A1)")])],
..Default::default()
};
assert_eq!(csv(&res), "body\r\n'=1+SUM(A1)\r\n");
res.rows = vec![row(&[("body", "@cmd,tail")])];
assert_eq!(csv(&res), "body\r\n\"'@cmd,tail\"\r\n");
res.rows = vec![row(&[("body", "-42")])];
assert_eq!(csv(&res), "body\r\n-42\r\n");
}
#[test]
fn json_output_is_columns_plus_row_objects_in_order() {
let res = ReportResult {
column_order: vec!["FILE".into(), "status".into()],
rows: vec![
row(&[("FILE", "a.jpg"), ("status", "ok")]),
row(&[("FILE", "b.jpg"), ("status", "error")]),
],
..Default::default()
};
let bytes = JsonWriter.write(&res, &Header::default()).unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["columns"], serde_json::json!(["FILE", "status"]));
assert_eq!(v["rows"][0]["FILE"], "a.jpg");
assert_eq!(v["rows"][1]["status"], "error");
let keys: Vec<&str> = v["rows"][0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(keys, vec!["FILE", "status"]);
}
#[test]
fn json_missing_source_uses_the_no_match_marker() {
let res = ReportResult {
no_match_marker: "∅".into(),
column_order: vec!["FILE".into(), "missing".into()],
rows: vec![row(&[("FILE", "a.jpg")])],
..Default::default()
};
let bytes = JsonWriter.write(&res, &Header::default()).unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["rows"][0]["missing"], "∅");
}
#[test]
fn html_output_is_a_self_contained_table_with_escaping_and_tints() {
let res = ReportResult {
column_order: vec!["FILE".into(), "Status".into()],
rows: vec![
row(&[("FILE", "a & <b>.jpg"), ("Status", "success")]),
row(&[("FILE", "c.jpg"), ("Status", "error")]),
],
..Default::default()
};
let bytes = HtmlWriter.write(&res, &Header::default()).unwrap();
let html = String::from_utf8(bytes).unwrap();
assert!(html.starts_with("<!DOCTYPE html>"), "is an HTML document");
assert!(html.contains("<table>"), "has a table");
assert!(html.contains("<th>FILE</th>"), "header cell: {html}");
assert!(
html.contains("a & <b>.jpg"),
"cell text is HTML-escaped: {html}"
);
assert!(
html.contains("<td class=\"pass\">success</td>"),
"success is tinted pass: {html}"
);
assert!(
html.contains("<td class=\"fail\">error</td>"),
"error is tinted fail: {html}"
);
assert!(!html.contains("http://") && !html.contains("https://"));
}
#[test]
fn html_columns_are_sized_to_their_content() {
let res = ReportResult {
column_order: vec!["Environment".into(), "Body".into()],
rows: vec![row(&[
("Environment", "staging_au"),
("Body", &"x".repeat(400)),
])],
..Default::default()
};
let widths = html_column_widths(&res.resolved_columns(&Header::default()), &res);
assert!(
widths[0] >= "Environment".len(),
"the header fits on one line, got {}ch",
widths[0]
);
assert_eq!(
widths[1], HTML_MAX_COL_WIDTH,
"a 400-character body is capped, not allowed to push everything else off the page"
);
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(
html.contains(&format!("<col style=\"width:{}ch\">", widths[0])),
"the widths reach the document: {html}"
);
assert!(html.contains("table-layout:fixed"), "{html}");
assert!(html.contains("width:max-content"), "{html}");
assert!(
html.contains("white-space:nowrap;overflow-wrap:normal"),
"{html}"
);
assert!(html.contains("overflow-wrap:anywhere"), "{html}");
}
#[test]
fn a_tiny_html_column_keeps_a_readable_minimum() {
let res = ReportResult {
column_order: vec!["#".into()],
rows: vec![row(&[("#", "1")])],
..Default::default()
};
let widths = html_column_widths(&res.resolved_columns(&Header::default()), &res);
assert_eq!(widths[0], HTML_MIN_COL_WIDTH);
}
#[test]
fn parse_report_number_accepts_quantities_but_not_identifiers() {
assert_eq!(parse_report_number(" 123 "), Some(123.0));
assert_eq!(parse_report_number("-3.5"), Some(-3.5));
assert_eq!(parse_report_number("0"), Some(0.0));
assert_eq!(parse_report_number("0.5"), Some(0.5));
assert_eq!(parse_report_number("007"), None);
assert_eq!(parse_report_number("-08"), None);
assert_eq!(parse_report_number(""), None);
assert_eq!(parse_report_number("High Risk"), None);
assert_eq!(parse_report_number("123 ms"), None);
}
#[test]
fn column_is_numeric_only_when_every_value_is_a_number() {
let numeric_col = OutputColumn {
header: "Time".into(),
sources: vec!["Time".into()],
stats: Vec::new(),
image: None,
truth: None,
detail: false,
};
let res = ReportResult {
no_match_marker: "-".into(),
column_order: vec!["Time".into()],
rows: vec![
row(&[("Time", "12")]),
row(&[("Time", "34.5")]),
row(&[]), ],
..Default::default()
};
assert!(column_is_numeric(&numeric_col, &res));
let mixed = ReportResult {
column_order: vec!["Time".into()],
rows: vec![row(&[("Time", "12")]), row(&[("Time", "n/a")])],
..Default::default()
};
assert!(!column_is_numeric(&numeric_col, &mixed));
let empty = ReportResult {
column_order: vec!["Time".into()],
rows: vec![row(&[])],
..Default::default()
};
assert!(!column_is_numeric(&numeric_col, &empty));
}
#[test]
fn text_width_measures_the_longest_line_not_the_whole_string() {
assert_eq!(text_display_width("abc"), 3);
assert_eq!(text_display_width("abc\nlonger line\nx"), 11);
assert_eq!(text_display_width(""), 0);
assert_eq!(text_display_width("héllo"), 5);
}
#[test]
fn measured_widths_are_clamped_to_the_readable_range() {
assert_eq!(clamp_xlsx_width(0), XLSX_MIN_COL_WIDTH);
assert_eq!(clamp_xlsx_width(1), XLSX_MIN_COL_WIDTH);
assert_eq!(clamp_xlsx_width(20), 20.0);
assert_eq!(clamp_xlsx_width(10_000), XLSX_MAX_COL_WIDTH);
}
#[test]
fn xlsx_columns_are_sized_to_their_widest_content() {
let res = ReportResult {
column_order: vec!["id".into(), "url".into()],
rows: vec![
row(&[
("id", "1"),
("url", "https://example.com/a/fairly/long/path"),
]),
row(&[("id", "2"), ("url", "short")]),
],
..Default::default()
};
let columns = res.resolved_columns(&Header::default());
let widths = xlsx_column_widths(&columns, &res);
assert_eq!(widths[0], XLSX_MIN_COL_WIDTH);
assert_eq!(
widths[1],
("https://example.com/a/fairly/long/path".len() + XLSX_CELL_PADDING) as f64
);
assert!(widths[1] > widths[0], "the wide column is genuinely wider");
}
#[test]
fn a_very_long_cell_is_capped_so_it_cannot_squeeze_out_every_other_column() {
let res = ReportResult {
column_order: vec!["body".into(), "id".into()],
rows: vec![row(&[("body", &"x".repeat(5_000)), ("id", "1")])],
..Default::default()
};
let columns = res.resolved_columns(&Header::default());
let widths = xlsx_column_widths(&columns, &res);
assert_eq!(widths[0], XLSX_MAX_COL_WIDTH);
assert_eq!(widths[1], XLSX_MIN_COL_WIDTH);
}
#[test]
fn a_long_header_widens_its_column_even_when_every_value_is_short() {
let res = ReportResult {
column_order: vec!["a_rather_long_column_header".into()],
rows: vec![row(&[("a_rather_long_column_header", "1")])],
..Default::default()
};
let columns = res.resolved_columns(&Header::default());
let widths = xlsx_column_widths(&columns, &res);
assert_eq!(
widths[0],
("a_rather_long_column_header".len() + XLSX_HEADER_PADDING) as f64
);
}
#[test]
fn statistics_labels_widen_the_column_they_sit_in() {
let res = stats_result();
let header = stats_header("Name, Time STATISTICS(DISTRIBUTION)");
let columns = res.resolved_columns(&header);
let widths = xlsx_column_widths(&columns, &res);
assert_eq!(
widths[0],
("Time = 100".len() + XLSX_HEADER_PADDING) as f64,
"label column must fit its widest statistics label"
);
}
#[test]
fn xlsx_output_is_a_valid_nonempty_zip() {
let res = ReportResult {
column_order: vec!["FILE".into(), "status".into()],
rows: vec![row(&[("FILE", "a.jpg"), ("status", "success")])],
..Default::default()
};
let bytes = XlsxWriter.write(&res, &Header::default()).unwrap();
assert!(!bytes.is_empty(), "xlsx produced bytes");
assert_eq!(&bytes[..2], b"PK", "starts with the ZIP local-file magic");
}
fn stats_header(spec: &str) -> Header {
Header {
lines: vec![super::super::flow::HeaderLine::Directive {
key: "columns".into(),
value: spec.into(),
}],
}
}
fn truth_result() -> (ReportResult, Header) {
use crate::report::model::Verdict;
let mut res = ReportResult {
column_order: vec!["Correct".into(), "Name".into(), "Verdict".into()],
rows: vec![
row(&[
("Name", "a"),
("Verdict", "Low Risk"),
("Correct", "correct"),
]),
row(&[
("Name", "b"),
("Verdict", "High Risk"),
("Correct", "correct"),
]),
row(&[
("Name", "c"),
("Verdict", "Low Risk"),
("Correct", "incorrect"),
]),
row(&[("Name", "d"), ("Verdict", "Low Risk")]),
],
..Default::default()
};
res.column_truths
.insert("Verdict".into(), "{{ expected }}".into());
for (r, (v, t)) in [
(Verdict::Correct, "real"),
(Verdict::Correct, "fake"),
(Verdict::Incorrect, "fake"),
]
.into_iter()
.enumerate()
{
res.verdicts.insert((r, "Verdict".into()), v);
res.truths.insert((r, "Verdict".into()), t.into());
}
let header = Header {
lines: vec![
super::super::flow::HeaderLine::Directive {
key: "labels".into(),
value: "Pass = pass, real, low risk".into(),
},
super::super::flow::HeaderLine::Directive {
key: "labels".into(),
value: "Fail = fail, fake, high risk".into(),
},
],
};
(res, header)
}
#[test]
fn csv_appends_the_ground_truth_metrics_to_the_footer() {
let (res, header) = truth_result();
let out = String::from_utf8(CsvWriter.write(&res, &header).unwrap()).unwrap();
assert!(
out.contains("3 of 4,Compared,3 of 4"),
"compared row: {out}"
);
assert!(out.contains("66.7%,Accuracy,66.7%"), "accuracy row: {out}");
let plain = String::from_utf8(
CsvWriter
.write(&stats_result(), &Header::default())
.unwrap(),
)
.unwrap();
assert!(!plain.contains("Accuracy"), "{plain}");
}
#[test]
fn html_draws_metric_cards_and_a_confusion_matrix() {
let (res, header) = truth_result();
let out = String::from_utf8(HtmlWriter.write(&res, &header).unwrap()).unwrap();
assert!(out.contains("class=\"metrics\""), "cards: {out}");
assert!(out.contains("66.7%"), "accuracy card: {out}");
assert!(out.contains("class=\"matrix\""), "matrix: {out}");
let pass = out.find("Pass").expect("Pass axis label");
let fail = out.find("Fail").expect("Fail axis label");
assert!(pass < fail, "the axis keeps its declared order");
assert!(
!out.contains("<tfoot"),
"the metrics are not also repeated in the footer: {out}"
);
assert!(
!out.contains("http://") && !out.contains("https://"),
"the export stays self-contained"
);
}
#[test]
fn json_exports_the_metrics_as_numbers() {
let (res, header) = truth_result();
let out = JsonWriter.write(&res, &header).unwrap();
let doc: serde_json::Value = serde_json::from_slice(&out).unwrap();
let col = &doc["metrics"]["columns"][0];
assert_eq!(col["column"], "Verdict");
assert_eq!(col["compared"], 3);
assert_eq!(col["incorrect"], 1);
assert!((col["accuracy"].as_f64().unwrap() - 2.0 / 3.0).abs() < 1e-9);
assert_eq!(col["confusion"]["axis"][0], "Pass");
assert_eq!(col["confusion"]["counts"][1][0], 1);
assert_eq!(doc["metrics"]["overall"]["correct"], 2);
}
#[test]
fn the_confusion_matrix_is_drawn_larger_than_the_table() {
let (res, header) = truth_result();
let out = String::from_utf8(HtmlWriter.write(&res, &header).unwrap()).unwrap();
assert!(
out.contains(".matrix table{width:auto;min-width:0;font-size:20px}"),
"the matrix has its own, larger type size: {out}"
);
assert!(
out.contains("padding:12px 18px"),
"and cells big enough to aim at: {out}"
);
}
#[test]
fn no_labels_directive_means_no_matrix_but_still_metrics() {
let (res, _) = truth_result();
let out = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(out.contains("class=\"metrics\""), "figures still shown");
assert!(!out.contains("class=\"matrix\""), "but no matrix: {out}");
}
fn stats_result() -> ReportResult {
ReportResult {
column_order: vec!["Name".into(), "Time".into()],
rows: vec![
row(&[("Name", "a"), ("Time", "100")]),
row(&[("Name", "b"), ("Time", "200")]),
row(&[("Name", "c"), ("Time", "300")]),
],
..Default::default()
}
}
#[test]
fn csv_appends_statistics_summary_rows() {
let res = stats_result();
let header = stats_header("Name, Time STATISTICS(SUM, MEAN)");
let text = String::from_utf8(CsvWriter.write(&res, &header).unwrap()).unwrap();
assert!(
text.contains("Sum,600"),
"CSV should carry the Sum row: {text}"
);
assert!(
text.contains("Mean,200"),
"CSV should carry the Mean row: {text}"
);
}
#[test]
fn csv_distribution_counts_each_value() {
let res = ReportResult {
column_order: vec!["Overall".into()],
rows: vec![
row(&[("Overall", "Low")]),
row(&[("Overall", "High")]),
row(&[("Overall", "Low")]),
],
..Default::default()
};
let header = stats_header("Overall STATISTICS(DISTRIBUTION)");
let text = String::from_utf8(CsvWriter.write(&res, &header).unwrap()).unwrap();
assert!(text.contains("2"), "Low should be counted twice: {text}");
assert!(text.contains("1"), "High should be counted once: {text}");
}
#[test]
fn json_includes_a_summary_array() {
let res = stats_result();
let header = stats_header("Name, Time STATISTICS(MEAN)");
let bytes = JsonWriter.write(&res, &header).unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let summary = v
.get("summary")
.and_then(|s| s.as_array())
.expect("summary array present");
assert!(!summary.is_empty(), "summary should hold the Mean row");
let mean = &summary[0];
assert_eq!(mean.get("Time").and_then(|t| t.as_str()), Some("200"));
assert_eq!(mean.get("Name").and_then(|t| t.as_str()), Some("Mean"));
}
#[test]
fn html_includes_a_tfoot_summary() {
let res = stats_result();
let header = stats_header("Name, Time STATISTICS(MEAN)");
let bytes = HtmlWriter.write(&res, &header).unwrap();
let html = String::from_utf8(bytes).unwrap();
assert!(
html.contains("<tfoot>"),
"HTML should carry a tfoot: {html}"
);
assert!(html.contains("Mean"), "tfoot should show the Mean label");
assert!(html.contains("200"), "tfoot should show the computed mean");
}
#[test]
fn xlsx_with_statistics_is_a_valid_zip() {
let res = stats_result();
let header = stats_header("Name, Time STATISTICS(MEAN, SUM)");
let bytes = XlsxWriter.write(&res, &header).unwrap();
assert!(!bytes.is_empty(), "xlsx with stats produced bytes");
assert_eq!(&bytes[..2], b"PK", "still a valid ZIP container");
}
fn image_result() -> (ReportResult, Header) {
use crate::report::flow::ImageSpec;
let png = crate::report::image::tests::png_1x1();
let mut res = ReportResult {
column_order: vec!["Name".into(), "Frame".into()],
rows: vec![row(&[("Name", "a"), ("Frame", "shots/a.png")])],
..Default::default()
};
res.column_images.insert(
"Frame".to_string(),
ImageSpec {
height: Some(60),
..Default::default()
},
);
res.images.insert(
(0, "Frame".to_string()),
crate::report::model::ImageData {
bytes: png,
mime: "image/png".to_string(),
natural: (1, 1),
},
);
(res, Header::default())
}
#[test]
fn the_header_block_says_what_moved_since_the_baseline() {
use crate::report::model::Trend;
let mut res = ReportResult {
column_order: vec!["Correct".into(), "Trend".into(), "Verdict".into()],
rows: vec![
row(&[("Correct", "correct"), ("Verdict", "pass")]),
row(&[("Correct", "incorrect"), ("Verdict", "fail")]),
],
..Default::default()
};
res.column_truths.insert("Verdict".into(), "{{ e }}".into());
res.verdicts.insert((0, "Verdict".into()), Verdict::Correct);
res.verdicts
.insert((1, "Verdict".into()), Verdict::Incorrect);
res.trends.insert((0, "Verdict".into()), Trend::Fixed);
res.trends.insert((1, "Verdict".into()), Trend::Regressed);
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(
html.contains("Fixed") && html.contains("Regressed"),
"both directions are stated, not just the bad one: {html}"
);
let json = String::from_utf8(JsonWriter.write(&res, &Header::default()).unwrap()).unwrap();
let doc: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(doc["metrics"]["movement"]["fixed"], 1);
assert_eq!(doc["metrics"]["movement"]["regressed"], 1);
let mut alone = res.clone();
alone.trends.clear();
let json =
String::from_utf8(JsonWriter.write(&alone, &Header::default()).unwrap()).unwrap();
let doc: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(doc["metrics"]["movement"].is_null());
}
#[test]
fn an_html_report_carries_a_dark_palette_as_well_as_a_light_one() {
let mut res = ReportResult::default();
res.column_order = vec!["Name".to_string()];
res.rows.push(ReportRow {
cells: HashMap::from([("Name".to_string(), "a".to_string())]),
..Default::default()
});
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(
html.contains("prefers-color-scheme: dark"),
"a reader whose system asks for dark gets it without being asked: {html}"
);
assert!(
html.contains("id=\"pb-theme\""),
"and a reader who disagrees with their system can say so"
);
assert!(
html.contains("data-pb-theme=\"dark\""),
"the override is an attribute on the document, so it beats the media query"
);
let style = html
.split("<style>")
.nth(1)
.and_then(|s| s.split("</style>").next())
.expect("the document has a stylesheet");
let body = style
.split(":root[data-pb-theme=\"dark\"]{")
.nth(1)
.and_then(|s| s.split_once('}'))
.expect("the explicit dark palette is the last of the palettes")
.1;
let stray: Vec<&str> = body
.match_indices('#')
.map(|(i, _)| &body[i..(i + 7).min(body.len())])
.filter(|c| c[1..].starts_with(|ch: char| ch.is_ascii_hexdigit()))
.filter(|c| !c.starts_with("#fff") && !c.starts_with("#0b0d10"))
.filter(|c| !c.starts_with("#eaf1ff"))
.collect();
assert!(
stray.is_empty(),
"every other colour comes from the palette, not from the rule: {stray:?}"
);
}
#[test]
fn html_embeds_each_picture_once_and_the_panel_borrows_it() {
let (res, header) = image_result();
let html = String::from_utf8(HtmlWriter.write(&res, &header).unwrap()).unwrap();
assert_eq!(
html.matches(";base64,").count(),
1,
"the bytes appear once, not once per view: {html}"
);
assert!(
html.contains("data-c=\"1\""),
"the grid cell is tagged with its column index: {html}"
);
assert!(
html.contains("class=\"full\" data-from=\"1\""),
"and the panel copy points back at it: {html}"
);
assert!(
html.contains("img.full[data-from]"),
"the script hydrates it on expand: {html}"
);
}
#[test]
fn the_drill_down_sections_hug_their_content() {
let (res, header) = image_result();
let html = String::from_utf8(HtmlWriter.write(&res, &header).unwrap()).unwrap();
assert!(
html.contains(".panel section{flex:0 1 auto;"),
"sections do not grow to fill the row: {html}"
);
assert!(
html.contains("align-items:flex-start"),
"and a short section is not stretched to the tallest one's height: {html}"
);
}
#[test]
fn html_embeds_a_detail_only_picture_in_the_panel_itself() {
let (mut res, header) = image_result();
res.column_details.insert("Frame".to_string());
let html = String::from_utf8(HtmlWriter.write(&res, &header).unwrap()).unwrap();
assert_eq!(
html.matches(";base64,").count(),
1,
"still exactly once -- but this time in the panel: {html}"
);
assert!(
!html.contains("data-from="),
"there is no cell to borrow from, so nothing is deferred: {html}"
);
}
#[test]
fn xlsx_embeds_a_resolved_picture_as_a_media_part() {
let (res, header) = image_result();
let bytes = XlsxWriter.write(&res, &header).unwrap();
assert_eq!(&bytes[..2], b"PK");
let hay = String::from_utf8_lossy(&bytes);
assert!(
hay.contains("xl/media/image"),
"the workbook should carry an embedded picture"
);
assert!(
hay.contains("xl/drawings/drawing1.xml"),
"and the drawing that anchors it"
);
}
#[test]
fn html_inlines_a_resolved_picture_as_a_data_uri() {
let (res, header) = image_result();
let html = String::from_utf8(HtmlWriter.write(&res, &header).unwrap()).unwrap();
assert!(
html.contains("<img style=\"width:60px;height:60px\""),
"sized from the IMAGE clause and the 1x1 aspect ratio: {html}"
);
assert!(
html.contains("data:image/png;base64,"),
"inlined rather than linked: {html}"
);
assert!(
html.contains("alt=\"shots/a.png\""),
"the source value survives as alt text: {html}"
);
}
#[test]
fn text_formats_ignore_the_image_clause_entirely() {
let (res, header) = image_result();
let text = String::from_utf8(CsvWriter.write(&res, &header).unwrap()).unwrap();
assert_eq!(text, "Name,Frame\r\na,shots/a.png\r\n");
let bytes = JsonWriter.write(&res, &header).unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
v["rows"][0]["Frame"].as_str(),
Some("shots/a.png"),
"JSON carries the value, not the picture"
);
}
#[test]
fn an_unresolved_image_cell_falls_back_to_its_text() {
let (mut res, header) = image_result();
res.rows
.push(row(&[("Name", "b"), ("Frame", "missing.png")]));
let html = String::from_utf8(HtmlWriter.write(&res, &header).unwrap()).unwrap();
assert!(
html.contains("missing.png</td>") || html.contains(">missing.png<"),
"the unresolved row keeps its text: {html}"
);
let bytes = XlsxWriter.write(&res, &header).unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("xl/media/image2"));
}
#[test]
fn a_verdict_tints_a_cell_by_correctness_not_by_its_wording() {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("Verdict", "fail"), ("Correct", "correct")]),
row(&[("Verdict", "pass"), ("Correct", "incorrect")]),
row(&[("Verdict", "pass"), ("Correct", "untested")]),
];
res.column_order = vec!["Correct".to_string(), "Verdict".to_string()];
res.verdicts.insert((0, "Verdict".into()), Verdict::Correct);
res.verdicts
.insert((1, "Verdict".into()), Verdict::Incorrect);
res.verdicts
.insert((2, "Verdict".into()), Verdict::Untested);
assert!(matches!(
run_cell_tint(&res, 0, "Verdict", "fail"),
Some(Tint::Green)
));
assert!(matches!(
run_cell_tint(&res, 1, "Verdict", "pass"),
Some(Tint::Red)
));
assert!(
run_cell_tint(&res, 2, "Verdict", "pass").is_none(),
"an untested row never borrows the look of a passing one"
);
assert!(matches!(
run_cell_tint(&res, 0, "Correct", "correct"),
Some(Tint::Green)
));
assert!(matches!(
run_cell_tint(&res, 1, "Correct", "incorrect"),
Some(Tint::Red)
));
let plain = ReportResult::default();
assert!(matches!(
run_cell_tint(&plain, 0, "Verdict", "fail"),
Some(Tint::Red)
));
}
#[test]
fn the_trend_column_tints_by_whether_the_row_is_right_not_by_its_word() {
use crate::report::model::Trend;
let mut res = ReportResult::default();
res.rows = vec![
row(&[("Verdict", "fail"), ("Trend", "fixed")]),
row(&[("Verdict", "pass"), ("Trend", "regressed")]),
row(&[("Verdict", "fail"), ("Trend", "unchanged")]),
];
res.verdicts.insert((0, "Verdict".into()), Verdict::Correct);
res.verdicts
.insert((1, "Verdict".into()), Verdict::Incorrect);
res.verdicts.insert((2, "Verdict".into()), Verdict::Correct);
res.trends.insert((0, "Verdict".into()), Trend::Fixed);
res.trends.insert((1, "Verdict".into()), Trend::Regressed);
res.trends.insert((2, "Verdict".into()), Trend::Unchanged);
assert!(
matches!(run_cell_tint(&res, 2, "Verdict", "fail"), Some(Tint::Green)),
"an unchanged-but-correct answer stays green, exactly as it is \
without a comparison"
);
assert!(matches!(
run_cell_tint(&res, 0, "Trend", Trend::Fixed.as_str()),
Some(Tint::Green)
));
assert!(matches!(
run_cell_tint(&res, 1, "Trend", Trend::Regressed.as_str()),
Some(Tint::Red)
));
let mut wrong = res.clone();
wrong
.trends
.insert((1, "Verdict".into()), Trend::StillWrong);
assert!(
run_cell_tint(&wrong, 1, "Trend", Trend::StillWrong.as_str()).is_none(),
"a row that didn't move is plain, however it scored"
);
assert!(
run_cell_tint(&res, 2, "Trend", Trend::Unchanged.as_str()).is_none(),
"and so is the other one"
);
assert_eq!(
Trend::StillWrong.as_str(),
Trend::Unchanged.as_str(),
"both say what the column is asking: this row did not move"
);
assert!(matches!(
run_cell_tint(&wrong, 1, CORRECT_COLUMN, Verdict::Incorrect.as_str()),
Some(Tint::Red)
));
}
#[test]
fn cell_tint_recognises_status_and_result_verdicts() {
assert!(matches!(cell_tint("Status", "success"), Some(Tint::Green)));
assert!(matches!(cell_tint("Status", "ERROR"), Some(Tint::Red)));
assert!(matches!(cell_tint("Status", "changed"), Some(Tint::Amber)));
assert!(matches!(cell_tint("HttpStatus", "200"), Some(Tint::Green)));
assert!(matches!(cell_tint("HttpStatus", "503"), Some(Tint::Red)));
assert!(cell_tint("Result", MATCH).is_none());
assert!(matches!(cell_tint("Result", NO_CANDIDATE), Some(Tint::Red)));
assert!(matches!(
cell_tint("Result", "status: a≠b"),
Some(Tint::Amber)
));
assert!(cell_tint("Name", "anything.jpg").is_none());
assert!(cell_tint("Status", " ").is_none());
}
#[test]
fn the_spreadsheets_picture_column_is_sized_to_the_picture() {
let (mut res, header) = image_result();
let long = "/home/somebody/Development/sample_images/absolute/trimmed/Real/image-real-6/Front-39.jpg";
res.rows[0]
.cells
.insert("Frame".to_string(), long.to_string());
let columns = res.resolved_columns(&header);
let widths = xlsx_column_widths(&columns, &res);
let ci = columns
.iter()
.position(|c| c.header == "Frame")
.expect("the picture column");
assert!(
widths[ci] < long.len() as f64 / 2.0,
"sized to the thumbnail, not the path: {}",
widths[ci]
);
assert!(
widths[ci] >= XLSX_MIN_COL_WIDTH,
"and not so narrow the picture is clipped: {}",
widths[ci]
);
}
#[test]
fn a_fit_picture_column_is_not_sized_to_its_path() {
use crate::report::flow::ImageSpec;
let (mut res, header) = image_result();
let long = "/home/somebody/Development/sample_images/absolute/trimmed/Real/image-real-6/Front-39.jpg";
res.rows[0]
.cells
.insert("Frame".to_string(), long.to_string());
res.column_images.insert(
"Frame".to_string(),
ImageSpec {
fit: true,
..Default::default()
},
);
let columns = res.resolved_columns(&header);
let ci = columns.iter().position(|c| c.header == "Frame").unwrap();
assert_eq!(xlsx_column_widths(&columns, &res)[ci], XLSX_FIT_IMAGE_WIDTH);
let html = html_column_widths(&columns, &res);
assert_eq!(html[ci], HTML_FIT_IMAGE_WIDTH);
}
#[test]
fn a_picture_column_is_sized_to_the_picture_not_to_its_path() {
let (mut res, header) = image_result();
let long = "/home/somebody/Development/sample_images/absolute/trimmed/Real/image-real-6/Front-39.jpg";
res.rows[0]
.cells
.insert("Frame".to_string(), long.to_string());
let widths = html_column_widths(&res.resolved_columns(&header), &res);
let ci = res
.resolved_columns(&header)
.iter()
.position(|c| c.header == "Frame")
.expect("the picture column");
assert!(
widths[ci] < long.len() / 2,
"sized to the thumbnail, not the path: {}ch",
widths[ci]
);
}
#[test]
fn many_columns_are_fitted_by_taking_from_the_widest() {
let unfitted = vec![
8,
9,
HTML_MAX_COL_WIDTH,
HTML_MAX_COL_WIDTH,
HTML_MAX_COL_WIDTH,
HTML_MAX_COL_WIDTH,
HTML_MAX_COL_WIDTH,
HTML_MAX_COL_WIDTH,
];
let fitted = fit_to_budget(unfitted.clone());
assert!(
fitted.iter().sum::<usize>() <= HTML_TOTAL_WIDTH_BUDGET,
"fitted to the budget: {fitted:?}"
);
assert_eq!(
(fitted[0], fitted[1]),
(8, 9),
"the narrow columns are untouched: {fitted:?}"
);
assert!(
fitted[2..].iter().all(|w| *w >= HTML_SHRINK_FLOOR),
"and nothing that was wide is squeezed into single words: {fitted:?}"
);
}
#[test]
fn a_narrow_table_is_left_alone() {
let widths = vec![10, 12, 30];
assert_eq!(fit_to_budget(widths.clone()), widths);
}
#[test]
fn a_ground_truthed_detail_section_is_marked_right_or_wrong() {
let mut res = ReportResult {
column_order: vec!["Name".into(), "Raw".into()],
rows: vec![row(&[("Name", "a"), ("Raw", "High Risk")])],
..Default::default()
};
res.column_details.insert("Raw".to_string());
res.verdicts.insert((0, "Raw".into()), Verdict::Incorrect);
res.truths.insert((0, "Raw".into()), "Low Risk".to_string());
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(
html.contains("<span class=\"verdict fail\">incorrect \u{2014} expected Low Risk"),
"the heading says it is wrong, and what was wanted: {html}"
);
assert!(
html.contains(".panel h3 .verdict.fail{background:var(--fail-bg)}"),
"and the badge is painted like the grid's own wrong cells: {html}"
);
}
#[test]
fn html_moves_a_detail_column_into_the_drill_down_and_csv_keeps_it_inline() {
let mut res = ReportResult {
column_order: vec!["Name".into(), "Raw".into()],
rows: vec![row(&[("Name", "a"), ("Raw", "{\"score\":7}")])],
..Default::default()
};
res.column_details.insert("Raw".to_string());
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
let head = &html[..html.find("<tbody>").unwrap()];
assert!(!head.contains("<th>Raw</th>"), "not a grid column: {head}");
assert!(
html.contains("<tr class=\"det\">") && html.contains("<h3>Raw</h3>"),
"it is in the panel instead: {html}"
);
assert!(
html.contains(""score": 7"),
"and pretty-printed, because a body arrives on one line: {html}"
);
let text = String::from_utf8(CsvWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert_eq!(text, "Name,Raw\r\na,\"{\"\"score\"\":7}\"\r\n");
}
#[test]
fn xlsx_groups_detail_columns_to_the_right_and_collapses_them() {
let mut res = ReportResult {
column_order: vec!["Raw".into(), "Name".into()],
rows: vec![row(&[("Name", "a"), ("Raw", "x")])],
..Default::default()
};
res.column_details.insert("Raw".to_string());
let bytes = XlsxWriter.write(&res, &Header::default()).unwrap();
assert_eq!(&bytes[..2], b"PK");
let mut res2 = res.clone();
res2.column_details.clear();
assert_ne!(
bytes,
XlsxWriter.write(&res2, &Header::default()).unwrap(),
"the flag changes the workbook"
);
}
#[test]
fn a_row_with_no_detail_gets_no_panel() {
let res = ReportResult {
column_order: vec!["Name".into()],
rows: vec![row(&[("Name", "a")])],
..Default::default()
};
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(!html.contains("class=\"det\""), "no panel row: {html}");
}
#[test]
fn a_plain_report_gets_a_find_box_but_no_filter_buttons() {
let res = ReportResult {
column_order: vec!["Name".into()],
rows: vec![row(&[("Name", "a")])],
..Default::default()
};
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(
!html.contains("data-i=\"0\""),
"a lone All button filters nothing, so it is not drawn: {html}"
);
assert!(html.contains("id=\"pb-find\""), "find box survives: {html}");
}
#[test]
fn a_report_with_something_to_filter_keeps_its_all_button() {
let mut res = ReportResult {
column_order: vec!["Name".into(), super::super::compare::CORRECT_COLUMN.into()],
rows: vec![
row(&[
("Name", "a"),
(super::super::compare::CORRECT_COLUMN, "incorrect"),
]),
row(&[
("Name", "b"),
(super::super::compare::CORRECT_COLUMN, "correct"),
]),
],
..Default::default()
};
res.column_details.clear();
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(html.contains("data-i=\"0\""), "All is back: {html}");
assert!(
html.contains("data-i=\"1\""),
"and the filter that earned it: {html}"
);
}
#[test]
fn an_all_detail_report_still_renders_its_grid() {
let mut res = ReportResult {
column_order: vec!["Raw".into()],
rows: vec![row(&[("Raw", "x")])],
..Default::default()
};
res.column_details.insert("Raw".to_string());
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(html.contains("<th>Raw</th>"), "the grid survives: {html}");
}
#[test]
fn rows_carry_the_filters_they_pass_and_the_toolbar_offers_them() {
let mut res = ReportResult {
column_order: vec!["Verdict".into(), "Correct".into()],
rows: vec![
row(&[("Verdict", "pass"), ("Correct", "correct")]),
row(&[("Verdict", "pass"), ("Correct", "incorrect")]),
],
..Default::default()
};
res.verdicts.insert((0, "Verdict".into()), Verdict::Correct);
res.verdicts
.insert((1, "Verdict".into()), Verdict::Incorrect);
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(
html.contains(">Incorrect</button>"),
"the class is offered: {html}"
);
assert!(
!html.contains(">Regressions</button>"),
"and one that could only ever select nothing is not: {html}"
);
let rows: Vec<&str> = html
.match_indices("data-f=\"")
.map(|(i, _)| {
let rest = &html[i + 8..];
&rest[..rest.find('"').unwrap()]
})
.collect();
assert_eq!(rows, vec!["0", "0 1"], "{html}");
}
#[test]
fn confusion_matrix_cells_are_clickable_filters() {
let mut res = ReportResult {
column_order: vec!["Verdict".into(), "Correct".into()],
rows: vec![
row(&[("Verdict", "pass"), ("Correct", "correct")]),
row(&[("Verdict", "pass"), ("Correct", "incorrect")]),
],
..Default::default()
};
res.verdicts.insert((0, "Verdict".into()), Verdict::Correct);
res.verdicts
.insert((1, "Verdict".into()), Verdict::Incorrect);
res.truths.insert((0, "Verdict".into()), "pass".into());
res.truths.insert((1, "Verdict".into()), "fail".into());
res.column_truths
.insert("Verdict".into(), "{{ expected }}".into());
let header = Header {
lines: vec![
crate::report::flow::HeaderLine::Directive {
key: "labels".into(),
value: "Pass = pass".into(),
},
crate::report::flow::HeaderLine::Directive {
key: "labels".into(),
value: "Fail = fail".into(),
},
],
};
let html = String::from_utf8(HtmlWriter.write(&res, &header).unwrap()).unwrap();
assert!(
html.contains(" pick\" data-i="),
"the counted cells are pickable: {html}"
);
let picks = html.matches("data-i=\"").count();
assert_eq!(picks, 4, "{html}");
}
#[test]
fn a_json_detail_column_is_diffed_against_the_baseline_row() {
let mut res = ReportResult {
column_order: vec!["Name".into(), "Raw".into()],
rows: vec![row(&[("Name", "a"), ("Raw", "{\"score\":9,\"id\":1}")])],
..Default::default()
};
res.column_details.insert("Raw".to_string());
res.baseline_rows
.insert(0, row(&[("Name", "a"), ("Raw", "{\"score\":7,\"id\":1}")]));
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(
html.contains("class=\"fdiff\""),
"a field table is drawn: {html}"
);
assert!(
html.contains("<tr class=\"chg\"><td>score</td><td>7</td><td>9</td></tr>"),
"the moved field is highlighted: {html}"
);
assert!(
html.contains("<tr><td>id</td><td>1</td><td>1</td></tr>"),
"and the unchanged one is kept for context: {html}"
);
}
#[test]
fn the_interactive_export_stays_self_contained_and_degrades_without_script() {
let mut res = ReportResult {
column_order: vec!["Name".into(), "Raw".into()],
rows: vec![row(&[("Name", "a"), ("Raw", "x")])],
..Default::default()
};
res.column_details.insert("Raw".to_string());
let html = String::from_utf8(HtmlWriter.write(&res, &Header::default()).unwrap()).unwrap();
assert!(
!html.contains("http://") && !html.contains("https://"),
"no external references"
);
assert!(!html.contains("<link"), "no external stylesheet");
assert!(
html.contains("<noscript><style>tr.det{display:table-row}"),
"the panels open without script: {html}"
);
assert!(html.contains("<script>") && html.contains("</script>"));
}
}