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

nordisk/facett

facett-icebergview/src/lib.rs

//! **facett-icebergview** — browse **and time-travel** one Iceberg table, drawn
//! from **Arrow `RecordBatch`es**. The component is *engine-agnostic*: you hand
//! it, per Iceberg snapshot, the snapshot's metadata + its materialized Arrow
//! frame; it renders a **snapshot picker** (newest-first, showing snapshot id /
//! timestamp / row-count) over a virtualized, **paged** row grid (the rows come
//! from [`facett_table::Table`], built from the batch via [`facett_arrow`]).
//! Selecting a snapshot loads *that* snapshot's frame — time travel.
//!
//! Like every facett component it implements [`Facet`](facett_core::Facet)
//! (`title` / `ui` / `state_json`) — here **via the canonical Elm bridge** — so it
//! drops into a `FacetDeck` and is robot-testable through `facett_core::harness`:
//! feed it [`Msg`]s ([`Msg::SelectSnapshot`] / [`Msg::NextPage`] / …) headlessly
//! and assert `state_json` (the current snapshot, the snapshot list, and the
//! visible rows all change). `state_json` exposes:
//!
//! - `table` — the table name,
//! - `snapshots` — every snapshot newest-first (`id`, `timestamp_ms`, `rows`),
//! - `current` — the selected snapshot id,
//! - `total_rows` / `page` / `page_size` / `page_count` / `columns` /
//!   `visible_rows` — the paged view.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! - **[`IcebergState`]** — the complete observable, serializable, round-trippable
//!   state: the table name, the snapshot **metas** (newest-first), the selected
//!   snapshot **id** (FC-5: a stable id, never a volatile picker index), and the
//!   paging window. [`IcebergView::state`] hands back a `&IcebergState`. The Arrow
//!   frames themselves are *not* observable state — they're the (non-serializable)
//!   source data the grid is derived from, kept out of the Model.
//! - **[`Msg`] + [`IcebergView::update`]** — the single mutation path (FC-2): select
//!   a snapshot **by id**, page, or resize the page; the same gestures the picker /
//!   pager drive, so a headless driver ([`facett_core::harness`]) reaches them too.
//! - **[`IcebergView::view`]** — a **pure** paint (FC-9): it reads `&self`, paints the
//!   picker + pager + 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 derived keys a driver reads (`total_rows`,
//!   `page_count`, `columns`, `visible_rows`) come from the rendered grid, not from a
//!   plain `serde(state())`, so the macro is invoked in **form 3** (`custom_state_json`).
//! - **No I/O (FC-8)** — every snapshot's frame is handed in already materialized;
//!   time-travel just re-slices in-memory data. The catalog/engine *loads* live
//!   host-side (skade / korp / nornir), so [`Effect`] is uninhabited on purpose.
//!
//! **Why not just `facett-table`?** A table viewer shows *one* frame. This adds
//! the Iceberg dimension — the history of snapshots and the control to scrub
//! between them — which is the whole point of a lakehouse table browser. The
//! adapter that *fills* it from a real catalog (skade) lives host-side (korp /
//! nornir) so this crate never takes a hard catalog/engine dependency.

use std::cell::RefCell;

use arrow_array::RecordBatch;
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{FacetCaps, theme};
use facett_core::Facet as _; // Table::ui (Facet) called in the pure view
use facett_table::Table;
use serde::{Deserialize, Serialize};

pub mod catalog;
pub use catalog::{CatalogState, CatalogView, ColumnInfo, TableInfo};

/// One Iceberg snapshot's identity, for the picker + `state_json`. Mirrors what
/// `iceberg::spec::Snapshot` exposes (`snapshot_id()` / `timestamp_ms()`) plus a
/// row count the host computes (scan count, or the snapshot summary's
/// `total-records`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SnapshotMeta {
    /// The Iceberg snapshot id.
    pub id: i64,
    /// Snapshot commit time, epoch milliseconds (Iceberg `timestamp_ms`).
    pub timestamp_ms: i64,
    /// Row count of this snapshot (the host fills it; 0 if unknown).
    pub rows: u64,
}

impl SnapshotMeta {
    pub fn new(id: i64, timestamp_ms: i64, rows: u64) -> Self {
        Self { id, timestamp_ms, rows }
    }
}

