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