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

nordisk/facett

facett-table/src/warehouse.rs

//! **WarehouseTableView** — a generic warehouse-style data browser: a left
//! **table picker**, and for the selected table either a striped grid OR (when a
//! numeric column exists) a hand-painted horizontal **bar chart**, with combo
//! pickers for the value/label columns. Generalised from nornir's
//! `src/viz/warehouse_tab.rs` (`WarehouseBrowser`).
//!
//! The nornir original couples this to an Iceberg warehouse + a `Warehouse.Scan`
//! gRPC; THIS is the reusable UI half — the source is just an in-memory map of
//! `table name → `[`PreviewTable`]`. A host that has Iceberg/RPC builds those
//! previews and feeds them in; this crate keeps no I/O. A canonical [`Facet`](facett_core::Facet).
//!
//! ## The canonical Elm split (FC-2 / FC-3 / FC-8 / FC-9)
//! Every interactive input is a [`Msg`] and the **only** mutation path is
//! [`WarehouseTableView::update`]; [`WarehouseTableView::view`] is a **pure**
//! function of `&self` that paints and *returns* the [`Msg`]s it produced. The
//! table **selection** (keyed on the stable table **name**, FC-5 — never the
//! picker's volatile index) and the **chart** controls (mode + value/label column)
//! route through the same [`Msg`]s a headless driver ([`facett_core::harness`])
//! feeds, so live == headless. The
//! [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge (form 3, so the
//! rich `state_json` — the table list, the selection, and the selected preview's
//! derived shape + numeric columns — is preserved verbatim) writes the
//! [`Facet`](facett_core::Facet) impl.

use facett_core::scroll_engine::{SmoothScroll, smooth_scroll_area};
use facett_core::{Elm, FacetCaps, Semantics};
use serde::{Deserialize, Serialize};
use std::cell::Cell;

const CELL_MAX_CHARS: usize = 80;

/// A previewed table: named `columns` + stringified `rows`. The shape nornir's
/// `IcebergWarehouse::scan_preview` produces — but source-agnostic.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct PreviewTable {
    pub columns: Vec<String>,
    pub rows: Vec<Vec<String>>,
}

impl PreviewTable {
    pub fn new(columns: Vec<String>, rows: Vec<Vec<String>>) -> Self {
        Self { columns, rows }
    }

    /// True if at least half of the column's non-empty cells parse as `f64` (and
    /// at least one does) — the test for offering it as a chart value column.
    /// Direct port of nornir's `col_is_numeric`.
    fn col_is_numeric(&self, col: usize) -> bool {
        let (mut seen, mut ok) = (0usize, 0usize);
        for row in &self.rows {
            let cell = row.get(col).map(String::as_str).unwrap_or("");
            if cell.is_empty() {
                continue;
            }
            seen += 1;
            if cell.trim().parse::<f64>().is_ok() {
                ok += 1;
            }
        }
        ok > 0 && ok * 2 >= seen
    }

    /// The indices of every numeric (chart-able) column.
    pub fn numeric_columns(&self) -> Vec<usize> {
        (0..self.columns.len()).filter(|&i| self.col_is_numeric(i)).collect()
    }
}

/// **The complete observable state (FC-1 / FC-3)** of a [`WarehouseTableView`], in
/// one serializable, round-trippable struct: the loaded named [`PreviewTable`]s, the
/// **selected table name** (keyed on the stable name, FC-5, not the picker index),
/// and the chart controls (mode + value/label column). [`WarehouseTableView::state`]
/// hands back a `&WarehouseState`; a headless driver ([`facett_core::harness`])
/// snapshots it after feeding a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WarehouseState {
    /// Tab/panel title (the deck keys facets off it).
    pub title: String,
    /// The loaded tables, in picker order (name → preview).
    pub tables: Vec<(String, PreviewTable)>,
    /// The **name** of the currently-selected table (FC-5), if any.
    pub selected: Option<String>,
    /// 📊 chart mode: render a numeric column as horizontal bars instead of the grid.
    pub chart: bool,
    /// The chart value (numeric) column index, if chosen.
    pub chart_val: Option<usize>,
    /// The chart label column index, or `None` for the row number.
    pub chart_label: Option<usize>,
}

