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

nordisk/facett

facett-plot/src/lib.rs

//! **facett-plot** — a **chart** component (line / bars) wrapping
//! [`egui_plot`]. A [`Facet`]: hand it named series of `(x, y)` points and it
//! draws an interactive plot (zoom/pan + per-series visibility toggles). For the
//! warehouse bar charts, bench trends, etc.
//!
//! Two halves, like the rest of facett:
//! - [`PlotState`] — the pure, serializable observable state (kind + series +
//!   the zoom **viewport** + which series are **hidden**), round-trippable so the
//!   headless [`Elm`](facett_core::Elm) harness can snapshot it.
//! - [`PlotView`] — the [`Facet`](facett_core::Facet) that paints it. The FC-9 pure
//!   [`view`](PlotView::view) reads the state and **returns** the [`Msg`]s a frame's
//!   interactions produced (drag/scroll → viewport, legend checkbox → visibility,
//!   Fit → reset); the FC-2 [`update`](PlotView::update) is the **only** path that
//!   mutates the state. So a headless `Vec<Msg>` reproduces the live transition.
//!
//! ## Residual impurity (honest)
//! `egui_plot` owns the *live* continuous pan/zoom gesture in its own widget
//! memory. The view mirrors the resulting bounds back into [`PlotState::viewport`]
//! as a per-frame [`Msg::SetViewport`] — so a **continuous** drag shows up as a
//! *stream* of discrete `SetViewport` messages, one per frame, rather than a single
//! atomic gesture. Programmatic control ([`Msg::SetViewport`] / [`Msg::ResetViewport`]
//! from a robot/CLI/host toolbar) is exact and fully headless-testable; the
//! frame-by-frame drag mirror is the one place the discrete-`Msg` model is a lossy
//! approximation of a mouse gesture.

use egui_plot::PlotBounds;
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::FacetCaps;
use serde::{Deserialize, Serialize};

/// Line chart or bar chart.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChartKind {
    Line,
    Bars,
}

impl ChartKind {
    /// The stable state_json tag (`"line"` / `"bars"`).
    pub fn tag(self) -> &'static str {
        match self {
            ChartKind::Line => "line",
            ChartKind::Bars => "bars",
        }
    }
}

/// One named series of `(x, y)` points.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Series {
    /// Legend name (also the stable visibility key).
    pub name: String,
    /// The `(x, y)` points.
    pub points: Vec<(f64, f64)>,
}

/// An explicit zoom/pan window in **plot coordinates** — the observable form of
/// what would otherwise be hidden inside `egui_plot`'s widget memory (FC-3). `None`
/// on [`PlotState::viewport`] means "auto-fit to the data".
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Viewport {
    pub x_min: f64,
    pub x_max: f64,
    pub y_min: f64,
    pub y_max: f64,
}

impl Viewport {
    fn to_bounds(self) -> PlotBounds {
        PlotBounds::from_min_max([self.x_min, self.y_min], [self.x_max, self.y_max])
    }
    fn from_bounds(b: &PlotBounds) -> Self {
        let (mn, mx) = (b.min(), b.max());
        Self { x_min: mn[0], y_min: mn[1], x_max: mx[0], y_max: mx[1] }
    }
    /// Same window to within a small relative epsilon (avoids a 1-frame push/echo
    /// oscillation between the model and the widget).
    fn approx_eq(&self, o: &Viewport) -> bool {
        let close = |a: f64, b: f64| (a - b).abs() <= 1e-6 * (1.0 + a.abs().max(b.abs()));
        close(self.x_min, o.x_min) && close(self.x_max, o.x_max) && close(self.y_min, o.y_min) && close(self.y_max, o.y_max)
    }
}

/// **The complete observable state (FC-1 / FC-3)** of a [`PlotView`], in one
/// serializable, round-trippable struct: the chart [`kind`](ChartKind), the named
/// [`series`](Series), the optional zoom [`viewport`](Viewport), and the set of
/// hidden series names. [`PlotView::state`] hands back a `&PlotState`; the headless
/// [`facett_core::harness`] snapshots it after a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlotState {
    /// Line vs bars.
    pub kind: ChartKind,
    /// Named `(x, y)` series (all of them — visibility is tracked in `hidden`).
    pub series: Vec<Series>,
    /// Explicit zoom/pan window; `None` = auto-fit to the data.
    pub viewport: Option<Viewport>,
    /// Series names the user toggled off (stable keys, not indices).
    pub hidden: Vec<String>,
}