/// One snapshot paired with the Arrow frame that materializes it. The frame is a
/// `Vec<RecordBatch>` exactly as a scan returns it; `IcebergView` concatenates
/// the rows into a paged [`Table`] for rendering. This is the **input** type the
/// constructor / [`IcebergView::set_snapshots`] accept; internally the view keeps
/// the metas (in the observable [`IcebergState`]) and the frames apart.
pub struct Snapshot {
    pub meta: SnapshotMeta,
    pub batches: Vec<RecordBatch>,
}

impl Snapshot {
    pub fn new(meta: SnapshotMeta, batches: Vec<RecordBatch>) -> Self {
        Self { meta, batches }
    }
    /// Total rows across the snapshot's batches.
    pub fn row_count(&self) -> usize {
        self.batches.iter().map(|b| b.num_rows()).sum()
    }
}

/// Default rows shown per page (the Table is virtualized, but paging keeps a huge
/// snapshot from being cell-formatted all at once and makes `visible_rows`
/// meaningful for headless assertions).
pub const DEFAULT_PAGE_SIZE: usize = 500;

/// A robot-/CLI-addressable control message (FC-2) — every input the viewer
/// accepts, and the **only** legal way to mutate [`IcebergState`]. Applied by
/// [`IcebergView::update`]; the picker / pager route their gestures through the
/// same messages, so live == headless. Serde-tagged (`snake_case`) so the robot
/// `update_json` seam parses one message straight off the wire.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// **Time-travel**: select the snapshot with this Iceberg **id** and load its
    /// frame (FC-5: keyed on the stable snapshot id, not a picker index). Resets to
    /// page 0. An unknown id is a no-op.
    SelectSnapshot(i64),
    /// Jump to page `p` (0-based) within the current snapshot, clamped to range.
    SetPage(usize),
    /// Advance one page (the `next ▶` button), clamped to the last page.
    NextPage,
    /// Step back one page (the `◀ prev` button), clamped at page 0.
    PrevPage,
    /// Set the rows-per-page window (clamped to ≥1); resets to page 0.
    SetPageSize(usize),
}

/// Side work as data (FC-8). The viewer does no I/O — every snapshot's frame is
/// handed in already materialized and time-travel just re-slices in-memory data,
/// so this is uninhabited on purpose: the type-checked statement that
/// [`IcebergView::update`] never asks the host to load or query anything. The
/// catalog/engine loads happen host-side (skade / korp / nornir) before frames
/// reach this crate.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

/// **The complete observable state (FC-1 / FC-3)** of an [`IcebergView`], in one
/// serializable, round-trippable struct: the table name, the snapshot **metas**
/// (newest-first — the picker order), the selected snapshot **id** (FC-5), and the
/// paging window. [`IcebergView::state`] hands back a `&IcebergState`; a headless
/// driver ([`facett_core::harness`]) snapshots it after feeding a `Vec<Msg>`. The
/// live row grid + row totals are *derived* from the (non-observable) Arrow frames.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct IcebergState {
    /// The table being browsed.
    pub table: String,
    /// Snapshots' metadata, **newest-first** (the picker order).
    pub snapshots: Vec<SnapshotMeta>,
    /// The selected (currently rendered) snapshot's **id** (FC-5). `None` when the
    /// table has no snapshots.
    pub current: Option<i64>,
    /// Current page (0-based) within the selected snapshot.
    pub page: usize,
    /// Rows per page.
    pub page_size: usize,
}

/// A browse + time-travel viewer for one Iceberg table.
pub struct IcebergView {
    title: String,
    /// All observable state (FC-3).
    state: IcebergState,
    /// The Arrow frames, **parallel to `state.snapshots` by index** (both sorted
    /// newest-first). NOT observable state — non-serializable source data the grid
    /// is derived from, kept out of the [`IcebergState`] Model.
    frames: Vec<Vec<RecordBatch>>,
    /// The rendered grid for the current snapshot + page. **Ephemeral render cache,
    /// not observable state** — rebuilt in [`update`](Self::update) on any switch,
    /// and interior-mutable so the FC-9 pure [`view`](Self::view) can paint it
    /// (which advances the grid's own hover/selection fades) through a `&self`
    /// borrow. Deliberately kept OUT of [`state`](Self::state).
    grid: RefCell<Table>,
}