impl WarehouseState {
    /// The currently-selected table's preview, resolved from its stable name (FC-5).
    fn current(&self) -> Option<&PreviewTable> {
        let name = self.selected.as_deref()?;
        self.tables.iter().find(|(n, _)| n == name).map(|(_, t)| t)
    }
}

/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver (or the in-view picker / checkbox / combos) drives the browser through.
/// [`WarehouseTableView::update`] applies it; it is the **only** way to mutate a
/// [`WarehouseState`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Select a table by its stable **name** (FC-5); clears any stale chart-column
    /// choices. No-op if no table has that name.
    Select(String),
    /// Turn chart mode on/off. Turning it on defaults the value column to the first
    /// numeric column and the label column to the first non-numeric column.
    SetChart(bool),
    /// Choose the chart **value** (numeric) column (or `None`).
    SetChartValue(Option<usize>),
    /// Choose the chart **label** column (or `None` for the row number).
    SetChartLabel(Option<usize>),
}

/// Side work as data (FC-8). Uninhabited on purpose: the browser renders host-fed
/// tables and performs **no** I/O — every [`Msg`] mutates only the in-memory
/// [`WarehouseState`] — so [`WarehouseTableView::update`] never asks the host to do
/// anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// A warehouse-style browser over a set of named [`PreviewTable`]s.
pub struct WarehouseTableView {
    /// All observable state (FC-3).
    state: WarehouseState,
    /// Smooth scroll (both axes) for the central data grid (SCRL-3): eased,
    /// injected-clock (FC-7). **Ephemeral render cache, not observable state** —
    /// interior-mutable (`SmoothScroll` is `Copy`) so the FC-9 pure [`view`](Self::view)
    /// can advance it through a `&self` borrow; kept OUT of [`state`](Self::state).
    grid_scroll_x: Cell<SmoothScroll>,
    grid_scroll_y: Cell<SmoothScroll>,
}

