facett-fleet-view/src/model.rs
//! **Pure model** behind the fleet coverage board — egui-free, `std` + `serde`
//! only, so the whole board is inject-and-assert testable (feed a
//! [`GateReport`] + a slice of [`TestResultRow`]s → assert the laid-out
//! [`FleetBoard`]) and folds straight into a `state_json` (the "see what the
//! user sees as data" LAW).
//!
//! The board is the fleet autotest run rendered as a grid:
//! * **rows = fleet members** (the apps/containers: `nornir`, `dwarves`,
//! `holger`, `korp`, …), taken from each member's *latest run* (newest
//! `ts_micros`, via [`summarize_runs`] — the SAME latest-run rule the
//! `facett-matrix` grid uses).
//! * **columns = driven surfaces/tabs** — the surface keys (`viz_tab:chat@thin`
//! …), the union of the aspects present in the rows and the surfaces the
//! [`GateReport`]'s gap references, so a never-driven surface still shows as a
//! grey column instead of silently vanishing.
//! * each **cell** = green (passed) / red (failed) / grey (not-yet-driven),
//! sourced from the [`TestResultRow`]s. A **dead member** (health
//! [`MemberHealth::Failed`]) reads as a **red row** — its not-yet-driven cells
//! go red, never a false blank.
//!
//! The verdict banner is derived from the [`GateReport`] directly:
//! `gate_green ⟺ report.is_green()` (Gap == ∅ AND no stale allowlist), and the
//! `missing` list is the honest `gap.missing` surface keys — the exact
//! `missing == [viz_tab:chat@thin]` semantics the fleet tests assert.
//!
//! All coverage/verdict types are **consumed** from `nornir-testmatrix`; this
//! module invents no parallel coverage model.
use std::collections::BTreeSet;
use nornir_testmatrix::{status, summarize_runs, GateReport, TestResultRow};
use serde::{Deserialize, Serialize};
/// The spin/health of one fleet member (the container/app itself, distinct from
/// the surfaces it drives). A member that failed to come up is [`Failed`] — the
/// board paints its whole row red so a dead member never reads as a false blank.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemberHealth {
/// The member spun up and is serving (Ready).
Ready,
/// The member failed to spin / crashed — a RED row, not a blank one.
Failed,
/// No health signal carried in the input (the default). Does NOT force red.
#[default]
Unknown,
}
impl MemberHealth {
pub fn label(self) -> &'static str {
match self {
MemberHealth::Ready => "ready",
MemberHealth::Failed => "failed",
MemberHealth::Unknown => "unknown",
}
}
pub fn is_failed(self) -> bool {
matches!(self, MemberHealth::Failed)
}
}
/// One declared fleet member + its optional spin/health. Declaring a member with
/// no result rows still gives it a board row (a dead/never-driven member is
/// visible, not missing).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FleetMember {
pub name: String,
#[serde(default)]
pub health: MemberHealth,
}
impl FleetMember {
pub fn new(name: impl Into<String>, health: MemberHealth) -> Self {
Self { name: name.into(), health }
}
/// A member with no health signal carried.
pub fn plain(name: impl Into<String>) -> Self {
Self::new(name, MemberHealth::Unknown)
}
}
/// One board cell's verdict: a member × surface leg in the member's latest run.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CellVerdict {
/// The member drove this surface and it passed (green).
Passed,
/// The member drove this surface and it failed — OR the member is dead (red).
Failed,
/// No (non-listed) result row for this member × surface yet (grey).
NotDriven,
}
impl CellVerdict {
pub fn label(self) -> &'static str {
match self {
CellVerdict::Passed => "passed",
CellVerdict::Failed => "failed",
CellVerdict::NotDriven => "not_driven",
}
}
pub fn is_green(self) -> bool {
matches!(self, CellVerdict::Passed)
}
pub fn is_red(self) -> bool {
matches!(self, CellVerdict::Failed)
}
pub fn driven(self) -> bool {
!matches!(self, CellVerdict::NotDriven)
}
}
/// One board row: a fleet member, its spin/health, whether the whole row reads
/// red (a dead member or any failed cell), and its per-surface cells (parallel to
/// [`FleetBoard::surfaces`]).
#[derive(Clone, Debug, PartialEq)]
pub struct MemberRow {
pub name: String,
pub health: MemberHealth,
pub row_red: bool,
pub cells: Vec<CellVerdict>,
}
/// The whole laid-out board + the coverage verdict, derived purely from a
/// [`GateReport`] and the result rows. The renderer ([`crate::FleetView`]) only
/// paints what this computes, and its `state_json` is [`FleetBoard::state_json`].
#[derive(Clone, Debug, PartialEq)]
pub struct FleetBoard {
/// The gate run identity (from the [`GateReport`]).
pub run_id: String,
/// The workspace the surface belongs to (from the [`GateReport`]).
pub workspace: String,
/// Surface/tab column keys (`kind:id@mode`), sorted.
pub surfaces: Vec<String>,
/// One row per fleet member, sorted by name.
pub members: Vec<MemberRow>,
/// Covered surface nodes (from `gap.covered`).
pub covered: usize,
/// Total discovered surface nodes (from `gap.total`).
pub total: usize,
/// `Gap == ∅` — no missing surface.
pub gap_clean: bool,
/// The HARD-zero gate: `Gap == ∅` AND no stale allowlist entry.
pub gate_green: bool,
/// Stale (no-longer-needed) allowlist entries.
pub stale: usize,
/// The missing surface keys (the actionable burn-down list), sorted.
pub missing: Vec<String>,
/// The allowlisted (excused) surface keys.
pub allowlisted: Vec<String>,
/// Cell tallies for the summary strip.
pub cells_green: usize,
pub cells_red: usize,
pub cells_not_driven: usize,
}
impl FleetBoard {
/// Build the board by differencing the member rows against the surface
/// columns, under the [`GateReport`]'s verdict. Pure: feed the report + rows +
/// declared members, get the laid-out board back.
pub fn build(report: &GateReport, rows: &[TestResultRow], members: &[FleetMember]) -> FleetBoard {
// ── columns: the driven surfaces (row aspects) unioned with every surface
// the gap references, so a never-driven surface is still a visible col.
let mut surf: BTreeSet<String> = BTreeSet::new();
for r in rows {
if !status::is_listed(&r.status) {
surf.insert(r.aspect.clone());
}
}
for n in &report.gap.missing {
surf.insert(n.key_str());
}
for n in &report.gap.allowlisted {
surf.insert(n.key_str());
}
let surfaces: Vec<String> = surf.into_iter().collect();
// ── rows: every member that produced a row, unioned with declared members.
let mut names: BTreeSet<String> = BTreeSet::new();
for r in rows {
names.insert(r.repo.clone());
}
for m in members {
names.insert(m.name.clone());
}
let health_of = |name: &str| members.iter().find(|m| m.name == name).map(|m| m.health).unwrap_or_default();
let mut member_rows = Vec::with_capacity(names.len());
let (mut cg, mut cr, mut cn) = (0usize, 0usize, 0usize);
for name in &names {
let health = health_of(name);
let mrows: Vec<&TestResultRow> = rows.iter().filter(|r| &r.repo == name).collect();
// Latest run for this member — the SAME rule the facett-matrix grid uses.
let latest_id = {
let owned: Vec<TestResultRow> = mrows.iter().map(|r| (*r).clone()).collect();
summarize_runs(&owned).first().map(|s| s.run_id.clone()).unwrap_or_default()
};
let mut cells = Vec::with_capacity(surfaces.len());
let mut row_red = health.is_failed();
for s in &surfaces {
let hits: Vec<&&TestResultRow> = mrows
.iter()
.filter(|r| r.run_id == latest_id && &r.aspect == s && !status::is_listed(&r.status))
.collect();
let base = if hits.is_empty() {
CellVerdict::NotDriven
} else if hits.iter().any(|r| status::is_red(&r.status)) {
CellVerdict::Failed
} else if hits.iter().any(|r| status::is_green(&r.status)) {
CellVerdict::Passed
} else {
// Only skip/neutral rows → nothing to prove yet.
CellVerdict::NotDriven
};
// A dead member's not-yet-driven cell reads RED, never a false blank.
let eff = if health.is_failed() && base == CellVerdict::NotDriven { CellVerdict::Failed } else { base };
match eff {
CellVerdict::Passed => cg += 1,
CellVerdict::Failed => cr += 1,
CellVerdict::NotDriven => cn += 1,
}
if eff.is_red() {
row_red = true;
}
cells.push(eff);
}
member_rows.push(MemberRow { name: name.clone(), health, row_red, cells });
}
FleetBoard {
run_id: report.run_id.clone(),
workspace: report.workspace.clone(),
surfaces,
members: member_rows,
covered: report.gap.covered,
total: report.gap.total,
gap_clean: report.gap.is_clean(),
gate_green: report.is_green(),
stale: report.stale.len(),
missing: report.gap.missing.iter().map(|n| n.key_str()).collect(),
allowlisted: report.gap.allowlisted.iter().map(|n| n.key_str()).collect(),
cells_green: cg,
cells_red: cr,
cells_not_driven: cn,
}
}
/// The introspection blob a host folds into a `state_json` (LAW 6): the gap
/// verdict, the missing list, the surface columns, and every member row's
/// per-cell verdicts — so a robot reads exactly what is painted without a
/// screenshot.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"run_id": self.run_id,
"workspace": self.workspace,
"member_count": self.members.len(),
"surface_count": self.surfaces.len(),
"covered": self.covered,
"total": self.total,
"gap_clean": self.gap_clean,
"gate_green": self.gate_green,
"stale": self.stale,
"missing": self.missing,
"allowlisted": self.allowlisted,
"surfaces": self.surfaces,
"cells_green": self.cells_green,
"cells_red": self.cells_red,
"cells_not_driven": self.cells_not_driven,
"members": self.members.iter().map(|m| serde_json::json!({
"name": m.name,
"health": m.health.label(),
"row_red": m.row_red,
"cells": m.cells.iter().zip(self.surfaces.iter()).map(|(c, s)| serde_json::json!({
"surface": s,
"verdict": c.label(),
"green": c.is_green(),
"red": c.is_red(),
"driven": c.driven(),
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_testmatrix::coverage::Allowlist;
use nornir_testmatrix::discover::{viz_tabs, Surface};
/// A pass/fail row for `member` driving `surface` (the `aspect` = surface key).
fn row(run: &str, member: &str, surface: &str, st: &str, ts: i64) -> TestResultRow {
TestResultRow {
run_id: run.into(),
repo: member.into(),
suite: member.into(),
test_name: format!("{member}::{surface}"),
status: st.into(),
duration_ms: 1.0,
ts_micros: ts,
message: if status::is_red(st) { "boom".into() } else { String::new() },
aspect: surface.into(),
metric: 0.0,
}
}
/// The surface = two tabs × {fat, thin}; `covered` = all keys minus `hole`.
fn report_missing(hole: Option<&str>) -> GateReport {
let mut surface = Surface::new();
surface.extend(viz_tabs(["chat", "board"]));
let covered: BTreeSet<String> = surface
.nodes
.iter()
.map(|n| n.key_str())
.filter(|k| hole != Some(k.as_str()))
.collect();
GateReport::compute("run-1", "nordisk", &surface, &covered, &Allowlist::new())
}
#[test]
fn fully_covered_board_is_all_green() {
let report = report_missing(None);
let surfaces = ["viz_tab:chat@fat", "viz_tab:chat@thin", "viz_tab:board@fat", "viz_tab:board@thin"];
let mut rows = Vec::new();
for m in ["nornir", "dwarves"] {
for s in surfaces {
rows.push(row("run-1", m, s, status::PASS, 100));
}
}
let board = FleetBoard::build(&report, &rows, &[]);
assert!(board.gate_green, "gate is green when the gap is empty");
assert!(board.gap_clean);
assert!(board.missing.is_empty());
assert_eq!(board.cells_red, 0);
assert_eq!(board.cells_not_driven, 0);
assert!(board.cells_green >= 8, "every driven cell is green");
}
#[test]
fn missing_surface_makes_a_red_banner_and_red_cell() {
let hole = "viz_tab:chat@thin";
let report = report_missing(Some(hole));
// The failing drive is WHY the surface is uncovered: nornir's chat@thin red.
let rows = vec![
row("run-1", "nornir", "viz_tab:chat@fat", status::PASS, 100),
row("run-1", "nornir", hole, status::FAIL, 100),
];
let board = FleetBoard::build(&report, &rows, &[]);
assert!(!board.gate_green, "gate is red when a surface is missing");
assert!(!board.gap_clean);
assert_eq!(board.missing, vec![hole.to_string()], "the exact missing node is named");
// The chat@thin cell for nornir is red.
let nornir = board.members.iter().find(|m| m.name == "nornir").unwrap();
let idx = board.surfaces.iter().position(|s| s == hole).unwrap();
assert!(nornir.cells[idx].is_red(), "the missing surface's cell is red");
assert!(nornir.row_red);
}
#[test]
fn dead_member_is_a_red_row_not_a_blank() {
let report = report_missing(None);
let rows = vec![row("run-1", "nornir", "viz_tab:chat@fat", status::PASS, 100)];
// korp declared but never spun (Failed) and produced no rows.
let members = vec![FleetMember::new("korp", MemberHealth::Failed)];
let board = FleetBoard::build(&report, &rows, &members);
let korp = board.members.iter().find(|m| m.name == "korp").unwrap();
assert!(korp.row_red, "a dead member's row is red");
assert!(korp.cells.iter().all(|c| c.is_red()), "its cells are red, not blank");
assert_eq!(korp.health, MemberHealth::Failed);
}
}