impl IcebergView {
    /// Build a viewer over one table's snapshots. `snapshots` may be in any
    /// order — they're sorted newest-first; the newest is selected initially.
    /// An empty `snapshots` yields an empty viewer (renders an empty-state hint).
    pub fn new(title: impl Into<String>, table_name: impl Into<String>, snapshots: Vec<Snapshot>) -> Self {
        let (metas, frames) = Self::split_sorted(snapshots);
        let current = metas.first().map(|m| m.id);
        let mut me = Self {
            title: title.into(),
            state: IcebergState {
                table: table_name.into(),
                snapshots: metas,
                current,
                page: 0,
                page_size: DEFAULT_PAGE_SIZE,
            },
            frames,
            grid: RefCell::new(Table::new("snapshot", Vec::new())),
        };
        me.rebuild_grid();
        me
    }

    /// Sort snapshots newest-first (latest timestamp on top; ties broken by the
    /// larger id — later commit) and split into parallel metas + frames.
    fn split_sorted(mut snapshots: Vec<Snapshot>) -> (Vec<SnapshotMeta>, Vec<Vec<RecordBatch>>) {
        snapshots.sort_by(|a, b| {
            b.meta.timestamp_ms.cmp(&a.meta.timestamp_ms).then(b.meta.id.cmp(&a.meta.id))
        });
        let mut metas = Vec::with_capacity(snapshots.len());
        let mut frames = Vec::with_capacity(snapshots.len());
        for s in snapshots {
            metas.push(s.meta);
            frames.push(s.batches);
        }
        (metas, frames)
    }

    /// Page size builder (rows per page). Clamped to ≥1.
    pub fn with_page_size(mut self, n: usize) -> Self {
        self.state.page_size = n.max(1);
        self.state.page = 0;
        self.rebuild_grid();
        self
    }

    /// The table name being browsed.
    pub fn table_name(&self) -> &str {
        &self.state.table
    }

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

    /// **Mutator**: replace the whole snapshot set in place (the host-driven
    /// counterpart of the constructor). `snapshots` may be in any order — they're
    /// re-sorted newest-first and the **newest** becomes selected. The viewer's
    /// **page is preserved** (clamped to the new snapshot's range) rather than reset
    /// to 0: a paged time-travel browser must stay on the row window the user is
    /// reading while a live feed (e.g. the demo's sim) streams new snapshots in each
    /// tick — otherwise every accrual would yank them back to page 0 mid-scroll. The
    /// grid is rebuilt. This is a data-ingestion mutator (the frames aren't
    /// serializable), deliberately NOT a [`Msg`] — the FC-2 surface is the *control*
    /// surface, not the data feed.
    pub fn set_snapshots(&mut self, snapshots: Vec<Snapshot>) {
        let (metas, frames) = Self::split_sorted(snapshots);
        self.state.current = metas.first().map(|m| m.id);
        self.state.snapshots = metas;
        self.frames = frames;
        // Keep the page the viewer is on, clamped to the freshly-installed
        // snapshot's page count (page_count() reads `self.frames`/`current`).
        let last = self.page_count().saturating_sub(1);
        self.state.page = self.state.page.min(last);
        self.rebuild_grid();
    }

    /// Snapshot metadata, newest-first (the picker order).
    pub fn snapshot_metas(&self) -> Vec<SnapshotMeta> {
        self.state.snapshots.clone()
    }

    /// Index into `snapshots`/`frames` of the selected snapshot (by its stable id).
    fn current_index(&self) -> Option<usize> {
        let id = self.state.current?;
        self.state.snapshots.iter().position(|m| m.id == id)
    }

    /// The selected snapshot's metadata (None if the table has no snapshots).
    pub fn current_snapshot(&self) -> Option<&SnapshotMeta> {
        self.current_index().and_then(|i| self.state.snapshots.get(i))
    }

    /// The selected snapshot's id (None if the table has no snapshots).
    pub fn current_id(&self) -> Option<i64> {
        self.state.current
    }

    /// Total rows in the selected snapshot.
    pub fn total_rows(&self) -> usize {
        self.current_index().map(|i| self.frames[i].iter().map(|b| b.num_rows()).sum()).unwrap_or(0)
    }

    /// Number of pages in the selected snapshot (≥1 while a snapshot exists).
    pub fn page_count(&self) -> usize {
        if self.state.snapshots.is_empty() {
            0
        } else {
            self.total_rows().max(1).div_ceil(self.state.page_size)
        }
    }

    /// **Time-travel**: select the snapshot with id `id` and load its frame.
    /// Returns true if the id was found (and selection changed or reloaded).
    /// Resets to page 0. Unknown id is a no-op returning false. Thin wrapper over
    /// the FC-2 [`update`](Self::update) mutation path ([`Msg::SelectSnapshot`]).
    pub fn select_snapshot(&mut self, id: i64) -> bool {
        if self.state.snapshots.iter().any(|m| m.id == id) {
            let _ = self.update(Msg::SelectSnapshot(id));
            true
        } else {
            false
        }
    }