impl WarehouseTableView {
    /// An empty browser titled `title`. Add tables with [`with_table`](Self::with_table).
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            state: WarehouseState {
                title: title.into(),
                tables: Vec::new(),
                selected: None,
                chart: false,
                chart_val: None,
                chart_label: None,
            },
            grid_scroll_x: Cell::new(SmoothScroll::default()),
            grid_scroll_y: Cell::new(SmoothScroll::default()),
        }
    }

    /// Add a named preview table (builder form). The first added becomes the
    /// default selection.
    pub fn with_table(mut self, name: impl Into<String>, table: PreviewTable) -> Self {
        let name = name.into();
        if self.state.tables.is_empty() {
            self.state.selected = Some(name.clone());
        }
        self.state.tables.push((name, table));
        self
    }

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

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

    /// **Mutator**: set (or replace) the named table's preview in place — the
    /// host-driven counterpart of the [`with_table`](Self::with_table) builder, used
    /// by a live feed that grows a table's rows each tick. If a table with `name`
    /// already exists its preview is swapped (selection preserved); otherwise the
    /// table is appended (and becomes the default selection if it's the first one).
    pub fn set_table(&mut self, name: impl Into<String>, table: PreviewTable) {
        let name = name.into();
        if let Some(slot) = self.state.tables.iter_mut().find(|(n, _)| *n == name) {
            slot.1 = table;
        } else {
            if self.state.tables.is_empty() {
                self.state.selected = Some(name.clone());
            }
            self.state.tables.push((name, table));
        }
    }

    /// The names of every loaded table (the left-panel list).
    pub fn table_names(&self) -> Vec<String> {
        self.state.tables.iter().map(|(n, _)| n.clone()).collect()
    }

    /// The name of the currently-selected table, if any.
    pub fn selected_name(&self) -> Option<&str> {
        self.state.selected.as_deref()
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. The in-view picker / chart
    /// controls route their [`Msg`]s here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::Select(name) => {
                if self.state.tables.iter().any(|(n, _)| *n == name) {
                    self.state.selected = Some(name);
                    // Switching tables clears stale chart-column choices.
                    self.state.chart_val = None;
                    self.state.chart_label = None;
                }
            }
            Msg::SetChart(on) => {
                self.state.chart = on;
                if on {
                    // Default the value/label columns from the current preview so the
                    // chart paints something sensible the moment it opens.
                    let (numeric, ncols) = match self.state.current() {
                        Some(p) => (p.numeric_columns(), p.columns.len()),
                        None => (Vec::new(), 0),
                    };
                    if self.state.chart_val.map(|i| !numeric.contains(&i)).unwrap_or(true) {
                        self.state.chart_val = numeric.first().copied();
                    }
                    if self.state.chart_label.is_none() {
                        self.state.chart_label = (0..ncols).find(|i| !numeric.contains(i));
                    }
                }
            }
            Msg::SetChartValue(v) => self.state.chart_val = v,
            Msg::SetChartLabel(v) => self.state.chart_label = v,
        }
        Vec::new()
    }

    /// Select a table by name (a thin wrapper over the FC-2 [`update`](Self::update)
    /// mutation path — the headless equivalent of clicking it); clears any stale
    /// chart-column choices. Returns `true` if such a table exists.
    pub fn select(&mut self, name: &str) -> bool {
        let exists = self.state.tables.iter().any(|(n, _)| n == name);
        if exists {
            let _ = self.update(Msg::Select(name.to_string()));
        }
        exists
    }

    fn current(&self) -> Option<&PreviewTable> {
        self.state.current()
    }

    /// **Discovery-contract `local()`** — seeds two realistic tables (one numeric,
    /// chart-able; one text) so the browser renders with no host attached.
    pub fn local() -> Self {
        Self::new("Warehouse")
            .with_table(
                "bench_runs",
                PreviewTable::new(
                    vec!["bench".into(), "ms".into()],
                    vec![
                        vec!["xml_parse".into(), "12.4".into()],
                        vec!["json_parse".into(), "8.1".into()],
                        vec!["zstd_decode".into(), "31.7".into()],
                    ],
                ),
            )
            .with_table(
                "repos",
                PreviewTable::new(
                    vec!["name".into(), "lang".into()],
                    vec![vec!["nornir".into(), "rust".into()], vec!["facett".into(), "rust".into()]],
                ),
            )
    }

    /// **Discovery-contract `remote()`** — same surface, re-titled (the variant a
    /// host builds from a server scan; no transport here).
    pub fn remote() -> Self {
        let mut v = Self::local();
        v.state.title = "Warehouse (remote)".into();
        v
    }
}

const ROW_H: f32 = 18.0;
const LABEL_W: f32 = 230.0;

impl WarehouseTableView {
    /// **FC-9 pure view** — paints the left table picker + (for the selected table)
    /// the striped grid or the auto bar-chart with its column combos, and **returns**
    /// the [`Msg`]s this frame's interactions produced. It does **not** mutate the
    /// [`WarehouseState`]; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm)
    /// bridge applies the returned messages through [`update`](Self::update), so a
    /// headless driver feeding the same [`Msg`]s reproduces the live behaviour
    /// exactly. The only `&self`-interior mutation is the ephemeral grid-scroll
    /// 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();

