Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-matrix/src/lib.rs

//! **facett-matrix** — the reusable **test-matrix grid**: a pure per-repo ×
//! per-aspect model ([`grid::Matrix`] / [`grid::RepoRow`] / [`grid::Cell`] +
//! health scoring + sparkline series) and a [`MatrixView`] canonical [`Facet`]
//! that paints it. Adopted from nornir's `src/viz/test_matrix_grid.rs`; the row
//! schema + status vocabulary + run summariser come from the shared
//! [`nornir_testmatrix`] pure model both grids derive from.
//!
//! Pure model: [`grid`] is egui-free and fully unit-testable (inject rows →
//! assert the laid-out grid). View: [`MatrixView`] holds the rows, builds the
//! [`grid::Matrix`], paints a coloured cell grid (green/red/skip/blank), and
//! exposes the whole grid through [`Facet::state_json`] so a headless driver
//! reads exactly what is painted (LAW 6).
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`MatrixState`]** — the complete observable, serializable, round-trippable
//!   state (the rows + expected surface + the drill-in selection). [`MatrixView::state`]
//!   hands back a `&MatrixState`.
//! - **[`Msg`] + [`MatrixView::update`]** — the single mutation path (FC-2): a repo
//!   row is selected / cleared by name, the same gesture the canvas click drives, so
//!   a headless driver ([`facett_core::harness`]) reaches it too.
//! - **[`MatrixView::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
//!   grid, and *returns* the [`Msg`]s the interactions produced; the
//!   [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge applies them.
//! - **Rich `state_json`** — the grid dims + per-cell tallies a driver reads are
//!   *derived* from [`MatrixView::matrix`], so the macro is invoked in **form 3**
//!   (`custom_state_json`) to publish those keys instead of a plain `serde(state())`.

pub mod grid;

pub use grid::{health_score, spark_series, Cell, Matrix, RedCase, RepoRow, ASPECT_DEFAULT, ASPECT_ORDER, SPARK_RUNS};
pub use nornir_testmatrix::{status, TestResultRow};

use std::cell::{Cell as StdCell, RefCell};

use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::effects::FadeTrack;
use facett_core::scroll_engine::{smooth_scroll_area, SmoothScroll};
use facett_core::{FacetCaps, Semantics};
use serde::{Deserialize, Serialize};

/// Hover / selection cross-fade length (seconds) for a repo row highlight.
const ROW_FADE_SECS: f32 = 0.12;

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or a host) drives the grid through, the same effect a repo-row click
/// produces. Applied by [`MatrixView::update`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// Drill into a repo's red cases by its name (the repo-row click).
    SelectRepo(String),
    /// Clear the drill-in selection.
    ClearSelection,
}

/// Side work as data (FC-8). The matrix does no I/O — every [`Msg`] mutates only
/// the in-memory [`MatrixState`] — so this is uninhabited on purpose: the
/// type-checked statement that [`MatrixView::update`] never asks the host to do
/// anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// **The complete observable state (FC-1 / FC-3)** of a [`MatrixView`], in one
/// serializable, round-trippable struct: the flat result rows, the expected
/// repo/aspect surface (shown as not-run before any run), and the drilled-in
/// selection (keyed on the repo **name**, FC-5). [`MatrixView::state`] hands back a
/// `&MatrixState`; a headless driver ([`facett_core::harness`]) snapshots it after
/// feeding a `Vec<Msg>`. The live [`Matrix`] grid is *derived* from this via
/// [`MatrixView::matrix`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MatrixState {
    /// The flat result rows the grid is built from.
    pub rows: Vec<TestResultRow>,
    /// Expected repos (shown as a full not-run row before anything ran).
    pub expected_repos: Vec<String>,
    /// Expected aspect columns (shown as not-run before anything ran).
    pub expected_aspects: Vec<String>,
    /// The currently drilled-in repo (its red cases show), if any.
    pub selected: Option<String>,
}