    /// Select a snapshot by its picker index (0 = newest). No-op if out of range.
    /// Resolves the index to the snapshot's stable id and routes through
    /// [`Msg::SelectSnapshot`] (FC-5 — the Model never stores the index).
    pub fn select_index(&mut self, i: usize) {
        if let Some(id) = self.state.snapshots.get(i).map(|m| m.id) {
            let _ = self.update(Msg::SelectSnapshot(id));
        }
    }

    /// Go to page `p` (0-based) within the current snapshot, clamped to range.
    /// Thin wrapper over [`Msg::SetPage`].
    pub fn set_page(&mut self, p: usize) {
        let _ = self.update(Msg::SetPage(p));
    }

    /// The currently-rendered grid (its rows are the visible page).
    pub fn grid(&self) -> std::cell::Ref<'_, Table> {
        self.grid.borrow()
    }

    /// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) [`Effect`]s the host should run. The picker / pager route
    /// their gestures here too, so live == headless.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::SelectSnapshot(id) => {
                if self.state.snapshots.iter().any(|m| m.id == id) {
                    self.state.current = Some(id);
                    self.state.page = 0;
                    self.rebuild_grid();
                }
                // Unknown id: no-op (keep the current selection).
            }
            Msg::SetPage(p) => {
                let last = self.page_count().saturating_sub(1);
                self.state.page = p.min(last);
                self.rebuild_grid();
            }
            Msg::NextPage => {
                let last = self.page_count().saturating_sub(1);
                self.state.page = (self.state.page + 1).min(last);
                self.rebuild_grid();
            }
            Msg::PrevPage => {
                self.state.page = self.state.page.saturating_sub(1);
                self.rebuild_grid();
            }
            Msg::SetPageSize(n) => {
                self.state.page_size = n.max(1);
                self.state.page = 0;
                self.rebuild_grid();
            }
        }
        Vec::new()
    }

    /// Rebuild the [`Table`] render cache for the current snapshot + page.
    fn rebuild_grid(&mut self) {
        let grid = self.build_grid();
        *self.grid.get_mut() = grid;
    }

    /// Build the [`Table`] for the current snapshot + page. The whole snapshot
    /// frame is concatenated per-batch and the page window sliced out, then each
    /// batch in the window is mapped to string rows via `facett_arrow`. Pure over
    /// `&self` — [`rebuild_grid`](Self::rebuild_grid) stores the result in the cache.
    fn build_grid(&self) -> Table {
        let Some(idx) = self.current_index() else {
            return Table::new("snapshot", Vec::new());
        };
        let batches = &self.frames[idx];
        let columns: Vec<String> = batches
            .first()
            .map(|b| b.schema().fields().iter().map(|f| f.name().clone()).collect())
            .unwrap_or_default();
        let mut grid = Table::new(format!("{} @ {}", self.state.table, self.state.snapshots[idx].id), columns);

        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
        let start = self.state.page * self.state.page_size;
        let end = (start + self.state.page_size).min(total);

        // Walk the batches, emitting only rows in [start, end).
        let mut row0 = 0usize; // absolute index of this batch's first row
        for batch in batches {
            let n = batch.num_rows();
            let b_lo = start.saturating_sub(row0).min(n);
            let b_hi = end.saturating_sub(row0).min(n);
            if b_lo < b_hi {
                // facett-arrow formats a whole batch; slice to the wanted window
                // first so we never format rows the page doesn't show.
                let window = batch.slice(b_lo, b_hi - b_lo);
                let t = facett_arrow::table_from_batch(&window, "");
                for r in t.rows {
                    grid.push_row(r);
                }
            }
            row0 += n;
            if row0 >= end {
                break;
            }
        }
        grid
    }

    /// **FC-9 render** — a **pure** function of `&self`: it paints the snapshot
    /// picker + pager + row grid and *returns* the [`Msg`]s the interactions
    /// produced. It MUST NOT mutate the [`IcebergState`]; 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 painting the ephemeral grid cache (its own hover/selection fades),
    /// 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 = theme(ui);
        if self.state.snapshots.is_empty() {
            ui.weak(format!("{} — no snapshots", self.state.table));
            // ── render-lane emit: empty-state early-out path RAN ──────────────
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-icebergview::IcebergView::view",
                "ui_render",
                // OK = nothing to show ⇒ the hint is the correct render.
                self.state.snapshots.is_empty() && self.total_rows() == 0,
                "drew=empty_hint",
            );
            return msgs;
        }

        // ── snapshot picker (newest-first) + time-travel ──────────────────────
        let cur = self.state.current;
        ui.horizontal(|ui| {
            ui.strong("⏱ snapshot:");
            egui::ComboBox::from_id_salt(("icebergview_snap", &self.title))
                .selected_text(
                    self.current_snapshot()
                        .map(|m| format!("#{} · {} rows", m.id, m.rows))
                        .unwrap_or_else(|| "—".into()),
                )
                .show_ui(ui, |ui| {
                    for (i, m) in self.state.snapshots.iter().enumerate() {
                        let label = format!(
                            "{}#{}  ·  t={}ms  ·  {} rows",
                            if i == 0 { "▲ " } else { "" },
                            m.id,
                            m.timestamp_ms,
                            m.rows,
                        );
                        if ui.selectable_label(cur == Some(m.id), label).clicked() {
                            // FC-9: emit the Msg instead of mutating `self`. FC-5:
                            // key on the stable snapshot id, not the picker index.
                            msgs.push(Msg::SelectSnapshot(m.id));
                        }
                    }
                });
            ui.weak(format!("({} snapshots)", self.state.snapshots.len()));
        });

        // ── pager ─────────────────────────────────────────────────────────────
        let pages = self.page_count();
        ui.horizontal(|ui| {
            ui.label(format!("table {}", self.state.table));
            ui.separator();
            if ui.add_enabled(self.state.page > 0, egui::Button::new("◀ prev")).clicked() {
                msgs.push(Msg::PrevPage);
            }
            ui.colored_label(th.text, format!("page {}/{}", self.state.page + 1, pages.max(1)));
            if ui.add_enabled(self.state.page + 1 < pages, egui::Button::new("next ▶")).clicked() {
                msgs.push(Msg::NextPage);
            }
            ui.separator();
            ui.weak(format!("{} rows total", self.total_rows()));
        });

        ui.separator();
        // The page's rows render through the virtualized facett-table grid. The
        // grid is an ephemeral render cache (rebuilt in `update` on any switch), so
        // painting it — which advances its own hover/selection fades — is interior
        // mutation of a cache OUTSIDE the observable Model (FC-9 leaves the Model
        // untouched here).
        self.grid.borrow_mut().ui(ui);

        // ── render-lane emit: this view path RAN ──────────────────────────────
        #[cfg(feature = "testmatrix")]
        {
            let g = self.grid.borrow();
            facett_core::testmatrix::emit(
                "facett-icebergview::IcebergView::view",
                "ui_render",
                // OK = the page window is a valid slice of the selected snapshot: the
                // visible grid rows never exceed the page size, and never exceed the
                // snapshot's total row count (rendered ≤ input).
                g.rows.len() <= self.state.page_size && g.rows.len() <= self.total_rows(),
                &format!(
                    "snapshots={} current={} page={}/{} visible_rows={} total_rows={}",
                    self.state.snapshots.len(),
                    self.state.current.map(|id| id.to_string()).unwrap_or_else(|| "none".into()),
                    self.state.page + 1,
                    pages.max(1),
                    g.rows.len(),
                    self.total_rows(),
                ),
            );
        }

        msgs
    }
}

