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");
}
}