/// A per-repo × per-aspect **test-matrix grid** Facet: holds a flat slice of
/// [`TestResultRow`]s + an expected repo/aspect surface, derives the
/// [`Matrix`], and paints a coloured cell grid (one row per repo, one column per
/// aspect; green = pass, red = fail, dim = skip, hollow = not-run) with a health
/// score per repo and a summary strip. The whole grid is in `state_json`.
pub struct MatrixView {
    title: String,
    /// All observable state (FC-3).
    state: MatrixState,
    /// Smooth scroll (both axes) for the cell grid (SCRL-3): eased, injected-clock
    /// (FC-7). **Ephemeral render cache, not observable state** — interior-mutable so
    /// the FC-9 pure [`view`](Self::view) can advance it through a `&self` borrow; it
    /// lives here (not egui memory) and is deliberately kept OUT of [`state`](Self::state).
    scroll_x: StdCell<SmoothScroll>,
    scroll_y: StdCell<SmoothScroll>,
    /// Per-repo-row hover + selection fades (injected-clock, FC-7). Same story as the
    /// scroll: ephemeral animation caches, interior-mutable, outside the Model.
    hover_fades: RefCell<FadeTrack>,
    sel_fades: RefCell<FadeTrack>,
}

impl MatrixView {
    /// An empty matrix titled `title` (no rows, no expected surface).
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            state: MatrixState {
                rows: Vec::new(),
                expected_repos: Vec::new(),
                expected_aspects: Vec::new(),
                selected: None,
            },
            scroll_x: StdCell::new(SmoothScroll::default()),
            scroll_y: StdCell::new(SmoothScroll::default()),
            hover_fades: RefCell::new(FadeTrack::default()),
            sel_fades: RefCell::new(FadeTrack::default()),
        }
    }

    /// Feed the result rows.
    pub fn with_rows(mut self, rows: Vec<TestResultRow>) -> Self {
        self.state.rows = rows;
        self
    }

    /// Set the expected repo/aspect surface (shown as not-run before any run).
    pub fn with_expected(mut self, repos: Vec<String>, aspects: &[&str]) -> Self {
        self.state.expected_repos = repos;
        self.state.expected_aspects = aspects.iter().map(|a| a.to_string()).collect();
        self
    }

    /// Re-title (the deck keys facets off [`Facet::title`]).
    pub fn set_title(&mut self, title: impl Into<String>) {
        self.title = title.into();
    }

    /// **FC-3** — read the complete observable state at any frame boundary.
    pub fn state(&self) -> &MatrixState {
        &self.state
    }

    /// Drill into a repo's red cases (or `None` to clear). A thin wrapper over the
    /// FC-2 [`update`](Self::update) mutation path.
    pub fn select(&mut self, repo: Option<String>) {
        let _ = match repo {
            Some(r) => self.update(Msg::SelectRepo(r)),
            None => self.update(Msg::ClearSelection),
        };
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. The canvas repo-row click
    /// routes its [`Msg`] here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SelectRepo(repo) => self.state.selected = Some(repo),
            Msg::ClearSelection => self.state.selected = None,
        }
        Vec::new()
    }

    /// Build the live matrix from the current rows + expected surface — the single
    /// source of truth for the painter and `state_json`.
    pub fn matrix(&self) -> Matrix {
        let aspects: Vec<&str> = self.state.expected_aspects.iter().map(|s| s.as_str()).collect();
        Matrix::build_with_expected(&self.state.expected_repos, &aspects, &self.state.rows)
    }

    /// **Discovery-contract `local()`** — a realistic 3-repo grid over the default
    /// aspect set: greens, one failing unit cell with a red case, a coverage %,
    /// and a clippy warning count, plus one expected-but-unrun repo (a NotRun
    /// row), so the grid paints real coloured cells with no host attached.
    pub fn local() -> Self {
        let r = |run: &str, repo: &str, aspect: &str, name: &str, st: &str, metric: f64| TestResultRow {
            run_id: run.into(),
            repo: repo.into(),
            suite: repo.into(),
            test_name: name.into(),
            status: st.into(),
            duration_ms: 1.0,
            ts_micros: 100,
            message: if status::is_red(st) { format!("{name} boom") } else { String::new() },
            aspect: aspect.into(),
            metric,
        };
        let rows = vec![
            r("run-a", "facett", "build", "build", status::PASS, 0.0),
            r("run-a", "facett", "unit", "f::t1", status::PASS, 0.0),
            r("run-a", "facett", "unit", "f::t2", status::FAIL, 0.0),
            r("run-a", "facett", "coverage", "coverage", status::PASS, 88.0),
            r("run-a", "facett", "clippy", "clippy", status::PASS, 0.0),
            r("run-b", "nornir", "build", "build", status::PASS, 0.0),
            r("run-b", "nornir", "unit", "n::t1", status::PASS, 0.0),
            r("run-b", "nornir", "clippy", "clippy", status::FAIL, 3.0),
            r("run-c", "znippy", "doctest", "doc", status::PASS, 0.0),
        ];
        Self::new("Matrix")
            .with_rows(rows)
            // `korp` is expected but never ran → a full row of NotRun cells.
            .with_expected(
                ["facett", "korp", "nornir", "znippy"].iter().map(|s| s.to_string()).collect(),
                ASPECT_DEFAULT,
            )
    }

    /// **Discovery-contract `remote()`** — same surface, the variant a host builds
    /// from server-streamed rows. No transport here, so identical to `local()`
    /// but re-titled, kept so the matrix sees both ctors.
    pub fn remote() -> Self {
        let mut v = Self::local();
        v.title = "Matrix (remote)".into();
        v
    }

    /// The cell fill for a status on the active theme.
    fn cell_color(th: &facett_core::theme::Theme, c: &Cell) -> egui::Color32 {
        if !c.ran() {
            th.text_dim.linear_multiply(0.25)
        } else if c.is_red() {
            facett_core::look::RED
        } else if c.is_green() {
            th.point
        } else {
            egui::Color32::from_rgb(150, 150, 160)
        }
    }
}

