facett-grid/src/tests.rs
//! **Inject-and-assert tests** (LAW #1) — feed a real Arrow `RecordBatch` (incl. a
//! 100k-row mixed int/float/string/bool batch) and assert *real* behaviour: type
//! detection maps columns, sort reorders the right rows, the visible window is the
//! expected slice, selection→TSV is the exact expected text, scale changes row
//! height, and copy() round-trips through the Facet contract.
//!
//! D2h inline-editing tests: begin_edit/commit_edit/cancel_edit + GridResponse
//! changes, type validation, state_json editing field, editable cap flag.
use super::*;
use arrow_array::{BooleanArray, Float64Array, Int64Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use facett_core::Facet;
use std::sync::Arc;
/// A 100k-row mixed batch: id (int), score (float), region (low-card enum),
/// active (bool), name (unique text).
fn big_batch(n: usize) -> RecordBatch {
let ids: Vec<i64> = (0..n as i64).collect();
let scores: Vec<f64> = (0..n).map(|i| ((i * 37) % 1000) as f64 / 10.0).collect();
let regions: Vec<&str> = (0..n).map(|i| ["north", "south", "east", "west"][i % 4]).collect();
let active: Vec<bool> = (0..n).map(|i| i % 3 == 0).collect();
let names: Vec<String> = (0..n).map(|i| format!("item-{i:06}")).collect();
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("score", DataType::Float64, false),
Field::new("region", DataType::Utf8, false),
Field::new("active", DataType::Boolean, false),
Field::new("name", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(ids)),
Arc::new(Float64Array::from(scores)),
Arc::new(StringArray::from(regions)),
Arc::new(BooleanArray::from(active)),
Arc::new(StringArray::from(names)),
],
)
.unwrap()
}
/// A tiny 3-row batch with int + float + text for inline-editing tests.
fn edit_batch() -> RecordBatch {
RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("count", DataType::Int64, false),
Field::new("ratio", DataType::Float64, false),
Field::new("label", DataType::Utf8, false),
])),
vec![
Arc::new(Int64Array::from(vec![10, 20, 30])),
Arc::new(Float64Array::from(vec![1.5, 2.5, 3.5])),
Arc::new(StringArray::from(vec!["alpha", "beta", "gamma"])),
],
)
.unwrap()
}
#[test]
fn type_detection_maps_columns_correctly() {
let g = Grid::from_record_batch("big", &big_batch(100_000));
assert_eq!(g.data().nrows(), 100_000);
assert_eq!(g.data().kinds[0], CellKind::Int);
assert_eq!(g.data().kinds[1], CellKind::Float);
assert_eq!(g.data().kinds[2], CellKind::Enum, "4 distinct regions → enum chip");
assert_eq!(g.data().kinds[3], CellKind::Bool);
assert_eq!(g.data().kinds[4], CellKind::Text, "unique names stay text");
}
#[test]
fn sort_reorders_the_right_rows() {
let mut g = Grid::from_record_batch("big", &big_batch(10_000));
// sort by score descending → first visual row is the global max score row.
g.click_header(1, false); // asc
g.click_header(1, false); // desc
let order = g.order().to_vec();
let col = &g.data().columns[1];
let top = col.numeric(order[0]).unwrap();
// global max
let global_max = (0..g.data().nrows()).filter_map(|i| col.numeric(i)).fold(f64::MIN, f64::max);
assert_eq!(top, global_max, "desc sort puts the max score first");
// and it's monotonically non-increasing
for w in order.windows(2).take(500) {
assert!(col.numeric(w[0]).unwrap() >= col.numeric(w[1]).unwrap());
}
}
#[test]
fn multi_sort_region_then_score() {
let mut g = Grid::from_record_batch("big", &big_batch(2_000));
g.click_header(2, false); // region asc
g.click_header(1, true); // then score asc
let order = g.order().to_vec();
let region = &g.data().columns[2];
let score = &g.data().columns[1];
// within each region run, score must be non-decreasing.
let mut prev_region = g.data().cell_string(order[0], 2);
let mut prev_score = score.numeric(order[0]).unwrap();
for &src in &order[1..] {
let r = g.data().cell_string(src, 2);
let s = score.numeric(src).unwrap();
if r == prev_region {
assert!(s >= prev_score, "within {r}, score must be non-decreasing");
}
prev_region = r;
prev_score = s;
let _ = region; // touched for clarity
}
}
#[test]
fn visible_window_is_the_expected_slice() {
let mut g = Grid::from_record_batch("big", &big_batch(1_000_000));
// density Comfortable → 24px rows at scale 1.0.
assert_eq!(g.row_h(), 24.0);
// scroll to y=24000 in a 480px viewport: first = 24000/24 = 1000.
let win = g.row_window_at(24_000.0, 480.0);
// overscan is 2, so first = 998.
assert_eq!(win.first, 998);
// 480/24 = 20 + 1 + 2*2 overscan = 25 rows.
assert_eq!(win.count, 25);
assert!(win.contains(1000) && win.contains(1018));
// The key property: the window count is bounded by the viewport, not the
// million rows — O(visible).
assert!(win.count < 64, "window stays tiny regardless of 1M total rows");
}
#[test]
fn selection_to_tsv_is_exact() {
// A small, deterministic batch for an exact-string assertion.
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("name", DataType::Utf8, false),
Field::new("ver", DataType::Utf8, false),
Field::new("n", DataType::Int64, false),
])),
vec![
Arc::new(StringArray::from(vec!["knut", "korp", "skade"])),
Arc::new(StringArray::from(vec!["0.1", "0.2", "0.3"])),
Arc::new(Int64Array::from(vec![1, 2, 3])),
],
)
.unwrap();
let mut g = Grid::new("repos", arrow::grid_data_from_batch(&batch));
g.ensure_order();
// select name+ver for rows 0,1
g.state.selection.click(0, 0);
g.state.selection.extend_to(1, 1);
let tsv = g.copy().unwrap();
assert_eq!(tsv, "name\tver\nknut\t0.1\nkorp\t0.2");
}
#[test]
fn copy_follows_sort_order() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)])),
vec![Arc::new(Int64Array::from(vec![30, 10, 20]))],
)
.unwrap();
let mut g = Grid::new("nums", arrow::grid_data_from_batch(&batch));
g.click_header(0, false); // sort asc → 10,20,30
g.ensure_order();
g.state.selection.click(0, 0);
g.state.selection.extend_to(2, 0);
let tsv = g.copy().unwrap();
assert_eq!(tsv, "n\n10\n20\n30", "copy reflects the post-sort order");
}
#[test]
fn copy_all_when_nothing_selected() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)])),
vec![Arc::new(Int64Array::from(vec![1, 2]))],
)
.unwrap();
let mut g = Grid::new("t", arrow::grid_data_from_batch(&batch));
let tsv = g.copy().unwrap();
assert_eq!(tsv, "n\n1\n2");
}
#[test]
fn scale_changes_row_height() {
let mut g = Grid::from_record_batch("big", &big_batch(10));
let base = g.row_h();
g.set_scale(2.0);
assert_eq!(g.scale(), 2.0);
assert_eq!(g.row_h(), base * 2.0, "scale multiplies row height");
assert_eq!(g.state_json()["scale"], 2.0);
// `row_h()` is the one half of the knob this test could see. The other half — the
// cell FONT (`FontId::proportional(13.0 * scale)`) — is only reachable by rendering:
// deleting it from the painter left this assertion, and the rest of the crate,
// green. Measure the glyph ink the painter really emitted.
let ink = |s: f32| {
let mut g = Grid::from_record_batch("big", &big_batch(10));
g.set_scale(s);
facett_core::harness::headless_render(&mut g).textured_ink_area
};
let (small, big) = (ink(1.0), ink(2.0));
assert!(small > 0.0, "the grid draws text at 1.0x ({small})");
assert!(
big > small * 2.0,
"doubling the scale must roughly QUADRUPLE the glyph ink, got {small:.0} -> \
{big:.0} pt^2 ({:.2}x) — the row height scaled but the type did not.",
big / small,
);
// density also changes height
g.set_scale(1.0);
let mut g2 = Grid::from_record_batch("big", &big_batch(10)).with_density(Density::Compact);
assert!(g2.row_h() < g.row_h(), "compact rows are shorter");
let _ = g2.visible_rows();
}
#[test]
fn filter_narrows_visible_rows() {
let mut g = Grid::from_record_batch("big", &big_batch(1_000));
let all = g.visible_rows();
assert_eq!(all, 1_000);
g.set_filter("north");
let some = g.visible_rows();
// regions cycle north/south/east/west → ~1/4 contain "north".
assert_eq!(some, 250);
g.set_filter("");
assert_eq!(g.visible_rows(), 1_000, "clearing the filter restores all rows");
}
#[test]
fn caps_declare_the_flagship_surface() {
let g = Grid::from_record_batch("big", &big_batch(4));
let c = g.caps();
assert!(c.selectable && c.copyable && c.searchable && c.scalable && c.resizable && c.themeable);
assert!(!c.pasteable && !c.cuttable, "a read-only grid does not paste/cut");
assert!(!c.editable, "editable is opt-in via with_editable()");
}
#[test]
fn state_json_exposes_the_contract() {
let mut g = Grid::from_record_batch("big", &big_batch(50)).pin_columns(1).with_totals(true);
g.click_header(0, false);
g.select_cell(2, 1);
g.ensure_order();
let s = g.state_json();
assert_eq!(s["rows"], 50);
assert_eq!(s["cols"], 5);
assert_eq!(s["visible_rows"], 50);
assert_eq!(s["pinned"], 1);
assert_eq!(s["totals"], true);
assert_eq!(s["sort"][0]["col"], "id");
assert_eq!(s["sort"][0]["dir"], "asc");
assert_eq!(s["kinds"][2], "enum");
assert_eq!(s["selection"]["lead"], serde_json::json!([2, 1]));
}
#[test]
fn headless_render_draws_a_populated_grid() {
use facett_core::harness::headless_render;
let mut g = Grid::from_record_batch("big", &big_batch(5_000))
.pin_columns(1)
.with_totals(true)
.with_cond_fmt(1, CondFmt::DataBar);
let r = headless_render(&mut g);
assert!(r.drew(), "a populated grid must tessellate to vertices");
assert_eq!(r.state["rows"], 5_000);
}
#[test]
fn themed_render_works_for_every_palette() {
use facett_core::harness::render_themed;
// `drew()` cannot see colour, so on its own this test passed with theming DELETED
// outright (measured: dropping `set_theme` from `render_themed` left it green).
// The palette a render actually painted is the thing to assert: seven distinct
// themes must produce seven distinct inks, or they never reached the paint.
let mut sigs: Vec<(&str, u64)> = Vec::new();
for ctor in facett_core::Theme::ALL {
let mut g = Grid::from_record_batch("big", &big_batch(200)).with_cond_fmt(1, CondFmt::ColorScale);
let r = render_themed(&mut g, ctor());
assert!(r.drew(), "themed grid drew nothing");
assert!(!r.ink_colors.is_empty(), "{}: the grid painted no colours at all", ctor().name);
sigs.push((ctor().name, r.ink_signature()));
}
let distinct: std::collections::HashSet<u64> = sigs.iter().map(|(_, s)| *s).collect();
assert_eq!(
distinct.len(),
sigs.len(),
"every palette must paint a DIFFERENT grid, got {} distinct inks across {} themes \
({:?}). All-equal means the component never consumed the theme and is painting \
private defaults.",
distinct.len(),
sigs.len(),
sigs,
);
}
// ── D2h inline-editing tests ──────────────────────────────────────────────────
#[test]
fn edit_begin_loads_cell_value_into_buffer() {
// Select cell (row=0, col=0) which has value 10 (int).
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
g.select_cell(0, 0);
let started = g.begin_edit();
assert!(started, "begin_edit should succeed on an int cell");
assert!(g.edit_state().active);
assert_eq!(g.edit_state().row, 0);
assert_eq!(g.edit_state().col, 0);
// The buffer should be loaded with the raw cell value.
assert_eq!(g.edit_state().buffer, "10", "buffer preloaded with raw cell value");
assert!(g.edit_state().valid);
}
#[test]
fn edit_commit_int_cell_emits_grid_change() {
// select cell (row=0, col=0), trigger edit, type "42", commit → GridChange with new="42"
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
g.select_cell(0, 0);
g.begin_edit();
// Simulate user typing "42"
g.state.edit.buffer ="42".to_string();
let change = g.commit_edit();
assert!(change.is_some(), "commit of valid int should produce a GridChange");
let c = change.unwrap();
assert_eq!(c.row, 0);
assert_eq!(c.col, 0);
assert_eq!(c.old_value_str, "10", "old value was 10");
assert_eq!(c.new_value_str, "42", "new value is 42");
// The data should be updated.
assert_eq!(g.data().cell_raw(0, 0), "42", "underlying data updated");
// Edit state should be cleared.
assert!(!g.edit_state().active);
}
#[test]
fn edit_invalid_float_input_sets_valid_false_no_change() {
// type "abc" in a float col → valid=false, no GridChange
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
g.select_cell(0, 1); // col 1 = float
g.begin_edit();
assert_eq!(g.edit_state().buffer, "1.5", "buffer preloaded with 1.5");
// Simulate invalid input
g.state.edit.buffer ="abc".to_string();
let change = g.commit_edit();
assert!(change.is_none(), "invalid float input must not produce a GridChange");
assert!(!g.edit_state().valid, "valid flag should be false for invalid input");
// The underlying data must not change.
assert_eq!(g.data().cell_raw(0, 1), "1.5", "data unchanged on invalid input");
// Edit state stays active (not cleared) so user can correct.
assert!(g.edit_state().active, "edit stays active on invalid input");
}
#[test]
fn edit_escape_cancels_no_change() {
// Escape cancels → no GridChange, edit cleared.
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
g.select_cell(0, 2); // col 2 = text
g.begin_edit();
g.state.edit.buffer ="something_new".to_string();
g.cancel_edit();
assert!(!g.edit_state().active, "edit state cleared after cancel");
assert_eq!(g.data().cell_raw(0, 2), "alpha", "data unchanged after cancel");
}
#[test]
fn edit_text_cell_commit_produces_change() {
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
g.select_cell(1, 2); // row=1, col=2 (text), original = "beta"
g.begin_edit();
assert_eq!(g.edit_state().buffer, "beta");
g.state.edit.buffer ="modified".to_string();
let change = g.commit_edit().expect("text edit commit should succeed");
assert_eq!(change.old_value_str, "beta");
assert_eq!(change.new_value_str, "modified");
assert_eq!(g.data().cell_raw(1, 2), "modified");
}
#[test]
fn edit_caps_flag_is_set_when_editable() {
let g_no = Grid::from_record_batch("big", &big_batch(4));
assert!(!g_no.caps().editable, "not editable by default");
let g_yes = Grid::from_record_batch("big", &big_batch(4)).with_editable();
assert!(g_yes.caps().editable, "editable after with_editable()");
}
#[test]
fn state_json_editing_field_reflects_active_edit() {
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
// Idle state: nulls.
let s = g.state_json();
assert_eq!(s["editing"]["row"], serde_json::Value::Null);
assert_eq!(s["editing"]["col"], serde_json::Value::Null);
assert_eq!(s["editing"]["value"], "");
assert_eq!(s["editing"]["valid"], true);
// Active edit on row=0, col=0.
g.select_cell(0, 0);
g.begin_edit();
g.state.edit.buffer ="99".to_string();
let s2 = g.state_json();
assert_eq!(s2["editing"]["row"], 0);
assert_eq!(s2["editing"]["col"], 0);
assert_eq!(s2["editing"]["value"], "99");
assert_eq!(s2["editing"]["valid"], true);
}
#[test]
fn validate_input_covers_all_kinds() {
assert!(validate_input("42", CellKind::Int));
assert!(!validate_input("abc", CellKind::Int));
assert!(validate_input("3.14", CellKind::Float));
assert!(!validate_input("notanumber", CellKind::Float));
assert!(validate_input("true", CellKind::Bool));
assert!(validate_input("1", CellKind::Bool));
assert!(!validate_input("yes", CellKind::Bool));
// Text/Enum/Date accept anything.
assert!(validate_input("anything", CellKind::Text));
assert!(validate_input("2024-01-01", CellKind::Date));
assert!(validate_input("chip", CellKind::Enum));
}
#[test]
fn begin_edit_not_available_on_non_editable_grid() {
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch()));
// editable not enabled.
g.ensure_order();
g.select_cell(0, 0);
let started = g.begin_edit();
assert!(!started, "begin_edit returns false on non-editable grid");
assert!(!g.edit_state().active);
}
#[test]
fn gui_path_commit_is_observable_via_take_changes_and_state_json() {
// FINDING 1: drive the real GUI path (Facet::ui → draw, whose GridResponse the
// host discards) and assert a committed edit is observable through the PUBLIC
// surface (take_changes / last_change) + state_json — not just commit_edit.
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
g.select_cell(0, 0); // int cell, original = 10
g.begin_edit();
g.state.edit.buffer ="42".to_string(); // user typed 42 into the active editor
// Run egui frames through the Facet trait (the host-facing path). Frame 1
// places + focuses the editor; frame 2 injects Enter to commit. The editor
// row must be inside the visible window (row 0 is).
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0));
let render = |ctx: &egui::Context, g: &mut Grid, events: Vec<egui::Event>| {
let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
ctx.run_ui(input, |ui| {
egui::CentralPanel::default().show(ui, |ui| {
// Call the Facet contract method a real host uses — it discards the
// returned GridResponse, so observability must come from elsewhere.
<Grid as Facet>::ui(g, ui);
});
}).textures_delta.clear();
};
// Frame 1: lay out + focus the editor (no key yet).
render(&ctx, &mut g, vec![]);
// Frame 2: press Enter → commit.
render(
&ctx,
&mut g,
vec![egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
}],
);
// The data was applied through the GUI path (the committed edit reached the
// underlying GridData, not just the discarded GridResponse).
assert_eq!(g.data().cell_raw(0, 0), "42", "GUI-path edit updated the cell");
// Observable via state_json BEFORE draining.
let s = g.state_json();
assert_eq!(s["changes"], 1, "one committed change is visible in state_json");
assert_eq!(s["last_change"]["row"], 0);
assert_eq!(s["last_change"]["col"], 0);
assert_eq!(s["last_change"]["old"], "10");
assert_eq!(s["last_change"]["new"], "42");
// last_change peeks without draining; take_changes drains.
assert_eq!(g.last_change().map(|c| c.new_value_str.clone()), Some("42".to_string()));
let drained = g.take_changes();
assert_eq!(drained.len(), 1, "take_changes yields the committed change");
assert_eq!(drained[0], GridChange { row: 0, source_row: Some(0), col: 0, old_value_str: "10".into(), new_value_str: "42".into() });
// After draining, the observable surface is empty again.
assert!(g.take_changes().is_empty(), "changes drained");
let s2 = g.state_json();
assert_eq!(s2["changes"], 0);
assert_eq!(s2["last_change"], serde_json::Value::Null);
}
#[test]
fn edit_float_valid_commit_applies_change() {
let mut g = Grid::new("edit", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
g.select_cell(0, 1); // float col, original = 1.5
g.begin_edit();
g.state.edit.buffer ="9.99".to_string();
let change = g.commit_edit().expect("valid float commit should succeed");
assert_eq!(change.old_value_str, "1.5");
assert_eq!(change.new_value_str, "9.99");
// Verify numeric value updated.
let numeric = g.data().columns[1].numeric(0).unwrap();
assert!((numeric - 9.99).abs() < 1e-9, "float data updated in-place");
}
// ── §16 typed copy/paste + §12 keymap navigation + §6 zero-copy ───────────────
#[test]
fn copy_columns_are_zero_copy_arc_shared_with_the_source_batch() {
use std::sync::Arc as StdArc;
let batch = StdArc::new(big_batch(10_000));
// Track the source column's Arc pointer.
let src_ptr = StdArc::as_ptr(batch.column(1)) as *const ();
let mut g = Grid::from_record_batch_arc("z", batch);
g.ensure_order();
// Select the whole "score" column (col 1).
g.select_cell(0, 1);
let last = g.visible_rows() - 1;
g.extend_selection(last, 1);
let cols = g.copy_columns().expect("a batch-backed grid copies typed columns");
assert_eq!(cols.len(), 1, "one selected column");
assert_eq!(cols[0].name, "score");
// The copied array is the SAME buffer (Arc), not a re-materialised Vec.
assert_eq!(StdArc::as_ptr(&cols[0].array) as *const (), src_ptr, "column copy must be zero-copy (Arc-shared)");
assert_eq!(cols[0].array.len(), 10_000, "full column shared");
}
#[test]
fn copy_payload_prefers_typed_columns_over_text() {
let mut g = Grid::from_record_batch("p", &big_batch(100));
g.ensure_order();
g.select_cell(0, 0);
g.extend_selection(2, 1);
let payload = g.copy_payload().expect("payload");
// Richest representation is DataColumns (not Text), per copy_kinds order.
assert_eq!(payload.kind(), facett_core::clip::ClipKind::DataColumns);
assert_eq!(g.copy_kinds()[0], facett_core::clip::ClipKind::DataColumns);
}
#[test]
fn column_copied_from_one_grid_pastes_into_another_as_typed_columns() {
use facett_core::clip::{self, ClipKind, ClipPayload};
clip::clear_typed();
// Source grid: copy a typed column.
let src = Grid::from_record_batch("src", &big_batch(50));
let cols = src.copy_columns().unwrap();
clip::put_typed_inproc(ClipPayload::DataColumns(cols));
// A consumer that accepts typed columns receives them un-stringified.
struct ColSink(Option<usize>);
impl clip::PasteTarget for ColSink {
fn accepts(&self, k: ClipKind) -> bool { matches!(k, ClipKind::DataColumns | ClipKind::Text) }
fn paste_payload(&mut self, p: &ClipPayload) {
if let ClipPayload::DataColumns(c) = p { self.0 = Some(c.len()); }
}
}
let mut sink = ColSink(None);
assert!(clip::paste_into(&mut sink));
assert_eq!(sink.0, Some(5), "the 5 batch columns transferred typed, zero-copy");
clip::clear_typed();
}
#[test]
fn editable_grid_pastes_tsv_into_the_selection() {
let mut g = Grid::new("paste", arrow::grid_data_from_batch(&edit_batch())).with_editable();
g.ensure_order();
g.select_cell(0, 0); // int col
// Paste a 2x1 TSV block at the lead.
assert!(g.paste("99\n100"));
assert_eq!(g.data().columns[0].numeric(0), Some(99.0));
assert_eq!(g.data().columns[0].numeric(1), Some(100.0));
// The committed changes are observable.
assert_eq!(g.take_changes().len(), 2);
}
#[test]
fn non_editable_grid_rejects_paste() {
let mut g = Grid::from_record_batch("ro", &big_batch(10));
assert!(!g.paste("x"), "read-only grid must not ingest a paste");
assert!(!g.accepts(facett_core::clip::ClipKind::Text));
}
#[test]
fn arrow_down_via_keymap_moves_the_active_cell() {
use facett_core::look::{Action, KeyMap, publish_keymap};
let mut g = Grid::from_record_batch("nav", &big_batch(20));
g.ensure_order();
g.select_cell(0, 0);
let ctx = egui::Context::default();
publish_keymap(&ctx, KeyMap::windows());
// Inject an ArrowDown press (the chord bound to Action::Down).
let sc = KeyMap::windows().shortcut(Action::Down).unwrap();
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0))),
events: vec![egui::Event::ModifiersChanged(sc.modifiers), egui::Event::Key {
key: sc.logical_key,
physical_key: None,
pressed: true,
repeat: false,
modifiers: sc.modifiers,
}],
..Default::default()
};
ctx.run_ui(input, |ui| {
egui::CentralPanel::default().show(ui, |ui| {
// Re-publish inside the frame (data is per-context, persists, but be safe).
publish_keymap(ui.ctx(), KeyMap::windows());
g.ui(ui);
});
}).textures_delta.clear();
// The lead moved from row 0 to row 1 (ArrowDown), same column.
assert_eq!(g.selection().lead, Some((1, 0)), "ArrowDown resolved via KeyMap moved the active cell down");
}
// ── FC-2 → FC-3 as a *headless* property: feed a `Vec<Msg>` through the core
// harness (no egui, no GPU) and assert the resulting `GridState`. ────────────
#[test]
fn harness_drives_sort_filter_density_and_snapshots_state() {
use facett_core::harness;
let mut g = Grid::from_record_batch("big", &big_batch(1_000));
// Drive the interaction surface through Msgs, then snapshot the observable Model.
let snap = harness::snapshot(
&mut g,
[
Msg::ClickHeader { col: 1, additive: false }, // sort score asc
Msg::ClickHeader { col: 2, additive: true }, // + region asc (multi-key)
Msg::SetFilter("north".into()),
Msg::CycleDensity, // Comfortable → Spacious
Msg::SetTotals(true),
Msg::SetScale(1.5),
],
);
assert_eq!(snap.sort.keys.len(), 2, "two sort keys applied via Msg");
assert_eq!(snap.sort.keys[0].col, 1);
assert_eq!(snap.filter, "north");
assert_eq!(snap.density, Density::Spacious);
assert!(snap.show_totals);
assert_eq!(snap.scale, 1.5);
// The filter really narrowed the derived (non-Model) row cache too.
assert_eq!(g.visible_rows(), 250, "~1/4 of rows contain 'north'");
assert_eq!(&snap, g.state(), "the snapshot is a clone of the live state");
}
#[test]
fn drive_reports_no_effects_and_state_round_trips_through_serde() {
use facett_core::harness;
let mut g = Grid::from_record_batch("big", &big_batch(100));
// FC-8: the grid does no I/O, so the effect stream is always empty.
let effects = harness::drive(
&mut g,
[Msg::ClickHeader { col: 0, additive: false }, Msg::SelectCell { row: 3, col: 1 }, Msg::SetTotals(true)],
);
assert!(effects.is_empty(), "FC-8: grid emits no Effects");
// FC-3: the observable Model round-trips through serde.
let json = serde_json::to_value(g.state()).unwrap();
let back: GridState = serde_json::from_value(json).unwrap();
assert_eq!(&back, g.state(), "serde(state) -> state round-trips");
}
#[test]
fn select_and_clear_selection_are_observable_headlessly() {
use facett_core::harness;
let mut g = Grid::from_record_batch("big", &big_batch(20));
// Select a cell by its stable source-row id.
let snap = harness::snapshot(&mut g, [Msg::SelectCell { row: 7, col: 2 }]);
assert_eq!(snap.active, Some((7, 2)), "active cell observable as a stable id");
assert_eq!(snap.selection.lead, Some((7, 2)), "identity order → visual == source");
// Clearing wipes both the visual selection and the stable identity.
let snap = harness::snapshot(&mut g, [Msg::ClearSelection]);
assert!(snap.selection.is_empty());
assert_eq!(snap.active, None);
}
// ── FC-5: selection keys on a STABLE id, not a volatile visual index ───────────
#[test]
fn fc5_active_cell_follows_the_data_row_across_a_resort() {
use facett_core::harness;
// score column (col 1) is NOT monotonic in source order, so sorting by it moves
// rows around — the perfect test that selection tracks identity, not position.
let mut g = Grid::from_record_batch("big", &big_batch(1_000));
// Select source row 500 (a stable id) in the score column.
let _ = g.update(Msg::SelectCell { row: 500, col: 1 });
let before = g.state().clone();
assert_eq!(before.active, Some((500, 1)));
let visual_before = before.selection.lead.unwrap().0;
// Re-sort by score descending (two clicks: asc then desc). The source row 500's
// visual position changes, but its STABLE id must not.
let after = harness::snapshot(
&mut g,
[Msg::ClickHeader { col: 1, additive: false }, Msg::ClickHeader { col: 1, additive: false }],
);
// FC-5 invariant: the active cell's stable source id is unchanged…
assert_eq!(after.active, Some((500, 1)), "the active source-row id is invariant across a re-sort");
// …and the highlight followed it to its NEW visual position, which really moved.
let visual_after = after.selection.lead.unwrap().0;
assert_eq!(g.order()[visual_after], 500, "the visual lead now points at source row 500");
assert_ne!(visual_after, visual_before, "the volatile visual index genuinely changed under the sort");
}
#[test]
fn fc5_headless_select_matches_the_gui_click_transition() {
// The canvas click and the headless Msg drive the IDENTICAL transition: both
// funnel through `update(Msg::SelectCell{ source_id, col })`.
let mut g = Grid::from_record_batch("big", &big_batch(50));
g.click_header(0, false); // id asc = identity order
let _ = g.update(Msg::SelectCell { row: 12, col: 3 });
assert_eq!(g.selection().lead, Some((12, 3)));
assert_eq!(g.state().active, Some((12, 3)));
// The FC-5 stable id is also published in state_json (additive, derived).
let s = <Grid as Facet>::state_json(&g);
assert_eq!(s["selection"]["active"], serde_json::json!([12, 3]));
assert_eq!(s["selection"]["lead"], serde_json::json!([12, 3]));
}
#[test]
fn totals_follow_filters_edits_and_paste() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)])),
vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
)
.unwrap();
let mut g = Grid::from_record_batch("sums", &batch).with_editable();
assert_eq!(g.column_sum(0), Some(6.0));
assert_eq!(g.column_sum(0), Some(6.0));
g.set_filter("2");
assert_eq!(g.column_sum(0), Some(2.0));
g.select_cell(0, 0);
assert!(g.begin_edit());
g.update(Msg::SetEditBuffer("20".into()));
g.update(Msg::CommitEdit);
assert_eq!(g.column_sum(0), Some(20.0));
assert_eq!(g.ranges[&0], (1.0, 20.0));
assert!(
g.copy_columns().is_none(),
"edited data must not copy the old Arrow values"
);
assert!(matches!(g.copy_payload(), Some(ClipPayload::Rows(s)) if s.contains("20")));
g.set_filter("");
assert_eq!(g.column_sum(0), Some(24.0));
g.select_cell(0, 0);
g.update(Msg::PasteTsv("7\n8".into()));
assert_eq!(g.column_sum(0), Some(18.0));
g.set_filter("missing");
assert_eq!(g.column_sum(0), None);
assert!(
matches!(g.copy_payload(), Some(ClipPayload::Rows(s)) if s == "n"),
"an empty filtered grid copies only its header"
);
}
#[test]
fn filter_reuses_sort_and_obsolete_searches_cannot_publish() {
let mut g = Grid::from_record_batch("search", &big_batch(50_000));
g.click_header(1, false);
let sorted = Arc::clone(&g.sorted);
g.update(Msg::SetFilter("north".into()));
assert!(g.filter_pending());
let old_generation = g.filter_generation;
g.update(Msg::ContinueFilter {
generation: old_generation,
});
g.update(Msg::SetFilter("SOUTH".into()));
g.update(Msg::ContinueFilter {
generation: old_generation,
});
assert_eq!(g.pending_filter.as_ref().unwrap().cursor, 0);
assert!(
Arc::ptr_eq(&sorted, &g.sorted),
"filtering must not rerun sorting"
);
g.finish_filter();
assert_eq!(g.order.len(), 12_500);
assert!(
g.order
.iter()
.all(|&row| g.data.cell_raw(row, 2).eq_ignore_ascii_case("south"))
);
assert!(
g.order
.windows(2)
.all(|rows| g.data.columns[1].numeric(rows[0]) <= g.data.columns[1].numeric(rows[1]))
);
g.update(Msg::SetFilter("north".into()));
let cancelled = g.filter_generation;
g.update(Msg::ClearSort);
g.update(Msg::ContinueFilter {
generation: cancelled,
});
g.finish_filter();
assert!(g.order.windows(2).all(|rows| rows[0] < rows[1]));
g.set_filter(" ");
assert!(!g.filter_pending());
assert!(Arc::ptr_eq(&g.sorted, &g.order));
}
#[test]
fn unsigned_values_sort_and_edit_without_losing_precision() {
use arrow_array::UInt64Array;
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("n", DataType::UInt64, true)])),
vec![Arc::new(UInt64Array::from(vec![
Some(u64::MAX),
None,
Some(u64::MAX - 1),
]))],
)
.unwrap();
let mut g = Grid::from_record_batch("uint", &batch).with_editable();
assert_eq!(g.data.cell_raw(0, 0), u64::MAX.to_string());
g.click_header(0, false);
assert_eq!(g.order(), &[2, 0, 1]);
g.click_header(0, false);
assert_eq!(g.order(), &[0, 2, 1]);
g.select_cell(0, 0);
assert!(g.begin_edit());
g.update(Msg::SetEditBuffer("-1".into()));
g.update(Msg::CommitEdit);
assert!(g.state.edit.active);
assert!(!g.state.edit.valid);
g.update(Msg::SetEditBuffer((u64::MAX - 2).to_string()));
g.update(Msg::CommitEdit);
assert_eq!(g.data.cell_raw(0, 0), (u64::MAX - 2).to_string());
assert_eq!(g.order(), &[2, 0, 1]);
assert_eq!(
g.state.selection.lead,
Some((1, 0)),
"selection follows the edited source row"
);
}
#[test]
fn an_edit_restarts_a_pending_filter_against_the_new_data() {
let mut g = Grid::from_record_batch("search", &big_batch(20_000)).with_editable();
g.update(Msg::SetFilter("unique-result".into()));
let generation = g.filter_generation;
g.update(Msg::ContinueFilter { generation });
g.select_cell(0, 4);
assert!(g.begin_edit());
g.update(Msg::SetEditBuffer("unique-result".into()));
g.update(Msg::CommitEdit);
g.update(Msg::ContinueFilter { generation });
assert_eq!(g.order(), &[0]);
}
#[test]
fn editing_and_pasting_preserve_selection_when_rows_do_not_move() {
let mut g = Grid::from_record_batch("edit", &big_batch(10)).with_editable();
g.select_cell(0, 0);
g.extend_selection(2, 1);
let selection = g.selection().clone();
assert!(g.begin_edit());
g.update(Msg::SetEditBuffer("42".into()));
g.update(Msg::CommitEdit);
assert_eq!(g.selection(), &selection);
g.update(Msg::PasteTsv("43\n44".into()));
assert_eq!(g.selection(), &selection);
}
#[test]
fn wide_tables_filter_incrementally_even_with_few_rows() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(
(0..80)
.map(|i| Field::new(format!("c{i}"), DataType::Utf8, false))
.collect::<Vec<_>>(),
)),
vec![Arc::new(StringArray::from(vec!["value"; 1000])) as arrow_array::ArrayRef; 80],
)
.unwrap();
let mut g = Grid::from_record_batch("wide", &batch);
g.update(Msg::SetFilter("missing".into()));
assert!(g.filter_pending());
g.update(Msg::ContinueFilter {
generation: g.filter_generation,
});
assert!(g.filter_pending());
g.update(Msg::ContinueFilter {
generation: g.filter_generation,
});
assert!(!g.filter_pending());
assert!(g.order.is_empty());
}
#[test]
fn pending_filter_does_not_leave_stale_totals_after_an_edit() {
let mut g = Grid::from_record_batch("edit", &big_batch(20_000)).with_editable();
let before = g.column_sum(0).unwrap();
g.update(Msg::SetFilter("missing".into()));
g.select_cell(0, 0);
assert!(g.begin_edit());
g.update(Msg::SetEditBuffer("42".into()));
g.update(Msg::CommitEdit);
assert!(g.filter_pending());
assert_eq!(g.column_sum(0), Some(before + 42.0));
g.finish_filter();
assert_eq!(g.column_sum(0), None);
}
#[test]
fn edit_events_keep_source_identity_after_resorting_or_filtering() {
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![Field::new("n", DataType::Int64, false)])),
vec![Arc::new(Int64Array::from(vec![1, 2]))],
)
.unwrap();
let mut g = Grid::from_record_batch("edit", &batch).with_editable();
g.click_header(0, false);
g.select_cell(0, 0);
assert!(g.begin_edit());
g.update(Msg::SetEditBuffer("3".into()));
g.update(Msg::CommitEdit);
assert_eq!(g.order(), &[1, 0]);
let change = g.take_changes().pop().unwrap();
assert_eq!(change.row, 0);
assert_eq!(change.source_row, Some(0));
assert_eq!(change.to_json()["source_row"], 0);
g.set_filter("3");
g.select_cell(0, 0);
g.update(Msg::PasteTsv("4".into()));
assert!(g.order().is_empty());
assert_eq!(g.take_changes()[0].source_row, Some(0));
let legacy: GridChange = serde_json::from_value(serde_json::json!({
"row": 1, "col": 0, "old_value_str": "1", "new_value_str": "2"
}))
.unwrap();
assert_eq!(legacy.source_row, None);
}
/// `grid_bench 100000`'s columns that the Σ footer sums: `id` (Int64) and `amount`
/// (Float64).
fn bench_numbers(n: usize) -> (Vec<i64>, Vec<f64>, RecordBatch) {
let ids: Vec<i64> = (0..n as i64).collect();
let amounts: Vec<f64> = (0..n).map(|i| ((i * 37) % 10_000) as f64 / 10.0).collect();
let batch = RecordBatch::try_new(
Arc::new(Schema::new(vec![
Field::new("id", DataType::Int64, false),
Field::new("amount", DataType::Float64, false),
])),
vec![Arc::new(Int64Array::from(ids.clone())), Arc::new(Float64Array::from(amounts.clone()))],
)
.unwrap();
(ids, amounts, batch)
}
/// PR #2's first frame cost 1.3 ms more than before (`grid_bench 100000` on loki,
/// 3.20 -> 4.54 ms), and all of it is the Σ footer: the first frame sums every
/// numeric column (later frames hit the cache), and PR #2 read each cell through
/// `Column::numeric` -> `ArrowColumn::value` (an edit-map probe, a bounds check and
/// a variant match per cell) where the pre-PR sum indexed a materialised `Vec`.
/// With totals off, PR #2's first frame is no slower than before.
///
/// Held to the pre-PR sum, measured in the same run so box load cancels: cold sums
/// (cache cleared) of both columns against the pre-PR `column_sum` loop over the
/// columns the pre-PR grid materialised, best of `ROUNDS` interleaved. Both loops
/// end up bound by the latency of the dependent float add, so a fixed sum equals
/// the pre-PR one (measured 0.75-1.00x on dwarf at load 15-31) and the budget
/// leaves room for scheduler noise; PR #2's per-cell reads measured 4-16x.
#[test]
fn first_frame_totals_cost_no_more_than_the_pre_pr_sum() {
const N: usize = 100_000;
const ROUNDS: usize = 21;
const BUDGET: f64 = 1.5;
/// `Column::is_null` + `numeric` and the `column_sum` loop, copied from
/// `facett-grid/src/{data,lib}.rs` at `2478f9d1`.
#[allow(dead_code)] // every variant, so `numeric` matches as it did
enum PrePr {
Int { v: Vec<i64>, nulls: Vec<bool> },
Float { v: Vec<f64>, nulls: Vec<bool> },
Text { v: Vec<String>, nulls: Vec<bool> },
Bool { v: Vec<bool>, nulls: Vec<bool> },
Date { v: Vec<String>, nulls: Vec<bool> },
}
impl PrePr {
fn is_null(&self, i: usize) -> bool {
let nulls = match self {
PrePr::Int { nulls, .. }
| PrePr::Float { nulls, .. }
| PrePr::Text { nulls, .. }
| PrePr::Bool { nulls, .. }
| PrePr::Date { nulls, .. } => nulls,
};
nulls.get(i).copied().unwrap_or(false)
}
fn numeric(&self, i: usize) -> Option<f64> {
if self.is_null(i) {
return None;
}
match self {
PrePr::Int { v, .. } => v.get(i).map(|&x| x as f64),
PrePr::Float { v, .. } => v.get(i).copied(),
PrePr::Bool { v, .. } => v.get(i).map(|&b| if b { 1.0 } else { 0.0 }),
_ => None,
}
}
fn column_sum(&self, order: &[usize]) -> Option<f64> {
let mut acc = 0.0;
let mut any = false;
for &src in order {
if let Some(x) = self.numeric(src) {
acc += x;
any = true;
}
}
any.then_some(acc)
}
}
let (ids, amounts, batch) = bench_numbers(N);
let pre_pr = [PrePr::Int { v: ids, nulls: vec![false; N] }, PrePr::Float { v: amounts, nulls: vec![false; N] }];
let g = Grid::from_record_batch("totals", &batch).with_totals(true);
let ms = |t: std::time::Instant| t.elapsed().as_secs_f64() * 1000.0;
let (mut grid_ms, mut ref_ms) = (f64::INFINITY, f64::INFINITY);
for _ in 0..ROUNDS {
g.sums.borrow_mut().clear();
let t = std::time::Instant::now();
let got = (std::hint::black_box(g.column_sum(0)), std::hint::black_box(g.column_sum(1)));
grid_ms = grid_ms.min(ms(t));
let t = std::time::Instant::now();
let want = (
std::hint::black_box(pre_pr[0].column_sum(&g.order)),
std::hint::black_box(pre_pr[1].column_sum(&g.order)),
);
ref_ms = ref_ms.min(ms(t));
assert_eq!(got, want, "the footer must show the pre-PR totals");
}
let ratio = grid_ms / ref_ms;
eprintln!("first-frame totals, {N} rows x 2 columns, best of {ROUNDS}: grid {grid_ms:.3} ms, pre-PR #2 {ref_ms:.3} ms ({ratio:.2}x)");
if cfg!(debug_assertions) {
// Unoptimised, the per-cell path and a fold over the column cost about
// the same (both ~3x the pre-PR loop, measured), so time is no verdict
// here; `sorting_and_totals_read_no_cell_one_at_a_time` holds the cause in
// every profile. This budget is for `cargo test --release`.
return;
}
assert!(
ratio <= BUDGET,
"the first frame's Σ footer costs {ratio:.2}x the pre-PR sum (grid {grid_ms:.3} ms vs {ref_ms:.3} ms); budget {BUDGET}x"
);
}
/// Both regressions had one cause: whole-column work read its cells one at a time
/// through `ArrowColumn::value` (the sort twice per comparison, the Σ footer once
/// per row). Counted, not timed, so this holds in every build profile: a sort and
/// the footer read no cell that way, except each edited cell, once.
#[test]
fn sorting_and_totals_read_no_cell_one_at_a_time() {
use crate::arrow::VALUE_READS;
let reads = || VALUE_READS.with(std::cell::Cell::get);
let (_, _, batch) = bench_numbers(100_000);
let mut g = Grid::from_record_batch("reads", &batch).with_totals(true);
let before = reads();
let sums = (g.column_sum(0), g.column_sum(1));
assert_eq!(reads() - before, 0, "the Σ footer read cells one at a time");
let before = reads();
g.click_header(1, false);
g.click_header(1, true);
g.click_header(0, true);
assert_eq!(reads() - before, 0, "the sort read cells one at a time");
assert!(sums.1.is_some());
let edited = [7, 50_000, 99_999];
for &row in &edited {
let Column::Arrow(c) = &mut g.data.columns[1] else { panic!("shared Arrow column") };
c.edit(row, "-1.5");
}
let before = reads();
let order = sort::sorted_order(&g.data, &SortSpec { keys: vec![SortKey { col: 1, dir: Dir::Asc }] });
assert_eq!(reads() - before, edited.len(), "the sort reads each edited cell once");
assert_eq!(order[..3], edited, "the edited rows sort first (-1.5), in source order");
g.sums.borrow_mut().clear();
let before = reads();
let _ = g.column_sum(1);
assert_eq!(reads() - before, edited.len(), "the Σ footer reads each edited cell once");
}
#[test]
fn the_totals_footer_does_not_change_when_the_grid_is_resorted() {
// Float addition is not associative: summed in the visual order, a re-sort
// could move the last digits of Σ although no visible row changed.
let (_, _, batch) = bench_numbers(100_000);
let mut g = Grid::from_record_batch("totals", &batch).with_totals(true);
let unsorted = (g.column_sum(0), g.column_sum(1));
for _ in 0..2 {
g.click_header(1, false);
assert_eq!((g.column_sum(0), g.column_sum(1)), unsorted, "sorted {:?}", g.sort_spec());
}
g.set_filter("9");
let filtered = g.column_sum(1);
g.click_header(1, false);
assert_eq!(g.column_sum(1), filtered, "a re-sort of a filtered grid keeps its Σ too");
}