        egui::Panel::left("wh_tables").default_size(220.0).show(ui, |ui| {
            ui.heading(format!("tables ({})", self.state.tables.len()));
            ui.separator();
            egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                let names: Vec<String> = self.table_names();
                for name in &names {
                    let selected = self.selected_name() == Some(name.as_str());
                    if ui.selectable_label(selected, name).clicked() {
                        // FC-9: emit the Msg instead of mutating `self`.
                        msgs.push(Msg::Select(name.clone()));
                    }
                }
            });
        });

        egui::CentralPanel::default().show(ui, |ui| {
            let th = facett_core::theme(ui);
            let Some(p) = self.current().cloned() else {
                ui.label("select a table on the left to view its rows");
                return;
            };
            let table_name = self.selected_name().unwrap_or("").to_string();
            let ncols = p.columns.len();
            let numeric = p.numeric_columns();
            let chart = self.state.chart;
            // Effective (clamped) chart columns for *display* — never mutated here.
            let chart_val = self.state.chart_val.filter(|&i| i < ncols);
            let chart_label = self.state.chart_label.filter(|&i| i < ncols);

            ui.horizontal(|ui| {
                ui.heading(&table_name);
                ui.label(format!("· {} rows · {} columns", p.rows.len(), ncols));
                if !numeric.is_empty() {
                    ui.separator();
                    let mut c = chart;
                    if ui.checkbox(&mut c, "📊 chart").changed() {
                        msgs.push(Msg::SetChart(c));
                    }
                }
            });

            if chart && !numeric.is_empty() {
                // The value/label the chart would paint (defaults resolved for display).
                let eff_val = if chart_val.map(|i| !numeric.contains(&i)).unwrap_or(true) {
                    numeric.first().copied()
                } else {
                    chart_val
                };
                let eff_label = if chart_label.is_none() {
                    (0..ncols).find(|i| !numeric.contains(i))
                } else {
                    chart_label
                };
                ui.horizontal(|ui| {
                    ui.label("value:");
                    let mut sel_val = eff_val;
                    egui::ComboBox::from_id_salt("wh_chart_val")
                        .selected_text(eff_val.map(|i| p.columns[i].clone()).unwrap_or_default())
                        .show_ui(ui, |ui| {
                            for &i in &numeric {
                                ui.selectable_value(&mut sel_val, Some(i), &p.columns[i]);
                            }
                        });
                    if sel_val != eff_val {
                        msgs.push(Msg::SetChartValue(sel_val));
                    }
                    ui.label("label:");
                    let mut sel_label = eff_label;
                    egui::ComboBox::from_id_salt("wh_chart_label")
                        .selected_text(eff_label.map(|i| p.columns[i].clone()).unwrap_or("(row #)".into()))
                        .show_ui(ui, |ui| {
                            ui.selectable_value(&mut sel_label, None, "(row #)");
                            for i in 0..ncols {
                                ui.selectable_value(&mut sel_label, Some(i), &p.columns[i]);
                            }
                        });
                    if sel_label != eff_label {
                        msgs.push(Msg::SetChartLabel(sel_label));
                    }
                });
                ui.separator();
                if let Some(v) = eff_val {
                    draw_bar_chart(ui, &p, v, eff_label, &th);
                }
            } else {
                ui.separator();
                let base_id = ui.id().with("wh-grid");
                // 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 closure.
                let dt = ui.input(|i| i.stable_dt);
                let (mut gx, mut gy) = (self.grid_scroll_x.get(), self.grid_scroll_y.get());
                smooth_scroll_area(
                    ui,
                    egui::ScrollArea::both().auto_shrink([false, false]),
                    dt,
                    &mut gy,
                    Some(&mut gx),
                    |ui| {
                        egui::Grid::new("wh_grid").striped(true).num_columns(ncols.max(1)).show(ui, |ui| {
                            for c in &p.columns {
                                ui.strong(c);
                            }
                            ui.end_row();
                            for (ri, row) in p.rows.iter().enumerate() {
                                for cell in row {
                                    let resp = ui
                                        .push_id(facett_core::stable_id(base_id, ri.to_string()), |ui| ui.label(truncate(cell)))
                                        .inner;
                                    let txt = truncate(cell);
                                    resp.widget_info(move || Semantics::new(egui::WidgetType::Label, txt.clone()).widget_info());
                                }
                                ui.end_row();
                            }
                        });
                    },
                );
                self.grid_scroll_x.set(gx);
                self.grid_scroll_y.set(gy);
            }
        });

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-table::WarehouseTableView::view",
            "ui_render",
            true,
            &format!("tables={} selected={:?} chart={}", self.state.tables.len(), self.selected_name(), self.state.chart),
        );

        msgs
    }

    /// The rich `state_json` the deck / render-matrix read: the table list, the
    /// selection (name), the chart controls, and the selected preview's derived
    /// shape (`rows` as a COUNT + `numeric_columns`) exactly as the browser paints.
    fn state_json_impl(&self) -> serde_json::Value {
        let preview = match self.current() {
            None => serde_json::json!({ "loaded": false }),
            Some(p) => serde_json::json!({
                "loaded": true,
                "columns": p.columns,
                "rows": p.rows.len(),
                "numeric_columns": p.numeric_columns(),
            }),
        };
        serde_json::json!({
            "title": self.state.title,
            "tables": self.table_names(),
            "table_count": self.state.tables.len(),
            "selected": self.selected_name(),
            "chart": { "on": self.state.chart, "value_col": self.state.chart_val, "label_col": self.state.chart_label },
            "preview": preview,
        })
    }
}

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

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