impl MatrixView {
    /// The copyable text: the resolved matrix as a TSV rectangle — header
    /// `repo \t <aspect…> \t health`, one row per repo carrying each cell's status.
    /// When a repo is selected the copy narrows to that repo's row.
    pub fn copy_text(&self) -> Option<String> {
        let m = self.matrix();
        if m.rows.is_empty() {
            return None;
        }
        let mut out = String::from("repo");
        for a in &m.aspects {
            out.push('\t');
            out.push_str(a);
        }
        out.push_str("\thealth");
        let want = self.state.selected.as_deref();
        let mut any = false;
        for r in &m.rows {
            if want.is_some_and(|w| w != r.repo) {
                continue;
            }
            any = true;
            out.push('\n');
            out.push_str(&r.repo);
            for c in &r.cells {
                out.push('\t');
                out.push_str(&c.status);
            }
            out.push_str(&format!("\t{:.1}", r.health));
        }
        any.then_some(out)
    }
}

// ── typed copy (§16) — read-only: the matrix (or selected repo) as rows ────────
impl CopySource for MatrixView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Rows, ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        self.copy_text().map(ClipPayload::Rows)
    }
}

impl MatrixView {
    /// **FC-9 render** — a **pure** function of `&self`: it paints the summary strip +
    /// the coloured cell grid and *returns* the [`Msg`]s the interactions produced. It
    /// MUST NOT mutate the [`MatrixState`]; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
    /// bridge applies the returned messages through [`update`](Self::update). The only
    /// `&self`-interior mutation is the ephemeral scroll/fade animation cache (FC-7),
    /// which is deliberately outside the observable Model.
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut msgs: Vec<Msg> = Vec::new();
        let th = facett_core::theme(ui);
        let m = self.matrix();

