Files
improvise/tests/records_wide_smoke.rs
T
Edward Langley 6a929c1d21 feat(records): a +cat column, so a schema can be built without leaving the grid
improvise-2zq step 2, on the user's ruling. Step 1 made this coherent by
stopping `on_category_added` from ejecting the user; this adds the column.

Typing a name into `+cat` creates that category, and because
`GridLayout::records_columns` filters on `kind.is_regular()` and never
looks at items or at an axis, the coordinate column appears immediately
and is typeable on the next keystroke.

**The two questions the ruling left open, and the answers.**

*Where the column goes.* Not a second trailing column, and not one `+`
disambiguated by a prefix. A prefix would put a sigil into the name space
— and a category and a measure may legitimately share a name, which is
the exact ambiguity `_Dim` already carries a column *index* rather than a
label to avoid (gotcha 1). A key was rejected too: the name still has to
be typed somewhere, and a minibuffer lands in the records-exit class bug
this PR characterizes but does not fix.

So each `+` sits adjacent to what it adds to — `[coords…, +cat,
measures…, +]`. Position tells the two apart instead of a label, which is
the clutter the ticket was worried about. It costs a mid-grid column
index, and the consequence worth naming is that a **blank** records grid
now opens with the cursor on `+cat` rather than `+`: a record needs
coordinates before it needs measures, so that order is defensible, but it
does change what the first keystroke in an empty grid does.

*Whether committing also sets the coordinate on the current row.* It does
not. It is symmetric with the measure `+`, which creates an empty column
and writes no value; and setting it would move **every** measure cell in
the row onto the new coordinate (gotcha 1) — a data migration across N
cells attached to the same Enter that made a schema change, in an app
with no undo. The follow-up edit is one column away.

**The refusal is visible, which took a change of order.** `MAX_CATEGORIES`
is reachable here rather than theoretical. `AddCategory` now aborts the
batch on refusal — same reason a failed save does (gotcha 21): what
follows assumes the category exists, and the trailing `EnterEditAtCursor`
would re-open the minibuffer *over* the message, since `draw_bottom_bar`
renders the minibuffer instead of the status line. And
`CommitAndAdvance` now emits its mode change **before** the commit rather
than after, so an abort cannot eat it. That is the third time this PR has
applied one rule — tidy-up goes where an abort cannot reach it — and the
comment at that site had already described the pattern for the *advance*
failing; the commit failing was simply not considered. The limit itself
is still checked in exactly one place, `Model::add_category`.

Evidence. Three new tests drive real keys:
`the_new_category_column_creates_a_category_and_its_column` (the column
is usable immediately), `..._writes_no_coordinate` (the deliberate
answer to question 2, asserted over every row), and
`..._refuses_past_the_limit_where_the_user_can_see_it`, which asserts the
mode has **no minibuffer** — proved discriminating by swapping the commit
back in front of the mode change and watching it report
"Maximum of 12 categories reached" from inside `RecordsEditing`, where
nothing would have drawn it.

Four existing characterization tests were updated knowingly, not
incidentally: the shared records fixture's column list (which accounted
for 8 of the 9 initial failures on its own), the core layout and
persistence smoke expectations, and the blank-model bootstrap, which now
has to step to the `+` column and so records the cursor-position
consequence above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:10:33 -07:00

68 lines
2.4 KiB
Rust

//! End-to-end check that a real imported model renders records mode in wide
//! format: CSV → import → save → load → records grid (improvise-c5v).
use improvise::import::wizard::ImportPipeline;
use improvise::persistence;
use improvise::view::{Axis, GridLayout};
/// Import a two-record CSV, round-trip it through `.improv`, and read the
/// records grid off the loaded model.
#[test]
fn imported_model_shows_one_row_per_record_with_measure_columns() {
let dir = tempfile::tempdir().expect("tempdir");
let csv = dir.path().join("w.csv");
std::fs::write(
&csv,
"region,product,revenue,cost\nEast,Shirts,100,40\nWest,Pants,200,80\n",
)
.expect("write csv");
let records = improvise::import::csv_parser::parse_csv(&csv).expect("csv parses");
let pipeline = ImportPipeline::new(serde_json::Value::Array(records));
let workbook = pipeline.build_model().expect("model builds");
let saved = dir.path().join("w.improv");
persistence::save(&workbook, &saved).expect("saves");
let mut wb = persistence::load(&saved).expect("loads");
let v = wb.active_view_mut();
v.set_axis("_Index", Axis::Row);
v.set_axis("_Dim", Axis::Column);
for cat in ["region", "product", "_Measure"] {
v.set_axis(cat, Axis::None);
}
let layout = GridLayout::new(&wb.model, wb.active_view());
assert!(layout.is_records_mode());
let cols: Vec<String> = (0..layout.col_count())
.map(|i| layout.col_label(i))
.collect();
assert_eq!(
cols,
vec!["product", "region", "+cat", "cost", "revenue", "+"],
"categories, the new-category column, one column per measure, then \
the new-measure column — each `+` sits next to what it adds to \
(improvise-2zq)"
);
assert_eq!(layout.row_count(), 2, "two source records, not four cells");
// Row order follows the store; the contract under test is that each
// source record is one row carrying both its measures side by side.
let mut rows: Vec<Vec<String>> = (0..layout.row_count())
.map(|r| {
(0..layout.col_count())
.map(|c| layout.records_display(r, c).unwrap())
.collect()
})
.collect();
rows.sort();
assert_eq!(
rows,
vec![
vec!["Pants", "West", "", "80", "200", ""],
vec!["Shirts", "East", "", "40", "100", ""],
],
"got {rows:?}"
);
}