// The bridge macro writes `impl Facet for WarehouseTableView` 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 browser publishes a
// RICHER `state_json` than a plain `serde(state())`: the table-name list, the
// selection, and the selected preview's DERIVED shape (`rows` as a count +
// `numeric_columns`) that a deck / mega_matrix reads come from `current()`, not the
// raw model — so the default serde `state_json` is suppressed and this one supplied.
facett_core::impl_facet_via_elm!(WarehouseTableView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        self.state_json_impl()
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.selectable().searchable().resizable().themeable()
    }

    fn selection_json(&self) -> serde_json::Value {
        serde_json::json!(self.selected_name())
    }

    /// Opt into typed downcast so a host can drive this view's mutators
    /// ([`set_table`](Self::set_table) / [`select`](Self::select)) when it lives boxed
    /// inside a `FacetDeck` (e.g. a live sim feeding a growing table each tick).
    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
});

/// Hand-painted horizontal bar chart over the preview rows (no plotting dep —
/// consistent with the nornir original). `val` is the numeric column; `label`
/// the optional label column (row index when `None`).
fn draw_bar_chart(ui: &mut egui::Ui, p: &PreviewTable, val: usize, label: Option<usize>, th: &facett_core::theme::Theme) {
    let red = facett_core::look::RED;
    let rows: Vec<(String, f64)> = p
        .rows
        .iter()
        .enumerate()
        .filter_map(|(i, row)| {
            let v: f64 = row.get(val)?.trim().parse().ok()?;
            let l = match label {
                Some(li) => truncate(row.get(li).map(String::as_str).unwrap_or("")),
                None => format!("#{i}"),
            };
            Some((l, v))
        })
        .take(120)
        .collect();
    if rows.is_empty() {
        ui.label("no numeric values to chart in this column");
        return;
    }
    let max = rows.iter().map(|(_, v)| v.abs()).fold(f64::EPSILON, f64::max);
    egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
        let h = ROW_H * rows.len() as f32 + 8.0;
        let (resp, painter) = ui.allocate_painter(egui::Vec2::new(ui.available_width(), h), egui::Sense::hover());
        let rect = resp.rect;
        let bar_w = (rect.width() - LABEL_W - 90.0).max(40.0);
        for (i, (l, v)) in rows.iter().enumerate() {
            let y = rect.top() + 4.0 + i as f32 * ROW_H;
            painter.text(egui::Pos2::new(rect.left() + 4.0, y + ROW_H / 2.0), egui::Align2::LEFT_CENTER, l, egui::FontId::monospace(11.0), th.text_dim);
            let w = ((v.abs() / max) as f32 * bar_w).max(1.0);
            let bar = egui::Rect::from_min_size(egui::Pos2::new(rect.left() + LABEL_W, y + 2.0), egui::Vec2::new(w, ROW_H - 5.0));
            painter.rect_filled(bar, 2.0, if *v < 0.0 { red } else { th.accent });
            painter.text(bar.right_center() + egui::Vec2::new(6.0, 0.0), egui::Align2::LEFT_CENTER, format!("{v}"), egui::FontId::monospace(11.0), th.text_dim);
        }
    });
}

