d00cea2477
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.
Two halves, per the user's ruling:
* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
noise on whole numbers is the cheaper error — a currency column with two
redundant zeroes is legible, a ratio with none is wrong — and a format cannot
be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
keeps the view's. `formula.pest` gains
`format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
reason (a predicate is invisible to the grammar-walking generator) and
`FORMAT` added to `quote::KEYWORDS`.
`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.
Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.
Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.
The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
391 lines
14 KiB
Rust
391 lines
14 KiB
Rust
//! improvise-1ey's stated scope, asserted end to end without a browser: a real
|
|
//! `Host` and a real `Client`. See `context/test-evidence/clients.md`.
|
|
|
|
use improvise_command::ui::app::App;
|
|
use improvise_core::model::cell::{CellKey, CellValue};
|
|
use improvise_core::view::Axis;
|
|
use improvise_core::workbook::Workbook;
|
|
use improvise_protocol::ClientFrame;
|
|
use improvise_wasm_client::render::RenderRow;
|
|
use improvise_wasm_client::{Client, RenderModel};
|
|
use improvise_ws_server::host::{Addressed, Host};
|
|
|
|
/// The document the server opens: two regions by two measures, one cell
|
|
/// already filled so the client has something to read before it writes.
|
|
fn host() -> Host {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item("Region", "North").unwrap();
|
|
wb.add_item("Region", "South").unwrap();
|
|
wb.add_item("_Measure", "Revenue").unwrap();
|
|
wb.add_item("_Measure", "Cost").unwrap();
|
|
wb.active_view_mut().set_axis("Region", Axis::Row);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
wb.model.set_cell(
|
|
CellKey::new(vec![
|
|
("Region".into(), "North".into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
]),
|
|
CellValue::Number(1200.0),
|
|
);
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
Host::new(app)
|
|
}
|
|
|
|
/// The page's own loop, minus the DOM: every frame the host addressed to
|
|
/// this session, in order, through the one call that folds them.
|
|
fn deliver(client: &mut Client, frames: &[Addressed]) {
|
|
for Addressed { msg, .. } in frames {
|
|
client
|
|
.on_frame(&serde_json::to_string(msg).expect("a server frame serializes"))
|
|
.expect("the client must fold every frame the host sends");
|
|
}
|
|
}
|
|
|
|
/// The one frame the page sends that is not a command: the handshake.
|
|
fn send(
|
|
host: &mut Host,
|
|
id: improvise_command::ui::project::SessionId,
|
|
json: &str,
|
|
) -> Vec<Addressed> {
|
|
let msg: ClientFrame =
|
|
serde_json::from_str(json).expect("the server must be able to read what the page sends");
|
|
host.handle(id, msg)
|
|
}
|
|
|
|
/// Where the first settable cell is, in **both** coordinate systems — which
|
|
/// are two numbers because they differ. See `context/test-evidence/clients.md`.
|
|
struct FirstEditable {
|
|
/// Indexes `RenderModel::rows`.
|
|
visual_row: usize,
|
|
/// Indexes the grid the server projects — what a command names.
|
|
data_row: usize,
|
|
col: usize,
|
|
}
|
|
|
|
/// Every settable cell the model offers, first per row, in both systems.
|
|
fn editable_cells(model: &RenderModel) -> Vec<FirstEditable> {
|
|
let mut data_row = 0usize;
|
|
let mut found = Vec::new();
|
|
for (visual_row, row) in model.rows.iter().enumerate() {
|
|
let RenderRow::Data { cells, .. } = row else {
|
|
continue;
|
|
};
|
|
if let Some(col) = cells.iter().position(|cell| cell.editable) {
|
|
found.push(FirstEditable {
|
|
visual_row,
|
|
data_row,
|
|
col,
|
|
});
|
|
}
|
|
data_row += 1;
|
|
}
|
|
found
|
|
}
|
|
|
|
fn first_editable(model: &RenderModel) -> FirstEditable {
|
|
editable_cells(model)
|
|
.into_iter()
|
|
.next()
|
|
.expect("a pivot of raw data must offer at least one settable cell")
|
|
}
|
|
|
|
#[test]
|
|
fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
|
|
let mut host = host();
|
|
let mut client = Client::new();
|
|
|
|
// The handshake. The extent comes first because the snapshot seeds the
|
|
// *visible* cells: one built at extent zero is an empty grid.
|
|
let handshake: ClientFrame = serde_json::from_str(&client.set_viewport(10, 10))
|
|
.expect("the viewport frame must be one the server reads");
|
|
let ClientFrame::Viewport { rows, cols } = handshake else {
|
|
panic!("set_viewport must produce a Viewport frame");
|
|
};
|
|
let (id, snapshot) = host.connect(rows, cols);
|
|
deliver(&mut client, &[snapshot]);
|
|
|
|
// ── what the page draws ─────────────────────────────────────────────
|
|
let model = client.model();
|
|
assert_eq!(
|
|
model
|
|
.rows
|
|
.iter()
|
|
.flat_map(|r| r.labels())
|
|
.collect::<Vec<_>>(),
|
|
vec!["North", "South"],
|
|
"the seed must carry the row labels framing the cells"
|
|
);
|
|
assert_eq!(model.col_levels[0], vec!["Revenue", "Cost"]);
|
|
assert_eq!(
|
|
model.rows[0].cells()[0].text,
|
|
"1,200.00",
|
|
"the value arrives *formatted* — the separator is the server's view \
|
|
format, and a client that re-derived the text would not have it"
|
|
);
|
|
assert!(model.rows[0].cells()[0].numeric);
|
|
|
|
// ── typing a number ─────────────────────────────────────────────────
|
|
let at = first_editable(&model);
|
|
assert_eq!(
|
|
(at.data_row, at.visual_row, at.col),
|
|
(0, 0, 0),
|
|
"with no category on the None axis every cell is a stored cell, and \
|
|
this fixture has no group headers, so the two row coordinates \
|
|
coincide here — see a_grouped_row_axis_does_not_shift_the_write"
|
|
);
|
|
let frame = client
|
|
.typed_cell(at.data_row, at.col, "7500")
|
|
.expect("serialization must not fail")
|
|
.expect("an editable cell must compose a write");
|
|
let frames = send(&mut host, id, &frame);
|
|
|
|
// ── the server value updated ────────────────────────────────────────
|
|
assert_eq!(
|
|
host.app()
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(&CellKey::new(vec![
|
|
("Region".into(), "North".into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
])),
|
|
Some(&CellValue::Number(7500.0)),
|
|
"the authoritative document is the thing that must have moved"
|
|
);
|
|
|
|
// ── and the projection arrives with the new value ───────────────────
|
|
assert!(
|
|
!frames.is_empty(),
|
|
"a cell write owes this client a grid projection"
|
|
);
|
|
let before = client.revision();
|
|
deliver(&mut client, &frames);
|
|
assert!(
|
|
client.revision() > before,
|
|
"folding the fan-out must move the client"
|
|
);
|
|
assert_eq!(
|
|
client.model().rows[at.visual_row].cells()[at.col].text,
|
|
"7,500.00",
|
|
"the page redraws from the server's formatting, never from the \
|
|
\"7500\" the user typed"
|
|
);
|
|
}
|
|
|
|
/// A group header is a row of the render model and **not** a row of the grid,
|
|
/// and confusing them writes a different record. Evidence file.
|
|
#[test]
|
|
fn a_grouped_row_axis_does_not_shift_the_write() {
|
|
let mut host = {
|
|
let mut wb = Workbook::new("Grouped");
|
|
wb.add_category("Region").unwrap();
|
|
// West first and ungrouped, so the `Coastal` header falls *between* the
|
|
// two rows — a leading header is dropped by `AxisFrame::body_from`.
|
|
wb.add_item("Region", "West").unwrap();
|
|
wb.add_item_in_group("Region", "East", "Coastal").unwrap();
|
|
wb.add_item("_Measure", "Revenue").unwrap();
|
|
wb.active_view_mut().set_axis("Region", Axis::Row);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
for region in ["East", "West"] {
|
|
wb.model.set_cell(
|
|
CellKey::new(vec![
|
|
("Region".into(), region.into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
]),
|
|
CellValue::Number(1.0),
|
|
);
|
|
}
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
Host::new(app)
|
|
};
|
|
let mut client = Client::new();
|
|
|
|
let (id, snapshot) = host.connect(10, 10);
|
|
deliver(&mut client, &[snapshot]);
|
|
|
|
let model = client.model();
|
|
let at = editable_cells(&model)
|
|
.pop()
|
|
.expect("both rows are stored cells");
|
|
assert_eq!(
|
|
(at.visual_row, at.data_row),
|
|
(2, 1),
|
|
"precondition: the Coastal header sits between the two rows, so the \
|
|
last row is visual 2 and grid 1 — without this divergence the two \
|
|
coordinates coincide and nothing below is being tested"
|
|
);
|
|
|
|
let frame = client
|
|
.typed_cell(at.data_row, at.col, "4200")
|
|
.expect("serialization must not fail")
|
|
.expect("an editable cell must compose a write");
|
|
send(&mut host, id, &frame);
|
|
|
|
assert_eq!(
|
|
host.app()
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(&CellKey::new(vec![
|
|
("Region".into(), "East".into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
])),
|
|
Some(&CellValue::Number(4200.0)),
|
|
"the write must land on grid row 1, East"
|
|
);
|
|
assert_eq!(
|
|
host.app()
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(&CellKey::new(vec![
|
|
("Region".into(), "West".into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
])),
|
|
Some(&CellValue::Number(1.0)),
|
|
"and must not have shifted: passing the VISUAL index writes nothing \
|
|
at all here (row 2 is off the grid), and in a document with one \
|
|
more row it would silently write the wrong one"
|
|
);
|
|
}
|
|
|
|
/// The other half of ADR 002, end to end: an aggregate renders non-editable,
|
|
/// so the page never offers an editor `SetCell` could not be composed for.
|
|
#[test]
|
|
fn an_aggregated_cell_reaches_the_page_as_not_editable() {
|
|
let mut host = {
|
|
let mut wb = Workbook::new("Test");
|
|
wb.add_category("Region").unwrap();
|
|
wb.add_item("Region", "North").unwrap();
|
|
// A regular category left on the None axis is what makes a cell an
|
|
// aggregate — the same question `EditOrDrill` asks in the TUI.
|
|
wb.add_category("Product").unwrap();
|
|
wb.add_item("Product", "Shirts").unwrap();
|
|
wb.add_item("Product", "Hats").unwrap();
|
|
wb.add_item("_Measure", "Revenue").unwrap();
|
|
wb.active_view_mut().set_axis("Region", Axis::Row);
|
|
wb.active_view_mut().set_axis("_Measure", Axis::Column);
|
|
wb.active_view_mut().set_axis("Product", Axis::None);
|
|
let mut app = App::new(wb, None);
|
|
app.rebuild_layout();
|
|
Host::new(app)
|
|
};
|
|
let mut client = Client::new();
|
|
let (_id, snapshot) = host.connect(10, 10);
|
|
deliver(&mut client, &[snapshot]);
|
|
|
|
let model = client.model();
|
|
assert!(
|
|
model
|
|
.rows
|
|
.iter()
|
|
.flat_map(|r| r.cells())
|
|
.all(|c| !c.editable),
|
|
"every cell here aggregates over Product, so none may be written"
|
|
);
|
|
assert_eq!(
|
|
client.typed_cell(0, 0, "42").unwrap(),
|
|
None,
|
|
"and typing into one composes nothing, because the id is of the wrong kind"
|
|
);
|
|
}
|
|
|
|
/// A resize is a fan-out with nothing dirty, and it is the page's job to send
|
|
/// one: no command carries the extent.
|
|
#[test]
|
|
fn a_resize_reprojects_the_grid_at_the_new_extent() {
|
|
let mut host = host();
|
|
let mut client = Client::new();
|
|
let (id, snapshot) = host.connect(1, 1);
|
|
deliver(&mut client, &[snapshot]);
|
|
assert_eq!(
|
|
client.model().rows.len(),
|
|
1,
|
|
"a one-row viewport is seeded with one row"
|
|
);
|
|
|
|
let frames = send(&mut host, id, &client.set_viewport(10, 10));
|
|
deliver(&mut client, &frames);
|
|
assert_eq!(
|
|
client.model().rows.len(),
|
|
2,
|
|
"the resize must bring the rest of the grid"
|
|
);
|
|
assert_eq!(client.model().col_levels[0], vec!["Revenue", "Cost"]);
|
|
}
|
|
|
|
/// improvise-955n: **the one real producer of a multi-command frame must still
|
|
/// be accepted** — and must land. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_commit_keypress_is_one_mutation_among_view_commands_and_is_accepted() {
|
|
use improvise_protocol::ServerFrame;
|
|
|
|
let mut host = host();
|
|
let mut client = Client::new();
|
|
let handshake: ClientFrame = serde_json::from_str(&client.set_viewport(10, 10))
|
|
.expect("the viewport frame must be one the server reads");
|
|
let ClientFrame::Viewport { rows, cols } = handshake else {
|
|
panic!("set_viewport must produce a Viewport frame");
|
|
};
|
|
let (id, snapshot) = host.connect(rows, cols);
|
|
deliver(&mut client, &[snapshot]);
|
|
|
|
// Type into the cell under the cursor the way the page does: enter edit
|
|
// mode, type, commit.
|
|
for key in ["i", "8", "8", "8"] {
|
|
if let Some(frame) = client
|
|
.on_key_event(key, false, false, false, false)
|
|
.unwrap()
|
|
{
|
|
send(&mut host, id, &frame);
|
|
}
|
|
}
|
|
let commit = client
|
|
.on_key_event("Enter", false, false, false, false)
|
|
.expect("serialization must not fail")
|
|
.expect("a commit must compose a frame");
|
|
|
|
// The shape under test: several commands, exactly one a mutation —
|
|
// asserted, or a `commit` that stopped batching would pass vacuously.
|
|
let parsed: ClientFrame = serde_json::from_str(&commit).expect("the commit frame parses");
|
|
let ClientFrame::Commands(cmds) = &parsed else {
|
|
panic!("a commit must be a Commands frame, got {parsed:?}");
|
|
};
|
|
assert!(cmds.len() > 1, "the shape under test is a batch: {cmds:?}");
|
|
// A characterization of what `keys::commit` emits, **not** a protocol
|
|
// constraint: a frame may carry more than one mutation.
|
|
assert_eq!(
|
|
cmds.iter()
|
|
.filter(|c| matches!(c, improvise_protocol::Command::Model { .. }))
|
|
.count(),
|
|
1,
|
|
"keys::commit is expected to emit exactly one mutation: {cmds:?}"
|
|
);
|
|
|
|
let answer = send(&mut host, id, &commit);
|
|
assert!(
|
|
!answer
|
|
.iter()
|
|
.any(|a| matches!(a.msg, ServerFrame::Rejected(_))),
|
|
"the batch a real keypress produces must not be refused: {answer:?}"
|
|
);
|
|
assert_eq!(
|
|
host.app()
|
|
.model_state
|
|
.workbook
|
|
.model
|
|
.get_cell(&CellKey::new(vec![
|
|
("Region".into(), "North".into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
])),
|
|
// `i` seeds the edit buffer, so typing 888 appends. Asserting the
|
|
// *appended* value is what makes this more than a coincidence.
|
|
Some(&CellValue::Number(1_200_888.0)),
|
|
"and it must actually land — 'not refused' alone would pass against \
|
|
a host that dropped the frame"
|
|
);
|
|
}
|