facett-matrix/src/grid.rs
//! **Pure model** behind a per-repo × per-aspect **test matrix** — a verbatim
//! re-home of nornir's `src/viz/test_matrix_grid.rs`. No egui, no I/O: everything
//! the grid renders is derived here from a flat slice of
//! [`TestResultRow`]s, so it is inject-and-assert testable and folds straight
//! into a `state_json` (the "see what the user sees as data" LAW). The renderer
//! ([`crate::MatrixView`]) only paints what these functions compute.
//!
//! The shape:
//! * **rows = repos**, taken from each repo's *latest run* (newest `ts_micros`).
//! * **columns = aspects**, in [`ASPECT_ORDER`], filtered to those present (or
//! an `expected` set unioned in, shown as not-run).
//! * each **cell** = the aspect's status + numeric metric in that repo's latest
//! run (plus the failing cases for drill-down), or "not-run" when absent.
//! * a **health score** (0–100) per repo, and **sparkline series** per
//! (repo, aspect) across recent runs so trends are visible.
//!
//! The row schema, status vocabulary, and run summariser come from
//! [`nornir_testmatrix`] (the shared pure model), so facett's grid and nornir's
//! grid derive from byte-identical inputs.
use nornir_testmatrix::{status, summarize_runs, RunSummary, TestResultRow};
/// Aspect columns in canonical display order.
pub const ASPECT_ORDER: &[&str] = &[
"build",
"unit",
"doctest",
"clippy",
"fmt",
"audit",
"bench-smoke",
"coverage",
"feature-powerset",
"msrv",
"examples",
"functional",
// ROBOT-UI scenarios (accesskit-driven pane drives) as a first-class aspect column,
// so a repo's robot-UI test suite shows its pass/fail alongside unit/bench-smoke.
// Deliberately NOT in `ASPECT_DEFAULT`: a host that wants it always-visible unions it
// into its own expected surface (dwarves' Testmatrix does), so the default not-run
// surface stays the six core aspects.
"robot-ui",
];
/// The **default expected** aspect columns — shown even before any run (as
/// NotRun), so the matrix is always visible for a workspace.
pub const ASPECT_DEFAULT: &[&str] = &["build", "unit", "doctest", "clippy", "fmt", "audit"];
/// How many recent runs (per repo) the trend sparklines span.
pub const SPARK_RUNS: usize = 12;
/// One cell of the matrix: a repo × aspect verdict in that repo's latest run.
#[derive(Debug, Clone, PartialEq)]
pub struct Cell {
/// `pass` | `fail` | `stalled` | `skip` | `ignored`, or `""` = not run.
pub status: String,
/// The aspect's numeric metric (coverage %, warning count, …); 0.0 if N/A.
pub metric: f64,
/// How many cases rolled into this cell (1 for synthetic aspects; N for unit).
pub count: usize,
/// For the unit aspect: passed / failed sub-counts (else 0 / 0).
pub passed: usize,
pub failed: usize,
/// Failing/stalled cases under this cell (for the drill-down panel).
pub red_cases: Vec<RedCase>,
}
impl Cell {
fn not_run() -> Self {
Self { status: String::new(), metric: 0.0, count: 0, passed: 0, failed: 0, red_cases: Vec::new() }
}
/// Was this aspect run at all in the repo's latest run?
pub fn ran(&self) -> bool {
!self.status.is_empty()
}
pub fn is_red(&self) -> bool {
status::is_red(&self.status)
}
pub fn is_green(&self) -> bool {
status::is_green(&self.status)
}
/// The type-aware badge text for this cell, e.g. `"87%"`, `"⚠3"`, `"🛡0"`,
/// `"142✓ 2✗"`. Empty when the cell didn't run or has nothing to show.
pub fn badge(&self, aspect: &str) -> String {
if !self.ran() {
return String::new();
}
match aspect {
"coverage" => format!("{:.0}%", self.metric),
"clippy" => format!("⚠{}", self.metric as i64),
"audit" => format!("🛡{}", self.metric as i64),
"fmt" => {
if self.metric > 0.0 {
format!("±{}", self.metric as i64)
} else {
String::new()
}
}
"unit" | "robot-ui" | "functional" => {
if self.failed > 0 {
format!("{}✓ {}✗", self.passed, self.failed)
} else {
format!("{}✓", self.passed)
}
}
_ => String::new(),
}
}
}
/// One failing case under a cell, surfaced in the drill-down.
#[derive(Debug, Clone, PartialEq)]
pub struct RedCase {
pub test_name: String,
pub suite: String,
pub status: String,
pub message: String,
}
/// One matrix row: a repo, its latest-run cells (one per aspect column), its
/// health score, and its run identity.
#[derive(Debug, Clone)]
pub struct RepoRow {
pub repo: String,
pub run_id: String,
pub ts_micros: i64,
/// Latest-run verdict per aspect column (parallel to [`Matrix::aspects`]).
pub cells: Vec<Cell>,
/// 0–100 derived health (green-ratio + coverage − warnings/advisories).
pub health: f64,
/// Per-aspect sparkline series across recent runs (parallel to `aspects`).
pub sparks: Vec<Vec<f64>>,
}
/// The whole grid + the summary strip totals.
#[derive(Debug, Clone)]
pub struct Matrix {
/// Aspect column labels actually present in the data, in [`ASPECT_ORDER`].
pub aspects: Vec<String>,
/// One row per repo, sorted by repo name.
pub rows: Vec<RepoRow>,
/// Summary strip: total cells that are green / red / skip / not-run.
pub total_green: usize,
pub total_red: usize,
pub total_skip: usize,
pub total_blank: usize,
}
impl Matrix {
/// Build the matrix from a flat slice of rows — axes derived purely from the
/// data. `build_with_expected` with no expectations.
pub fn build(rows: &[TestResultRow]) -> Self {
Self::build_with_expected(&[], &[], rows)
}
/// Build the matrix over an EXPECTED surface, so cells are visible — and
/// honestly marked **not-run** — even before anything has run. The data's own
/// repos/aspects are unioned in so nothing is hidden.
pub fn build_with_expected(
expected_repos: &[String],
expected_aspects: &[&str],
rows: &[TestResultRow],
) -> Self {
use std::collections::{BTreeMap, BTreeSet, HashSet};
let mut by_repo: BTreeMap<String, Vec<&TestResultRow>> = BTreeMap::new();
for r in rows {
by_repo.entry(r.repo.clone()).or_default().push(r);
}
let mut want: HashSet<&str> = expected_aspects.iter().copied().collect();
for r in rows {
want.insert(r.aspect.as_str());
}
let aspects: Vec<String> =
ASPECT_ORDER.iter().filter(|a| want.contains(**a)).map(|a| a.to_string()).collect();
let mut repos: BTreeSet<String> = expected_repos.iter().cloned().collect();
for repo in by_repo.keys() {
repos.insert(repo.clone());
}
let mut out_rows = Vec::new();
let (mut tg, mut tr, mut ts, mut tb) = (0usize, 0usize, 0usize, 0usize);
for repo in &repos {
let repo_rows = by_repo.get(repo);
let summaries = repo_rows
.map(|rr| summarize_runs(&rr.iter().map(|r| (*r).clone()).collect::<Vec<_>>()))
.unwrap_or_default();
let latest = summaries.first();
let latest_id = latest.map(|s| s.run_id.clone()).unwrap_or_default();
let ts_micros = latest.map(|s| s.ts_micros).unwrap_or(0);
let mut cells = Vec::with_capacity(aspects.len());
for aspect in &aspects {
let cell = match repo_rows {
Some(rr) if !latest_id.is_empty() => build_cell(rr, &latest_id, aspect),
_ => Cell::not_run(),
};
match () {
_ if !cell.ran() => tb += 1,
_ if cell.is_red() => tr += 1,
_ if cell.is_green() => tg += 1,
_ => ts += 1,
}
cells.push(cell);
}
let health = health_score(&aspects, &cells);
let sparks = match repo_rows {
Some(rr) => aspects.iter().map(|a| spark_series(rr, &summaries, a)).collect(),
None => aspects.iter().map(|_| Vec::new()).collect(),
};
out_rows.push(RepoRow { repo: repo.clone(), run_id: latest_id, ts_micros, cells, health, sparks });
}
Matrix { aspects, rows: out_rows, total_green: tg, total_red: tr, total_skip: ts, total_blank: tb }
}
/// The introspection blob a host folds into a `state_json` (LAW 6): aspects,
/// per-repo cells with their badges + health, and the summary totals — so a
/// driver reads the rendered grid without a screenshot.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"aspects": self.aspects,
"repo_count": self.rows.len(),
"total_green": self.total_green,
"total_red": self.total_red,
"total_skip": self.total_skip,
"total_blank": self.total_blank,
"rows": self.rows.iter().map(|r| serde_json::json!({
"repo": r.repo,
"run_id": r.run_id,
"health": r.health,
"cells": r.cells.iter().zip(self.aspects.iter()).map(|(c, a)| serde_json::json!({
"aspect": a,
"status": c.status,
"ran": c.ran(),
"green": c.is_green(),
"red": c.is_red(),
"metric": c.metric,
"badge": c.badge(a),
"red_cases": c.red_cases.iter().map(|rc| serde_json::json!({
"test": rc.test_name,
"suite": rc.suite,
"status": rc.status,
"message": rc.message,
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
}
}
/// Aspects whose rows are **one named case each** (many cases per cell) and so roll up
/// into a pass/fail tally + red-case list, exactly like `unit`. `robot-ui` (accesskit
/// pane-drive scenarios) and `functional` (functional-status checks) are multi-case the
/// same way a unit suite is; the synthetic single-value aspects (coverage/clippy/…) are
/// not.
fn is_multicase(aspect: &str) -> bool {
matches!(aspect, "unit" | "robot-ui" | "functional")
}
/// Roll the latest run's rows for one aspect into a [`Cell`]. `listed` rows are
/// discovery-only (a test that EXISTS but has not run yet) → they never make a
/// cell "ran"; a real `pass`/`fail`/`ignored` row overrides the seed.
fn build_cell(repo_rows: &[&TestResultRow], run_id: &str, aspect: &str) -> Cell {
let hits: Vec<&&TestResultRow> = repo_rows
.iter()
.filter(|r| r.run_id == run_id && r.aspect == aspect)
.filter(|r| !status::is_listed(&r.status))
.collect();
if hits.is_empty() {
return Cell::not_run();
}
if is_multicase(aspect) {
let mut passed = 0;
let mut failed = 0;
let mut red_cases = Vec::new();
for r in &hits {
if status::is_green(&r.status) {
passed += 1;
} else if status::is_red(&r.status) {
failed += 1;
}
if status::is_red(&r.status) {
red_cases.push(RedCase {
test_name: r.test_name.clone(),
suite: r.suite.clone(),
status: r.status.clone(),
message: r.message.clone(),
});
}
}
let cell_status = if failed > 0 { status::FAIL } else { status::PASS };
return Cell {
status: cell_status.to_string(),
metric: failed as f64,
count: hits.len(),
passed,
failed,
red_cases,
};
}
let r = hits[0];
let red_cases = if status::is_red(&r.status) {
vec![RedCase {
test_name: r.test_name.clone(),
suite: r.suite.clone(),
status: r.status.clone(),
message: r.message.clone(),
}]
} else {
Vec::new()
};
Cell {
status: r.status.clone(),
metric: r.metric,
count: hits.len(),
passed: usize::from(status::is_green(&r.status)),
failed: usize::from(status::is_red(&r.status)),
red_cases,
}
}
/// A 0–100 health score for a repo from its latest-run cells: green-ratio (60) +
/// coverage (25) − a penalty for clippy warnings + audit advisories.
pub fn health_score(aspects: &[String], cells: &[Cell]) -> f64 {
let mut green = 0.0;
let mut considered = 0.0;
let mut coverage: Option<f64> = None;
let mut penalty = 0.0;
for (a, c) in aspects.iter().zip(cells) {
if !c.ran() {
continue;
}
if c.is_green() || c.is_red() || c.status == status::SKIP {
considered += 1.0;
if c.is_green() {
green += 1.0;
}
}
match a.as_str() {
"coverage" if c.ran() => coverage = Some(c.metric),
"clippy" => penalty += c.metric.min(8.0) * 4.0,
"audit" => penalty += c.metric.min(8.0) * 6.0,
_ => {}
}
}
if considered == 0.0 {
return 0.0;
}
let green_ratio = green / considered;
let score = match coverage {
Some(cov) => green_ratio * 60.0 + (cov / 100.0).clamp(0.0, 1.0) * 25.0 + 15.0,
None => green_ratio * 85.0 + 15.0,
};
(score - penalty).clamp(0.0, 100.0)
}
/// A per-(repo, aspect) sparkline: one normalized point per recent run (oldest →
/// newest, last [`SPARK_RUNS`]). Aspect-aware: coverage → the %; clippy/audit/fmt
/// → the count **inverted** (fewer = up); else 100 green / 0 red / 50 neutral.
pub fn spark_series(repo_rows: &[&TestResultRow], summaries: &[RunSummary], aspect: &str) -> Vec<f64> {
let mut runs: Vec<&RunSummary> = summaries.iter().take(SPARK_RUNS).collect();
runs.reverse();
let mut series = Vec::with_capacity(runs.len());
for run in runs {
let hits: Vec<&&TestResultRow> =
repo_rows.iter().filter(|r| r.run_id == run.run_id && r.aspect == aspect).collect();
if hits.is_empty() {
continue;
}
let v = match aspect {
"coverage" => hits.iter().map(|r| r.metric).fold(0.0, f64::max),
"clippy" | "audit" | "fmt" => {
let count = hits.iter().map(|r| r.metric).fold(0.0, f64::max);
(1.0 - (count / 10.0).clamp(0.0, 1.0)) * 100.0
}
"unit" => {
let total = hits.len() as f64;
let pass = hits.iter().filter(|r| status::is_green(&r.status)).count() as f64;
if total == 0.0 {
50.0
} else {
pass / total * 100.0
}
}
_ => {
let r = hits[0];
if status::is_green(&r.status) {
100.0
} else if status::is_red(&r.status) {
0.0
} else {
50.0
}
}
};
series.push(v);
}
series
}
#[cfg(test)]
mod tests {
use super::*;
fn row(run: &str, repo: &str, aspect: &str, name: &str, st: &str, metric: f64, ts: i64) -> TestResultRow {
TestResultRow {
run_id: run.into(),
repo: repo.into(),
suite: repo.into(),
test_name: name.into(),
status: st.into(),
duration_ms: 1.0,
ts_micros: ts,
message: if status::is_red(st) { format!("{name} boom") } else { String::new() },
aspect: aspect.into(),
metric,
}
}
#[test]
fn matrix_rows_are_repos_cols_are_aspects() {
let rows = vec![
row("r1", "alpha", "build", "build", status::PASS, 0.0, 100),
row("r1", "alpha", "clippy", "clippy", status::FAIL, 3.0, 100),
row("r1", "beta", "build", "build", status::PASS, 0.0, 100),
row("r1", "beta", "coverage", "coverage", status::PASS, 87.0, 100),
];
let m = Matrix::build(&rows);
assert_eq!(m.rows.iter().map(|r| r.repo.as_str()).collect::<Vec<_>>(), vec!["alpha", "beta"]);
assert_eq!(m.aspects, vec!["build", "clippy", "coverage"]);
}
#[test]
fn cells_carry_status_and_metric_badges() {
let rows = vec![
row("r1", "alpha", "clippy", "clippy", status::FAIL, 3.0, 100),
row("r1", "alpha", "coverage", "coverage", status::PASS, 87.0, 100),
row("r1", "alpha", "audit", "audit", status::PASS, 0.0, 100),
];
let m = Matrix::build(&rows);
let a = &m.rows[0];
let at = |x: &str| m.aspects.iter().position(|y| y == x).unwrap();
assert_eq!(a.cells[at("clippy")].badge("clippy"), "⚠3");
assert_eq!(a.cells[at("coverage")].badge("coverage"), "87%");
assert_eq!(a.cells[at("audit")].badge("audit"), "🛡0");
}
#[test]
fn unit_cell_rolls_up_pass_fail_counts_and_badge() {
let rows = vec![
row("r1", "alpha", "unit", "a::t1", status::PASS, 0.0, 100),
row("r1", "alpha", "unit", "a::t2", status::PASS, 0.0, 100),
row("r1", "alpha", "unit", "a::t3", status::FAIL, 0.0, 100),
];
let m = Matrix::build(&rows);
let unit = &m.rows[0].cells[0];
assert_eq!(unit.passed, 2);
assert_eq!(unit.failed, 1);
assert_eq!(unit.status, status::FAIL);
assert_eq!(unit.badge("unit"), "2✓ 1✗");
assert_eq!(unit.red_cases.len(), 1);
assert_eq!(unit.red_cases[0].test_name, "a::t3");
}
#[test]
fn latest_run_wins_per_repo() {
let rows = vec![
row("old", "alpha", "clippy", "clippy", status::FAIL, 5.0, 100),
row("new", "alpha", "clippy", "clippy", status::PASS, 0.0, 200),
];
let m = Matrix::build(&rows);
assert_eq!(m.rows[0].run_id, "new");
assert_eq!(m.rows[0].cells[0].status, status::PASS);
}
#[test]
fn health_high_for_green_high_coverage_low_warnings() {
let aspects: Vec<String> = vec!["build".into(), "coverage".into(), "clippy".into()];
let cells = vec![
Cell { status: status::PASS.into(), metric: 0.0, count: 1, passed: 1, failed: 0, red_cases: vec![] },
Cell { status: status::PASS.into(), metric: 95.0, count: 1, passed: 1, failed: 0, red_cases: vec![] },
Cell { status: status::PASS.into(), metric: 0.0, count: 1, passed: 1, failed: 0, red_cases: vec![] },
];
assert!(health_score(&aspects, &cells) > 90.0);
}
#[test]
fn health_drops_for_failures_and_warnings() {
let aspects: Vec<String> = vec!["build".into(), "coverage".into(), "clippy".into()];
let cells = vec![
Cell { status: status::FAIL.into(), metric: 1.0, count: 1, passed: 0, failed: 1, red_cases: vec![] },
Cell { status: status::PASS.into(), metric: 40.0, count: 1, passed: 1, failed: 0, red_cases: vec![] },
Cell { status: status::FAIL.into(), metric: 6.0, count: 1, passed: 0, failed: 1, red_cases: vec![] },
];
assert!(health_score(&aspects, &cells) < 50.0);
}
#[test]
fn sparkline_tracks_coverage_over_runs() {
let rows = vec![
row("run1", "alpha", "coverage", "coverage", status::PASS, 70.0, 100),
row("run2", "alpha", "coverage", "coverage", status::PASS, 80.0, 200),
row("run3", "alpha", "coverage", "coverage", status::PASS, 90.0, 300),
];
let m = Matrix::build(&rows);
let cov_idx = m.aspects.iter().position(|x| x == "coverage").unwrap();
assert_eq!(&m.rows[0].sparks[cov_idx], &vec![70.0, 80.0, 90.0]);
}
#[test]
fn expected_surface_shows_not_run_before_any_result() {
let expected = vec!["znippy".to_string(), "nornir".to_string()];
let m = Matrix::build_with_expected(&expected, ASPECT_DEFAULT, &[]);
assert_eq!(m.rows.iter().map(|r| r.repo.as_str()).collect::<Vec<_>>(), vec!["nornir", "znippy"]);
assert_eq!(m.aspects, vec!["build", "unit", "doctest", "clippy", "fmt", "audit"]);
assert!(m.rows.iter().all(|r| r.cells.iter().all(|c| !c.ran())));
assert_eq!(m.total_blank, 2 * ASPECT_DEFAULT.len());
assert_eq!(m.total_green + m.total_red + m.total_skip, 0);
}
/// ROBOT-UI is a first-class aspect column: a repo carrying `robot-ui` result rows
/// gets a `robot-ui` column whose cell reflects the scenario verdict (green when the
/// pane-drives passed, red when one failed). This is how a driver reads "robot-UI
/// tests + their status" out of the matrix.
#[test]
fn robot_ui_is_an_aspect_column_with_status() {
assert!(ASPECT_ORDER.contains(&"robot-ui"), "robot-ui is a known aspect");
let rows = vec![
row("r1", "dwarves", "robot-ui", "benches_tab_drives", status::PASS, 0.0, 100),
row("r1", "dwarves", "robot-ui", "forge_tab_drives", status::FAIL, 0.0, 100),
row("r1", "dwarves", "unit", "u::t1", status::PASS, 0.0, 100),
];
let m = Matrix::build(&rows);
// A `robot-ui` column exists, after the core aspects (ASPECT_ORDER position).
assert!(m.aspects.iter().any(|a| a == "robot-ui"), "robot-ui column present: {:?}", m.aspects);
let ru = m.aspects.iter().position(|a| a == "robot-ui").unwrap();
let cell = &m.rows[0].cells[ru];
// One scenario failed → the aggregate robot-ui cell is red and NAMES the failure.
assert!(cell.ran());
assert!(cell.is_red(), "a failed robot-ui scenario reds the cell");
assert!(cell.red_cases.iter().any(|c| c.test_name == "forge_tab_drives"));
let j = m.state_json();
let dwarves = j["rows"].as_array().unwrap().iter().find(|r| r["repo"] == "dwarves").unwrap();
let ru_cell = dwarves["cells"].as_array().unwrap().iter().find(|c| c["aspect"] == "robot-ui").unwrap();
assert_eq!(ru_cell["red"], true, "robot-ui status surfaces in state_json");
}
#[test]
fn listed_only_cell_is_not_run() {
let rows = vec![
row("r1", "alpha", "unit", "a::t1", status::LISTED, 0.0, 100),
row("r1", "alpha", "unit", "a::t2", status::LISTED, 0.0, 100),
];
let m = Matrix::build(&rows);
let unit = &m.rows[0].cells[m.aspects.iter().position(|x| x == "unit").unwrap()];
assert!(!unit.ran());
assert_eq!(m.total_blank, m.aspects.len());
}
/// inject-assert (LAW 6): the matrix's `state_json` exposes the rendered grid
/// — aspects, per-cell verdict/badge, summary totals — a driver asserts on.
#[test]
fn state_json_exposes_grid_cells_and_totals() {
let rows = vec![
row("r1", "alpha", "unit", "a::t1", status::PASS, 0.0, 100),
row("r1", "alpha", "unit", "a::t2", status::FAIL, 0.0, 100),
row("r1", "alpha", "coverage", "coverage", status::PASS, 91.0, 100),
row("r1", "beta", "build", "build", status::PASS, 0.0, 100),
];
let m = Matrix::build(&rows);
let j = m.state_json();
assert_eq!(j["repo_count"], 2);
assert!(j["aspects"].as_array().unwrap().iter().any(|a| a == "coverage"));
// alpha row: a failing unit cell + a green coverage cell with a "91%" badge.
let alpha = j["rows"].as_array().unwrap().iter().find(|r| r["repo"] == "alpha").unwrap();
let cov = alpha["cells"].as_array().unwrap().iter().find(|c| c["aspect"] == "coverage").unwrap();
assert_eq!(cov["green"], true);
assert_eq!(cov["badge"], "91%");
let unit = alpha["cells"].as_array().unwrap().iter().find(|c| c["aspect"] == "unit").unwrap();
assert_eq!(unit["red"], true);
assert_eq!(unit["badge"], "1✓ 1✗");
assert_eq!(unit["red_cases"].as_array().unwrap().len(), 1);
// totals: one red (alpha unit), greens for alpha coverage + beta build.
assert!(j["total_red"].as_u64().unwrap() >= 1);
assert!(j["total_green"].as_u64().unwrap() >= 2);
}
}