facett-stat/src/lib.rs
//! **facett-stat** — the generic, *non-domain* viz stat widgets factored out of
//! nornir's `src/viz/facett_ui.rs` (status chips, a health pill, themed
//! sparklines) and the inline-bar stat table from `src/viz/mcp_tab.rs`. The
//! nornir originals lived in the viz crate only because of an egui-version skew
//! (the viz is pinned to egui 0.33 by `egui-snarl`, facett is on 0.34) — the
//! header of `facett_ui.rs` says verbatim *"These widgets … should live in the
//! upstream `facett` crate"*. facett is already on 0.34, so this is the re-home.
//!
//! Two layers:
//! 1. **Free painter helpers** — [`status_verdict`], [`sparkline`], [`health_pill`]
//! — palette-aware, hold no state, draw straight onto an [`egui::Painter`].
//! 2. **[`StatGridView`]** — a canonical [`Facet`] over a list of [`StatRow`]s,
//! each a label + value + an inline normalized bar + an optional sparkline and
//! status verdict (the shape of nornir's MCP/bench stat tables). `local()`/
//! `remote()`/`state_json()`/`caps()` so the deck + test matrix discover it.
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{Elm, FacetCaps, Semantics};
use serde::{Deserialize, Serialize};
/// The warm red used across facett for failure/negative (no semantic red in the
/// base palette) — shared by the verdict chip + the health ramp. Single source
/// of truth is [`facett_core::look::RED`]; re-exported here so the canonical
/// status vocabulary keeps a stable `facett_stat::RED` path.
pub use facett_core::look::{AMBER, RED};
/// A generic status **verdict** — the small vocabulary panes map their domain
/// status onto (pass/fail/skip/ignore/running). Mirrors nornir's `status_chip`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Pass,
Fail,
Skip,
Ignore,
Running,
}
impl Verdict {
/// Parse a free-text status (case-insensitive) onto the closed vocabulary;
/// `None` for an unrecognised word (callers paint it dim).
pub fn parse(status: &str) -> Option<Verdict> {
match status.to_ascii_lowercase().as_str() {
"pass" | "passed" | "ok" | "succeeded" | "done" => Some(Verdict::Pass),
"fail" | "failed" | "error" => Some(Verdict::Fail),
"skip" | "skipped" => Some(Verdict::Skip),
"ignored" | "ignore" => Some(Verdict::Ignore),
"running" | "in_progress" => Some(Verdict::Running),
_ => None,
}
}
/// The short chip glyph.
pub fn tag(self) -> &'static str {
match self {
Verdict::Pass => "PASS",
Verdict::Fail => "FAIL",
Verdict::Skip => "SKIP",
Verdict::Ignore => "IGN",
Verdict::Running => "RUN",
}
}
/// The chip colour on the active theme.
pub fn color(self, th: &facett_core::theme::Theme) -> egui::Color32 {
match self {
Verdict::Pass => th.point,
Verdict::Fail => RED,
Verdict::Running => AMBER,
Verdict::Skip => egui::Color32::from_rgb(150, 150, 160),
Verdict::Ignore => th.text_dim,
}
}
}
/// `(tag, colour)` for a free-text status on the active theme — the direct port
/// of nornir's `viz::facett_ui::status_chip`. An unknown status is `("?", dim)`.
pub fn status_verdict(th: &facett_core::theme::Theme, status: &str) -> (&'static str, egui::Color32) {
match Verdict::parse(status) {
Some(v) => (v.tag(), v.color(th)),
None => ("?", th.text_dim),
}
}
/// A tiny themed **sparkline** of a value series (line = palette `point`). Values
/// self-normalize; higher = up. No-op for <2 points. Direct port of nornir's
/// `viz::facett_ui::sparkline`.
pub fn sparkline(painter: &egui::Painter, th: &facett_core::theme::Theme, series: &[f64], rect: egui::Rect) {
if series.len() < 2 {
return;
}
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for &v in series {
lo = lo.min(v);
hi = hi.max(v);
}
let span = (hi - lo).max(1.0);
let n = series.len();
let pts: Vec<egui::Pos2> = series
.iter()
.enumerate()
.map(|(i, &v)| {
let t = i as f32 / (n - 1) as f32;
let x = rect.min.x + t * rect.width();
let norm = ((v - lo) / span) as f32;
let y = rect.max.y - norm * rect.height();
egui::Pos2::new(x, y)
})
.collect();
for w in pts.windows(2) {
painter.line_segment([w[0], w[1]], egui::Stroke::new(1.4_f32, th.point));
}
if let Some(last) = pts.last() {
painter.circle_filled(*last, 1.8, th.point);
}
}
/// A 0–100 **health pill** on a red→amber→green ramp. Port of nornir's
/// `viz::facett_ui::health_pill` (its `Theme::health_color` ramp inlined here so
/// the widget is self-contained — facett-core's `Theme` carries no health ramp).
pub fn health_pill(painter: &egui::Painter, score: f64, top_left: egui::Pos2, w: f32, h: f32) {
let col = health_color(score);
let pill = egui::Rect::from_min_size(
egui::Pos2::new(top_left.x + 4.0, top_left.y + 9.0),
egui::Vec2::new(w - 12.0, h - 18.0),
);
painter.rect_filled(pill, egui::CornerRadius::same(7), col.linear_multiply(0.28));
painter.rect_stroke(pill, egui::CornerRadius::same(7), egui::Stroke::new(1.5_f32, col), egui::StrokeKind::Inside);
painter.text(pill.center(), egui::Align2::CENTER_CENTER, format!("{score:.0}"), egui::FontId::proportional(16.0), col);
}
/// The red→amber→green health colour for a 0–100 score (the ramp nornir's theme
/// bakes in; inlined so facett-stat needs no extra theme surface).
pub fn health_color(score: f64) -> egui::Color32 {
let s = score.clamp(0.0, 100.0);
if s >= 80.0 {
egui::Color32::from_rgb(90, 200, 140)
} else if s >= 50.0 {
AMBER
} else {
RED
}
}
/// One row of a [`StatGridView`]: a label, a numeric value (drawn as an inline
/// normalized bar against the grid's max), an optional sparkline series, and an
/// optional status verdict chip. The shape of nornir's MCP/bench stat tables.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StatRow {
pub label: String,
pub value: f64,
#[serde(default)]
pub spark: Vec<f64>,
#[serde(default)]
pub status: Option<String>,
}
impl StatRow {
pub fn new(label: impl Into<String>, value: f64) -> Self {
Self { label: label.into(), value, spark: Vec::new(), status: None }
}
pub fn with_spark(mut self, spark: Vec<f64>) -> Self {
self.spark = spark;
self
}
pub fn with_status(mut self, status: impl Into<String>) -> Self {
self.status = Some(status.into());
self
}
}
/// A stat table: each row a label + inline bar + optional sparkline + verdict
/// chip. A canonical [`Facet`](facett_core::Facet).
///
/// **This is its own [`Elm::Model`]** (FC-1 / FC-3): the entire observable state
/// of a stat grid is exactly `title + rows` — there are no runtime controls
/// (no selection, no zoom, no toggles) — so the view struct *is* the serializable,
/// round-trippable state. `state()` hands back `&self`; the headless
/// [`facett_core::harness`] snapshots a clone of it after feeding a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StatGridView {
title: String,
pub rows: Vec<StatRow>,
}
impl StatGridView {
pub fn new(title: impl Into<String>) -> Self {
Self { title: title.into(), rows: Vec::new() }
}
/// Re-title the view (the deck keys facets off [`Facet::title`]).
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
/// The max absolute value across rows, the bar normalizer (`>= EPS`).
pub fn max_value(&self) -> f64 {
self.rows.iter().map(|r| r.value.abs()).fold(f64::EPSILON, f64::max)
}
/// **Discovery-contract `local()`** — seeds a small realistic stat table
/// (per-tool call counts with sparklines + verdicts) so the pane renders real
/// rows with no host attached.
pub fn local() -> Self {
let mut v = Self::new("Stats");
v.rows = vec![
StatRow::new("search.query", 1240.0).with_spark(vec![3.0, 5.0, 4.0, 9.0, 12.0]).with_status("pass"),
StatRow::new("warehouse.scan", 860.0).with_spark(vec![10.0, 8.0, 6.0, 7.0, 5.0]).with_status("pass"),
StatRow::new("docs.render", 120.0).with_spark(vec![1.0, 0.0, 2.0, 1.0, 0.0]).with_status("running"),
StatRow::new("release.gate", 14.0).with_spark(vec![2.0, 1.0, 0.0, 3.0, 4.0]).with_status("fail"),
];
v
}
/// **Discovery-contract `remote()`** — same surface, re-titled.
pub fn remote() -> Self {
let mut v = Self::local();
v.title = "Stats (remote)".into();
v
}
}
const ROW_H: f32 = 22.0;
const LABEL_W: f32 = 180.0;
const BAR_W: f32 = 160.0;
const SPARK_W: f32 = 80.0;
impl StatGridView {
/// The grid as a TSV rectangle (`label \t value \t status`, header first) — the
/// spreadsheet-pasteable view used by [`CopySource`].
pub fn to_tsv(&self) -> String {
let mut out = String::from("label\tvalue\tstatus");
for r in &self.rows {
out.push('\n');
out.push_str(&format!("{}\t{}\t{}", r.label, r.value, r.status.as_deref().unwrap_or("")));
}
out
}
}
// ── typed copy (§16) — the stat grid is a read-only tabular source ────────────
impl CopySource for StatGridView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Rows, ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
if self.rows.is_empty() {
return None;
}
Some(ClipPayload::Rows(self.to_tsv()))
}
}
/// A robot-/CLI-addressable control message — the named boundary a headless driver
/// (or a host) mutates the stat grid through. The stat grid has **no interactive
/// widgets** (the view is a pure read-only render), so these are the *host
/// injection* surface the old public `rows` field + `set_title` offered, now made
/// FC-2-addressable: every mutation of the observable [`StatGridView`] model flows
/// through [`StatGridView::update`]. Applied by [`StatGridView::update`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// Re-title the grid (the deck keys facets off the title).
SetTitle(String),
/// Append one row.
AddRow(StatRow),
/// Replace the whole row set.
SetRows(Vec<StatRow>),
/// Drop every row (back to the empty "no stats" pane).
ClearRows,
}
/// Side work as data (FC-8). Uninhabited on purpose: the stat grid renders a
/// host-fed table and performs **no** I/O, so [`StatGridView::update`] never asks
/// the host to do anything — the type-checked statement of that fact.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
impl StatGridView {
/// **FC-3** — read the complete observable state at any frame boundary. The
/// whole view *is* its model (`title + rows`), so this hands back `&self`.
pub fn state(&self) -> &StatGridView {
self
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. The convenience setters
/// ([`set_title`](Self::set_title), the public `rows` field) are host data
/// injection; this is the addressable, testable control surface.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SetTitle(t) => self.title = t,
Msg::AddRow(r) => self.rows.push(r),
Msg::SetRows(rows) => self.rows = rows,
Msg::ClearRows => self.rows.clear(),
}
Vec::new()
}
/// **FC-9 pure view** — paints the stat table (header row-count, per-row label +
/// inline normalized bar + optional sparkline + verdict chip) and **returns**
/// the [`Msg`]s this frame's interactions produced. The grid has no interactive
/// widgets, so the returned vec is always empty — but the signature keeps the
/// view a pure function of `&self`: it MUST NOT mutate the model. The
/// [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies any
/// returned messages through [`update`](Self::update).
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
let th = facett_core::theme(ui);
ui.label(egui::RichText::new(format!("{} rows", self.rows.len())).color(th.text).strong());
ui.separator();
if self.rows.is_empty() {
ui.label(egui::RichText::new("no stats").color(th.text_dim));
return Vec::new();
}
let max = self.max_value();
let base_id = ui.id().with("stat-rows");
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
for r in &self.rows {
let resp = ui
.push_id(facett_core::stable_id(base_id, &r.label), |ui| {
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(ui.available_width().max(LABEL_W + BAR_W + SPARK_W + 80.0), ROW_H),
egui::Sense::hover(),
);
let p = ui.painter_at(rect);
let y = rect.center().y;
// label
p.text(
egui::pos2(rect.left() + 4.0, y),
egui::Align2::LEFT_CENTER,
&r.label,
egui::FontId::monospace(12.0),
th.text_dim,
);
// inline bar, normalized to the grid max
let bx = rect.left() + LABEL_W;
let w = ((r.value.abs() / max) as f32 * BAR_W).max(1.0);
let bar = egui::Rect::from_min_size(egui::pos2(bx, y - 6.0), egui::vec2(w, 12.0));
p.rect_filled(bar, 2.0, if r.value < 0.0 { RED } else { th.accent });
// numeric value
p.text(
egui::pos2(bx + BAR_W + 8.0, y),
egui::Align2::LEFT_CENTER,
format!("{}", r.value),
egui::FontId::monospace(11.0),
th.text,
);
// sparkline
if r.spark.len() >= 2 {
let sx = bx + BAR_W + 80.0;
let srect = egui::Rect::from_min_size(egui::pos2(sx, y - 7.0), egui::vec2(SPARK_W, 14.0));
sparkline(&p, &th, &r.spark, srect);
}
// verdict chip
if let Some(s) = &r.status {
let (tag, col) = status_verdict(&th, s);
p.text(rect.right_center() - egui::vec2(40.0, 0.0), egui::Align2::RIGHT_CENTER, tag, egui::FontId::proportional(11.0), col);
}
resp
})
.inner;
let (label, value) = (r.label.clone(), r.value);
resp.widget_info(move || Semantics::new(egui::WidgetType::Label, format!("{label}: {value}")).value(value).widget_info());
}
});
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-stat::StatGridView::view",
"ui_render",
true,
&format!("rows={} max={max}", self.rows.len()),
);
Vec::new()
}
/// The rich `state_json` the deck + render matrix read: the grid shape
/// (`title`/`rows` count/`max` bar-normalizer) plus a per-row `stats` array
/// carrying each row's `label`/`value`/`spark_points`/`status`/derived
/// `verdict`. Kept identical to the pre-migration hand-written `Facet::state_json`
/// so the observable contract (and `facett-demo`'s `mega_matrix`) is unchanged.
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"title": self.title,
"rows": self.rows.len(),
"max": self.max_value(),
"stats": self.rows.iter().map(|r| serde_json::json!({
"label": r.label,
"value": r.value,
"spark_points": r.spark.len(),
"status": r.status,
"verdict": r.status.as_deref().and_then(Verdict::parse).map(|v| v.tag()),
})).collect::<Vec<_>>(),
})
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
//
// The Model/Msg/Effect/pure-view split lives on the inherent methods above; the
// `Elm` impl delegates to them. The Model *is* the view (`title + rows`).
impl Elm for StatGridView {
type Model = StatGridView;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &StatGridView {
self
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
StatGridView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
StatGridView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for StatGridView` from the `Elm` impl:
// `title`, the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), and the splices
// below. **Form 3** (`custom_state_json`) because the grid publishes a *richer*
// `state_json` than plain `serde(state())` — the derived `max` normalizer + the
// per-row `verdict` the deck/matrix read — so the macro's default serde dump is
// suppressed and the inherent `state_json` above is used verbatim. Also splices the
// preserved `copy` + `caps` (themeable/resizable/copyable). No `selection_json` /
// `kind`: the pre-migration grid exposed neither, so neither is added.
facett_core::impl_facet_via_elm!(StatGridView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
StatGridView::state_json(self)
}
/// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
/// apply it through the single [`Elm::update`](facett_core::Elm::update)
/// mutation path. Malformed / unknown JSON is ignored (never a panic), so a
/// headless robot or the shared `probe_and_emit` harness drives the host
/// injection surface (re-title / add / replace / clear rows) without the
/// concrete `Msg` type at the call site.
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().copyable()
}
});
#[cfg(test)]
mod tests {
use super::*;
// `Elm` + `CopySource` are already in scope via the glob above; add `Facet`
// for `state_json`/`title`/`copy` used by the render/matrix assertions.
use facett_core::Facet;
#[test]
fn verdict_vocabulary_parses_and_tags() {
assert_eq!(Verdict::parse("pass"), Some(Verdict::Pass));
assert_eq!(Verdict::parse("PASSED"), Some(Verdict::Pass)); // case-insensitive
assert_eq!(Verdict::parse("failed"), Some(Verdict::Fail));
assert_eq!(Verdict::parse("running"), Some(Verdict::Running));
assert_eq!(Verdict::parse("???"), None);
assert_eq!(Verdict::Pass.tag(), "PASS");
assert_eq!(Verdict::Fail.tag(), "FAIL");
// unknown status paints dim on the active theme.
let th = facett_core::theme::Theme::default();
assert_eq!(status_verdict(&th, "???"), ("?", th.text_dim));
assert_eq!(status_verdict(&th, "pass").0, "PASS");
}
#[test]
fn health_color_ramps_red_amber_green() {
assert_eq!(health_color(95.0), egui::Color32::from_rgb(90, 200, 140));
assert_eq!(health_color(60.0), AMBER);
assert_eq!(health_color(10.0), RED);
// clamps out of range.
assert_eq!(health_color(120.0), egui::Color32::from_rgb(90, 200, 140));
assert_eq!(health_color(-5.0), RED);
}
/// inject-assert (LAW 6): feed real stat rows, assert `state_json` reports the
/// shape, the bar normalizer, and the per-row verdict a driver reads.
#[test]
fn state_json_reports_rows_max_and_verdicts() {
let mut v = StatGridView::new("Tools");
v.rows = vec![
StatRow::new("a", 100.0).with_spark(vec![1.0, 2.0, 3.0]).with_status("pass"),
StatRow::new("b", 400.0).with_status("fail"),
StatRow::new("c", -50.0),
];
let j = v.state_json();
assert_eq!(j["rows"], 3);
assert_eq!(j["max"], 400.0, "max is the largest |value|");
let stats = j["stats"].as_array().unwrap();
assert_eq!(stats.len(), 3);
assert_eq!(stats[0]["label"], "a");
assert_eq!(stats[0]["spark_points"], 3);
assert_eq!(stats[0]["verdict"], "PASS");
assert_eq!(stats[1]["verdict"], "FAIL");
// a row with no status reports null verdict (not an error).
assert_eq!(stats[2]["status"], serde_json::Value::Null);
assert_eq!(stats[2]["verdict"], serde_json::Value::Null);
assert_eq!(stats[2]["value"], -50.0);
}
#[test]
fn local_ctor_is_renderable_and_non_empty() {
let v = StatGridView::local();
let j = v.state_json();
assert!(j.as_object().map(|o| !o.is_empty()).unwrap_or(false));
assert!(v.rows.len() >= 4);
assert_eq!(<StatGridView as Facet>::title(&v), "Stats");
assert_eq!(<StatGridView as Facet>::title(&StatGridView::remote()), "Stats (remote)");
}
#[test]
fn copy_payload_is_a_tsv_rectangle_of_the_rows() {
let mut v = StatGridView::new("Tools");
v.rows.push(StatRow::new("a", 3.0).with_status("pass"));
v.rows.push(StatRow::new("b", 7.0));
let p = v.copy_payload().expect("populated grid copies");
assert_eq!(p.kind(), facett_core::clip::ClipKind::Rows);
assert_eq!(p.as_text(), "label\tvalue\tstatus\na\t3\tpass\nb\t7\t");
assert_eq!(<StatGridView as Facet>::copy(&mut v), Some(p.as_text()));
assert!(StatGridView::new("empty").copy_payload().is_none());
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness and assert the resulting Model — no egui, no GPU. ────────────────
#[test]
fn update_msgs_drive_the_model() {
let mut v = StatGridView::new("Tools");
assert!(v.rows.is_empty());
// AddRow appends; SetTitle re-titles; the model observes both.
v.update(Msg::AddRow(StatRow::new("a", 3.0).with_status("pass")));
v.update(Msg::AddRow(StatRow::new("b", 7.0)));
v.update(Msg::SetTitle("Renamed".into()));
assert_eq!(v.rows.len(), 2);
assert_eq!(<StatGridView as Elm>::title(&v), "Renamed");
// SetRows replaces wholesale; ClearRows empties.
v.update(Msg::SetRows(vec![StatRow::new("only", 1.0)]));
assert_eq!(v.rows.len(), 1);
assert_eq!(v.rows[0].label, "only");
v.update(Msg::ClearRows);
assert!(v.rows.is_empty());
}
#[test]
fn harness_snapshot_drives_add_and_retitle() {
use facett_core::harness;
let mut v = StatGridView::new("Tools");
// A build-up sequence, applied purely via `Elm::update` (no render).
let snap = harness::snapshot(
&mut v,
[
Msg::AddRow(StatRow::new("search.query", 1240.0).with_status("pass")),
Msg::AddRow(StatRow::new("release.gate", 14.0).with_status("fail")),
Msg::SetTitle("Stats (driven)".into()),
],
);
assert_eq!(snap.rows.len(), 2, "two rows are observable in the snapshot");
assert_eq!(<StatGridView as Elm>::title(&snap), "Stats (driven)");
// The snapshot is a clone of the live state — same transition, headlessly.
assert_eq!(&snap, Elm::state(&v));
}
#[test]
fn drive_reports_no_effects_and_state_json_round_trips() {
use facett_core::harness;
let mut v = StatGridView::local();
// FC-8: the stat grid does no I/O, so the effect stream is always empty.
let effects = harness::drive(
&mut v,
[Msg::AddRow(StatRow::new("extra", 9.0).with_status("running")), Msg::SetTitle("t".into())],
);
assert!(effects.is_empty(), "FC-8: stat emits no Effects");
// FC-3: the observable Model round-trips through serde (the view IS the Model).
let json = serde_json::to_value(Elm::state(&v)).unwrap();
let back: StatGridView = serde_json::from_value(json).unwrap();
assert_eq!(&back, Elm::state(&v), "serde(state) -> state round-trips");
}
/// The macro-generated `Facet::state_json` (form 3) is the rich inherent one, so
/// the deck/matrix keys survive the migration unchanged after a driven sequence.
#[test]
fn facet_state_json_is_the_rich_form_after_driving() {
use facett_core::harness;
let mut v = StatGridView::new("Tools");
harness::drive(
&mut v,
[
Msg::AddRow(StatRow::new("a", 100.0).with_spark(vec![1.0, 2.0, 3.0]).with_status("pass")),
Msg::AddRow(StatRow::new("b", 400.0).with_status("fail")),
],
);
let j = Facet::state_json(&v);
assert_eq!(j["rows"], 2);
assert_eq!(j["max"], 400.0, "the derived bar normalizer is still published");
let stats = j["stats"].as_array().unwrap();
assert_eq!(stats[0]["verdict"], "PASS");
assert_eq!(stats[0]["spark_points"], 3);
assert_eq!(stats[1]["verdict"], "FAIL");
}
}