// ── typed copy (§16) — the current snapshot's visible page as TSV rows ─────────
impl CopySource for IcebergView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Rows, ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        // Delegate to the inner grid: the currently-shown snapshot page as a TSV
        // rectangle (header + the visible rows).
        self.grid.borrow().copy_payload()
    }
}

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

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

// The bridge macro writes `impl Facet for IcebergView` 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 viewer publishes a RICHER
// `state_json` than a plain `serde(state())`: the derived `total_rows` /
// `page_count` / `columns` / `visible_rows` a driver reads come from the rendered
// grid + Arrow frames, not from the raw Model — so the default `state_json` is
// suppressed and this one supplied (preserving the exact pre-migration key set).
facett_core::impl_facet_via_elm!(IcebergView, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        let g = self.grid.borrow();
        serde_json::json!({
            "table": self.state.table,
            "snapshots": self.state.snapshots.iter().map(|m| serde_json::json!({
                "id": m.id,
                "timestamp_ms": m.timestamp_ms,
                "rows": m.rows,
            })).collect::<Vec<_>>(),
            "current": self.state.current,
            "total_rows": self.total_rows(),
            "page": self.state.page,
            "page_size": self.state.page_size,
            "page_count": self.page_count(),
            "columns": g.columns,
            "visible_rows": g.rows.len(),
        })
    }

    /// 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. The `impl_facet_via_elm!` bridge does NOT generate an
    /// `update_json`, so without this override the JSON drive seam is a dead no-op
    /// (a headless robot's time-travel / paging messages mutate nothing). Malformed
    /// / unknown JSON is ignored (never a panic).
    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.current {
            Some(id) => serde_json::json!(id),
            None => serde_json::Value::Null,
        }
    }

    /// Standard egui widgets (ComboBox, buttons, the inner Table) follow the
    /// active `Theme`'s `Visuals`. Selectable (the snapshot) + resizable.
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().selectable().resizable().copyable()
    }

    /// Opt into typed downcast so a host can drive time-travel
    /// ([`IcebergView::select_snapshot`] / [`IcebergView::set_page`]) when this view
    /// lives boxed inside a `FacetDeck` (e.g. the demo's robot-UI driver).
    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
        Some(self)
    }
});

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

    use arrow_array::{Int64Array, StringArray};
    use arrow_schema::{DataType, Field, Schema};

    #[test]
    fn typed_copy_is_the_current_snapshot_page_as_rows() {
        use facett_core::clip::{ClipKind, CopySource};
        let v = two_snapshot_view();
        // Newest snapshot (v2) is selected: its 3-row page copies as a TSV rect.
        let p = v.copy_payload().expect("a snapshot page copies");
        assert_eq!(p.kind(), ClipKind::Rows);
        assert_eq!(p.as_text(), "id\tname\n1\tknut\n2\tkorp\n3\tskade");
    }

    /// A `value`-column batch with `vals` rows.
    fn batch(ids: &[i64], names: &[&str]) -> RecordBatch {
        RecordBatch::try_new(
            Arc::new(Schema::new(vec![
                Field::new("id", DataType::Int64, false),
                Field::new("name", DataType::Utf8, false),
            ])),
            vec![
                Arc::new(Int64Array::from(ids.to_vec())),
                Arc::new(StringArray::from(names.to_vec())),
            ],
        )
        .unwrap()
    }

    /// Two snapshots of the same table: v1 (older, 2 rows) and v2 (newer, 3 rows).
    fn two_snapshot_view() -> IcebergView {
        let v1 = Snapshot::new(SnapshotMeta::new(100, 1_000, 2), vec![batch(&[1, 2], &["knut", "korp"])]);
        let v2 = Snapshot::new(
            SnapshotMeta::new(200, 2_000, 3),
            vec![batch(&[1, 2, 3], &["knut", "korp", "skade"])],
        );
        // Pass oldest-first on purpose — the view must sort newest-first.
        IcebergView::new("🧊 Tables", "Person", vec![v1, v2])
    }

    #[test]
    fn newest_snapshot_selected_first_and_listed_newest_first() {
        let v = two_snapshot_view();
        // Picker order: newest (200) first.
        let metas = v.snapshot_metas();
        assert_eq!(metas[0].id, 200);
        assert_eq!(metas[1].id, 100);
        // Initial selection = newest.
        assert_eq!(v.current_id(), Some(200));
        assert_eq!(v.total_rows(), 3);
    }

    #[test]
    fn switching_snapshots_changes_rendered_rows_and_state() {
        let mut v = two_snapshot_view();

        // Newest (v2): 3 rows rendered, third row is skade.
        assert_eq!(v.grid().rows.len(), 3);
        assert_eq!(v.grid().rows[2], vec!["3".to_string(), "skade".to_string()]);
        let s2 = v.state_json();
        assert_eq!(s2["current"], 200);
        assert_eq!(s2["total_rows"], 3);
        assert_eq!(s2["visible_rows"], 3);

        // TIME TRAVEL → older snapshot v1: rendered rows shrink to 2, no skade.
        assert!(v.select_snapshot(100), "100 is a known snapshot");
        assert_eq!(v.current_id(), Some(100));
        assert_eq!(v.grid().rows.len(), 2);
        assert!(v.grid().rows.iter().all(|r| r[1] != "skade"), "v1 predates skade");
        let s1 = v.state_json();
        assert_eq!(s1["current"], 100);
        assert_eq!(s1["total_rows"], 2);
        assert_eq!(s1["visible_rows"], 2);

        // The two snapshots produced demonstrably different rendered state.
        assert_ne!(s1["current"], s2["current"]);
        assert_ne!(s1["visible_rows"], s2["visible_rows"]);

        // Unknown id is a no-op.
        assert!(!v.select_snapshot(999));
        assert_eq!(v.current_id(), Some(100));
    }

    #[test]
    fn state_json_carries_full_snapshot_list() {
        let v = two_snapshot_view();
        let j = v.state_json();
        assert_eq!(j["table"], "Person");
        let snaps = j["snapshots"].as_array().unwrap();
        assert_eq!(snaps.len(), 2);
        assert_eq!(snaps[0]["id"], 200); // newest-first
        assert_eq!(snaps[0]["rows"], 3);
        assert_eq!(snaps[1]["id"], 100);
        assert_eq!(j["columns"].as_array().unwrap().len(), 2);
    }

    #[test]
    fn paging_windows_the_rows() {
        // 5 rows, page_size 2 → 3 pages; each page shows its slice.
        let snap = Snapshot::new(
            SnapshotMeta::new(1, 10, 5),
            vec![batch(&[1, 2, 3, 4, 5], &["a", "b", "c", "d", "e"])],
        );
        let mut v = IcebergView::new("t", "T", vec![snap]).with_page_size(2);
        assert_eq!(v.page_count(), 3);
        assert_eq!(v.grid().rows.len(), 2);
        assert_eq!(v.grid().rows[0][1], "a");
        v.set_page(1);
        assert_eq!(v.grid().rows.len(), 2);
        assert_eq!(v.grid().rows[0][1], "c");
        v.set_page(2);
        assert_eq!(v.grid().rows.len(), 1); // last page has the remainder
        assert_eq!(v.grid().rows[0][1], "e");
        // Clamp beyond the last page.
        v.set_page(99);
        assert_eq!(v.state_json()["page"], 2);
    }

    #[test]
    fn paging_windows_across_multiple_batches() {
        // Two batches (3 + 3 rows), page_size 4 → page 0 spans the batch boundary.
        let snap = Snapshot::new(
            SnapshotMeta::new(7, 70, 6),
            vec![batch(&[1, 2, 3], &["a", "b", "c"]), batch(&[4, 5, 6], &["d", "e", "f"])],
        );
        let mut v = IcebergView::new("t", "T", vec![snap]).with_page_size(4);
        assert_eq!(v.grid().rows.len(), 4);
        assert_eq!(v.grid().rows[3][1], "d", "page 0 crosses into the second batch");
        v.set_page(1);
        assert_eq!(v.grid().rows.len(), 2);
        assert_eq!(v.grid().rows[0][1], "e");
    }

    #[test]
    fn headless_render_draws_and_reports_state() {
        // Inject 2 snapshots, render the newest offscreen, time-travel, render
        // again — assert it both DREW and that state reflects the switch.
        let mut v = two_snapshot_view();
        let r2 = harness::headless_render(&mut v);
        assert_eq!(r2.title, "🧊 Tables");
        assert!(r2.drew(), "a snapshot of rows should tessellate to vertices");
        assert_eq!(r2.state["current"], 200);
        assert_eq!(r2.state["visible_rows"], 3);

        v.select_snapshot(100);
        let r1 = harness::headless_render(&mut v);
        assert_eq!(r1.state["current"], 100);
        assert_eq!(r1.state["visible_rows"], 2);
        assert_ne!(r1.state["visible_rows"], r2.state["visible_rows"]);
    }

    #[test]
    fn empty_view_renders_hint_without_panic() {
        let mut v = IcebergView::new("t", "Empty", Vec::new());
        assert_eq!(v.current_id(), None);
        assert_eq!(v.total_rows(), 0);
        assert_eq!(v.page_count(), 0);
        let r = harness::headless_render(&mut v);
        assert_eq!(r.state["snapshots"].as_array().unwrap().len(), 0);
        assert_eq!(r.state["current"], serde_json::Value::Null);
    }

    #[test]
    fn caps_advertise_themeable_selectable_resizable() {
        let c = two_snapshot_view().caps();
        assert!(c.themeable && c.selectable && c.resizable && c.copyable);
        assert!(!c.scalable);
    }

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

    #[test]
    fn harness_snapshot_drives_time_travel_by_id() {
        // Selecting a snapshot by its stable id (FC-5) is observable in the snapshot,
        // and resets the page to 0.
        let mut v = two_snapshot_view();
        let snap = harness::snapshot(&mut v, [Msg::SelectSnapshot(100)]);
        assert_eq!(snap.current, Some(100), "time-travel selection is observable headlessly");
        assert_eq!(snap.page, 0, "selecting a snapshot resets to page 0");
        assert_eq!(&snap, v.state(), "the snapshot is a clone of the live state");
        // An unknown id is a no-op — the newest stays selected.
        let snap = harness::snapshot(&mut v, [Msg::SelectSnapshot(999)]);
        assert_eq!(snap.current, Some(100), "unknown id leaves the selection untouched");
    }

    #[test]
    fn harness_snapshot_drives_the_pager() {
        // 5 rows, page_size 2 → 3 pages. Drive Next/Prev/SetPage through the harness
        // and assert the observable page, with clamping at both ends.
        let snap0 = Snapshot::new(
            SnapshotMeta::new(1, 10, 5),
            vec![batch(&[1, 2, 3, 4, 5], &["a", "b", "c", "d", "e"])],
        );
        let mut v = IcebergView::new("t", "T", vec![snap0]).with_page_size(2);

        let s = harness::snapshot(&mut v, [Msg::NextPage, Msg::NextPage]);
        assert_eq!(s.page, 2, "two Next steps land on the last page");
        // NextPage clamps at the last page.
        let s = harness::snapshot(&mut v, [Msg::NextPage]);
        assert_eq!(s.page, 2, "NextPage is clamped at the last page");
        // PrevPage steps back and clamps at 0.
        let s = harness::snapshot(&mut v, [Msg::PrevPage, Msg::PrevPage, Msg::PrevPage]);
        assert_eq!(s.page, 0, "PrevPage clamps at page 0");
        // SetPage jumps + clamps beyond the last page.
        let s = harness::snapshot(&mut v, [Msg::SetPage(99)]);
        assert_eq!(s.page, 2, "SetPage clamps to the last page");
    }

    #[test]
    fn set_page_size_rewindows_and_resets_page() {
        let snap0 = Snapshot::new(
            SnapshotMeta::new(1, 10, 5),
            vec![batch(&[1, 2, 3, 4, 5], &["a", "b", "c", "d", "e"])],
        );
        let mut v = IcebergView::new("t", "T", vec![snap0]).with_page_size(2);
        v.set_page(2);
        assert_eq!(v.state().page, 2);
        // Re-windowing resets to page 0 and re-slices the grid.
        let s = harness::snapshot(&mut v, [Msg::SetPageSize(10)]);
        assert_eq!(s.page_size, 10);
        assert_eq!(s.page, 0, "changing the page size resets to page 0");
        assert_eq!(v.page_count(), 1, "all 5 rows now fit one page");
        assert_eq!(v.grid().rows.len(), 5);
        // Clamped to >= 1.
        let s = harness::snapshot(&mut v, [Msg::SetPageSize(0)]);
        assert_eq!(s.page_size, 1, "page size is clamped to >= 1");
    }

    #[test]
    fn drive_reports_no_effects_and_state_round_trips() {
        // FC-8: the viewer does no I/O, so the effect stream is always empty.
        let mut v = two_snapshot_view();
        let effects = harness::drive(&mut v, [Msg::SelectSnapshot(100), Msg::NextPage, Msg::SetPageSize(4)]);
        assert!(effects.is_empty(), "FC-8: icebergview emits no Effects (loads are host-side)");
        // FC-3: the observable Model round-trips through serde.
        let json = serde_json::to_value(v.state()).unwrap();
        assert_eq!(json["current"], 100);
        let back: IcebergState = serde_json::from_value(json).unwrap();
        assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
    }

    #[test]
    fn snapshot_json_matches_facet_state_json_keys() {
        // The macro-generated Facet bridge + the harness JSON expose the same
        // observable Model keys (the Facet form additionally derives the grid keys).
        let mut v = two_snapshot_view();
        let js = harness::snapshot_json(&mut v, [Msg::SelectSnapshot(100)]);
        assert_eq!(js["table"], "Person");
        assert_eq!(js["current"], 100);
        assert_eq!(js["page"], 0);
        // The Facet form preserves the pre-migration rich keys too.
        let fj = v.state_json();
        assert_eq!(fj["total_rows"], 2);
        assert_eq!(fj["visible_rows"], 2);
        assert_eq!(fj["page_count"], 1);
    }
}