impl PlotState {
    fn is_hidden(&self, name: &str) -> bool {
        self.hidden.iter().any(|h| h == name)
    }
    fn has_series(&self, name: &str) -> bool {
        self.series.iter().any(|s| s.name == name)
    }
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver or a host toolbar drives the view through, the same effect the canvas
/// gestures produce. Applied by [`PlotView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Switch between a line chart and a bar chart.
    SetKind(ChartKind),
    /// Pan/zoom to an explicit window (bounds are normalised so `min < max`). The
    /// live drag/scroll gesture emits this each frame; a robot/CLI can emit it
    /// directly.
    SetViewport(Viewport),
    /// Drop the zoom override — back to auto-fit.
    ResetViewport,
    /// Toggle whether the named series is drawn (no-op if the name is absent).
    ToggleSeries(String),
    /// Make every series visible again (clears `hidden`).
    ShowAllSeries,
}

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

/// A chart: named series of `(x, y)` points, plus the observable view state.
pub struct PlotView {
    /// Tab/panel title.
    pub title: String,
    /// All observable state (FC-3).
    pub state: PlotState,
}

impl PlotView {
    fn from_series(title: impl Into<String>, kind: ChartKind, series: Vec<(String, Vec<(f64, f64)>)>) -> Self {
        let series = series.into_iter().map(|(name, points)| Series { name, points }).collect();
        Self { title: title.into(), state: PlotState { kind, series, viewport: None, hidden: Vec::new() } }
    }

    /// A **line** chart of the given named series.
    pub fn line(title: impl Into<String>, series: Vec<(String, Vec<(f64, f64)>)>) -> Self {
        Self::from_series(title, ChartKind::Line, series)
    }

    /// A **bar** chart of the given named series.
    pub fn bars(title: impl Into<String>, series: Vec<(String, Vec<(f64, f64)>)>) -> Self {
        Self::from_series(title, ChartKind::Bars, series)
    }

    /// The chart [`kind`](ChartKind) (line / bars).
    pub fn kind(&self) -> ChartKind {
        self.state.kind
    }

    /// The named series (all of them, regardless of visibility).
    pub fn series(&self) -> &[Series] {
        &self.state.series
    }

    /// `true` if the named series is currently hidden.
    pub fn is_hidden(&self, name: &str) -> bool {
        self.state.is_hidden(name)
    }

    /// The active zoom window if one is set (else `None` = auto-fit).
    pub fn viewport(&self) -> Option<Viewport> {
        self.state.viewport
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. The canvas gestures route
    /// their [`Msg`]s here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SetKind(k) => self.state.kind = k,
            Msg::SetViewport(mut vp) => {
                if vp.x_min > vp.x_max {
                    std::mem::swap(&mut vp.x_min, &mut vp.x_max);
                }
                if vp.y_min > vp.y_max {
                    std::mem::swap(&mut vp.y_min, &mut vp.y_max);
                }
                // Guard against a degenerate zero-span window (a representable
                // relative nudge — `f64::EPSILON` would round away at larger
                // magnitudes).
                let widen = |lo: f64, hi: f64| if hi > lo { hi } else { lo + lo.abs().max(1.0) * 1e-9 };
                vp.x_max = widen(vp.x_min, vp.x_max);
                vp.y_max = widen(vp.y_min, vp.y_max);
                self.state.viewport = Some(vp);
            }
            Msg::ResetViewport => self.state.viewport = None,
            Msg::ToggleSeries(name) => {
                if self.state.has_series(&name) {
                    if let Some(i) = self.state.hidden.iter().position(|h| *h == name) {
                        self.state.hidden.remove(i);
                    } else {
                        self.state.hidden.push(name);
                    }
                }
            }
            Msg::ShowAllSeries => self.state.hidden.clear(),
        }
        Vec::new()
    }
}

