fix: show decimals by default, and let a formula name its own format
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
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use improvise::format::parse_number_format;
|
||||
use improvise::format::FormatPlan;
|
||||
use improvise::model::cell::{CellKey, CellValue};
|
||||
use improvise::view::Axis;
|
||||
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 {
|
||||
let layout = &app.layout;
|
||||
let model = &app.model_state.workbook.model;
|
||||
let (comma, decimals) =
|
||||
parse_number_format(&app.model_state.workbook.active_view().number_format);
|
||||
let plan = FormatPlan::of(model, app.model_state.workbook.active_view());
|
||||
let mut best = Duration::MAX;
|
||||
for _ in 0..iters.max(3) {
|
||||
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
|
||||
|
||||
@@ -820,3 +820,121 @@ 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
|
||||
landed — and it is the more useful half of this entry, because it says where the
|
||||
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 one hash get per
|
||||
`(category, item)` pair in the plan, of which there is normally one.
|
||||
|
||||
**A column total takes the format of its own column's first row.** `GridBody`
|
||||
carries `col_formats` beside `col_widths` so the totals strip and the cells
|
||||
above it cannot disagree. Where `_Measure` is on the *row* axis a column is not
|
||||
a measure at all, so no formula format applies to any cell in it and the first
|
||||
row's answer is the view's — the rule only has to be decided for the case it
|
||||
cannot arise in.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
@@ -4216,3 +4216,31 @@ 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`
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1062,3 +1062,48 @@ qualification.
|
||||
It asserts `effective_item_names("_Measure")` **by value** rather than
|
||||
`!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.
|
||||
|
||||
---
|
||||
|
||||
## `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.
|
||||
|
||||
@@ -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`
|
||||
already holds groups to that standard. This test was the item-list half of the
|
||||
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(),
|
||||
expr: crate::formula::ast::Expr::Number(0.0),
|
||||
filter: None,
|
||||
format: None,
|
||||
});
|
||||
let layout = make_layout(&m);
|
||||
let reg = make_registry();
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::rc::Rc;
|
||||
|
||||
use crate::command::cmd::CmdContext;
|
||||
use crate::command::keymap::{Keymap, KeymapSet};
|
||||
use crate::format::parse_number_format;
|
||||
use crate::format::FormatPlan;
|
||||
use crate::import::wizard::ImportWizard;
|
||||
use crate::model::cell::{CellKey, CellValue, DataStore};
|
||||
use crate::persistence;
|
||||
@@ -796,13 +796,8 @@ impl App {
|
||||
display_value: self.display_value_at_cursor(),
|
||||
visible_rows: (self.term_height as usize).saturating_sub(8),
|
||||
visible_cols: {
|
||||
let (fmt_comma, fmt_decimals) = parse_number_format(&view.number_format);
|
||||
let col_widths = compute_col_widths(
|
||||
&self.model_state.workbook.model,
|
||||
layout,
|
||||
fmt_comma,
|
||||
fmt_decimals,
|
||||
);
|
||||
let model = &self.model_state.workbook.model;
|
||||
let col_widths = compute_col_widths(model, layout, &FormatPlan::of(model, view));
|
||||
let row_header_width = compute_row_header_width(layout);
|
||||
compute_visible_cols(
|
||||
&col_widths,
|
||||
|
||||
@@ -453,7 +453,7 @@ mod tests {
|
||||
|
||||
let mut seen = vec![connect.msg];
|
||||
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 —
|
||||
@@ -483,7 +483,7 @@ mod tests {
|
||||
|
||||
let mut seen = vec![bootstrap.msg];
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
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::category::MEASURE_CATEGORY;
|
||||
use improvise_core::model::cell::CellValue;
|
||||
@@ -141,12 +141,12 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache {
|
||||
let elsewhere =
|
||||
(name != app.view_name()).then(|| GridLayout::with_frozen_records(model, view, None));
|
||||
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 cols = viewport.cols_in(layout.col_count());
|
||||
// ONE walk: the widths, the totals and this window's values all come off the
|
||||
// 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 {
|
||||
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)| {
|
||||
cols.clone()
|
||||
.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(),
|
||||
totals: column_totals(&body, cols, comma, decimals),
|
||||
totals: column_totals(&body, cols),
|
||||
page_coords: layout.page_coords.clone(),
|
||||
// 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.
|
||||
@@ -252,17 +252,12 @@ fn axis_frame(
|
||||
|
||||
/// 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).
|
||||
fn column_totals(
|
||||
body: &GridBody,
|
||||
cols: std::ops::Range<usize>,
|
||||
comma: bool,
|
||||
decimals: u8,
|
||||
) -> Vec<CellDisplay> {
|
||||
fn column_totals(body: &GridBody, cols: std::ops::Range<usize>) -> Vec<CellDisplay> {
|
||||
match body.totals.is_empty() {
|
||||
true => Vec::new(),
|
||||
false => cols
|
||||
.filter_map(|c| body.totals.get(c))
|
||||
.map(|total| CellDisplay::number(format_f64(*total, comma, decimals)))
|
||||
.filter_map(|c| Some((body.totals.get(c)?, body.col_formats.get(c)?)))
|
||||
.map(|(total, fmt)| CellDisplay::number(fmt.number(*total)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -275,8 +270,7 @@ fn cell_display(
|
||||
row: usize,
|
||||
col: usize,
|
||||
value: &Option<CellValue>,
|
||||
comma: bool,
|
||||
decimals: u8,
|
||||
plan: &FormatPlan,
|
||||
) -> CellDisplay {
|
||||
if layout.is_records_mode() {
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
let key = layout.cell_key(row, col);
|
||||
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(_))),
|
||||
error: matches!(value, Some(CellValue::Error(_))),
|
||||
id: cell_id(app, layout, row, col),
|
||||
// The *unformatted* value the TUI seeds its edit buffer from. Projecting it
|
||||
// beats a client un-formatting `text` (gotcha 47).
|
||||
edit_text: layout
|
||||
.cell_key(row, col)
|
||||
.map(|key| app.display_value_in(layout, &key)),
|
||||
edit_text: key.map(|key| app.display_value_in(layout, &key)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1811,7 +1804,7 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(cells.len(), 1, "expected one changed cell, got {cells:?}");
|
||||
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
|
||||
@@ -2733,7 +2726,8 @@ mod tests {
|
||||
|
||||
let model = &app.model_state.workbook.model;
|
||||
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!(
|
||||
one_row.window.len(),
|
||||
1,
|
||||
@@ -2752,7 +2746,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -4396,7 +4390,7 @@ mod cell_identity {
|
||||
|
||||
assert_eq!(
|
||||
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],
|
||||
"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
|
||||
@@ -4510,6 +4504,39 @@ mod cell_identity {
|
||||
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");
|
||||
}
|
||||
|
||||
fn column_named(app: &App, label: &str) -> usize {
|
||||
(0..app.layout.col_count())
|
||||
.find(|&c| app.layout.col_label(c) == label)
|
||||
@@ -5040,7 +5067,7 @@ mod basis_check {
|
||||
.cells
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|c| c.text == "99"),
|
||||
.any(|c| c.text == "99.00"),
|
||||
"the accepted write never reached the document"
|
||||
);
|
||||
}
|
||||
@@ -5077,7 +5104,7 @@ mod basis_check {
|
||||
assert_eq!(rejection(applied), Rejection::RecordMoved(target.record));
|
||||
assert_eq!(
|
||||
value_at(&app, 1),
|
||||
"7",
|
||||
"7.00",
|
||||
"a rejected batch must leave the document exactly as it was"
|
||||
);
|
||||
}
|
||||
@@ -5106,7 +5133,7 @@ mod basis_check {
|
||||
matches!(applied, Applied::Diff { .. }),
|
||||
"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
|
||||
@@ -5146,7 +5173,11 @@ mod basis_check {
|
||||
|
||||
let applied = sessions.apply(&mut app, id, &write(target.clone(), basis));
|
||||
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 ────────────────────────────────────────────
|
||||
@@ -5273,7 +5304,7 @@ mod basis_check {
|
||||
matches!(applied, Applied::Diff { .. }),
|
||||
"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
|
||||
@@ -5331,6 +5362,6 @@ mod basis_check {
|
||||
matches!(applied, Applied::Diff { .. }),
|
||||
"a re-composed batch was refused: {applied:?}"
|
||||
);
|
||||
assert_eq!(value_at(&app, 1), "99");
|
||||
assert_eq!(value_at(&app, 1), "99.00");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use improvise_protocol::header_level_widths;
|
||||
|
||||
use crate::format::format_f64;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::format::{FormatPlan, NumberFormat};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::{Aggregation, Model};
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
|
||||
@@ -23,6 +23,9 @@ pub use improvise_protocol::{MAX_ROW_HEADER_W, MIN_ROW_HEADER_W};
|
||||
pub struct GridBody {
|
||||
/// One per column of the WHOLE grid, clamped, never windowed.
|
||||
pub col_widths: Vec<u16>,
|
||||
/// The format each column's cells were drawn with, read off the top row so
|
||||
/// the totals strip agrees with them — `context/code/command.md`.
|
||||
pub col_formats: Vec<NumberFormat>,
|
||||
/// 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.
|
||||
pub totals: Vec<f64>,
|
||||
@@ -35,8 +38,7 @@ pub struct GridBody {
|
||||
pub fn grid_body(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
fmt_comma: bool,
|
||||
fmt_decimals: u8,
|
||||
plan: &FormatPlan,
|
||||
rows: std::ops::Range<usize>,
|
||||
cols: std::ops::Range<usize>,
|
||||
) -> GridBody {
|
||||
@@ -49,6 +51,7 @@ pub fn grid_body(
|
||||
false => Vec::new(),
|
||||
};
|
||||
let mut window: Vec<Vec<Option<CellValue>>> = Vec::with_capacity(rows.len());
|
||||
let mut col_formats: Vec<NumberFormat> = vec![plan.view_format(); n];
|
||||
|
||||
for ri in 0..layout.row_count() {
|
||||
let drawn = rows.contains(&ri);
|
||||
@@ -57,10 +60,14 @@ pub fn grid_body(
|
||||
false => Vec::new(),
|
||||
};
|
||||
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());
|
||||
if let Some(slot) = col_formats.get_mut(ci).filter(|_| ri == 0) {
|
||||
*slot = fmt;
|
||||
}
|
||||
let text = match records {
|
||||
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());
|
||||
if let Some(sum) = totals.get_mut(ci) {
|
||||
@@ -75,8 +82,8 @@ pub fn grid_body(
|
||||
}
|
||||
}
|
||||
for (ci, total) in totals.iter().enumerate() {
|
||||
let width = format_f64(*total, fmt_comma, fmt_decimals).width();
|
||||
widen(&mut widths, ci, width);
|
||||
let fmt = col_formats.get(ci).copied().unwrap_or(plan.view_format());
|
||||
widen(&mut widths, ci, fmt.number(*total).width());
|
||||
}
|
||||
|
||||
GridBody {
|
||||
@@ -84,6 +91,7 @@ pub fn grid_body(
|
||||
.into_iter()
|
||||
.map(|w| (w + 1).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH))
|
||||
.collect(),
|
||||
col_formats,
|
||||
totals,
|
||||
window,
|
||||
}
|
||||
@@ -96,12 +104,16 @@ fn cell_value(
|
||||
records: bool,
|
||||
row: usize,
|
||||
col: usize,
|
||||
) -> Option<CellValue> {
|
||||
) -> (Option<CellKey>, Option<CellValue>) {
|
||||
match records {
|
||||
true => layout.records_value(row, col),
|
||||
false => layout
|
||||
.cell_key(row, col)
|
||||
.and_then(|key| model.evaluate_aggregated(&key, Aggregation::of(&layout.none_cats))),
|
||||
true => (None, layout.records_value(row, col)),
|
||||
false => {
|
||||
let key = layout.cell_key(row, col);
|
||||
let value = key
|
||||
.as_ref()
|
||||
.and_then(|k| model.evaluate_aggregated(k, Aggregation::of(&layout.none_cats)));
|
||||
(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,13 +143,8 @@ fn widen(widths: &mut [u16], col: usize, width: usize) {
|
||||
|
||||
/// 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.
|
||||
pub fn compute_col_widths(
|
||||
model: &Model,
|
||||
layout: &GridLayout,
|
||||
fmt_comma: bool,
|
||||
fmt_decimals: u8,
|
||||
) -> Vec<u16> {
|
||||
grid_body(model, layout, fmt_comma, fmt_decimals, 0..0, 0..0).col_widths
|
||||
pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan) -> Vec<u16> {
|
||||
grid_body(model, layout, plan, 0..0, 0..0).col_widths
|
||||
}
|
||||
|
||||
/// The total row-header width for a layout. The rule is the protocol's; this is the
|
||||
|
||||
@@ -1,4 +1,84 @@
|
||||
use crate::model::cell::CellValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which format each cell is drawn with: the view's `format:`, unless one of the
|
||||
/// cell's coordinates names a formula carrying its own (`context/code/core.md`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FormatPlan {
|
||||
view: NumberFormat,
|
||||
by_target: HashMap<String, HashMap<String, NumberFormat>>,
|
||||
}
|
||||
|
||||
impl FormatPlan {
|
||||
pub fn of(model: &Model, view: &View) -> Self {
|
||||
let mut by_target: HashMap<String, HashMap<String, NumberFormat>> = HashMap::new();
|
||||
for f in model.formulas() {
|
||||
if let Some(fmt) = &f.format {
|
||||
by_target
|
||||
.entry(f.target_category.clone())
|
||||
.or_default()
|
||||
.insert(f.target.clone(), NumberFormat::parse(fmt));
|
||||
}
|
||||
}
|
||||
Self {
|
||||
view: NumberFormat::parse(&view.number_format),
|
||||
by_target,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
by_target: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The view's own answer: what a cell naming no formula target 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.by_target.is_empty()) {
|
||||
None => self.view,
|
||||
Some(key) => self
|
||||
.by_target
|
||||
.iter()
|
||||
.find_map(|(cat, items)| items.get(key.get(cat)?).copied())
|
||||
.unwrap_or(self.view),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a CellValue for display with number formatting options.
|
||||
pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String {
|
||||
@@ -292,3 +372,63 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,15 +547,17 @@ impl GridLayout {
|
||||
model: &Model,
|
||||
row: usize,
|
||||
col: usize,
|
||||
fmt_comma: bool,
|
||||
fmt_decimals: u8,
|
||||
plan: &crate::format::FormatPlan,
|
||||
) -> String {
|
||||
if self.is_records_mode() {
|
||||
self.records_display(row, col).unwrap_or_default()
|
||||
} else {
|
||||
self.cell_key(row, col)
|
||||
.and_then(|key| model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats)))
|
||||
.map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals))
|
||||
.and_then(|key| {
|
||||
let value =
|
||||
model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats))?;
|
||||
Some(plan.at(Some(&key)).value(Some(&value)))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -809,7 +811,10 @@ mod tests {
|
||||
!layout.none_cats.is_empty(),
|
||||
"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]
|
||||
|
||||
@@ -53,7 +53,9 @@ impl View {
|
||||
page_selections: HashMap::new(),
|
||||
hidden_items: 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ pub struct Formula {
|
||||
pub expr: Expr,
|
||||
/// Optional WHERE 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 {
|
||||
@@ -73,6 +77,14 @@ impl Formula {
|
||||
target_category: target_category.into(),
|
||||
expr,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ WHITESPACE = _{ " " | "\t" }
|
||||
|
||||
// ---- 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
|
||||
// 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`.
|
||||
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 --------------------------------------------------------
|
||||
|
||||
// Used by parse_expr() — forces a standalone expression to consume the
|
||||
|
||||
@@ -42,6 +42,7 @@ fn build_formula(pair: Pair<Rule>, raw: &str, target_category: &str) -> Formula
|
||||
let mut target = None;
|
||||
let mut expr = None;
|
||||
let mut filter = None;
|
||||
let mut format = None;
|
||||
for inner in pair.into_inner() {
|
||||
match inner.as_rule() {
|
||||
// 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::expr => expr = Some(build_expr(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.
|
||||
Rule::EOI => {}
|
||||
Rule::WHITESPACE
|
||||
@@ -77,6 +82,7 @@ fn build_formula(pair: Pair<Rule>, raw: &str, target_category: &str) -> Formula
|
||||
| Rule::pipe_quoted
|
||||
| Rule::bare_ident
|
||||
| Rule::filter_value
|
||||
| Rule::format_spec
|
||||
| Rule::string
|
||||
| Rule::number => {
|
||||
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),
|
||||
filter,
|
||||
)
|
||||
.with_format(format)
|
||||
}
|
||||
|
||||
fn build_expr(pair: Pair<Rule>) -> Expr {
|
||||
@@ -166,6 +173,8 @@ fn build_unary(pair: Pair<Rule>) -> Expr {
|
||||
| Rule::pipe_quoted
|
||||
| Rule::bare_ident
|
||||
| Rule::filter_value
|
||||
| Rule::format_clause
|
||||
| Rule::format_spec
|
||||
| Rule::string
|
||||
| Rule::number => {
|
||||
unreachable!(
|
||||
@@ -214,6 +223,8 @@ fn build_primary(pair: Pair<Rule>) -> Expr {
|
||||
| Rule::pipe_quoted
|
||||
| Rule::bare_ident
|
||||
| Rule::filter_value
|
||||
| Rule::format_clause
|
||||
| Rule::format_spec
|
||||
| Rule::string => {
|
||||
unreachable!(
|
||||
"{GRAMMAR_INVARIANT}: primary alternative {:?}",
|
||||
@@ -324,6 +335,8 @@ fn filter_value_to_string(pair: Pair<Rule>) -> String {
|
||||
| Rule::ref_expr
|
||||
| Rule::identifier
|
||||
| Rule::filter_value
|
||||
| Rule::format_clause
|
||||
| Rule::format_spec
|
||||
| Rule::number => {
|
||||
unreachable!(
|
||||
"{GRAMMAR_INVARIANT}: filter_value alternative {:?}",
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
/// 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.
|
||||
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`]
|
||||
/// entry. The keyword clause is canonical — `context/code/formula-and-io.md`.
|
||||
|
||||
@@ -453,11 +453,10 @@ mod tests {
|
||||
/// `f64`, so a value the grid shows as "5" must reach the table as 5.25.
|
||||
#[test]
|
||||
fn a_cell_reaches_the_table_unrounded() {
|
||||
let (mut model, view) = fixture();
|
||||
assert_eq!(
|
||||
view.number_format, ",.0",
|
||||
"the fixture view rounds to integers"
|
||||
);
|
||||
let (mut model, mut view) = fixture();
|
||||
// Pinned, not inherited: the default shows two decimals since
|
||||
// improvise-e0ex, and 5.25 under it is not rounded at all.
|
||||
view.number_format = ",.0".to_string();
|
||||
model.set_cell(
|
||||
record_key("North", "Web", "Q1", "n1", "Units"),
|
||||
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).
|
||||
let layout = GridLayout::with_frozen_records(&workbook.model, view, None);
|
||||
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()
|
||||
.flexible(true)
|
||||
@@ -66,10 +66,7 @@ fn render_csv(workbook: &Workbook, view_name: &str) -> Result<Vec<u8>> {
|
||||
if !row_label.is_empty() {
|
||||
record.push(row_label);
|
||||
}
|
||||
record.extend(
|
||||
(0..layout.col_count())
|
||||
.map(|ci| layout.display_text(model, ri, ci, fmt_comma, fmt_decimals)),
|
||||
);
|
||||
record.extend((0..layout.col_count()).map(|ci| layout.display_text(model, ri, ci, &plan)));
|
||||
wtr.write_record(&record)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -1121,3 +1121,40 @@ fn a_cell_with_no_coordinates_round_trips() {
|
||||
);
|
||||
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.rows[0].cells()[0].text,
|
||||
"1,200",
|
||||
"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"
|
||||
);
|
||||
@@ -168,7 +168,7 @@ fn a_browser_client_seeds_types_a_number_and_sees_it_come_back() {
|
||||
);
|
||||
assert_eq!(
|
||||
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 \
|
||||
\"7500\" the user typed"
|
||||
);
|
||||
|
||||
@@ -358,7 +358,7 @@ mod tests {
|
||||
fn a_wire_edit_reaches_the_document_and_comes_back_as_a_projection() {
|
||||
let (mut worker, _) = worker_over_storage();
|
||||
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 cmd = reduce::caught_up(
|
||||
@@ -371,7 +371,7 @@ mod tests {
|
||||
let out = worker.on_frame(ClientFrame::command(cmd));
|
||||
let grid = folded(&mut grid, &out);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ fn the_worker_loads_projects_edits_and_persists_over_post_message() {
|
||||
main.grid.row_frame.leaf_labels(),
|
||||
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);
|
||||
main.post(&main.caught_up(ModelCommand::SetCell {
|
||||
@@ -172,7 +172,7 @@ fn the_worker_loads_projects_edits_and_persists_over_post_message() {
|
||||
}));
|
||||
assert_eq!(
|
||||
main.text_at(2, 0),
|
||||
"31",
|
||||
"31.00",
|
||||
"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 });
|
||||
assert_eq!(
|
||||
reloaded.text_at(0, 0),
|
||||
"77",
|
||||
"77.00",
|
||||
"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!(
|
||||
main.text_at(0, 0),
|
||||
"2",
|
||||
"2.00",
|
||||
"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(),
|
||||
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
|
||||
// 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,
|
||||
value: CellValue::Number(20000.0),
|
||||
}));
|
||||
assert_eq!(main.text_at(0, 0), "20,000");
|
||||
assert_eq!(main.text_at(0, 2), "13,800", "20,000 - 6,200");
|
||||
assert_eq!(main.text_at(0, 0), "20,000.00");
|
||||
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
|
||||
|
||||
@@ -133,7 +133,7 @@ fn a_client_on_a_socket_is_seeded_and_gets_its_edit_back() {
|
||||
waited += 1;
|
||||
}
|
||||
assert_eq!(
|
||||
grid.cells[0][0].text, "12",
|
||||
grid.cells[0][0].text, "12.00",
|
||||
"the edit never came back down the socket"
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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 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.
|
||||
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)", {
|
||||
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 seed. `repaint` early-returns while `editing` is set, so a `18400`
|
||||
// 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 expect(cells.nth(4)).toHaveClass(/selected/);
|
||||
|
||||
@@ -273,8 +273,8 @@ test.describe("the standalone deployment", () => {
|
||||
await input.fill("20000");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(cell).toHaveText("20,000", { timeout: 10_000 });
|
||||
expect(before).not.toBe("20,000");
|
||||
await expect(cell).toHaveText("20,000.00", { timeout: 10_000 });
|
||||
expect(before).not.toBe("20,000.00");
|
||||
// The editor is gone and did not come back: the half of the re-entry guard
|
||||
// that IS observable from the DOM.
|
||||
await expect(page.locator("#grid input")).toHaveCount(0);
|
||||
|
||||
@@ -883,6 +883,9 @@ mod tests {
|
||||
m.add_item_in_group("Month", "Jan", "Q1").unwrap();
|
||||
m.add_item_in_group("Month", "Apr", "Q2").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 mo in ["Jan", "Apr"] {
|
||||
m.model.set_cell(
|
||||
|
||||
@@ -384,8 +384,7 @@ fn wide_cell_clamps_without_overflow() {
|
||||
let widths = improvise::ui::viewport::compute_col_widths(
|
||||
&app.model_state.workbook.model,
|
||||
&app.layout,
|
||||
false,
|
||||
0,
|
||||
&improvise::format::FormatPlan::uniform(""),
|
||||
);
|
||||
assert_eq!(
|
||||
widths,
|
||||
|
||||
@@ -76,5 +76,8 @@ fn the_web_demo_shows_a_formula_beside_its_inputs() {
|
||||
vec!["Espresso", "Latte", "Cold Brew", "Pastry"]
|
||||
);
|
||||
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
@@ -8,7 +8,7 @@ _Measure: column
|
||||
Quarter: page, Q1
|
||||
_Index: none
|
||||
_Dim: none
|
||||
format: ,.0
|
||||
format: ,.2
|
||||
|
||||
## View: Season
|
||||
Quarter: row
|
||||
@@ -16,7 +16,7 @@ _Measure: column
|
||||
Product: page, Latte
|
||||
_Index: none
|
||||
_Dim: none
|
||||
format: ,.0
|
||||
format: ,.2
|
||||
|
||||
## Formulas
|
||||
- Profit = Revenue - Cost
|
||||
|
||||
Reference in New Issue
Block a user