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