impl PlotView {
    /// The chart data as a TSV rectangle (`series \t x \t y`, header first) — the
    /// spreadsheet-pasteable view of every point across every series (all series,
    /// regardless of visibility).
    pub fn to_tsv(&self) -> String {
        let mut out = String::from("series\tx\ty");
        for s in &self.state.series {
            for &(x, y) in &s.points {
                out.push('\n');
                out.push_str(&format!("{}\t{x}\t{y}", s.name));
            }
        }
        out
    }
}

// ── typed copy (§16) — read-only: the chart points as a TSV rectangle ─────────
impl CopySource for PlotView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Rows, ClipKind::Text]
    }

    fn copy_payload(&self) -> Option<ClipPayload> {
        if self.state.series.iter().all(|s| s.points.is_empty()) {
            return None;
        }
        Some(ClipPayload::Rows(self.to_tsv()))
    }
}

impl PlotView {
    /// **FC-9 pure view** — paints the legend + interactive plot and **returns** the
    /// [`Msg`]s this frame's interactions produced (Fit button, per-series
    /// visibility checkboxes, drag/scroll → viewport). It does **not** mutate
    /// `self`; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge
    /// applies the returned messages through [`update`](Self::update).
    pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        use egui_plot::{Bar, BarChart, Line, Plot, PlotPoints};
        let mut msgs: Vec<Msg> = Vec::new();

        // ── legend / toolbar: per-series visibility + a Fit reset ─────────────────
        ui.horizontal_wrapped(|ui| {
            for s in &self.state.series {
                let mut visible = !self.state.is_hidden(&s.name);
                // A pure checkbox: reads `visible`, and if the user flips it we emit a
                // toggle Msg (we never write the model here).
                if ui.checkbox(&mut visible, &s.name).changed() {
                    msgs.push(Msg::ToggleSeries(s.name.clone()));
                }
            }
            if self.state.viewport.is_some() && ui.button("⤢ Fit").on_hover_text("Reset the zoom window").clicked() {
                msgs.push(Msg::ResetViewport);
            }
        });

        // ── the plot itself ───────────────────────────────────────────────────────
        let vp = self.state.viewport;
        let kind = self.state.kind;
        let resp = Plot::new(self.title.as_str())
            .allow_drag(true)
            .allow_zoom(true)
            .allow_scroll(true)
            .show(ui, |pui| {
                // If the model holds an explicit viewport that differs from what the
                // widget currently shows, push it (honors a headless `SetViewport`
                // or a `load_state` restore). In the steady state after a live drag
                // the model already equals the widget bounds, so nothing is pushed
                // and the gesture stays live.
                if let Some(vp) = vp {
                    let cur = Viewport::from_bounds(&pui.plot_bounds());
                    if !vp.approx_eq(&cur) {
                        pui.set_plot_bounds(vp.to_bounds());
                    }
                }
                for s in &self.state.series {
                    if self.state.is_hidden(&s.name) {
                        continue;
                    }
                    match kind {
                        ChartKind::Line => {
                            let points: Vec<[f64; 2]> = s.points.iter().map(|&(x, y)| [x, y]).collect();
                            pui.line(Line::new(s.name.clone(), PlotPoints::from(points)));
                        }
                        ChartKind::Bars => {
                            let bars: Vec<Bar> = s.points.iter().map(|&(x, y)| Bar::new(x, y)).collect();
                            pui.bar_chart(BarChart::new(s.name.clone(), bars));
                        }
                    }
                }
            });

