use std::collections::HashMap;
use super::flow::{Header, ImageSpec};
pub const TARGET_COLUMN: &str = "TARGET";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReportRow {
pub cells: HashMap<String, String>,
pub vars: HashMap<String, String>,
pub key: Vec<String>,
pub path: Vec<(usize, usize)>,
pub target: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ReportResult {
pub rows: Vec<ReportRow>,
pub column_order: Vec<String>,
pub pending: std::collections::HashSet<usize>,
pub no_match_marker: String,
pub errors: Vec<String>,
pub column_stats: std::collections::HashMap<String, Vec<StatKind>>,
pub column_images: std::collections::HashMap<String, ImageSpec>,
pub column_truths: std::collections::HashMap<String, String>,
pub column_details: std::collections::HashSet<String>,
pub timing_columns: std::collections::HashSet<String>,
pub images: std::collections::HashMap<(usize, String), ImageData>,
pub verdicts: std::collections::HashMap<(usize, String), Verdict>,
pub truths: std::collections::HashMap<(usize, String), String>,
pub baseline_rows: std::collections::HashMap<usize, ReportRow>,
pub track_baseline: bool,
pub trends: std::collections::HashMap<(usize, String), Trend>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trend {
Unchanged,
Fixed,
Regressed,
StillWrong,
}
impl Trend {
pub fn as_str(self) -> &'static str {
match self {
Trend::Unchanged | Trend::StillWrong => "unchanged",
Trend::Fixed => "fixed",
Trend::Regressed => "regressed",
}
}
pub fn of(baseline: Verdict, candidate: Verdict) -> Option<Trend> {
match (baseline, candidate) {
(Verdict::Untested, _) | (_, Verdict::Untested) => None,
(Verdict::Correct, Verdict::Correct) => Some(Trend::Unchanged),
(Verdict::Incorrect, Verdict::Correct) => Some(Trend::Fixed),
(Verdict::Correct, Verdict::Incorrect) => Some(Trend::Regressed),
(Verdict::Incorrect, Verdict::Incorrect) => Some(Trend::StillWrong),
}
}
fn severity(self) -> u8 {
match self {
Trend::Unchanged => 0,
Trend::Fixed => 1,
Trend::StillWrong => 2,
Trend::Regressed => 3,
}
}
pub fn rollup(trends: impl IntoIterator<Item = Trend>) -> Option<Trend> {
trends.into_iter().max_by_key(|t| t.severity())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Correct,
Incorrect,
Untested,
}
impl Verdict {
pub fn as_str(self) -> &'static str {
match self {
Verdict::Correct => "correct",
Verdict::Incorrect => "incorrect",
Verdict::Untested => "untested",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageData {
pub bytes: Vec<u8>,
pub mime: String,
pub natural: (u32, u32),
}
impl ReportResult {
pub fn note_column(&mut self, key: &str) {
if !self.column_order.iter().any(|c| c == key) {
self.column_order.push(key.to_string());
}
}
pub fn resolved_columns(&self, header: &Header) -> Vec<OutputColumn> {
let mut columns = match header.columns() {
Some(spec) => parse_columns(spec),
None => self
.column_order
.iter()
.map(|k| OutputColumn {
header: k.clone(),
sources: vec![k.clone()],
stats: Vec::new(),
image: None,
truth: None,
detail: false,
})
.collect(),
};
if !self.column_stats.is_empty() {
for col in &mut columns {
if col.stats.is_empty()
&& let Some(stats) = self.column_stats.get(&col.header)
{
col.stats = stats.clone();
}
if col.stats.is_empty()
&& let Some(rest) = col.header.strip_prefix("baseline.")
&& let Some((_, field)) = rest.rsplit_once('.')
&& let Some(stats) = self.column_stats.get(&format!("baseline.*.{field}"))
{
col.stats = stats.clone();
}
}
}
if !self.column_images.is_empty() {
for col in &mut columns {
if col.image.is_none()
&& let Some(img) = self.column_images.get(&col.header)
{
col.image = Some(*img);
}
}
}
if !self.column_truths.is_empty() {
for col in &mut columns {
if col.truth.is_none()
&& let Some(t) = self.column_truths.get(&col.header)
{
col.truth = Some(t.clone());
}
}
}
if !self.column_details.is_empty() {
for col in &mut columns {
col.detail = col.detail || self.column_details.contains(&col.header);
}
}
columns
}
pub fn footer_rows(&self, columns: &[OutputColumn], header: &Header) -> Vec<SummaryRow> {
let mut rows = self.summary_rows(columns);
if let Some(metrics) = self.metrics(columns, header) {
rows.extend(metrics.summary_rows(columns));
}
rows
}
pub fn metrics(
&self,
columns: &[OutputColumn],
header: &Header,
) -> Option<super::metrics::Metrics> {
let labels = super::labels::LabelMap::parse(&header.labels());
super::metrics::Metrics::compute(self, columns, &labels)
}
pub fn row_trend(&self, r: usize) -> Option<Trend> {
Trend::rollup(
self.trends
.iter()
.filter(|((row, _), _)| *row == r)
.map(|(_, t)| *t),
)
}
pub fn summary_rows(&self, columns: &[OutputColumn]) -> Vec<SummaryRow> {
if columns.iter().all(|c| c.stats.is_empty()) {
return Vec::new();
}
let column_values = |col: &OutputColumn| -> Vec<String> {
self.rows
.iter()
.enumerate()
.filter(|(i, _)| !self.pending.contains(i))
.map(|(_, row)| col.value(row, &self.no_match_marker))
.filter(|v| !v.trim().is_empty() && *v != self.no_match_marker)
.collect()
};
let mut out = Vec::new();
for stat in StatKind::SUMMARY_ORDER {
let requested: Vec<usize> = columns
.iter()
.enumerate()
.filter(|(_, c)| c.stats.contains(&stat))
.map(|(i, _)| i)
.collect();
if requested.is_empty() {
continue;
}
let mut cells = vec![None; columns.len()];
for &ci in &requested {
let values = column_values(&columns[ci]);
let numeric = column_numeric(&values);
if let Some(text) = compute_stat(stat, &values) {
cells[ci] = Some(StatValue {
text,
stat: Some(stat),
numeric,
match_value: None,
});
}
}
if cells.iter().any(Option::is_some) {
out.push(SummaryRow {
label: stat.label().to_string(),
cells,
});
}
}
for (ci, col) in columns.iter().enumerate() {
if !col.stats.contains(&StatKind::Distribution) {
continue;
}
for (value, count) in distinct_counts(&column_values(col)) {
let mut cells = vec![None; columns.len()];
cells[ci] = Some(StatValue {
text: count.to_string(),
stat: Some(StatKind::Distribution),
numeric: true,
match_value: Some(value.clone()),
});
out.push(SummaryRow {
label: format!("{} = {}", col.header, value),
cells,
});
}
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatKind {
Mean,
Median,
Mode,
Min,
Max,
Sum,
Count,
StdDev,
Distribution,
}
impl StatKind {
pub const SUMMARY_ORDER: [StatKind; 8] = [
StatKind::Count,
StatKind::Sum,
StatKind::Mean,
StatKind::Median,
StatKind::Mode,
StatKind::Min,
StatKind::Max,
StatKind::StdDev,
];
pub const CHOOSABLE: [StatKind; 9] = [
StatKind::Count,
StatKind::Sum,
StatKind::Mean,
StatKind::Median,
StatKind::Mode,
StatKind::Min,
StatKind::Max,
StatKind::StdDev,
StatKind::Distribution,
];
pub fn parse(s: &str) -> Option<StatKind> {
match s.trim().to_ascii_uppercase().as_str() {
"MEAN" | "AVG" | "AVERAGE" => Some(StatKind::Mean),
"MEDIAN" => Some(StatKind::Median),
"MODE" => Some(StatKind::Mode),
"MIN" | "MINIMUM" => Some(StatKind::Min),
"MAX" | "MAXIMUM" => Some(StatKind::Max),
"SUM" | "TOTAL" => Some(StatKind::Sum),
"COUNT" => Some(StatKind::Count),
"STDDEV" | "STDEV" | "STD" => Some(StatKind::StdDev),
"DISTRIBUTION" | "DIST" => Some(StatKind::Distribution),
_ => None,
}
}
pub fn keyword(self) -> &'static str {
match self {
StatKind::Mean => "MEAN",
StatKind::Median => "MEDIAN",
StatKind::Mode => "MODE",
StatKind::Min => "MIN",
StatKind::Max => "MAX",
StatKind::Sum => "SUM",
StatKind::Count => "COUNT",
StatKind::StdDev => "STDDEV",
StatKind::Distribution => "DISTRIBUTION",
}
}
pub fn label(self) -> &'static str {
match self {
StatKind::Mean => "Mean",
StatKind::Median => "Median",
StatKind::Mode => "Mode",
StatKind::Min => "Min",
StatKind::Max => "Max",
StatKind::Sum => "Sum",
StatKind::Count => "Count",
StatKind::StdDev => "Std dev",
StatKind::Distribution => "Distribution",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatValue {
pub text: String,
pub stat: Option<StatKind>,
pub numeric: bool,
pub match_value: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryRow {
pub label: String,
pub cells: Vec<Option<StatValue>>,
}
impl SummaryRow {
pub fn text_cell(&self, column: usize) -> String {
match self.cells.get(column).and_then(|c| c.as_ref()) {
Some(v) => v.text.clone(),
None if column == 0 => self.label.clone(),
None => String::new(),
}
}
}
fn column_numeric(values: &[String]) -> bool {
!values.is_empty()
&& values
.iter()
.all(|v| crate::report::writer::parse_report_number(v).is_some())
}
fn numeric_values(values: &[String]) -> Vec<f64> {
values
.iter()
.filter_map(|v| crate::report::writer::parse_report_number(v))
.collect()
}
fn compute_stat(stat: StatKind, values: &[String]) -> Option<String> {
match stat {
StatKind::Count => (!values.is_empty()).then(|| values.len().to_string()),
StatKind::Mode => mode(values),
StatKind::Distribution => None,
_ => {
let nums = numeric_values(values);
if nums.is_empty() {
return None;
}
let v = match stat {
StatKind::Mean => nums.iter().sum::<f64>() / nums.len() as f64,
StatKind::Sum => nums.iter().sum::<f64>(),
StatKind::Min => nums.iter().copied().fold(f64::INFINITY, f64::min),
StatKind::Max => nums.iter().copied().fold(f64::NEG_INFINITY, f64::max),
StatKind::Median => median(&nums),
StatKind::StdDev => std_dev(&nums),
_ => unreachable!(),
};
Some(format_number(v))
}
}
}
fn median(nums: &[f64]) -> f64 {
let mut sorted = nums.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
if n % 2 == 1 {
sorted[n / 2]
} else {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
}
}
fn std_dev(nums: &[f64]) -> f64 {
let mean = nums.iter().sum::<f64>() / nums.len() as f64;
let var = nums.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / nums.len() as f64;
var.sqrt()
}
fn mode(values: &[String]) -> Option<String> {
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut best: Option<(&str, usize, usize)> = None; for (i, v) in values.iter().enumerate() {
let c = counts.entry(v.as_str()).or_insert(0);
*c += 1;
let count = *c;
let better = match best {
None => true,
Some((_, bc, bi)) => count > bc || (count == bc && i < bi),
};
if better {
best = Some((v.as_str(), count, i));
}
}
best.map(|(v, _, _)| v.to_string())
}
fn distinct_counts(values: &[String]) -> Vec<(String, usize)> {
let mut order: Vec<String> = Vec::new();
let mut counts: HashMap<String, usize> = HashMap::new();
for v in values {
if !counts.contains_key(v) {
order.push(v.clone());
}
*counts.entry(v.clone()).or_insert(0) += 1;
}
order
.into_iter()
.map(|v| {
let c = counts[&v];
(v, c)
})
.collect()
}
pub(crate) fn format_number(n: f64) -> String {
if !n.is_finite() {
return n.to_string();
}
if n.fract() == 0.0 && n.abs() < 1e15 {
return format!("{}", n as i64);
}
let s = format!("{n:.6}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputColumn {
pub header: String,
pub sources: Vec<String>,
pub stats: Vec<StatKind>,
pub image: Option<ImageSpec>,
pub truth: Option<String>,
pub detail: bool,
}
impl OutputColumn {
pub fn value(&self, row: &ReportRow, no_match: &str) -> String {
for src in &self.sources {
if let Some(v) = row.cells.get(src)
&& !v.is_empty()
{
return v.clone();
}
if let Some(v) = row.vars.get(src)
&& !v.is_empty()
{
return v.clone();
}
if src == TARGET_COLUMN
&& let Some(t) = &row.target
&& !t.is_empty()
{
return t.clone();
}
}
no_match.to_string()
}
}
pub fn parse_columns(spec: &str) -> Vec<OutputColumn> {
split_top_level(spec, ',')
.into_iter()
.filter_map(|part| {
let part = part.trim();
if part.is_empty() {
return None;
}
let (part, clauses) = split_column_clauses(part);
let ColumnClauses {
stats,
image,
truth,
detail,
} = clauses;
let (sources_part, header) = split_as(part);
let sources: Vec<String> = sources_part
.split('|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if sources.is_empty() {
return None;
}
let header = header.unwrap_or_else(|| sources[0].clone());
Some(OutputColumn {
header,
sources,
stats,
image,
truth,
detail,
})
})
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ColumnClauses {
pub stats: Vec<StatKind>,
pub image: Option<ImageSpec>,
pub truth: Option<String>,
pub detail: bool,
}
pub(crate) fn split_column_clauses(part: &str) -> (&str, ColumnClauses) {
let mut rest = part;
let mut out = ColumnClauses::default();
loop {
let (sr, s) = split_statistics(rest);
let (ir, im) = split_image(rest);
let (tr, tv) = split_truth(rest);
let (dr, dv) = split_detail(rest);
let cands = [
(!s.is_empty(), sr.len()),
(im.is_some(), ir.len()),
(tv.is_some(), tr.len()),
(dv, dr.len()),
];
let latest = cands
.iter()
.enumerate()
.filter(|(_, (found, _))| *found)
.max_by_key(|(_, (_, len))| *len)
.map(|(i, _)| i);
match latest {
Some(0) => {
out.stats = s;
rest = sr;
}
Some(1) => {
out.image = im;
rest = ir;
}
Some(2) => {
out.truth = tv;
rest = tr;
}
Some(3) => {
out.detail = true;
rest = dr;
}
_ => return (rest, out),
}
}
}
pub(crate) fn split_detail(part: &str) -> (&str, bool) {
let bytes = part.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if in_quote && c == b'\\' {
i += 2;
continue;
}
if c == b'"' {
in_quote = !in_quote;
i += 1;
continue;
}
if !in_quote
&& (c == b'd' || c == b'D')
&& (i == 0 || bytes[i - 1].is_ascii_whitespace())
&& part
.get(i..i + 6)
.is_some_and(|w| w.eq_ignore_ascii_case("detail"))
&& part
.as_bytes()
.get(i + 6)
.is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_')
&& part[i + 6..].trim_start().is_empty()
{
return (part[..i].trim_end(), true);
}
i += 1;
}
(part, false)
}
pub(crate) fn split_truth(part: &str) -> (&str, Option<String>) {
let bytes = part.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if in_quote && c == b'\\' {
i += 2;
continue;
}
if c == b'"' {
in_quote = !in_quote;
i += 1;
continue;
}
if !in_quote
&& (c == b't' || c == b'T')
&& (i == 0 || bytes[i - 1].is_ascii_whitespace())
&& part
.get(i..i + 5)
.is_some_and(|w| w.eq_ignore_ascii_case("truth"))
&& part
.as_bytes()
.get(i + 5)
.is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_')
&& let Some(template) = quoted_arg(part[i + 5..].trim_start())
{
return (part[..i].trim_end(), Some(template));
}
i += 1;
}
(part, None)
}
fn quoted_arg(text: &str) -> Option<String> {
let mut chars = text.chars();
if chars.next()? != '"' {
return None;
}
let mut out = String::new();
let mut escaped = false;
for c in chars {
if escaped {
out.push(c);
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
return Some(out);
} else {
out.push(c);
}
}
None
}
pub(crate) fn split_image(part: &str) -> (&str, Option<ImageSpec>) {
let bytes = part.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if in_quote && c == b'\\' {
i += 2;
continue;
}
if c == b'"' {
in_quote = !in_quote;
i += 1;
continue;
}
if !in_quote
&& (c == b'i' || c == b'I')
&& (i == 0 || bytes[i - 1].is_ascii_whitespace())
&& part
.get(i..i + 5)
.is_some_and(|w| w.eq_ignore_ascii_case("image"))
&& part
.as_bytes()
.get(i + 5)
.is_none_or(|b| !b.is_ascii_alphanumeric() && *b != b'_')
{
let after = part[i + 5..].trim_start();
if let Some(inner) = after.strip_prefix('(') {
if let Some(close) = inner.find(')') {
return (
part[..i].trim_end(),
Some(parse_image_opts(&inner[..close])),
);
}
} else {
return (part[..i].trim_end(), Some(ImageSpec::default()));
}
}
i += 1;
}
(part, None)
}
fn parse_image_opts(inner: &str) -> ImageSpec {
let mut spec = ImageSpec::default();
for opt in inner.split(',') {
let opt = opt.trim();
if opt.eq_ignore_ascii_case("fit") {
spec.fit = true;
continue;
}
let mut parts = opt.split_whitespace();
let (Some(key), Some(val)) = (parts.next(), parts.next()) else {
continue;
};
let Ok(n) = val.parse::<u32>() else { continue };
if n == 0 {
continue;
}
if key.eq_ignore_ascii_case("height") {
spec.height = Some(n);
} else if key.eq_ignore_ascii_case("width") {
spec.width = Some(n);
}
}
spec
}
pub(crate) fn split_statistics(part: &str) -> (&str, Vec<StatKind>) {
let bytes = part.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if in_quote && c == b'\\' {
i += 2;
continue;
}
if c == b'"' {
in_quote = !in_quote;
i += 1;
continue;
}
if !in_quote
&& (c == b's' || c == b'S')
&& (i == 0 || bytes[i - 1].is_ascii_whitespace())
&& part
.get(i..i + 10)
.is_some_and(|w| w.eq_ignore_ascii_case("statistics"))
{
let after = part[i + 10..].trim_start();
if let Some(inner) = after.strip_prefix('(')
&& let Some(close) = inner.find(')')
{
let stats = inner[..close]
.split(',')
.filter_map(StatKind::parse)
.collect();
return (part[..i].trim_end(), stats);
}
}
i += 1;
}
(part, Vec::new())
}
fn split_as(part: &str) -> (&str, Option<String>) {
let bytes = part.as_bytes();
let mut in_quote = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i] as char;
if c == '"' {
in_quote = !in_quote;
i += 1;
continue;
}
if !in_quote
&& (c == 'A' || c == 'a')
&& bytes
.get(i + 1)
.is_some_and(|b| b.eq_ignore_ascii_case(&b's'))
&& i > 0
&& bytes[i - 1].is_ascii_whitespace()
&& bytes.get(i + 2).is_some_and(|b| b.is_ascii_whitespace())
{
let sources = part[..i].trim();
let header = unquote(part[i + 2..].trim());
return (sources, Some(header));
}
i += 1;
}
(part, None)
}
fn split_top_level(s: &str, sep: char) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_quote = false;
let mut depth = 0usize;
for c in s.chars() {
match c {
'"' => {
in_quote = !in_quote;
cur.push(c);
}
'(' if !in_quote => {
depth += 1;
cur.push(c);
}
')' if !in_quote => {
depth = depth.saturating_sub(1);
cur.push(c);
}
_ if c == sep && !in_quote && depth == 0 => {
out.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
}
out.push(cur);
out
}
fn unquote(s: &str) -> String {
let s = s.trim();
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_detail_only_takes_a_trailing_bare_keyword() {
assert_eq!(
split_detail("jsonpath \"$.a\" DETAIL"),
("jsonpath \"$.a\"", true)
);
assert_eq!(
split_detail("jsonpath \"$.a\" detail "),
("jsonpath \"$.a\"", true)
);
for src in [
"jsonpath \"$.a\" DETAIL EXTRA",
"jsonpath \"$.DETAIL\"",
"jsonpath \"$.a\" DETAILS",
] {
assert_eq!(split_detail(src), (src, false), "{src}");
}
}
#[test]
fn column_detail_from_the_flow_survives_a_columns_header() {
let mut res = ReportResult::default();
res.column_details.insert("Payload".into());
let header = Header {
lines: vec![crate::report::flow::HeaderLine::Directive {
key: "columns".to_string(),
value: "Payload, Name DETAIL".to_string(),
}],
};
let cols = res.resolved_columns(&header);
assert!(cols.iter().all(|c| c.detail), "both are detail columns");
}
fn row(cells: &[(&str, &str)], vars: &[(&str, &str)], target: Option<&str>) -> ReportRow {
ReportRow {
cells: cells
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
vars: vars
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
key: vec![],
path: Vec::new(),
target: target.map(str::to_string),
}
}
#[test]
fn default_columns_follow_first_seen_order() {
let mut res = ReportResult::default();
res.note_column("proc.status");
res.note_column("proc.Time");
res.note_column("proc.status"); let cols = res.resolved_columns(&Header::default());
let headers: Vec<&str> = cols.iter().map(|c| c.header.as_str()).collect();
assert_eq!(headers, vec!["proc.status", "proc.Time"]);
}
#[test]
fn columns_directive_renames_and_reorders() {
let cols = parse_columns("FILE as Name, proc.status as Status, proc.Time as Time");
assert_eq!(cols.len(), 3);
assert_eq!(cols[0].header, "Name");
assert_eq!(cols[0].sources, vec!["FILE"]);
assert_eq!(cols[1].header, "Status");
assert_eq!(cols[2].header, "Time");
}
#[test]
fn columns_directive_supports_quoted_headers_with_spaces() {
let cols = parse_columns("proc.Response as \"Main Results\"");
assert_eq!(cols[0].header, "Main Results");
assert_eq!(cols[0].sources, vec!["proc.Response"]);
}
#[test]
fn columns_directive_with_non_ascii_does_not_panic() {
for spec in ["año", "naïve", "aé", "café as Name", "Naïve AS Rôle"] {
let _ = parse_columns(spec); }
let cols = parse_columns("café AS Rôle");
assert_eq!(cols[0].header, "Rôle");
assert_eq!(cols[0].sources, vec!["café"]);
}
#[test]
fn columns_directive_coalesces_sources() {
let cols = parse_columns("a.status | b.status as Status");
assert_eq!(cols[0].header, "Status");
assert_eq!(cols[0].sources, vec!["a.status", "b.status"]);
}
#[test]
fn coalesce_takes_first_non_empty_source() {
let col = OutputColumn {
header: "Status".into(),
sources: vec!["a.status".into(), "b.status".into()],
stats: Vec::new(),
image: None,
truth: None,
detail: false,
};
let r = row(&[("a.status", ""), ("b.status", "ok")], &[], None);
assert_eq!(col.value(&r, "-"), "ok");
}
#[test]
fn value_falls_back_to_vars_then_no_match_marker() {
let col = OutputColumn {
header: "Name".into(),
sources: vec!["FILE".into()],
stats: Vec::new(),
image: None,
truth: None,
detail: false,
};
let r = row(&[], &[("FILE", "a.jpg")], None);
assert_eq!(col.value(&r, "∅"), "a.jpg");
let empty = row(&[], &[], None);
assert_eq!(col.value(&empty, "∅"), "∅");
}
#[test]
fn target_is_available_as_a_column_source() {
let col = OutputColumn {
header: "Env".into(),
sources: vec![TARGET_COLUMN.to_string()],
stats: Vec::new(),
image: None,
truth: None,
detail: false,
};
let r = row(&[], &[], Some("staging-au"));
assert_eq!(col.value(&r, "-"), "staging-au");
}
#[test]
fn parse_columns_reads_statistics_clause() {
let cols = parse_columns("Time STATISTICS(MEAN, MEDIAN), Overall STATISTICS(DISTRIBUTION)");
assert_eq!(cols[0].header, "Time");
assert_eq!(cols[0].stats, vec![StatKind::Mean, StatKind::Median]);
assert_eq!(cols[1].header, "Overall");
assert_eq!(cols[1].stats, vec![StatKind::Distribution]);
}
#[test]
fn parse_columns_statistics_after_as_rename() {
let cols = parse_columns("proc.Time AS \"Pretty time\" STATISTICS(MEAN)");
assert_eq!(cols[0].header, "Pretty time");
assert_eq!(cols[0].sources, vec!["proc.Time"]);
assert_eq!(cols[0].stats, vec![StatKind::Mean]);
}
#[test]
fn parse_columns_reads_image_clause_in_either_order() {
use crate::report::flow::ImageSpec;
let cols = parse_columns(
"face.Frame AS Face IMAGE, Doc IMAGE(HEIGHT 110), Sig IMAGE(WIDTH 200, HEIGHT 100), Thumb IMAGE(FIT), Time STATISTICS(MEAN) IMAGE(HEIGHT 20), Other IMAGE(HEIGHT 20) STATISTICS(MEAN)",
);
assert_eq!(cols[0].header, "Face");
assert_eq!(cols[0].sources, vec!["face.Frame"]);
assert_eq!(cols[0].image, Some(ImageSpec::default()));
assert_eq!(
cols[1].image,
Some(ImageSpec {
height: Some(110),
..Default::default()
})
);
assert_eq!(
cols[2].image,
Some(ImageSpec {
width: Some(200),
height: Some(100),
fit: false
})
);
assert_eq!(
cols[3].image,
Some(ImageSpec {
fit: true,
..Default::default()
})
);
for c in &cols[4..6] {
assert_eq!(c.stats, vec![StatKind::Mean], "{}", c.header);
assert_eq!(
c.image,
Some(ImageSpec {
height: Some(20),
..Default::default()
}),
"{}",
c.header
);
}
}
#[test]
fn parse_columns_leaves_other_columns_without_an_image() {
let cols = parse_columns("Face IMAGE, Time");
assert!(cols[0].image.is_some());
assert_eq!(cols[1].image, None);
}
#[test]
fn resolved_columns_merge_flow_images_and_inline_wins() {
use crate::report::flow::ImageSpec;
let mut res = ReportResult::default();
res.rows = vec![row(&[("Face", "a.png"), ("Doc", "b.png")], &[], None)];
res.column_order = vec!["Face".to_string(), "Doc".to_string()];
res.column_images.insert(
"Face".to_string(),
ImageSpec {
height: Some(110),
..Default::default()
},
);
res.column_images.insert(
"Doc".to_string(),
ImageSpec {
height: Some(110),
..Default::default()
},
);
let header = Header {
lines: vec![crate::report::flow::HeaderLine::Directive {
key: "columns".to_string(),
value: "Face, Doc IMAGE(HEIGHT 40)".to_string(),
}],
};
let cols = res.resolved_columns(&header);
assert_eq!(
cols[0].image,
Some(ImageSpec {
height: Some(110),
..Default::default()
}),
"a column with no inline clause takes the flow's"
);
assert_eq!(
cols[1].image,
Some(ImageSpec {
height: Some(40),
..Default::default()
}),
"an inline IMAGE(…) overrides the flow's"
);
}
#[test]
fn image_spec_scales_proportionally_from_one_dimension() {
use crate::report::flow::{DEFAULT_IMAGE_HEIGHT, ImageSpec};
let natural = (400, 200);
assert_eq!(
ImageSpec {
height: Some(100),
..Default::default()
}
.scaled_size(natural),
Some((200.0, 100.0))
);
assert_eq!(
ImageSpec {
width: Some(100),
..Default::default()
}
.scaled_size(natural),
Some((100.0, 50.0))
);
assert_eq!(
ImageSpec {
width: Some(100),
height: Some(100),
fit: false
}
.scaled_size(natural),
Some((100.0, 100.0))
);
let h = DEFAULT_IMAGE_HEIGHT as f64;
assert_eq!(
ImageSpec::default().scaled_size(natural),
Some((h * 2.0, h))
);
assert_eq!(
ImageSpec {
fit: true,
..Default::default()
}
.scaled_size(natural),
None
);
}
#[test]
fn summary_rows_compute_numeric_stats() {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("Time", "100")], &[], None),
row(&[("Time", "200")], &[], None),
row(&[("Time", "300")], &[], None),
];
let cols = parse_columns("Time STATISTICS(MEAN, MEDIAN, SUM, MIN, MAX, COUNT, STDDEV)");
let summary = res.summary_rows(&cols);
let get = |label: &str| {
summary
.iter()
.find(|r| r.label == label)
.map(|r| r.text_cell(0))
};
assert_eq!(get("Count").as_deref(), Some("3"));
assert_eq!(get("Sum").as_deref(), Some("600"));
assert_eq!(get("Mean").as_deref(), Some("200"));
assert_eq!(get("Median").as_deref(), Some("200"));
assert_eq!(get("Min").as_deref(), Some("100"));
assert_eq!(get("Max").as_deref(), Some("300"));
assert!(get("Std dev").unwrap().starts_with("81.6"));
}
#[test]
fn baseline_show_statistics_reach_the_column_they_name() {
let mut res = ReportResult::default();
res.note_column("proc.Time");
res.note_column("baseline.proc.Time");
res.column_stats
.insert("baseline.*.Time".to_string(), vec![StatKind::Mean]);
let cols = res.resolved_columns(&Header::default());
let baseline_col = cols
.iter()
.find(|c| c.header == "baseline.proc.Time")
.expect("the copied baseline column");
assert_eq!(baseline_col.stats, vec![StatKind::Mean]);
let own = cols.iter().find(|c| c.header == "proc.Time").unwrap();
assert!(own.stats.is_empty());
}
#[test]
fn statistics_skip_rows_still_waiting_on_their_result() {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("Time", "100")], &[], None),
row(&[("Time", "200")], &[], None),
row(&[("Time", "0")], &[], None),
row(&[("Time", "0")], &[], None),
];
res.pending = [2, 3].into_iter().collect();
let cols = parse_columns("Time STATISTICS(MEAN, COUNT)");
let summary = res.summary_rows(&cols);
let get = |label: &str| {
summary
.iter()
.find(|r| r.label == label)
.map(|r| r.text_cell(0))
};
assert_eq!(get("Count").as_deref(), Some("2"), "only the finished rows");
assert_eq!(get("Mean").as_deref(), Some("150"));
}
#[test]
fn summary_rows_distribution_counts_each_value() {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("File", "a"), ("Overall", "Low")], &[], None),
row(&[("File", "b"), ("Overall", "High")], &[], None),
row(&[("File", "c"), ("Overall", "Low")], &[], None),
];
let cols = parse_columns("File, Overall STATISTICS(DISTRIBUTION)");
let summary = res.summary_rows(&cols);
let low = summary
.iter()
.find(|r| r.label == "Overall = Low")
.expect("Low row");
assert_eq!(low.text_cell(0), "Overall = Low"); assert_eq!(low.text_cell(1), "2"); let high = summary
.iter()
.find(|r| r.label == "Overall = High")
.expect("High row");
assert_eq!(high.text_cell(1), "1");
}
#[test]
fn numeric_stats_skipped_on_non_numeric_column() {
let mut res = ReportResult::default();
res.rows = vec![
row(&[("V", "abc")], &[], None),
row(&[("V", "abc")], &[], None),
row(&[("V", "def")], &[], None),
];
let cols = parse_columns("V STATISTICS(MEAN, COUNT, MODE)");
let summary = res.summary_rows(&cols);
assert!(
!summary.iter().any(|r| r.label == "Mean"),
"a numeric stat on a text column produces no row"
);
assert_eq!(
summary
.iter()
.find(|r| r.label == "Count")
.unwrap()
.text_cell(0),
"3"
);
assert_eq!(
summary
.iter()
.find(|r| r.label == "Mode")
.unwrap()
.text_cell(0),
"abc" );
}
#[test]
fn report_statement_statistics_merge_into_resolved_columns() {
let mut res = ReportResult::default();
res.note_column("Time");
res.column_stats
.insert("Time".to_string(), vec![StatKind::Mean]);
let cols = res.resolved_columns(&Header::default());
assert_eq!(cols[0].header, "Time");
assert_eq!(cols[0].stats, vec![StatKind::Mean]);
}
}