/// Char-safe truncation (never splits a multibyte char) for grid/chart cells.
fn truncate(s: &str) -> String {
    if s.chars().count() > CELL_MAX_CHARS {
        let head: String = s.chars().take(CELL_MAX_CHARS).collect();
        format!("{head}…")
    } else {
        s.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use facett_core::{Facet, harness};

    #[test]
    fn numeric_column_detection() {
        let p = PreviewTable::new(
            vec!["name".into(), "ms".into(), "mixed".into()],
            vec![
                vec!["a".into(), "1.0".into(), "1".into()],
                vec!["b".into(), "2.5".into(), "x".into()],
                vec!["c".into(), "3".into(), "y".into()],
            ],
        );
        // col 1 is fully numeric; col 0 not; col 2 only 1/3 numeric (< half).
        assert_eq!(p.numeric_columns(), vec![1]);
    }

    #[test]
    fn select_switches_table_and_clears_chart_cols() {
        let mut v = WarehouseTableView::local();
        // default selection is the first added table (keyed on NAME, FC-5).
        assert_eq!(v.selected_name(), Some("bench_runs"));
        let _ = v.update(Msg::SetChartValue(Some(1)));
        assert_eq!(v.state().chart_val, Some(1));
        assert!(v.select("repos"));
        assert_eq!(v.selected_name(), Some("repos"));
        assert_eq!(v.state().chart_val, None, "switching tables clears stale chart cols");
        assert!(!v.select("nope"));
    }

    /// inject-assert (LAW 6): feed real tables, assert `state_json` reports the
    /// list, the selection, and the selected preview's shape + numeric columns.
    #[test]
    fn state_json_reports_tables_selection_and_preview() {
        let v = WarehouseTableView::local();
        let j = Facet::state_json(&v);
        assert_eq!(j["table_count"], 2);
        assert_eq!(j["tables"], serde_json::json!(["bench_runs", "repos"]));
        assert_eq!(j["selected"], "bench_runs");
        assert_eq!(j["preview"]["loaded"], true);
        assert_eq!(j["preview"]["rows"], 3);
        assert_eq!(j["preview"]["columns"], serde_json::json!(["bench", "ms"]));
        // the `ms` column (index 1) is detected numeric → chart-able.
        assert_eq!(j["preview"]["numeric_columns"], serde_json::json!([1]));
    }

    #[test]
    fn empty_view_reports_no_preview() {
        let v = WarehouseTableView::new("Empty");
        let j = Facet::state_json(&v);
        assert_eq!(j["table_count"], 0);
        assert_eq!(j["selected"], serde_json::Value::Null);
        assert_eq!(j["preview"]["loaded"], false);
    }

    #[test]
    fn local_remote_titles() {
        assert_eq!(<WarehouseTableView as Facet>::title(&WarehouseTableView::local()), "Warehouse");
        assert_eq!(<WarehouseTableView as Facet>::title(&WarehouseTableView::remote()), "Warehouse (remote)");
    }

    // ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
    //    harness and assert the resulting `WarehouseState` — no egui, no GPU. ──────

    #[test]
    fn harness_select_drives_the_picker() {
        let mut v = WarehouseTableView::local();
        // Selecting an existing table by name is observable in the snapshot.
        let snap = harness::snapshot(&mut v, [Msg::Select("repos".into())]);
        assert_eq!(snap.selected.as_deref(), Some("repos"), "selection is observable headlessly");
        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
        // Selecting an absent table is a no-op.
        let snap = harness::snapshot(&mut v, [Msg::Select("nope".into())]);
        assert_eq!(snap.selected.as_deref(), Some("repos"), "selecting an absent table is a no-op");
    }

    #[test]
    fn harness_chart_toggle_defaults_value_and_label_columns() {
        let mut v = WarehouseTableView::local(); // selected = bench_runs (col 1 numeric)
        assert!(!v.state().chart);
        // Turning chart on defaults value → first numeric col (1), label → first
        // non-numeric col (0).
        let snap = harness::snapshot(&mut v, [Msg::SetChart(true)]);
        assert!(snap.chart);
        assert_eq!(snap.chart_val, Some(1), "value defaults to the first numeric column");
        assert_eq!(snap.chart_label, Some(0), "label defaults to the first non-numeric column");
        // Explicit column choices are honoured.
        let snap = harness::snapshot(&mut v, [Msg::SetChartLabel(None)]);
        assert_eq!(snap.chart_label, None);
        // Turning chart off preserves the chosen columns.
        let snap = harness::snapshot(&mut v, [Msg::SetChart(false)]);
        assert!(!snap.chart);
        assert_eq!(snap.chart_val, Some(1));
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        let mut v = WarehouseTableView::local();
        // FC-8: the browser does no I/O, so the effect stream is always empty.
        let effects = harness::drive(&mut v, [Msg::Select("repos".into()), Msg::SetChart(true)]);
        assert!(effects.is_empty(), "FC-8: warehouse 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"], "repos");
        let back: WarehouseState = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
    }
}