        // ── mirror the live pan/zoom gesture back into the model as a Msg ─────────
        // `egui_plot` owns the continuous gesture; we capture the resulting bounds
        // ONLY when the user actually dragged or scroll/pinch-zoomed over the plot,
        // so a mere hover (or an untouched auto-fit frame) never turns the viewport
        // explicit. A continuous drag therefore emits one `SetViewport` per frame —
        // the honest, lossy discretisation of a mouse gesture.
        let (scroll, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta()));
        let gesture = resp.response.dragged() || (resp.response.hovered() && (scroll.abs() > 0.5 || (zoom - 1.0).abs() > 1e-4));
        if gesture {
            let got = Viewport::from_bounds(resp.transform.bounds());
            if self.state.viewport.map(|v| !v.approx_eq(&got)).unwrap_or(true) {
                msgs.push(Msg::SetViewport(got));
            }
        }

        // ── render-lane emit: this pure-view path RAN ─────────────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-plot::PlotView::view",
            "ui_render",
            // OK = the chart drew the geometry it declares: an empty chart has no
            // series, and any VISIBLE series the view holds is plotted (finite
            // coords — no NaN egui_plot would drop).
            self.state
                .series
                .iter()
                .filter(|s| !self.state.is_hidden(&s.name))
                .all(|s| s.points.iter().all(|&(x, y)| x.is_finite() && y.is_finite())),
            &format!(
                "series={} visible={} points={}",
                self.state.series.len(),
                self.state.series.iter().filter(|s| !self.state.is_hidden(&s.name)).count(),
                self.state.series.iter().map(|s| s.points.len()).sum::<usize>(),
            ),
        );

        msgs
    }
}

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

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

// The bridge macro writes `impl Facet for PlotView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`). **Form 3**
// (`custom_state_json`) because the observable `state_json` is RICHER than plain
// `serde(state())`: it reports each series' point **count** (not the raw point
// array) plus a `visible` flag and the viewport — the exact shape the deck / demo
// mega-matrix read (`kind`, `series[i].points` == a count). Caps/copy are spliced
// into the SAME impl block and preserved bit-for-bit (compliance matrix pins them).
facett_core::impl_facet_via_elm!(PlotView, custom_state_json, {
    fn copy(&mut self) -> Option<String> {
        self.copy_payload().map(|p| p.as_text())
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "kind": self.state.kind.tag(),
            "series": self
                .state
                .series
                .iter()
                .map(|s| serde_json::json!({
                    "name": s.name,
                    "points": s.points.len(),
                    "visible": !self.state.is_hidden(&s.name),
                }))
                .collect::<Vec<_>>(),
            "viewport": self.state.viewport.map(|v| serde_json::json!({
                "x_min": v.x_min, "x_max": v.x_max, "y_min": v.y_min, "y_max": v.y_max,
            })),
            "hidden": self.state.hidden,
        })
    }

    /// `egui_plot::Plot` paints its grid/axes/background/labels from the active
    /// egui `Visuals`, which [`facett_core::set_theme`] derives from the `Theme`,
    /// so the chart frame follows the palette. (Per-series colours are still
    /// egui_plot's auto-assigned defaults.) The plot owns zoom/pan and resizes with
    /// its host rect. Caps are preserved exactly (the compliance matrix pins them):
    /// **themeable + copyable + resizable** — NOT `scalable`/`selectable`, even
    /// though the viewport now lives in the model.
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable().copyable()
    }

    /// The portable subset a same-kind sibling can adopt: the zoom window, the
    /// hidden-series set, and the chart kind (NOT the series data, which the host
    /// owns).
    fn portable_state(&self) -> Option<serde_json::Value> {
        Some(serde_json::json!({
            "kind": self.state.kind.tag(),
            "viewport": self.state.viewport.map(|v| [v.x_min, v.x_max, v.y_min, v.y_max]),
            "hidden": self.state.hidden,
        }))
    }

    fn load_state(&mut self, state: &serde_json::Value) -> bool {
        let mut changed = false;
        if let Some(k) = state.get("kind").and_then(|v| v.as_str()) {
            self.state.kind = match k {
                "bars" => ChartKind::Bars,
                _ => ChartKind::Line,
            };
            changed = true;
        }
        if let Some(w) = state.get("viewport") {
            self.state.viewport = w.as_array().and_then(|a| {
                Some(Viewport {
                    x_min: a.first()?.as_f64()?,
                    x_max: a.get(1)?.as_f64()?,
                    y_min: a.get(2)?.as_f64()?,
                    y_max: a.get(3)?.as_f64()?,
                })
            });
            changed = true;
        }
        if let Some(h) = state.get("hidden").and_then(|v| v.as_array()) {
            self.state.hidden = h.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
            changed = true;
        }
        changed
    }
});