        // ── summary strip ──────────────────────────────────────────────────
        ui.horizontal_wrapped(|ui| {
            ui.label(egui::RichText::new(format!("{} repo(s)", m.rows.len())).color(th.text).strong());
            ui.separator();
            ui.label(egui::RichText::new(format!("✓ {} green", m.total_green)).color(th.point));
            ui.label(
                egui::RichText::new(format!("✗ {} red", m.total_red)).color(if m.total_red > 0 {
                    facett_core::look::RED
                } else {
                    th.text_dim
                }),
            );
            ui.label(egui::RichText::new(format!("· {} not-run", m.total_blank)).color(th.text_dim));
        });
        ui.separator();

        if m.rows.is_empty() {
            ui.label(egui::RichText::new("no repos").color(th.text_dim));
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit("facett-matrix::MatrixView::view", "ui_render", true, "repos=0 drew=empty_hint");
            return msgs;
        }

        // ── header row: aspect column labels ───────────────────────────────
        // Smooth-scroll both axes (SCRL-3) from this frame's `stable_dt` (FC-7);
        // `SmoothScroll` is `Copy`, so work on copies pulled from the interior-
        // mutable cache and write them back after the scroll closure returns.
        let dt = ui.input(|i| i.stable_dt);
        let (mut sx, mut sy) = (self.scroll_x.get(), self.scroll_y.get());
        // Collect which repo rows are hovered / selected this frame; the fade
        // trackers (interior-mutable on `self`) are advanced after the closure.
        let mut lit_hover: Vec<u64> = Vec::new();
        let mut lit_sel: Vec<u64> = Vec::new();
        smooth_scroll_area(
            ui,
            egui::ScrollArea::both().auto_shrink([false, false]),
            dt,
            &mut sy,
            Some(&mut sx),
            |ui| {
            let pointer = ui.input(|i| i.pointer.hover_pos());
            egui::Grid::new(ui.id().with("matrix-grid")).striped(true).spacing([8.0, 4.0]).show(ui, |ui| {
                ui.label(egui::RichText::new("repo").color(th.text_dim).strong());
                for a in &m.aspects {
                    ui.label(egui::RichText::new(a).color(th.text_dim).strong());
                }
                ui.label(egui::RichText::new("health").color(th.text_dim).strong());
                ui.end_row();

                for row in &m.rows {
                    // Reserve a paint slot UNDER the row for its hover/selection fill.
                    let bg_idx = ui.painter().add(egui::Shape::Noop);
                    let is_sel = self.state.selected.as_deref() == Some(row.repo.as_str());
                    let repo_resp = ui.selectable_label(
                        is_sel,
                        egui::RichText::new(&row.repo).color(th.text),
                    );
                    repo_resp.widget_info({
                        let r = row.repo.clone();
                        move || Semantics::list_item(r.clone(), is_sel).widget_info()
                    });
                    if repo_resp.clicked() {
                        // FC-9: emit the Msg instead of mutating `self`.
                        msgs.push(Msg::SelectRepo(row.repo.clone()));
                    }
                    for (cell, aspect) in row.cells.iter().zip(m.aspects.iter()) {
                        let col = MatrixView::cell_color(&th, cell);
                        let txt = if cell.ran() {
                            let b = cell.badge(aspect);
                            if b.is_empty() { "●".to_string() } else { b }
                        } else {
                            "·".to_string()
                        };
                        ui.label(egui::RichText::new(txt).color(col).monospace());
                    }
                    let health_resp = ui.label(egui::RichText::new(format!("{:.0}", row.health)).color(th.accent));
                    ui.end_row();

                    // Whole-row highlight: an injected-clock cross-fade of hover +
                    // selection painted BEHIND the cells (reserved slot), plus an
                    // eased selection-glow outline on top.
                    let row_rect = repo_resp.rect.union(health_resp.rect);
                    let key = FadeTrack::key(row.repo.as_str());
                    let hovered = pointer.map(|p| row_rect.contains(p)).unwrap_or(false);
                    if hovered {
                        lit_hover.push(key);
                    }
                    if is_sel {
                        lit_sel.push(key);
                    }
                    let hf = self.hover_fades.borrow().factor(key);
                    let sf = self.sel_fades.borrow().factor(key);
                    let accent_a = 0.10 * hf + 0.06 * sf;
                    if accent_a > 0.001 {
                        ui.painter().set(
                            bg_idx,
                            egui::Shape::rect_filled(
                                row_rect.expand2(egui::vec2(3.0, 1.0)),
                                3.0,
                                th.accent.linear_multiply(accent_a.min(0.18)),
                            ),
                        );
                    }
                    if sf > 0.01 {
                        ui.painter().rect_stroke(
                            row_rect.expand2(egui::vec2(3.0, 1.0)),
                            3.0,
                            egui::Stroke::new(1.0_f32, th.accent.linear_multiply(0.5 * sf)),
                            egui::StrokeKind::Inside,
                        );
                    }
                }
            });
            },
        );
        self.scroll_x.set(sx);
        self.scroll_y.set(sy);
        // Advance the row fades from this frame's hovered/selected set (FC-7) and
        // repaint while any highlight is still easing. Interior-mutable, ephemeral.
        {
            let mut hover_fades = self.hover_fades.borrow_mut();
            hover_fades.begin();
            for k in lit_hover {
                hover_fades.lit(k);
            }
        }
        {
            let mut sel_fades = self.sel_fades.borrow_mut();
            sel_fades.begin();
            for k in lit_sel {
                sel_fades.lit(k);
            }
        }
        let a = self.hover_fades.borrow_mut().advance(dt, ROW_FADE_SECS);
        let b = self.sel_fades.borrow_mut().advance(dt, ROW_FADE_SECS);
        if a || b {
            ui.ctx().request_repaint();
        }

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-matrix::MatrixView::view",
            "ui_render",
            !m.rows.is_empty(),
            &format!("repos={} green={} red={}", m.rows.len(), m.total_green, m.total_red),
        );

        msgs
    }
}

// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for MatrixView {
    type Model = MatrixState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &MatrixState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        MatrixView::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        MatrixView::view(self, ui)
    }
}

// The bridge macro writes `impl Facet for MatrixView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the extra overrides
// below. **Form 3** (`custom_state_json`) because the matrix publishes a RICHER
// `state_json` than a plain `serde(state())`: the derived grid dims + per-cell
// tallies (`repo_count`, `total_green`/`total_red`/…, `rows[].cells[]`) that
// facett-demo's mega_matrix reads come from `matrix()`, not from the raw rows —
// so the default `state_json` is suppressed and this one supplied.
facett_core::impl_facet_via_elm!(MatrixView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let mut j = self.matrix().state_json();
        if let Some(obj) = j.as_object_mut() {
            obj.insert("title".into(), serde_json::json!(self.title));
            obj.insert("selected".into(), serde_json::json!(self.state.selected));
        }
        j
    }

    /// 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 can drive the repo
    /// drill-in 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 selection_json(&self) -> serde_json::Value {
        match &self.state.selected {
            Some(repo) => serde_json::json!({ "repo": repo }),
            None => serde_json::Value::Null,
        }
    }

    /// Themed (cell colours follow the palette) + resizable (the grid scrolls) +
    /// selectable (a repo row drills into its red cases).
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().selectable().copyable()
    }

    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    // `Facet` for `state_json`/`selection_json`/`title`; `state()` is inherent.
    use facett_core::Facet;

    #[test]
    fn typed_copy_is_the_matrix_or_selected_repo_rows() {
        let mut v = MatrixView::local();
        // No selection -> a TSV rectangle: repo + one column per aspect + health.
        let p = v.copy_payload().expect("populated matrix copies");
        assert_eq!(p.kind(), ClipKind::Rows);
        let text = p.as_text();
        let header = text.lines().next().unwrap();
        assert!(header.starts_with("repo\t"), "header first: {header}");
        assert!(header.ends_with("\thealth"), "health last: {header}");
        assert!(text.lines().count() >= 5, "header + >=4 repo rows: {text}");
        // Selecting a repo narrows the copy to that one row (plus the header).
        v.select(Some("facett".into()));
        let sel = v.copy_payload().unwrap().as_text();
        assert_eq!(sel.lines().count(), 2, "header + the one selected row: {sel}");
        assert!(sel.lines().nth(1).unwrap().starts_with("facett\t"));
    }

    /// inject-assert (LAW 6): the local view seeds a real coloured grid; assert
    /// its `state_json` reports the repos, the failing cells, and the totals a
    /// driver reads — and that the expected-but-unrun repo shows all not-run.
    #[test]
    fn local_view_paints_a_real_grid_with_a_notrun_repo() {
        let v = MatrixView::local();
        let j = Facet::state_json(&v);
        assert_eq!(<MatrixView as Facet>::title(&v), "Matrix");
        // four repos (three with runs + one expected-but-unrun).
        assert_eq!(j["repo_count"], 4);
        let rows = j["rows"].as_array().unwrap();
        // facett: a failing unit cell (one red case) + an 88% coverage badge.
        let facett = rows.iter().find(|r| r["repo"] == "facett").unwrap();
        let cells = facett["cells"].as_array().unwrap();
        let unit = cells.iter().find(|c| c["aspect"] == "unit").unwrap();
        assert_eq!(unit["red"], true, "facett unit cell is red (one test failed)");
        assert_eq!(unit["red_cases"].as_array().unwrap().len(), 1);
        let cov = cells.iter().find(|c| c["aspect"] == "coverage").unwrap();
        assert_eq!(cov["badge"], "88%");
        // nornir: a failing clippy cell with a ⚠3 badge.
        let nornir = rows.iter().find(|r| r["repo"] == "nornir").unwrap();
        let clippy = nornir["cells"].as_array().unwrap().iter().find(|c| c["aspect"] == "clippy").unwrap();
        assert_eq!(clippy["badge"], "⚠3");
        // korp: expected but never ran → every cell not-run, no run id.
        let korp = rows.iter().find(|r| r["repo"] == "korp").unwrap();
        assert_eq!(korp["run_id"], "");
        assert!(korp["cells"].as_array().unwrap().iter().all(|c| c["ran"] == false));
        // the false-empty guard: real runs → non-zero green + red.
        assert!(j["total_green"].as_u64().unwrap() >= 3);
        assert!(j["total_red"].as_u64().unwrap() >= 2);
    }

    #[test]
    fn select_drills_into_a_repo() {
        let mut v = MatrixView::local();
        assert_eq!(Facet::selection_json(&v), serde_json::Value::Null);
        v.select(Some("facett".into()));
        assert_eq!(Facet::selection_json(&v)["repo"], "facett");
        assert_eq!(Facet::state_json(&v)["selected"], "facett");
    }

    #[test]
    fn empty_view_is_sane() {
        let v = MatrixView::new("Matrix");
        let j = Facet::state_json(&v);
        assert_eq!(j["repo_count"], 0);
        assert_eq!(j["total_green"], 0);
        assert_eq!(j["total_red"], 0);
        assert_eq!(<MatrixView as Facet>::title(&MatrixView::remote()), "Matrix (remote)");
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `MatrixState` — no egui, no GPU. ────────
    #[test]
    fn harness_snapshot_drives_select_and_clear() {
        use facett_core::harness;
        let mut v = MatrixView::local();
        // Selecting an existing repo is observable in the snapshot.
        let snap = harness::snapshot(&mut v, [Msg::SelectRepo("nornir".into())]);
        assert_eq!(snap.selected.as_deref(), Some("nornir"), "selection is observable headlessly");
        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
        // Clearing wipes it.
        let snap = harness::snapshot(&mut v, [Msg::SelectRepo("facett".into()), Msg::ClearSelection]);
        assert_eq!(snap.selected, None, "ClearSelection empties the selection");
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        use facett_core::harness;
        let mut v = MatrixView::local();
        // FC-8: the matrix does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut v, [Msg::SelectRepo("facett".into())]);
        assert!(effects.is_empty(), "FC-8: matrix emits no Effects");
        // FC-3: the observable Model round-trips through serde.
        let json = serde_json::to_value(v.state()).unwrap();
        assert_eq!(json["selected"], "facett");
        let back: MatrixState = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
    }
}