Merge branch 'main' into ui/header-stable

This commit is contained in:
Edward Langley
2026-09-22 00:17:27 -07:00
31 changed files with 809 additions and 106 deletions
+3 -4
View File
@@ -3,7 +3,7 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use improvise::format::parse_number_format; use improvise::format::FormatPlan;
use improvise::model::cell::{CellKey, CellValue}; use improvise::model::cell::{CellKey, CellValue};
use improvise::view::Axis; use improvise::view::Axis;
use improvise::workbook::Workbook; use improvise::workbook::Workbook;
@@ -94,12 +94,11 @@ fn time(iters: u32, app: &App, viewport: Viewport) -> Duration {
fn time_widths(iters: u32, app: &App) -> Duration { fn time_widths(iters: u32, app: &App) -> Duration {
let layout = &app.layout; let layout = &app.layout;
let model = &app.model_state.workbook.model; let model = &app.model_state.workbook.model;
let (comma, decimals) = let plan = FormatPlan::of(model, app.model_state.workbook.active_view());
parse_number_format(&app.model_state.workbook.active_view().number_format);
let mut best = Duration::MAX; let mut best = Duration::MAX;
for _ in 0..iters.max(3) { for _ in 0..iters.max(3) {
let start = Instant::now(); let start = Instant::now();
std::hint::black_box(compute_col_widths(model, layout, comma, decimals)); std::hint::black_box(compute_col_widths(model, layout, &plan));
best = best.min(start.elapsed()); best = best.min(start.elapsed());
} }
best best
+143
View File
@@ -820,3 +820,146 @@ renders a cell and asserts its digits. That gap is named rather than closed —
those files are `improvise-command`, which was another wave's ground when this those files are `improvise-command`, which was another wave's ground when this
landed — and it is the more useful half of this entry, because it says where the landed — and it is the more useful half of this entry, because it says where the
next regression would go unseen. next regression would go unseen.
**That gap is now closed, and the sentence above is kept as the record of it.**
`ui::project::cell_identity::a_formulas_format_reaches_its_own_column_and_no_other`
renders cells *and* the totals strip and asserts their digits (improvise-e0ex);
`ui/app.rs`'s own call is still unguarded, and now reaches the same `FormatPlan`
rather than a second `parse_number_format`, so there is one fewer place to drift.
## The decimal default and a formula's own format (improvise-e0ex)
`View::new` seeds `,.2`, not `,.0`. The symptom that forced it was
`Margin = Profit / Revenue` drawing as `1`: a 0.66 ratio under *zero* decimals,
rounded half-away-from-zero by `format_f64`. Three things worth stating, because
each was a candidate answer that is wrong.
**It is a formatting bug and not an arithmetic one.** Exact decimal arithmetic
(ADR 005) would not change the rendering by one character — `0.66` under `,.0`
is still `1` — so nothing here waits on it and nothing here touches the
evaluator. Gotcha 8 is unchanged: `format_f64` is called in rendering and
nowhere else, and full `f64` is what eval sees.
**The cost is paid on whole numbers, knowingly.** Revenue now draws
`18,400.00`. That is what an accounting application does, and it is the cheaper
error: a currency column with two redundant zeroes is *legible*, where a ratio
with none is **wrong**. A format cannot be guessed from a value — 0.66 is a
ratio and 0.66 is also two-thirds of a dollar — so the default must be the one
whose failure mode is noise rather than a false number.
**The override is spelled in the formula, not per `_Measure` item.** The user's
ruling ("unless the format is explicit in the formula") picks the narrower of
two nearly-coincident units: a formula *target* is a `_Measure` item (gotcha 7),
so per-formula and per-measure differ only for a measure no formula owns — and
such a measure is raw data, whose format is the view's job. So a formula may
carry its own format, which wins for its target; a raw measure keeps the view's.
### Where the format is resolved: `FormatPlan`
`FormatPlan::of(model, view)` is the one answer, and every render path takes it
where it used to take a `(comma, decimals)` pair: `GridLayout::display_text`,
`ui::viewport::grid_body`/`compute_col_widths`, `ui::project::grid_in` and
`persistence/export.rs::render_csv`. Replacing the pair rather than adding a
second argument beside it is the `row_levels`-next-to-`row_labels` rule from
design-principles §5 — two answers to "what format is this cell" is the disease.
**It is asked of the cell's `CellKey`, never of the axes.** A cell aggregating
over `_Measure` carries no target coordinate and so keeps the view's format,
which falls out of the key rather than needing a rule. Deriving it from "is
`_Measure` on a visible axis" would be a second derivation that could agree
with itself while disagreeing with the cells.
**The empty plan short-circuits**, so a document with no formula format pays one
`is_empty` per cell and nothing else; the lookup is otherwise a scan of the
formula list, which is short and must stay ordered for the reason below.
**The owner is the formula `Model::formula_for` names, and nothing else.**
`FormatPlan` holds every formula's `(target_category, target, Option<format>)`
in the model's own order and takes the **first** coordinate match, which is the
same rule and the same order evaluation uses to decide whose value the cell is.
Two things that shape enforces, both of which a `HashMap` keyed by target got
wrong and review caught (Codex, PR #125):
- **A key may be claimed by two formulas**, because a target category need not
be `_Measure` — `- Tax = Revenue * 0.1 [CustomCat]` is in the format's own
documentation. Iterating a `HashMap` answered from whichever category hashed
first, so the *displayed* format was nondeterministic per process while the
*value* was not. That is gotcha 18 exactly: an index whose values are hashed
is for lookup, never for choosing between candidates.
- **An owner that names no format still owns the cell.** Indexing only the
formatted formulas let a later formula's `FORMAT` reach a cell whose value it
does not produce. So the list holds *every* formula and a match with
`format: None` falls through to the view — it is a match, not a miss.
The list is dropped entirely when no formula names a format, so the common
document pays one `is_empty` per cell rather than a scan.
**A column total is resolved from the column, never from a row.**
`GridLayout::column_key(col)` is the page/filter constraints plus the column's
own tuple — the coordinates that survive the row aggregation — and `GridBody`
carries the resulting `col_formats` so the strip and the cells cannot disagree.
Reading it off row 0 was the first version and is wrong twice over, also found
by review: a formula may target a **row-axis** item (its target category is any
category), so the total inherited a row-local override for a row it had summed
away, **and** re-ordering the rows changed the total's spelling. The guard is
`a_column_total_does_not_inherit_a_row_local_format`, which builds the same grid
with the owned row first and last and requires the two strips to agree.
### The grammar: `FORMAT <spec>` in the formula, not in the `.improv` line
`formula.pest` gains `format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }` after
the optional `WHERE`. **`improv.pest` is unchanged**, and that is the design
rather than an omission: `formula_raw` already takes the `## Formulas` line
verbatim up to the optional `[Category]` suffix, so a format inside the formula
*text* is carried by the existing grammar with nothing to add. Two consequences.
The grammar-walking generator (which reads `improv.pest`, not `formula.pest`) is
untouched, so there is no new spelling for it to violate. And the same text a
user types into the TUI formula minibuffer is the text the file carries — one
namespace, one spelling rule, exactly as gotcha 34 says of names.
`sp+` rather than a `!bare_char` lookahead, for `WHERE`'s reason (gotcha 34):
a predicate is invisible to a generator. `FORMAT` joins `quote::KEYWORDS`, so an
item literally named `FORMAT` is written `|FORMAT|` — the keyword guard is
canonical and this is one more entry in it, not a new rule.
**`format_spec` is `(ASCII_ALPHANUMERIC | "," | "." | "%")+`** — a bounded token
where the view's `format:` line is `rest_of_line`. The line can afford to be
opaque because a newline ends it; a formula clause cannot, because something may
follow. The set covers every spelling `parse_number_format` can read (`,.2f`,
`.4`, `%.2f`) and nothing the tokenizer would have to disambiguate.
### The trap this feature sets, and what is asserted instead
`format_md` writes `Formula::raw`, so a format inside the raw text round-trips
**whether or not the walker parsed it into a field**. That is gotcha 34's bug
exactly — a target that kept its pipes survived every round-trip test because
`raw` was unaffected. So the round-trip test asserts `Formula::format`, the
parsed field, and its `raw` assertion is there to *show* that `raw` cannot be
the witness. Injecting the fault (a walker that drops the clause) fails that one
test and **nothing else in `improvise-io`** — 242 other tests, including the
whole `format_md`↔`parse_md` characterization suite and the grammar-walking
properties, pass. That is the measurement, not an argument.
### What persistence does and does not do here
The user's constraint — "should not affect persistence at all, except to store
format metadata" — holds by construction and the job was to not break it.
`format_f64` is display; `persistence/format.rs::format_number` is a different
function in a different crate writing Rust's shortest round-trip form. **The
regression to watch for is routing a save through the display formatter** so the
file matches what the user sees: a model has many views (gotcha 29), so there is
no single right answer and the last save would win. Gotcha 8 forbids feeding a
rounded value back into *eval*; this is its persistence twin.
What did change on disk is metadata only: newly created views write
`format: ,.2` where they wrote `format: ,.0`, and a formula line may now carry
a `FORMAT` suffix. Existing documents are unaffected — `format_md` has always
written the `format:` line explicitly whenever `number_format` is non-empty and
`View::new`'s value has never been empty, so every document improvise has ever
written carries its own and no migration exists to get wrong.
One consumer that is display but reads like persistence: **CSV export** goes
through `display_text` and therefore through the view's format, as it always
has. A document with no explicit `format:` line now exports two decimals. That
is pre-existing behaviour meeting a new default, not a new coupling.
+52
View File
@@ -4216,3 +4216,55 @@ fixture loads **both** stacks first so a lost forward stack is visible at all.
The neighbouring test `a_refused_switch_corrects_the_asker_and_nobody_else` The neighbouring test `a_refused_switch_corrects_the_asker_and_nobody_else`
compared `active_view` alone and stayed green through all of this: the check compared `active_view` alone and stayed green through all of this: the check
that answers a neighbouring question, in the place the doctrine warns about. that answers a neighbouring question, in the place the doctrine warns about.
---
## The format a projected cell is drawn with (improvise-e0ex)
### a_formulas_format_reaches_its_own_column_and_no_other
`context/code/core.md` recorded, as the more useful half of improvise-711n's
entry, that `ui/project.rs::grid_of` "has no test that renders a cell and
asserts its digits". This is that test, and it is why the gap was worth naming:
the per-formula format is resolved in `improvise-core` and could have been
resolved correctly there while never reaching the grid.
It asserts four things that fail independently: the formula column's cell, the
raw column's cell beside it, and **both** totals. The totals matter separately
because they are formatted on their own path — `GridBody::col_formats`, read off
the top row — so a correct cell says nothing about the strip under it.
Two faults injected:
- `FormatPlan::at` returning the view's format for every key fails the cell
assertion (`left: Some("0.50") right: Some("0.5000")`).
- Dropping only the `col_formats` write in `viewport::grid_body`, leaving the
cells correct, fails the totals assertion (`left: "1.50" right: "1.5000"`).
The second is the one that earns the test its length: without it, the totals
strip could round every formula column to the view's format and no assertion in
the workspace would move.
### a_column_total_does_not_inherit_a_row_local_format
Found by review (ChatGPT Codex, PR #125). The first version of `grid_body` read
each column's total format off **row 0**, and `context/code/core.md` argued that
was safe because "where `_Measure` is on the row axis a column is not a measure
at all". That argument is wrong: a formula's target category is *any* category,
so a formula can own a **row-axis item**, and then row 0's format is a row-local
override applied to a total that has summed that row away — and re-ordering the
rows changes the total's spelling.
The fixture builds the same grid twice with `12.5 FORMAT .4` owning the **first**
row in one and the **last** row in the other, and requires the two totals strips
to agree. Every raw cell in `raw_workbook` holds the same value per measure, so
the two grids' totals are numerically identical and the *only* thing that can
differ is the format — the assertion has nowhere to hide.
It asserts the owned cell first (`12.5000`), so a plan that applies no format at
all fails on the fixture rather than passing the comparison vacuously.
Fault injected: `plan.at(layout.cell_key(0, ci).as_ref())` in place of
`layout.column_key(ci)` — the rejected version, restored. Fails with
`left: ["14.5000", "16.5000"] right: ["14.50", "16.50"]`, which is the bug in
one line: row order decided the spelling.
+81
View File
@@ -1062,3 +1062,84 @@ qualification.
It asserts `effective_item_names("_Measure")` **by value** rather than It asserts `effective_item_names("_Measure")` **by value** rather than
`!contains("Profit")`, so a fix that removed the formula and also lost `!contains("Profit")`, so a fix that removed the formula and also lost
`Revenue` or `Cost` on the way fails here rather than passing a negative. `Revenue` or `Cost` on the way fails here rather than passing a negative.
---
## `format.rs` — the decimal default and a formula's own format (improvise-e0ex)
### a_ratio_keeps_its_decimals_under_a_fresh_views_format
The bug the ticket reports, at the smallest place it is visible.
`View::new`'s `number_format` was `,.0` — *zero* decimals — so `2/3` reached
`format_f64` and came back as `"1"`, half-away-from-zero. Written before the
fix and observed failing with `left: "1" right: "0.67"`.
It asks the **view's default** rather than a literal `",.2"`, so the assertion
is about the shipped default and not about `parse_number_format`, which already
had its own tests. Reverting `View::new` to `,.0` fails it.
What it deliberately does not assert: that the default is exactly two places.
`0.67` would also fail under `.1` or `.3`, which is the point — the test names
"a ratio keeps its decimals", and the precise number is a product decision
recorded in `context/code/core.md`, not a property.
### a_formulas_own_format_wins_for_its_target_and_nowhere_else
The override, and both halves of "nowhere else" — a raw measure beside the
formula target, and a key with no `_Measure` coordinate at all (the aggregating
cell). The fixture uses `.4` against a `,.2` view so the two answers differ in
*digits*, not merely in grouping.
Two faults injected, in opposite directions, because one alone is not enough:
- `FormatPlan::at` returning `self.view` unconditionally (the override never
applies) fails with `left: "0.67" right: "0.6667"`.
- `FormatPlan::at` returning the first entry in the plan for any key (the
override leaks to every cell) fails with `left: "0.6667" right: "0.67"`.
A test asserting only the target's own column passes the second fault, which is
why the two neighbours are in the same test rather than in a sibling that could
be deleted on its own.
### a_formula_naming_no_format_leaves_its_target_on_the_views
The fallback, asserted where it is easiest to lose: a formula target whose
formula carries no format must be indistinguishable from a raw measure. It
keeps `FormatPlan::of` from indexing every target with some default and then
"overriding" it back, which would be a second copy of the view's answer.
### the_formula_that_owns_a_cell_names_its_format_even_when_it_names_none
Found by review (ChatGPT Codex, PR #125), not by the tests above, which is the
entry's point: they used one formula and so could not see an ownership question
at all.
A target category need not be `_Measure` — `- Tax = Revenue * 0.1 [CustomCat]`
is in the format's own documentation — so a key may carry coordinates two
formulas claim. The first `FormatPlan` indexed **only the formatted** formulas,
so a later formula's `FORMAT` reached a cell whose value an earlier, unformatted
formula produces: the number and its spelling came from different formulas.
The fixture puts the same two formulas in both orders and asserts the owner's
answer each time, so a plan that prefers the formatted one fails the first half
and a plan that ignores formats entirely fails the second.
Fault injected: `.filter(|t| t.format.is_some())` before the `find` — precisely
the behaviour of the version review rejected. Fails with `left: "0.6667"
right: "0.67"`.
### the_owner_is_the_one_model_order_names_and_not_the_one_a_hash_does
The other half of the same finding, and the one a single run cannot see.
`by_target` was a `HashMap<category, HashMap<item, _>>` and `at` iterated it, so
with two claiming categories the answer came from whichever hashed first —
**a different answer per process**, gotcha 18's shape exactly (an index whose
values are hashed is for lookup, never for choosing between candidates).
It rebuilds the plan five times rather than asserting once, because each
`HashMap` gets its own `RandomState` and a single construction can be right by
luck. It is the same shape as `analyze_records`' five-iteration guard.
This one cannot be made to fail by re-introducing a `HashMap` *reliably*, which
is why the test that carries the load is its sibling above; kept because the
repetition is what documents that hash order was the defect.
+33
View File
@@ -1255,3 +1255,36 @@ well as membership — insertion order is load-bearing here (gotcha 18), and the
neighbouring `two_sections_naming_one_category_round_trip_to_a_stable_group_order` neighbouring `two_sections_naming_one_category_round_trip_to_a_stable_group_order`
already holds groups to that standard. This test was the item-list half of the already holds groups to that standard. This test was the item-list half of the
same invariant, held to a weaker one. same invariant, held to a weaker one.
---
## A formula's own number format on disk (improvise-e0ex)
### parse_md_round_trips_the_format_a_formula_carries
`format_md` writes `Formula::raw`, so a `FORMAT .4` suffix inside the formula
text survives a save/load **whether or not the tree-walker parsed it into
`Formula::format`**. That is gotcha 34's bug wearing new clothes: a target that
kept its pipe delimiters round-tripped perfectly and still created a ghost
`_Measure` item, precisely because `raw` was unaffected.
So this test asserts the **parsed field**, and its `raw` assertion is there to
show why `raw` cannot be the witness rather than to check the writer.
Fault injected: `build_formula`'s `Rule::format_clause` arm discarded instead of
recorded (`let _ = first_inner(inner);`). It fails with
`left: None right: Some(".4")` — and **every other test in `improvise-io`
passes**, 242 of them, including the whole `format_md`↔`parse_md`
characterization suite and the grammar-walking generator properties. That
measurement is the entry's point: no text-comparing test can see this field
go missing.
Note what the injected fault does *not* do: it leaves the grammar alone, so the
line still parses and the file is still well-formed. A fault that also broke
the parse would fail the test for the wrong reason.
### parse_md_reads_a_formula_with_no_format_as_none
The absent case, so the field above is something the file carried rather than a
constant the walker invents. Without it, a walker hard-coding `Some(".4")`
would pass the test above.
@@ -406,6 +406,7 @@ mod tests {
target_category: "Type".to_string(), target_category: "Type".to_string(),
expr: crate::formula::ast::Expr::Number(0.0), expr: crate::formula::ast::Expr::Number(0.0),
filter: None, filter: None,
format: None,
}); });
let layout = make_layout(&m); let layout = make_layout(&m);
let reg = make_registry(); let reg = make_registry();
+3 -8
View File
@@ -17,7 +17,7 @@ use std::rc::Rc;
use crate::command::cmd::CmdContext; use crate::command::cmd::CmdContext;
use crate::command::keymap::{Keymap, KeymapSet}; use crate::command::keymap::{Keymap, KeymapSet};
use crate::format::parse_number_format; use crate::format::FormatPlan;
use crate::import::wizard::ImportWizard; use crate::import::wizard::ImportWizard;
use crate::model::cell::{CellKey, CellValue, DataStore}; use crate::model::cell::{CellKey, CellValue, DataStore};
use crate::persistence; use crate::persistence;
@@ -796,13 +796,8 @@ impl App {
display_value: self.display_value_at_cursor(), display_value: self.display_value_at_cursor(),
visible_rows: (self.term_height as usize).saturating_sub(8), visible_rows: (self.term_height as usize).saturating_sub(8),
visible_cols: { visible_cols: {
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format); let model = &self.model_state.workbook.model;
let col_widths = compute_col_widths( let col_widths = compute_col_widths(model, layout, &FormatPlan::of(model, view));
&self.model_state.workbook.model,
layout,
fmt_comma,
fmt_decimals,
);
let row_header_width = compute_row_header_width(layout); let row_header_width = compute_row_header_width(layout);
compute_visible_cols( compute_visible_cols(
&col_widths, &col_widths,
+2 -2
View File
@@ -453,7 +453,7 @@ mod tests {
let mut seen = vec![connect.msg]; let mut seen = vec![connect.msg];
seen.extend(to(&frames, id)); seen.extend(to(&frames, id));
assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "42"); assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "42.00");
} }
/// `doctrine(check_the_checker)`: inject the fault — never send the command — /// `doctrine(check_the_checker)`: inject the fault — never send the command —
@@ -483,7 +483,7 @@ mod tests {
let mut seen = vec![bootstrap.msg]; let mut seen = vec![bootstrap.msg];
seen.extend(to(&frames, bystander)); seen.extend(to(&frames, bystander));
assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "7"); assert_eq!(cell_text(&folded_grid(&seen), 0, 0), "7.00");
} }
/// A view-only command reaches nobody else — the counterpart that keeps the /// A view-only command reaches nobody else — the counterpart that keeps the
+108 -30
View File
@@ -3,7 +3,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use improvise_core::format::{format_f64, format_value, parse_number_format}; use improvise_core::format::FormatPlan;
use improvise_core::model::Model; use improvise_core::model::Model;
use improvise_core::model::category::MEASURE_CATEGORY; use improvise_core::model::category::MEASURE_CATEGORY;
use improvise_core::model::cell::CellValue; use improvise_core::model::cell::CellValue;
@@ -141,12 +141,12 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
let elsewhere = let elsewhere =
(name != app.view_name()).then(|| GridLayout::with_frozen_records(model, view, None)); (name != app.view_name()).then(|| GridLayout::with_frozen_records(model, view, None));
let layout = elsewhere.as_ref().unwrap_or(&app.layout); let layout = elsewhere.as_ref().unwrap_or(&app.layout);
let (comma, decimals) = parse_number_format(&view.number_format); let plan = FormatPlan::of(model, view);
let rows = viewport.rows_in(layout.row_count()); let rows = viewport.rows_in(layout.row_count());
let cols = viewport.cols_in(layout.col_count()); let cols = viewport.cols_in(layout.col_count());
// ONE walk: the widths, the totals and this window's values all come off the // ONE walk: the widths, the totals and this window's values all come off the
// same evaluation — `context/code/command.md` (improvise-jmjz). // same evaluation — `context/code/command.md` (improvise-jmjz).
let body = grid_body(model, layout, comma, decimals, rows.clone(), cols.clone()); let body = grid_body(model, layout, &plan, rows.clone(), cols.clone());
GridCache { GridCache {
row_frame: axis_frame(&layout.row_cats, &layout.row_items, view, rows.clone()), row_frame: axis_frame(&layout.row_cats, &layout.row_items, view, rows.clone()),
@@ -161,11 +161,11 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
.map(|(r, values)| { .map(|(r, values)| {
cols.clone() cols.clone()
.zip(values.iter()) .zip(values.iter())
.map(|(c, value)| cell_display(app, layout, r, c, value, comma, decimals)) .map(|(c, value)| cell_display(app, layout, r, c, value, &plan))
.collect() .collect()
}) })
.collect(), .collect(),
totals: column_totals(&body, cols, comma, decimals), totals: column_totals(&body, cols),
page_coords: layout.page_coords.clone(), page_coords: layout.page_coords.clone(),
// The whole grid, not the window: this is what a client clamps its own // The whole grid, not the window: this is what a client clamps its own
// cursor against (improvise-bpq2). Off the layout, not the clipped ranges. // cursor against (improvise-bpq2). Off the layout, not the clipped ranges.
@@ -252,17 +252,12 @@ fn axis_frame(
/// The totals strip: each visible column summed over **every** row, not just the /// The totals strip: each visible column summed over **every** row, not just the
/// viewport's — which is why it is projected. Empty means "no totals" (gotcha 33). /// viewport's — which is why it is projected. Empty means "no totals" (gotcha 33).
fn column_totals( fn column_totals(body: &GridBody, cols: std::ops::Range<usize>) -> Vec<CellDisplay> {
body: &GridBody,
cols: std::ops::Range<usize>,
comma: bool,
decimals: u8,
) -> Vec<CellDisplay> {
match body.totals.is_empty() { match body.totals.is_empty() {
true => Vec::new(), true => Vec::new(),
false => cols false => cols
.filter_map(|c| body.totals.get(c)) .filter_map(|c| Some((body.totals.get(c)?, body.col_formats.get(c)?)))
.map(|total| CellDisplay::number(format_f64(*total, comma, decimals))) .map(|(total, fmt)| CellDisplay::number(fmt.number(*total)))
.collect(), .collect(),
} }
} }
@@ -275,8 +270,7 @@ fn cell_display(
row: usize, row: usize,
col: usize, col: usize,
value: &Option<CellValue>, value: &Option<CellValue>,
comma: bool, plan: &FormatPlan,
decimals: u8,
) -> CellDisplay { ) -> CellDisplay {
if layout.is_records_mode() { if layout.is_records_mode() {
// The value, not its spelling: this module used to infer numeric-ness // The value, not its spelling: this module used to infer numeric-ness
@@ -290,16 +284,15 @@ fn cell_display(
id: cell_id(app, layout, row, col), id: cell_id(app, layout, row, col),
}; };
} }
let key = layout.cell_key(row, col);
CellDisplay { CellDisplay {
text: format_value(value.as_ref(), comma, decimals), text: plan.at(key.as_ref()).value(value.as_ref()),
numeric: matches!(value, Some(CellValue::Number(_))), numeric: matches!(value, Some(CellValue::Number(_))),
error: matches!(value, Some(CellValue::Error(_))), error: matches!(value, Some(CellValue::Error(_))),
id: cell_id(app, layout, row, col), id: cell_id(app, layout, row, col),
// The *unformatted* value the TUI seeds its edit buffer from. Projecting it // The *unformatted* value the TUI seeds its edit buffer from. Projecting it
// beats a client un-formatting `text` (gotcha 47). // beats a client un-formatting `text` (gotcha 47).
edit_text: layout edit_text: key.map(|key| app.display_value_in(layout, &key)),
.cell_key(row, col)
.map(|key| app.display_value_in(layout, &key)),
} }
} }
@@ -1811,7 +1804,7 @@ mod tests {
.collect(); .collect();
assert_eq!(cells.len(), 1, "expected one changed cell, got {cells:?}"); assert_eq!(cells.len(), 1, "expected one changed cell, got {cells:?}");
assert_eq!(cells[0].0, (1, 1)); assert_eq!(cells[0].0, (1, 1));
assert_eq!(cells[0].1.text, "7"); assert_eq!(cells[0].1.text, "7.00");
} }
/// Scrolling is `ViewOnly` — no other client hears it — but the client /// Scrolling is `ViewOnly` — no other client hears it — but the client
@@ -2733,7 +2726,8 @@ mod tests {
let model = &app.model_state.workbook.model; let model = &app.model_state.workbook.model;
assert_eq!(app.layout.row_count(), 3, "precondition: three rows"); assert_eq!(app.layout.row_count(), 3, "precondition: three rows");
let one_row = grid_body(model, &app.layout, false, 0, 0..1, 0..1); let plan = improvise_core::format::FormatPlan::uniform("");
let one_row = grid_body(model, &app.layout, &plan, 0..1, 0..1);
assert_eq!( assert_eq!(
one_row.window.len(), one_row.window.len(),
1, 1,
@@ -2752,7 +2746,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
one_row.col_widths, one_row.col_widths,
compute_col_widths(model, &app.layout, false, 0), compute_col_widths(model, &app.layout, &plan),
"the keypress path and the projection must size a column the same" "the keypress path and the projection must size a column the same"
); );
} }
@@ -4396,7 +4390,7 @@ mod cell_identity {
assert_eq!( assert_eq!(
grid_of(&app, WHOLE).cell(row, 0).map(|c| c.text.clone()), grid_of(&app, WHOLE).cell(row, 0).map(|c| c.text.clone()),
Some("42".to_string()) Some("42.00".to_string())
); );
} }
@@ -4432,7 +4426,7 @@ mod cell_identity {
vec![3], vec![3],
"exactly (1, 1) may move: {before:?} {after:?}" "exactly (1, 1) may move: {before:?} {after:?}"
); );
assert_eq!(after[3], "999"); assert_eq!(after[3], "999.00");
} }
/// A surrogate that does not resolve applies *nothing*. The floor underneath the /// A surrogate that does not resolve applies *nothing*. The floor underneath the
@@ -4510,6 +4504,86 @@ mod cell_identity {
app app
} }
/// improvise-e0ex: a formula's own format reaches the drawn grid — cells and
/// the totals strip — and stops there. `context/test-evidence/command.md`.
#[test]
fn a_formulas_format_reaches_its_own_column_and_no_other() {
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula("Margin = Revenue / Cost FORMAT .4", "_Measure")
.unwrap(),
);
let app = App::new(wb, None);
let grid = grid_of(&app, WHOLE);
let margin = column_named(&app, "Margin");
let revenue = column_named(&app, "Revenue");
assert_eq!(
grid.cell(0, margin).map(|c| c.text.clone()),
Some("0.5000".to_string()),
"the formula's own four decimals, not the view's two"
);
assert_eq!(
grid.cell(0, revenue).map(|c| c.text.clone()),
Some("1.00".to_string()),
"a raw measure keeps the view's format"
);
// The totals strip is formatted on its own path (`col_formats`), so it
// can disagree with the cells above it without any cell being wrong.
assert_eq!(
grid.totals[margin].text, "1.5000",
"three rows of 0.5, under the column's own format"
);
assert_eq!(grid.totals[revenue].text, "3.00");
}
/// A formula owning one ROW, so its format is row-local and the totals have
/// aggregated that row away. `context/test-evidence/command.md`.
fn row_targeted_app(region: &str) -> App {
let mut wb = raw_workbook();
wb.model.add_formula(
improvise_formula::parse_formula(&format!("{region} = 12.5 FORMAT .4"), "Region")
.unwrap(),
);
App::new(wb, None)
}
/// improvise-e0ex, found by review: reading a total's format off row 0 made
/// it depend on row ORDER. `context/test-evidence/command.md`.
#[test]
fn a_column_total_does_not_inherit_a_row_local_format() {
let first = row_targeted_app("North");
let last = row_targeted_app("East");
let (fg, lg) = (grid_of(&first, WHOLE), grid_of(&last, WHOLE));
// The row the formula owns really is drawn with the formula's format —
// without this the test would pass against a plan that does nothing.
let owned = fg.cell(0, 0).map(|c| c.text.clone());
assert_eq!(
owned,
Some("12.5000".to_string()),
"fixture: North is owned"
);
// North is row 0 and East is the last row, so a total read off row 0
// answers differently in the two grids. It must not.
let totals = |g: &improvise_protocol::render::GridCache| -> Vec<String> {
g.totals.iter().map(|t| t.text.clone()).collect()
};
assert_eq!(
totals(&fg),
totals(&lg),
"which row a formula owns changed the totals' spelling"
);
for t in totals(&fg) {
assert_eq!(
t.split('.').nth(1).map(str::len),
Some(2),
"a total must keep the VIEW's two decimals, got {t:?}"
);
}
}
fn column_named(app: &App, label: &str) -> usize { fn column_named(app: &App, label: &str) -> usize {
(0..app.layout.col_count()) (0..app.layout.col_count())
.find(|&c| app.layout.col_label(c) == label) .find(|&c| app.layout.col_label(c) == label)
@@ -5040,7 +5114,7 @@ mod basis_check {
.cells .cells
.iter() .iter()
.flatten() .flatten()
.any(|c| c.text == "99"), .any(|c| c.text == "99.00"),
"the accepted write never reached the document" "the accepted write never reached the document"
); );
} }
@@ -5077,7 +5151,7 @@ mod basis_check {
assert_eq!(rejection(applied), Rejection::RecordMoved(target.record)); assert_eq!(rejection(applied), Rejection::RecordMoved(target.record));
assert_eq!( assert_eq!(
value_at(&app, 1), value_at(&app, 1),
"7", "7.00",
"a rejected batch must leave the document exactly as it was" "a rejected batch must leave the document exactly as it was"
); );
} }
@@ -5106,7 +5180,7 @@ mod basis_check {
matches!(applied, Applied::Diff { .. }), matches!(applied, Applied::Diff { .. }),
"another row's edit invalidated this one: {applied:?}" "another row's edit invalidated this one: {applied:?}"
); );
assert_eq!(value_at(&app, 1), "99"); assert_eq!(value_at(&app, 1), "99.00");
} }
/// Class 2 by the other route: "gone" and "changed" must not collapse into one /// Class 2 by the other route: "gone" and "changed" must not collapse into one
@@ -5146,7 +5220,11 @@ mod basis_check {
let applied = sessions.apply(&mut app, id, &write(target.clone(), basis)); let applied = sessions.apply(&mut app, id, &write(target.clone(), basis));
assert_eq!(rejection(applied), Rejection::RecordUnstated(target.record)); assert_eq!(rejection(applied), Rejection::RecordUnstated(target.record));
assert_eq!(value_at(&app, 1), "1", "the omitted claim let a write land"); assert_eq!(
value_at(&app, 1),
"1.00",
"the omitted claim let a write land"
);
} }
// ── tier 1: the document ──────────────────────────────────────────── // ── tier 1: the document ────────────────────────────────────────────
@@ -5273,7 +5351,7 @@ mod basis_check {
matches!(applied, Applied::Diff { .. }), matches!(applied, Applied::Diff { .. }),
"a basis composed only from the published stream was refused: {applied:?}" "a basis composed only from the published stream was refused: {applied:?}"
); );
assert_eq!(value_at(&app, 1), "99"); assert_eq!(value_at(&app, 1), "99.00");
} }
/// The other side of publication: after somebody else writes, the stream carries /// The other side of publication: after somebody else writes, the stream carries
@@ -5331,6 +5409,6 @@ mod basis_check {
matches!(applied, Applied::Diff { .. }), matches!(applied, Applied::Diff { .. }),
"a re-composed batch was refused: {applied:?}" "a re-composed batch was refused: {applied:?}"
); );
assert_eq!(value_at(&app, 1), "99"); assert_eq!(value_at(&app, 1), "99.00");
} }
} }
+28 -20
View File
@@ -5,8 +5,8 @@ use unicode_width::UnicodeWidthStr;
use improvise_protocol::header_level_widths; use improvise_protocol::header_level_widths;
use crate::format::format_f64; use crate::format::{FormatPlan, NumberFormat};
use crate::model::cell::CellValue; use crate::model::cell::{CellKey, CellValue};
use crate::model::{Aggregation, Model}; use crate::model::{Aggregation, Model};
use crate::view::{AxisEntry, GridLayout}; use crate::view::{AxisEntry, GridLayout};
@@ -23,6 +23,9 @@ pub use improvise_protocol::{MAX_ROW_HEADER_W, MIN_ROW_HEADER_W};
pub struct GridBody { pub struct GridBody {
/// One per column of the WHOLE grid, clamped, never windowed. /// One per column of the WHOLE grid, clamped, never windowed.
pub col_widths: Vec<u16>, pub col_widths: Vec<u16>,
/// The format each column's TOTAL is drawn with, resolved from the
/// coordinates that survive the row aggregation — `context/code/core.md`.
pub col_formats: Vec<NumberFormat>,
/// Column sums over every row. **Empty** in records mode or an empty grid, /// Column sums over every row. **Empty** in records mode or an empty grid,
/// which is "this grid has no totals" rather than a strip of zeroes. /// which is "this grid has no totals" rather than a strip of zeroes.
pub totals: Vec<f64>, pub totals: Vec<f64>,
@@ -35,8 +38,7 @@ pub struct GridBody {
pub fn grid_body( pub fn grid_body(
model: &Model, model: &Model,
layout: &GridLayout, layout: &GridLayout,
fmt_comma: bool, plan: &FormatPlan,
fmt_decimals: u8,
rows: std::ops::Range<usize>, rows: std::ops::Range<usize>,
cols: std::ops::Range<usize>, cols: std::ops::Range<usize>,
) -> GridBody { ) -> GridBody {
@@ -49,6 +51,11 @@ pub fn grid_body(
false => Vec::new(), false => Vec::new(),
}; };
let mut window: Vec<Vec<Option<CellValue>>> = Vec::with_capacity(rows.len()); let mut window: Vec<Vec<Option<CellValue>>> = Vec::with_capacity(rows.len());
// Off the COLUMN, not off row 0: a formula targeting a row-axis item is a
// row-local override, and the total has aggregated that row away.
let col_formats: Vec<NumberFormat> = (0..n)
.map(|ci| plan.at(layout.column_key(ci).as_ref()))
.collect();
for ri in 0..layout.row_count() { for ri in 0..layout.row_count() {
let drawn = rows.contains(&ri); let drawn = rows.contains(&ri);
@@ -57,10 +64,11 @@ pub fn grid_body(
false => Vec::new(), false => Vec::new(),
}; };
for ci in 0..n { for ci in 0..n {
let value = cell_value(model, layout, records, ri, ci); let (key, value) = cell_value(model, layout, records, ri, ci);
let fmt = plan.at(key.as_ref());
let text = match records { let text = match records {
true => value.as_ref().map(|v| v.to_string()).unwrap_or_default(), true => value.as_ref().map(|v| v.to_string()).unwrap_or_default(),
false => crate::format::format_value(value.as_ref(), fmt_comma, fmt_decimals), false => fmt.value(value.as_ref()),
}; };
widen(&mut widths, ci, text.width()); widen(&mut widths, ci, text.width());
if let Some(sum) = totals.get_mut(ci) { if let Some(sum) = totals.get_mut(ci) {
@@ -75,8 +83,8 @@ pub fn grid_body(
} }
} }
for (ci, total) in totals.iter().enumerate() { for (ci, total) in totals.iter().enumerate() {
let width = format_f64(*total, fmt_comma, fmt_decimals).width(); let fmt = col_formats.get(ci).copied().unwrap_or(plan.view_format());
widen(&mut widths, ci, width); widen(&mut widths, ci, fmt.number(*total).width());
} }
GridBody { GridBody {
@@ -84,6 +92,7 @@ pub fn grid_body(
.into_iter() .into_iter()
.map(|w| (w + 1).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH)) .map(|w| (w + 1).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH))
.collect(), .collect(),
col_formats,
totals, totals,
window, window,
} }
@@ -96,12 +105,16 @@ fn cell_value(
records: bool, records: bool,
row: usize, row: usize,
col: usize, col: usize,
) -> Option<CellValue> { ) -> (Option<CellKey>, Option<CellValue>) {
match records { match records {
true => layout.records_value(row, col), true => (None, layout.records_value(row, col)),
false => layout false => {
.cell_key(row, col) let key = layout.cell_key(row, col);
.and_then(|key| model.evaluate_aggregated(&key, Aggregation::of(&layout.none_cats))), let value = key
.as_ref()
.and_then(|k| model.evaluate_aggregated(k, Aggregation::of(&layout.none_cats)));
(key, value)
}
} }
} }
@@ -131,13 +144,8 @@ fn widen(widths: &mut [u16], col: usize, width: usize) {
/// Adaptive column widths alone, for the keypress path, which needs no cells. /// Adaptive column widths alone, for the keypress path, which needs no cells.
/// It is [`grid_body`] with an empty window so there is one width rule. /// It is [`grid_body`] with an empty window so there is one width rule.
pub fn compute_col_widths( pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan) -> Vec<u16> {
model: &Model, grid_body(model, layout, plan, 0..0, 0..0).col_widths
layout: &GridLayout,
fmt_comma: bool,
fmt_decimals: u8,
) -> Vec<u16> {
grid_body(model, layout, fmt_comma, fmt_decimals, 0..0, 0..0).col_widths
} }
/// The total row-header width for a layout. The rule is the protocol's; this is the /// The total row-header width for a layout. The rule is the protocol's; this is the
+226 -1
View File
@@ -1,4 +1,101 @@
use crate::model::cell::CellValue; use crate::model::cell::{CellKey, CellValue};
use crate::model::types::Model;
use crate::view::types::View;
/// Comma grouping and decimal places — the pair every render path used to
/// thread as two scalars. `context/code/core.md`, "The number format".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NumberFormat {
pub comma: bool,
pub decimals: u8,
}
impl NumberFormat {
pub fn parse(fmt: &str) -> Self {
let (comma, decimals) = parse_number_format(fmt);
Self { comma, decimals }
}
pub fn value(&self, v: Option<&CellValue>) -> String {
format_value(v, self.comma, self.decimals)
}
pub fn number(&self, n: f64) -> String {
format_f64(n, self.comma, self.decimals)
}
}
/// One formula's claim on a cell: the coordinate it owns, and the format it
/// asks for — `None` where it names none. `context/code/core.md`.
#[derive(Clone, Debug)]
struct TargetFormat {
category: String,
target: String,
format: Option<NumberFormat>,
}
/// Which format each cell is drawn with: the view's `format:`, unless the
/// formula that OWNS the cell names its own (`context/code/core.md`).
#[derive(Clone, Debug)]
pub struct FormatPlan {
view: NumberFormat,
/// Every formula, in the model's own order, or **empty** where none names a
/// format — the ownership rule is `Model::formula_for`'s, not a hash's.
targets: Vec<TargetFormat>,
}
impl FormatPlan {
pub fn of(model: &Model, view: &View) -> Self {
let targets: Vec<TargetFormat> = model
.formulas()
.iter()
.map(|f| TargetFormat {
category: f.target_category.clone(),
target: f.target.clone(),
format: f.format.as_deref().map(NumberFormat::parse),
})
.collect();
Self {
view: NumberFormat::parse(&view.number_format),
// Where no formula names a format every answer is the view's, so
// the list is dropped rather than scanned once per cell.
targets: match targets.iter().any(|t| t.format.is_some()) {
true => targets,
false => Vec::new(),
},
}
}
/// One format for every cell — for the paths that have a format string and
/// no model to ask, and for tests that are not about the override.
pub fn uniform(fmt: &str) -> Self {
Self {
view: NumberFormat::parse(fmt),
targets: Vec::new(),
}
}
/// The view's own answer: what a cell no formula owns is drawn with.
pub fn view_format(&self) -> NumberFormat {
self.view
}
/// Asked of the *key*, never of the axes: a cell aggregating over `_Measure`
/// carries no target coordinate and so keeps the view's format.
pub fn at(&self, key: Option<&CellKey>) -> NumberFormat {
match key.filter(|_| !self.targets.is_empty()) {
None => self.view,
// The first match in the model's order — `formula_for`'s own rule,
// an owner naming no format included. `context/code/core.md`.
Some(key) => self
.targets
.iter()
.find(|t| key.get(&t.category) == Some(t.target.as_str()))
.and_then(|t| t.format)
.unwrap_or(self.view),
}
}
}
/// Format a CellValue for display with number formatting options. /// Format a CellValue for display with number formatting options.
pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String { pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
@@ -292,3 +389,131 @@ mod tests {
assert_eq!(format_f64(f64::INFINITY, false, 2), "inf"); assert_eq!(format_f64(f64::INFINITY, false, 2), "inf");
} }
} }
#[cfg(test)]
mod plan_tests {
use super::*;
use crate::formula::parse_formula;
use crate::model::cell::CellKey;
use crate::view::types::View;
fn ratio() -> CellValue {
CellValue::Number(2.0 / 3.0)
}
/// improvise-e0ex — see `context/test-evidence/core.md`.
#[test]
fn a_ratio_keeps_its_decimals_under_a_fresh_views_format() {
let plan = FormatPlan::uniform(&View::new("Default").number_format);
assert_eq!(plan.view_format().value(Some(&ratio())), "0.67");
}
fn model_with(raw: &str) -> Model {
let mut m = Model::new("m");
m.add_formula(parse_formula(raw, "_Measure").expect("parses"));
m
}
fn measure(item: &str) -> CellKey {
CellKey::new(vec![("_Measure".to_string(), item.to_string())])
}
/// improvise-e0ex — see `context/test-evidence/core.md`.
#[test]
fn a_formulas_own_format_wins_for_its_target_and_nowhere_else() {
let model = model_with("Margin = Profit / Revenue FORMAT .4");
let view = View::new("Default");
let plan = FormatPlan::of(&model, &view);
assert_eq!(
plan.at(Some(&measure("Margin"))).value(Some(&ratio())),
"0.6667"
);
// The raw measure beside it keeps the view's format, and so does a cell
// naming no measure at all — the two halves of "nowhere else".
assert_eq!(
plan.at(Some(&measure("Revenue"))).value(Some(&ratio())),
"0.67"
);
assert_eq!(plan.at(None).value(Some(&ratio())), "0.67");
}
fn two_owner_model(first: &str, second: &str) -> Model {
let mut m = Model::new("m");
m.add_category("Region").unwrap();
m.add_formula(parse_formula(first, "_Measure").expect("parses"));
m.add_formula(parse_formula(second, "Region").expect("parses"));
m
}
/// A key both formulas claim: `_Measure=Margin` and `Region=North`.
fn contested() -> CellKey {
CellKey::new(vec![
("_Measure".to_string(), "Margin".to_string()),
("Region".to_string(), "North".to_string()),
])
}
/// improvise-e0ex — see `context/test-evidence/core.md`.
#[test]
fn the_formula_that_owns_a_cell_names_its_format_even_when_it_names_none() {
// Owner first, and it names NO format: the view answers, and the later
// formula's `.4` must not reach a cell whose value it does not produce.
let owner_unformatted = two_owner_model(
"Margin = Profit / Revenue",
"North = Profit / Revenue FORMAT .4",
);
let view = View::new("Default");
assert_eq!(
FormatPlan::of(&owner_unformatted, &view)
.at(Some(&contested()))
.value(Some(&ratio())),
"0.67",
"a later formula's format overrode the formula that owns the cell"
);
// The same two formulas the other way round: now the owner names `.4`.
let owner_formatted = two_owner_model(
"Margin = Profit / Revenue FORMAT .4",
"North = Profit / Revenue",
);
assert_eq!(
FormatPlan::of(&owner_formatted, &view)
.at(Some(&contested()))
.value(Some(&ratio())),
"0.6667",
"the owner's own format must win"
);
}
/// improvise-e0ex — see `context/test-evidence/core.md`.
#[test]
fn the_owner_is_the_one_model_order_names_and_not_the_one_a_hash_does() {
let model = two_owner_model(
"Margin = Profit / Revenue",
"North = Profit / Revenue FORMAT .4",
);
let view = View::new("Default");
// A `HashMap` gets a fresh `RandomState` per process, so one run cannot
// see hash order (gotcha 18); the plan is rebuilt to make that the point.
for _ in 0..5 {
assert_eq!(
FormatPlan::of(&model, &view)
.at(Some(&contested()))
.value(Some(&ratio())),
"0.67"
);
}
}
/// improvise-e0ex — see `context/test-evidence/core.md`.
#[test]
fn a_formula_naming_no_format_leaves_its_target_on_the_views() {
let model = model_with("Margin = Profit / Revenue");
let plan = FormatPlan::of(&model, &View::new("Default"));
assert_eq!(
plan.at(Some(&measure("Margin"))).value(Some(&ratio())),
"0.67"
);
}
}
+21 -5
View File
@@ -547,15 +547,17 @@ impl GridLayout {
model: &Model, model: &Model,
row: usize, row: usize,
col: usize, col: usize,
fmt_comma: bool, plan: &crate::format::FormatPlan,
fmt_decimals: u8,
) -> String { ) -> String {
if self.is_records_mode() { if self.is_records_mode() {
self.records_display(row, col).unwrap_or_default() self.records_display(row, col).unwrap_or_default()
} else { } else {
self.cell_key(row, col) self.cell_key(row, col)
.and_then(|key| model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats))) .and_then(|key| {
.map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals)) let value =
model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats))?;
Some(plan.at(Some(&key)).value(Some(&value)))
})
.unwrap_or_default() .unwrap_or_default()
} }
} }
@@ -584,6 +586,17 @@ impl GridLayout {
Some(CellKey::new(coords)) Some(CellKey::new(coords))
} }
/// The coordinates a COLUMN answers for once its rows are aggregated away:
/// the page/filter constraints and the column's own tuple (core.md §6).
pub fn column_key(&self, col: usize) -> Option<CellKey> {
let col_item = data_items(&self.col_items).nth(col)?;
let mut coords = page_constraints(&self.page_coords);
for (cat, item) in self.col_cats.iter().zip(col_item.iter()) {
coords.push((cat.clone(), item.clone()));
}
Some(CellKey::new(coords))
}
/// Visual index of the nth data row (skipping group headers). /// Visual index of the nth data row (skipping group headers).
pub fn data_row_to_visual(&self, data_row: usize) -> Option<usize> { pub fn data_row_to_visual(&self, data_row: usize) -> Option<usize> {
data_index_to_visual(&self.row_items, data_row) data_index_to_visual(&self.row_items, data_row)
@@ -809,7 +822,10 @@ mod tests {
!layout.none_cats.is_empty(), !layout.none_cats.is_empty(),
"a default view must have None-axis categories for this to be the bug" "a default view must have None-axis categories for this to be the bug"
); );
assert_eq!(layout.display_text(&wb.model, 0, 0, false, 0), "pending"); assert_eq!(
layout.display_text(&wb.model, 0, 0, &crate::format::FormatPlan::uniform("")),
"pending"
);
} }
#[test] #[test]
+3 -1
View File
@@ -53,7 +53,9 @@ impl View {
page_selections: HashMap::new(), page_selections: HashMap::new(),
hidden_items: HashMap::new(), hidden_items: HashMap::new(),
collapsed_groups: HashMap::new(), collapsed_groups: HashMap::new(),
number_format: ",.0".to_string(), // Decimals by default: a ratio is the case a format cannot be
// guessed for, and `.0` draws 2/3 as `1` (improvise-e0ex).
number_format: ",.2".to_string(),
prune_empty: false, prune_empty: false,
} }
} }
+12
View File
@@ -57,6 +57,10 @@ pub struct Formula {
pub expr: Expr, pub expr: Expr,
/// Optional WHERE filter /// Optional WHERE filter
pub filter: Option<Filter>, pub filter: Option<Filter>,
/// How this formula's target is drawn, overriding the view's `format:` for
/// that one measure. `None` means the view answers — `context/code/core.md`.
#[serde(default)]
pub format: Option<String>,
} }
impl Formula { impl Formula {
@@ -73,6 +77,14 @@ impl Formula {
target_category: target_category.into(), target_category: target_category.into(),
expr, expr,
filter, filter,
format: None,
} }
} }
/// The same formula drawn with `format`. Separate from [`Formula::new`] so
/// the five-argument constructor every caller already spells stays put.
pub fn with_format(mut self, format: Option<String>) -> Self {
self.format = format;
self
}
} }
+6 -1
View File
@@ -13,7 +13,7 @@ WHITESPACE = _{ " " | "\t" }
// ---- top-level ---------------------------------------------------------- // ---- top-level ----------------------------------------------------------
formula = { SOI ~ target ~ "=" ~ expr ~ where_clause? ~ EOI } formula = { SOI ~ target ~ "=" ~ expr ~ where_clause? ~ format_clause? ~ EOI }
// The target is an ordinary identifier: parser.rs unquotes it exactly the // The target is an ordinary identifier: parser.rs unquotes it exactly the
// way it unquotes a `ref_expr`, so `|Total Revenue| = ...` and a reference // way it unquotes a `ref_expr`, so `|Total Revenue| = ...` and a reference
@@ -24,6 +24,11 @@ target = { identifier }
// `WHEREAS x = y` reads as a WHERE on a category `AS`. // `WHEREAS x = y` reads as a WHERE on a category `AS`.
where_clause = ${ ^"WHERE" ~ sp+ ~ identifier ~ sp* ~ "=" ~ sp* ~ filter_value } where_clause = ${ ^"WHERE" ~ sp+ ~ identifier ~ sp* ~ "=" ~ sp* ~ filter_value }
// How this formula's TARGET is drawn, overriding the view's `format:` for that
// one measure. `sp+` for the same reason `WHERE` has it — `context/code/core.md`.
format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }
format_spec = @{ (ASCII_ALPHANUMERIC | "," | "." | "%")+ }
// ---- expressions -------------------------------------------------------- // ---- expressions --------------------------------------------------------
// Used by parse_expr() — forces a standalone expression to consume the // Used by parse_expr() — forces a standalone expression to consume the
+13
View File
@@ -42,6 +42,7 @@ fn build_formula(pair: Pair<Rule>, raw: &str, target_category: &str) -> Formula
let mut target = None; let mut target = None;
let mut expr = None; let mut expr = None;
let mut filter = None; let mut filter = None;
let mut format = None;
for inner in pair.into_inner() { for inner in pair.into_inner() {
match inner.as_rule() { match inner.as_rule() {
// Unquoted exactly like `Expr::Ref`: the same spelling must name // Unquoted exactly like `Expr::Ref`: the same spelling must name
@@ -49,6 +50,10 @@ fn build_formula(pair: Pair<Rule>, raw: &str, target_category: &str) -> Formula
Rule::target => target = Some(identifier_to_string(first_inner(inner))), Rule::target => target = Some(identifier_to_string(first_inner(inner))),
Rule::expr => expr = Some(build_expr(inner)), Rule::expr => expr = Some(build_expr(inner)),
Rule::where_clause => filter = Some(build_filter(inner)), Rule::where_clause => filter = Some(build_filter(inner)),
// The spec, not the clause: `FORMAT ` is syntax and not the picture.
Rule::format_clause => {
format = Some(first_inner(inner).as_str().to_string());
}
// Ignored as an ARM, so "ignored" is a decision and not a silence. // Ignored as an ARM, so "ignored" is a decision and not a silence.
Rule::EOI => {} Rule::EOI => {}
Rule::WHITESPACE Rule::WHITESPACE
@@ -77,6 +82,7 @@ fn build_formula(pair: Pair<Rule>, raw: &str, target_category: &str) -> Formula
| Rule::pipe_quoted | Rule::pipe_quoted
| Rule::bare_ident | Rule::bare_ident
| Rule::filter_value | Rule::filter_value
| Rule::format_spec
| Rule::string | Rule::string
| Rule::number => { | Rule::number => {
unreachable!("{GRAMMAR_INVARIANT}: formula part {:?}", inner.as_rule()) unreachable!("{GRAMMAR_INVARIANT}: formula part {:?}", inner.as_rule())
@@ -90,6 +96,7 @@ fn build_formula(pair: Pair<Rule>, raw: &str, target_category: &str) -> Formula
expr.expect(GRAMMAR_INVARIANT), expr.expect(GRAMMAR_INVARIANT),
filter, filter,
) )
.with_format(format)
} }
fn build_expr(pair: Pair<Rule>) -> Expr { fn build_expr(pair: Pair<Rule>) -> Expr {
@@ -166,6 +173,8 @@ fn build_unary(pair: Pair<Rule>) -> Expr {
| Rule::pipe_quoted | Rule::pipe_quoted
| Rule::bare_ident | Rule::bare_ident
| Rule::filter_value | Rule::filter_value
| Rule::format_clause
| Rule::format_spec
| Rule::string | Rule::string
| Rule::number => { | Rule::number => {
unreachable!( unreachable!(
@@ -214,6 +223,8 @@ fn build_primary(pair: Pair<Rule>) -> Expr {
| Rule::pipe_quoted | Rule::pipe_quoted
| Rule::bare_ident | Rule::bare_ident
| Rule::filter_value | Rule::filter_value
| Rule::format_clause
| Rule::format_spec
| Rule::string => { | Rule::string => {
unreachable!( unreachable!(
"{GRAMMAR_INVARIANT}: primary alternative {:?}", "{GRAMMAR_INVARIANT}: primary alternative {:?}",
@@ -324,6 +335,8 @@ fn filter_value_to_string(pair: Pair<Rule>) -> String {
| Rule::ref_expr | Rule::ref_expr
| Rule::identifier | Rule::identifier
| Rule::filter_value | Rule::filter_value
| Rule::format_clause
| Rule::format_spec
| Rule::number => { | Rule::number => {
unreachable!( unreachable!(
"{GRAMMAR_INVARIANT}: filter_value alternative {:?}", "{GRAMMAR_INVARIANT}: filter_value alternative {:?}",
+3 -1
View File
@@ -3,7 +3,9 @@
/// Words the formula grammar gives a meaning of their own. A name spelled like /// Words the formula grammar gives a meaning of their own. A name spelled like
/// one is pipe-quoted even though it is otherwise a legal bare identifier. /// one is pipe-quoted even though it is otherwise a legal bare identifier.
const KEYWORDS: [&str; 8] = ["WHERE", "SUM", "AVG", "MIN", "MAX", "COUNT", "IF", "IS"]; const KEYWORDS: [&str; 9] = [
"WHERE", "SUM", "AVG", "MIN", "MAX", "COUNT", "IF", "IS", "FORMAT",
];
/// Bare-writable: matches `[A-Za-z_][A-Za-z0-9_-]*` and is not a [`KEYWORDS`] /// Bare-writable: matches `[A-Za-z_][A-Za-z0-9_-]*` and is not a [`KEYWORDS`]
/// entry. The keyword clause is canonical — `context/code/formula-and-io.md`. /// entry. The keyword clause is canonical — `context/code/formula-and-io.md`.
+4 -5
View File
@@ -453,11 +453,10 @@ mod tests {
/// `f64`, so a value the grid shows as "5" must reach the table as 5.25. /// `f64`, so a value the grid shows as "5" must reach the table as 5.25.
#[test] #[test]
fn a_cell_reaches_the_table_unrounded() { fn a_cell_reaches_the_table_unrounded() {
let (mut model, view) = fixture(); let (mut model, mut view) = fixture();
assert_eq!( // Pinned, not inherited: the default shows two decimals since
view.number_format, ",.0", // improvise-e0ex, and 5.25 under it is not rounded at all.
"the fixture view rounds to integers" view.number_format = ",.0".to_string();
);
model.set_cell( model.set_cell(
record_key("North", "Web", "Q1", "n1", "Units"), record_key("North", "Web", "Q1", "n1", "Units"),
CellValue::Number(5.25), CellValue::Number(5.25),
@@ -34,7 +34,7 @@ fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
// writes rows the grid does not draw (improvise-iot6). // writes rows the grid does not draw (improvise-iot6).
let layout = GridLayout::with_frozen_records(&workbook.model, view, None); let layout = GridLayout::with_frozen_records(&workbook.model, view, None);
let model = &workbook.model; let model = &workbook.model;
let (fmt_comma, fmt_decimals) = crate::format::parse_number_format(&view.number_format); let plan = crate::format::FormatPlan::of(model, view);
let mut wtr = csv::WriterBuilder::new() let mut wtr = csv::WriterBuilder::new()
.flexible(true) .flexible(true)
@@ -66,10 +66,7 @@ fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
if !row_label.is_empty() { if !row_label.is_empty() {
record.push(row_label); record.push(row_label);
} }
record.extend( record.extend((0..layout.col_count()).map(|ci| layout.display_text(model, ri, ci, &plan)));
(0..layout.col_count())
.map(|ci| layout.display_text(model, ri, ci, fmt_comma, fmt_decimals)),
);
wtr.write_record(&record)?; wtr.write_record(&record)?;
} }
@@ -1121,3 +1121,40 @@ fn a_cell_with_no_coordinates_round_trips() {
); );
assert_eq!(format_md(&back), text); assert_eq!(format_md(&back), text);
} }
// ── parse_md: a formula's own number format (improvise-e0ex) ─────────
/// The PARSED format, never the re-emitted line — `raw` cannot be the
/// witness (gotcha 34). Evidence: `context/test-evidence/formula-and-io.md`.
#[test]
fn parse_md_round_trips_the_format_a_formula_carries() {
let mut m = two_cat_model();
m.model
.add_formula(parse_formula("Margin = Revenue / Cost FORMAT .4", "_Measure").unwrap());
let text = format_md(&m);
let loaded = parse_md(&text).unwrap();
let f = &loaded.model.formulas()[0];
assert_eq!(
f.format.as_deref(),
Some(".4"),
"the format must survive as a parsed field, not only inside `raw`"
);
// The two halves this test exists to keep apart: `raw` is byte-identical
// whether or not the walker kept the field, so it cannot be the witness.
assert_eq!(f.raw, "Margin = Revenue / Cost FORMAT .4");
assert_eq!(f.target, "Margin");
}
/// A formula naming no format reads back as `None`, so the field above is a
/// decision the file carried. Evidence: `context/test-evidence/formula-and-io.md`.
#[test]
fn parse_md_reads_a_formula_with_no_format_as_none() {
let mut m = two_cat_model();
m.model
.add_formula(parse_formula("Margin = Revenue / Cost", "_Measure").unwrap());
let loaded = parse_md(&format_md(&m)).unwrap();
assert_eq!(loaded.model.formulas()[0].format, None);
}
@@ -120,7 +120,7 @@ fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
assert_eq!(model.col_levels[0], vec!["Revenue", "Cost"]); assert_eq!(model.col_levels[0], vec!["Revenue", "Cost"]);
assert_eq!( assert_eq!(
model.rows[0].cells()[0].text, model.rows[0].cells()[0].text,
"1,200", "1,200.00",
"the value arrives *formatted* — the separator is the server's view \ "the value arrives *formatted* — the separator is the server's view \
format, and a client that re-derived the text would not have it" format, and a client that re-derived the text would not have it"
); );
@@ -168,7 +168,7 @@ fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
); );
assert_eq!( assert_eq!(
client.model().rows[at.visual_row].cells()[at.col].text, client.model().rows[at.visual_row].cells()[at.col].text,
"7,500", "7,500.00",
"the page redraws from the server's formatting, never from the \ "the page redraws from the server's formatting, never from the \
\"7500\" the user typed" \"7500\" the user typed"
); );
+2 -2
View File
@@ -358,7 +358,7 @@ mod tests {
fn a_wire_edit_reaches_the_document_and_comes_back_as_a_projection() { fn a_wire_edit_reaches_the_document_and_comes_back_as_a_projection() {
let (mut worker, _) = worker_over_storage(); let (mut worker, _) = worker_over_storage();
let mut grid = connect(&mut worker); let mut grid = connect(&mut worker);
assert_eq!(grid.cells[0][0].text, "10"); assert_eq!(grid.cells[0][0].text, "10.00");
let cell = addressed(&grid, 0, 0); let cell = addressed(&grid, 0, 0);
let cmd = reduce::caught_up( let cmd = reduce::caught_up(
@@ -371,7 +371,7 @@ mod tests {
let out = worker.on_frame(ClientFrame::command(cmd)); let out = worker.on_frame(ClientFrame::command(cmd));
let grid = folded(&mut grid, &out); let grid = folded(&mut grid, &out);
assert_eq!( assert_eq!(
grid.cells[0][0].text, "42", grid.cells[0][0].text, "42.00",
"the edit did not come back through the projection stream" "the edit did not come back through the projection stream"
); );
} }
@@ -163,7 +163,7 @@ fn the_worker_loads_projects_edits_and_persists_over_post_message() {
main.grid.row_frame.leaf_labels(), main.grid.row_frame.leaf_labels(),
vec!["North", "South", "East"] vec!["North", "South", "East"]
); );
assert_eq!(main.text_at(0, 0), "10"); assert_eq!(main.text_at(0, 0), "10.00");
let cell = main.cell(2, 0); let cell = main.cell(2, 0);
main.post(&main.caught_up(ModelCommand::SetCell { main.post(&main.caught_up(ModelCommand::SetCell {
@@ -172,7 +172,7 @@ fn the_worker_loads_projects_edits_and_persists_over_post_message() {
})); }));
assert_eq!( assert_eq!(
main.text_at(2, 0), main.text_at(2, 0),
"31", "31.00",
"the edit must come back through the projection stream, not be assumed" "the edit must come back through the projection stream, not be assumed"
); );
@@ -227,7 +227,7 @@ fn a_reload_over_the_same_origin_reopens_what_was_saved() {
reloaded.post(&ClientFrame::Viewport { rows: 10, cols: 4 }); reloaded.post(&ClientFrame::Viewport { rows: 10, cols: 4 });
assert_eq!( assert_eq!(
reloaded.text_at(0, 0), reloaded.text_at(0, 0),
"77", "77.00",
"the reload re-seeded over the user's saved work" "the reload re-seeded over the user's saved work"
); );
} }
@@ -258,7 +258,7 @@ fn a_batch_composed_against_a_moved_document_is_refused() {
); );
assert_eq!( assert_eq!(
main.text_at(0, 0), main.text_at(0, 0),
"2", "2.00",
"a refused batch changed the document" "a refused batch changed the document"
); );
} }
@@ -336,7 +336,7 @@ fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() {
main.grid.col_frame.leaf_labels(), main.grid.col_frame.leaf_labels(),
vec!["Revenue", "Cost", "Profit"] vec!["Revenue", "Cost", "Profit"]
); );
assert_eq!(main.text_at(0, 2), "12,200", "Profit is live on arrival"); assert_eq!(main.text_at(0, 2), "12,200.00", "Profit is live on arrival");
// `cell` panics on anything but a stored cell, which is exactly the failure a // `cell` panics on anything but a stored cell, which is exactly the failure a
// visitor meets as a cell that does not answer a click (ADR 002). // visitor meets as a cell that does not answer a click (ADR 002).
@@ -362,8 +362,8 @@ fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() {
cell: revenue, cell: revenue,
value: CellValue::Number(20000.0), value: CellValue::Number(20000.0),
})); }));
assert_eq!(main.text_at(0, 0), "20,000"); assert_eq!(main.text_at(0, 0), "20,000.00");
assert_eq!(main.text_at(0, 2), "13,800", "20,000 - 6,200"); assert_eq!(main.text_at(0, 2), "13,800.00", "20,000 - 6,200");
} }
/// improvise-z754: `on_frame` built the client's frames and persisted AFTER, so /// improvise-z754: `on_frame` built the client's frames and persisted AFTER, so
+1 -1
View File
@@ -133,7 +133,7 @@ fn a_client_on_a_socket_is_seeded_and_gets_its_edit_back() {
waited += 1; waited += 1;
} }
assert_eq!( assert_eq!(
grid.cells[0][0].text, "12", grid.cells[0][0].text, "12.00",
"the edit never came back down the socket" "the edit never came back down the socket"
); );
+3 -3
View File
@@ -159,7 +159,7 @@ test.describe("every field the render model projects reaches the screen", () =>
// The totals strip. Q1 Revenue is 18400+24600+9200+11500 — a sum over // The totals strip. Q1 Revenue is 18400+24600+9200+11500 — a sum over
// EVERY row, which is why it is projected and not added up here. // EVERY row, which is why it is projected and not added up here.
await expect(page.locator("#grid tfoot th")).toHaveText("Total"); await expect(page.locator("#grid tfoot th")).toHaveText("Total");
await expect(page.locator("#grid tfoot td").first()).toHaveText("63,700"); await expect(page.locator("#grid tfoot td").first()).toHaveText("63,700.00");
// A total is not a cell anyone may type into. // A total is not a cell anyone may type into.
await expect(page.locator("#grid tfoot td.editable")).toHaveCount(0); await expect(page.locator("#grid tfoot td.editable")).toHaveCount(0);
@@ -170,7 +170,7 @@ test.describe("every field the render model projects reaches the screen", () =>
await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q2 \(2\/4\)/, { await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q2 \(2\/4\)/, {
timeout: 10_000, timeout: 10_000,
}); });
await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400"); await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400.00");
}); });
}); });
@@ -280,7 +280,7 @@ test.describe("a pointer event reaches the client and its answer is painted", ()
// The third edit of the same repair: `close` restores the DRAWN text, not // The third edit of the same repair: `close` restores the DRAWN text, not
// the seed. `repaint` early-returns while `editing` is set, so a `18400` // the seed. `repaint` early-returns while `editing` is set, so a `18400`
// put back here stays on screen (improvise-0x9u). // put back here stays on screen (improvise-0x9u).
await expect(cells.nth(0)).toHaveText("18,400"); await expect(cells.nth(0)).toHaveText("18,400.00");
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(cells.nth(4)).toHaveClass(/selected/); await expect(cells.nth(4)).toHaveClass(/selected/);
+1 -1
View File
@@ -58,7 +58,7 @@ test.describe("a page axis can be picked from a menu", () => {
// Espresso Revenue is 18,400 in Q1 and 20,300 in Q4, so a menu that moved // Espresso Revenue is 18,400 in Q1 and 20,300 in Q4, so a menu that moved
// only the label would leave this unchanged. // only the label would leave this unchanged.
await expect(firstCell).not.toHaveText(q1 ?? ""); await expect(firstCell).not.toHaveText(q1 ?? "");
await expect(firstCell).toHaveText("20,300"); await expect(firstCell).toHaveText("20,300.00");
}); });
// The menu is operable without a mouse, and while it is open it owns the // The menu is operable without a mouse, and while it is open it owns the
+2 -2
View File
@@ -273,8 +273,8 @@ test.describe("the standalone deployment", () => {
await input.fill("20000"); await input.fill("20000");
await input.press("Enter"); await input.press("Enter");
await expect(cell).toHaveText("20,000", { timeout: 10_000 }); await expect(cell).toHaveText("20,000.00", { timeout: 10_000 });
expect(before).not.toBe("20,000"); expect(before).not.toBe("20,000.00");
// The editor is gone and did not come back: the half of the re-entry guard // The editor is gone and did not come back: the half of the re-entry guard
// that IS observable from the DOM. // that IS observable from the DOM.
await expect(page.locator("#grid input")).toHaveCount(0); await expect(page.locator("#grid input")).toHaveCount(0);
+3
View File
@@ -861,6 +861,9 @@ mod tests {
m.add_item_in_group("Month", "Jan", "Q1").unwrap(); m.add_item_in_group("Month", "Jan", "Q1").unwrap();
m.add_item_in_group("Month", "Apr", "Q2").unwrap(); m.add_item_in_group("Month", "Apr", "Q2").unwrap();
m.add_item("Payer", "Alice").unwrap(); m.add_item("Payer", "Alice").unwrap();
// Pinned so the byte-exact frames below characterize the LAYOUT and not
// the view default, which grew two decimals with improvise-e0ex.
m.active_view_mut().number_format = ",.0".to_string();
for (r, v) in [("East", 100.0), ("West", 50.0)] { for (r, v) in [("East", 100.0), ("West", 50.0)] {
for mo in ["Jan", "Apr"] { for mo in ["Jan", "Apr"] {
m.model.set_cell( m.model.set_cell(
+1 -2
View File
@@ -384,8 +384,7 @@ fn wide_cell_clamps_without_overflow() {
let widths = improvise::ui::viewport::compute_col_widths( let widths = improvise::ui::viewport::compute_col_widths(
&app.model_state.workbook.model, &app.model_state.workbook.model,
&app.layout, &app.layout,
false, &improvise::format::FormatPlan::uniform(""),
0,
); );
assert_eq!( assert_eq!(
widths, widths,
+4 -1
View File
@@ -76,5 +76,8 @@ fn the_web_demo_shows_a_formula_beside_its_inputs() {
vec!["Espresso", "Latte", "Cold Brew", "Pastry"] vec!["Espresso", "Latte", "Cold Brew", "Pastry"]
); );
let profit = grid.cell(0, 2).expect("the Profit cell"); let profit = grid.cell(0, 2).expect("the Profit cell");
assert_eq!(profit.text, "12,200", "18,400 - 6,200, recomputed on load"); assert_eq!(
profit.text, "12,200.00",
"18,400 - 6,200, recomputed on load, under the decimal default"
);
} }
+2 -2
View File
@@ -8,7 +8,7 @@ _Measure: column
Quarter: page, Q1 Quarter: page, Q1
_Index: none _Index: none
_Dim: none _Dim: none
format: ,.0 format: ,.2
## View: Season ## View: Season
Quarter: row Quarter: row
@@ -16,7 +16,7 @@ _Measure: column
Product: page, Latte Product: page, Latte
_Index: none _Index: none
_Dim: none _Dim: none
format: ,.0 format: ,.2
## Formulas ## Formulas
- Profit = Revenue - Cost - Profit = Revenue - Cost