#[cfg(test)]
mod tests {
    use super::*;
    // `Elm` for `state()`; `Facet` for `state_json`/`portable_state`.
    use facett_core::{Elm, Facet};

    fn demo() -> PlotView {
        PlotView::line(
            "trend",
            vec![
                ("ops/s".into(), vec![(0.0, 1.0), (1.0, 2.0), (2.0, 4.0)]),
                ("errors".into(), vec![(0.0, 0.0), (1.0, 1.0)]),
            ],
        )
    }

    // ── state_json preserves the observable shape the demo mega-matrix reads ─────
    #[test]
    fn state_json_reports_series_kind_and_point_counts() {
        let v = PlotView::line("trend", vec![("ops/s".into(), vec![(0.0, 1.0), (1.0, 2.0), (2.0, 4.0)])]);
        let j = Facet::state_json(&v);
        assert_eq!(j["kind"], "line");
        // `series` is an array whose length is the series count …
        assert_eq!(j["series"].as_array().unwrap().len(), 1);
        // … and each entry's `points` is a COUNT (not the raw point array).
        assert_eq!(j["series"][0]["points"], 3);
        assert_eq!(j["series"][0]["visible"], true);
        assert_eq!(j["viewport"], serde_json::Value::Null, "no zoom override by default");
    }

    // ── caps are preserved bit-for-bit (the compliance matrix pins them) ─────────
    #[test]
    fn caps_are_themeable_copyable_resizable_only() {
        let c = demo().caps();
        assert!(c.themeable && c.copyable && c.resizable);
        assert!(!c.scalable && !c.selectable && !c.searchable, "plot does NOT declare scalable/selectable");
    }

    #[test]
    fn typed_copy_is_a_tsv_of_all_points() {
        let mut v = PlotView::line("trend", vec![("ops/s".into(), vec![(0.0, 1.0), (1.0, 2.0)])]);
        let p = v.copy_payload().expect("a series with points copies");
        assert_eq!(p.kind(), ClipKind::Rows);
        assert_eq!(p.as_text(), "series\tx\ty\nops/s\t0\t1\nops/s\t1\t2");
        assert_eq!(<PlotView as Facet>::copy(&mut v), Some(p.as_text()));
        assert!(PlotView::line("empty", vec![]).copy_payload().is_none());
        // Copy ignores visibility — hidden series still export.
        v.update(Msg::ToggleSeries("ops/s".into()));
        assert_eq!(<PlotView as Facet>::copy(&mut v), Some(p.as_text()), "hidden series still copy");
    }

    // ── FC-2: update() is the single mutation path; assert the transitions ───────
    #[test]
    fn msg_setkind_switches_line_and_bars() {
        let mut v = demo();
        assert_eq!(v.kind(), ChartKind::Line);
        v.update(Msg::SetKind(ChartKind::Bars));
        assert_eq!(v.kind(), ChartKind::Bars);
        assert_eq!(Facet::state_json(&v)["kind"], "bars");
    }

    #[test]
    fn msg_setviewport_normalises_reversed_and_zero_span_bounds() {
        let mut v = demo();
        assert!(v.viewport().is_none());
        // Reversed x/y bounds get ordered.
        v.update(Msg::SetViewport(Viewport { x_min: 5.0, x_max: 1.0, y_min: 9.0, y_max: 2.0 }));
        let vp = v.viewport().expect("viewport set");
        assert!(vp.x_min < vp.x_max && (vp.x_min - 1.0).abs() < 1e-9, "reversed x ordered");
        assert!(vp.y_min < vp.y_max && (vp.y_min - 2.0).abs() < 1e-9, "reversed y ordered");
        // A zero-span window is nudged to a non-empty span.
        v.update(Msg::SetViewport(Viewport { x_min: 3.0, x_max: 3.0, y_min: 4.0, y_max: 4.0 }));
        let vp = v.viewport().unwrap();
        assert!(vp.x_max > vp.x_min && vp.y_max > vp.y_min, "zero-span window widened");
        // Reset drops the override.
        v.update(Msg::ResetViewport);
        assert!(v.viewport().is_none(), "reset -> auto-fit");
    }

