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

nordisk/facett

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