    #[test]
    fn msg_toggleseries_hides_and_shows_by_name() {
        let mut v = demo();
        assert!(!v.is_hidden("ops/s"));
        v.update(Msg::ToggleSeries("ops/s".into()));
        assert!(v.is_hidden("ops/s"), "toggled off");
        assert_eq!(Facet::state_json(&v)["series"][0]["visible"], false);
        v.update(Msg::ToggleSeries("ops/s".into()));
        assert!(!v.is_hidden("ops/s"), "toggled back on");
        // Unknown series is a no-op.
        v.update(Msg::ToggleSeries("nope".into()));
        assert!(v.state.hidden.is_empty(), "toggling an absent series does nothing");
        // ShowAllSeries clears everything.
        v.update(Msg::ToggleSeries("ops/s".into()));
        v.update(Msg::ToggleSeries("errors".into()));
        assert_eq!(v.state.hidden.len(), 2);
        v.update(Msg::ShowAllSeries);
        assert!(v.state.hidden.is_empty(), "ShowAllSeries reveals every series");
    }

    // ── FC-2 → FC-3 as a headless property: feed a Vec<Msg> through the harness ──
    #[test]
    fn harness_snapshot_drives_kind_zoom_and_visibility() {
        use facett_core::harness;
        let mut v = demo();
        let snap = harness::snapshot(
            &mut v,
            [
                Msg::SetKind(ChartKind::Bars),
                Msg::SetViewport(Viewport { x_min: 0.0, x_max: 2.0, y_min: 0.0, y_max: 5.0 }),
                Msg::ToggleSeries("errors".into()),
            ],
        );
        assert_eq!(snap.kind, ChartKind::Bars, "kind switch is observable");
        assert_eq!(snap.viewport, Some(Viewport { x_min: 0.0, x_max: 2.0, y_min: 0.0, y_max: 5.0 }), "zoom window observable");
        assert_eq!(snap.hidden, vec!["errors".to_string()], "visibility observable");
        // The snapshot is a clone of the live state — same transition, headlessly.
        assert_eq!(&snap, v.state());
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        use facett_core::harness;
        let mut v = demo();
        // FC-8: the plot does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut v, [Msg::SetKind(ChartKind::Bars), Msg::ToggleSeries("ops/s".into())]);
        assert!(effects.is_empty(), "FC-8: plot emits no Effects");
        // FC-3: the observable Model round-trips through serde.
        let json = serde_json::to_value(v.state()).unwrap();
        let back: PlotState = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
    }

    // ── FC-9: the macro-generated `ui` bridge (view -> update loop) renders
    //    headlessly (no display / GPU) without panicking and draws geometry. This
    //    also exercises the live drag-readback path (no gesture -> viewport stays
    //    None, so an idle render never turns the viewport explicit). ─────────────
    #[test]
    fn headless_render_draws_and_keeps_state() {
        use facett_core::harness;
        let mut v = demo();
        let report = harness::headless_render(&mut v);
        assert!(report.drew(), "the plot tessellated geometry");
        assert_eq!(report.state["kind"], "line");
        assert_eq!(report.state["series"].as_array().unwrap().len(), 2);
        // An idle frame (no pointer gesture) must not fabricate a viewport.
        assert!(v.viewport().is_none(), "an idle render does not turn the viewport explicit");
        assert!(v.state.hidden.is_empty());
    }

    #[test]
    fn portable_state_round_trips_kind_viewport_and_hidden() {
        let mut a = demo();
        a.update(Msg::SetKind(ChartKind::Bars));
        a.update(Msg::SetViewport(Viewport { x_min: 1.0, x_max: 3.0, y_min: 0.0, y_max: 9.0 }));
        a.update(Msg::ToggleSeries("errors".into()));
        let portable = a.portable_state().unwrap();
        let mut b = demo();
        assert!(b.load_state(&portable));
        assert_eq!(b.kind(), ChartKind::Bars);
        let vp = b.viewport().unwrap();
        assert!((vp.x_min - 1.0).abs() < 1e-9 && (vp.y_max - 9.0).abs() < 1e-9);
        assert!(b.is_hidden("errors"));
    }
}