Merge pull request #81 from fiddlerwoaroof/wave/core-formula-io
core/formula/io: a duplicate-section round trip, the WHERE word boundary, and a parameter that promised a filter
This commit is contained in:
@@ -39,6 +39,29 @@ differences are the whole content of the design (improvise-kdp):
|
||||
for `SUM`/`COUNT`, which have an identity, and an error for
|
||||
`AVG`/`MIN`/`MAX`, which do not.
|
||||
|
||||
### `evaluate_aggregated` takes `Aggregation`, not a list it cannot read
|
||||
|
||||
The parameter was `none_cats: &[String]`, and the body consulted it exactly
|
||||
once, for emptiness — a boolean wearing a list's type, since the aggregation
|
||||
itself runs over `key.0` through `matching_values`. It never filtered by that
|
||||
list, in any revision: `git log -S` back to the commit that introduced
|
||||
`Axis::None` shows the same `is_empty()` and nothing else. So the name promised
|
||||
a filter the body has never performed, and the next caller to build a careful
|
||||
list would have threaded it into a parameter that cannot use it.
|
||||
|
||||
`Aggregation::{Exact, OverHidden}` admits only the two states the body
|
||||
distinguishes, and `Aggregation::of(none_cats)` performs the reduction once, at
|
||||
the boundary that holds the list (improvise-5jm5,
|
||||
`doctrine(an_invariant_is_made_unrepresentable_not_validated)` — the same answer
|
||||
`prune_axis_items` and `gp-tree`'s `Row<T>` got). It is an enum rather than a
|
||||
`bool` because the two states have names worth reading at the call site, and the
|
||||
regression guard is the **signature**: the thing the ticket describes — two
|
||||
different lists, same emptiness, same answer — is no longer constructible.
|
||||
|
||||
Note what does *not* change: `recompute_formulas` and `aggregate_raw` keep
|
||||
`&[String]`, because they walk the elements (stripping each hidden coordinate
|
||||
off a stem). Two parameters that looked alike were doing two different jobs.
|
||||
|
||||
### Non-numeric values reduce by unanimity, and the branch order is the rule
|
||||
|
||||
`aggregate_cell_values` checks the **all-numeric branch first**. If every value
|
||||
|
||||
@@ -92,6 +92,28 @@ round-tripped from the day it exists.
|
||||
What the *evaluator* does with it, and why a fitted tree is always wrapped in a
|
||||
`SUM`, is `context/code/core.md` §13.
|
||||
|
||||
### `WHERE` carries the same boundary, and the mechanism has to be generatable
|
||||
|
||||
`where_clause` and `inline_where` are compound-atomic with `sp+` after the
|
||||
keyword, exactly as `is_expr` is (improvise-r08u). Non-atomic, pest auto-skipped
|
||||
`WHITESPACE` after `^"WHERE"`, so `SUM(Rev WHEREAS x = y)` read as a `WHERE` on
|
||||
a category `AS` — a silently wrong filter where it should have been a syntax
|
||||
error, the same class of defect `IS` had.
|
||||
|
||||
Two things decided the shape, and the second is the one worth carrying. A
|
||||
negative lookahead `!bare_char` with `sp*` would have been *strictly* more
|
||||
permissive — it keeps `WHERE|Region|=East`, which the old rule accepted — but
|
||||
**the grammar-walking generator cannot honour a predicate**: it walks
|
||||
`formula.pest` through `pest_meta` and emits `WHEREa=""`, text the grammar then
|
||||
refuses, so `generated_formula_parses` goes red. A rule whose only guard is a
|
||||
lookahead is a rule the generator can violate. `sp+` is generatable, matches
|
||||
`IS`, and costs exactly one accepted spelling: a category may no longer abut the
|
||||
keyword, so `WHERE |Region|` needs its space.
|
||||
|
||||
Note compound-atomicity *alone* closes the ticket's own reproducer, because
|
||||
`AS x` cannot be one identifier once implicit whitespace is gone. `sp+` is what
|
||||
closes the residue, `WHEREAS=y`, and the negative test names both.
|
||||
|
||||
## A formula target is unquoted, exactly as a reference is
|
||||
|
||||
`parse_formula` strips the pipes off the target the same way it does off an
|
||||
@@ -158,6 +180,18 @@ format order-independent — a `## View:` may name a category defined further
|
||||
down. `PCategory::items` is a `Vec` in file order because `IndexMap` insertion
|
||||
order is load-bearing (gotcha 18), so pass 2 must replay the list as written.
|
||||
|
||||
Two `## Category:` sections naming one category are **one** category, and the
|
||||
merge is a concatenation performed in pass 1 (`ParsedFile::push_category`,
|
||||
improvise-349m). Merging in pass 2 — building each section in turn — was not
|
||||
idempotent. A group is created either by the item that names it or by a `>`
|
||||
line, and `build_category` creates its own section's item-groups *before* its
|
||||
own `>` lines; so the first section's `>` groups landed ahead of the second
|
||||
section's item-groups, while `format_md` writes one section with all the items
|
||||
before all the `>` lines. The reparse then built the other order, and the two
|
||||
`format_md` outputs differed. Concatenating first makes the built order the
|
||||
written order by construction. Refusing a duplicate header was the alternative
|
||||
and would have made a legal hand-written file an error for no gain.
|
||||
|
||||
### A section header takes its name as the whole line, totally
|
||||
|
||||
`header_name = { pipe_quoted ~ &NEWLINE | line_name }` (improvise-pczv). The
|
||||
@@ -390,7 +424,21 @@ and the derived `_Month`/`_Quarter` categories inherit the error. What makes it
|
||||
tolerable is that the wizard shows the detected format and lets the user change
|
||||
it — the same argument two-digit years already rested on. It is pinned by a test
|
||||
so that changing the answer is a decision somebody makes rather than a side
|
||||
effect of reordering the table. Filed as **improvise-l8rh**.
|
||||
effect of reordering the table.
|
||||
|
||||
**Ruled a known limit** (improvise-l8rh, the user, 2026-09-20). Two things the
|
||||
ruling rests on. Changing the default to day-first only moves which half of the
|
||||
world imports silently wrong; and the honest fix is larger than the heuristic —
|
||||
an ambiguous column should *surface* its ambiguity rather than resolve it, which
|
||||
changes `FieldProposal`'s shape and the wizard step that displays it, the way
|
||||
`Dataset::warnings` returns a decision the user should hear about as data. Worth
|
||||
doing deliberately, not as a patch.
|
||||
|
||||
What makes the limit reasonable to live with is that it is now *deterministic*:
|
||||
`analyze_records` iterated a `HashSet` until improvise-7yx, so the ten samples
|
||||
`detect_date_format` inspects were a different ten every run and a column whose
|
||||
eleventh value ruled out `%m/%d/%Y` detected a different format run to run. The
|
||||
ten-sample window itself is unchanged and is part of the limit.
|
||||
|
||||
### CSV parsing
|
||||
|
||||
|
||||
@@ -145,11 +145,18 @@ Escapes inside pipes: `\|` (literal pipe), `\\` (backslash), `\n` (newline).
|
||||
This convention is shared between the `.improv` persistence format and the
|
||||
formula parser's identifier syntax.
|
||||
|
||||
### Formula Tokenizer: Identifiers and Quoting
|
||||
### Formula Identifiers and Quoting
|
||||
|
||||
**Bare identifiers** support multi-word names (e.g., `Total Revenue`) by
|
||||
allowing spaces when followed by non-operator, non-keyword characters. Keywords
|
||||
(`WHERE`, `SUM`, `AVG`, `MIN`, `MAX`, `COUNT`, `IF`) act as token boundaries.
|
||||
**Bare identifiers** are `(ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")*`
|
||||
— no spaces, the same shape `improv.pest`'s `bare_name` has. A multi-word name
|
||||
such as `Total Revenue` must be pipe-quoted.
|
||||
|
||||
**Keywords need a word boundary after them, not before.** `IS`, `WHERE` and the
|
||||
aggregate names are spelled `^"…"` in `formula.pest`, and `is_expr`/`where_clause`/
|
||||
`inline_where` are compound-atomic with `sp+` so that `Region ISLAND` and
|
||||
`SUM(Rev WHEREAS x = y)` are parse errors rather than silently-wrong filters. A
|
||||
`SUM`/`IF` without parens falls through to `ref_expr`, which is why a bare
|
||||
keyword still names an item; the writer quotes it anyway (`quote::quote_name`).
|
||||
|
||||
**Pipe-quoted identifiers** (`|...|`) allow any characters — including spaces,
|
||||
keywords, and operators — inside the delimiters. Use pipes when a category or
|
||||
@@ -162,11 +169,10 @@ item name collides with a keyword or contains special characters:
|
||||
SUM(|Net Revenue| WHERE |Region Name| = |East Coast|)
|
||||
```
|
||||
|
||||
Pipes produce `Token::Ident` (same as bare identifiers), so they work
|
||||
everywhere an identifier is expected: expressions, aggregate arguments, WHERE
|
||||
clause category names and filter values. Double-quoted strings (`"..."`)
|
||||
remain `Token::Str` and are used only for WHERE filter values in the
|
||||
`split_where` pre-parse step.
|
||||
`identifier` is `pipe_quoted | bare_ident`, so a pipe-quoted name works
|
||||
everywhere a bare one does: expressions, aggregate arguments, and a `WHERE`
|
||||
clause's category name. A double-quoted string is a `filter_value` only — the
|
||||
right-hand side of a `WHERE` or an `IS`, never an expression.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,6 +12,20 @@ none.
|
||||
|
||||
## `model/types.rs`
|
||||
|
||||
### a_hidden_dimension_list_reduces_to_one_of_two_states
|
||||
|
||||
improvise-5jm5. `evaluate_aggregated` took `none_cats: &[String]` and consulted
|
||||
it once, for emptiness — so two lists with different contents and the same
|
||||
emptiness gave the same answer, while the name promised a filter. Nothing
|
||||
misbehaved; the signature described something the body never did.
|
||||
|
||||
This test is the *smaller* half of the guard and says so. The real guard is the
|
||||
signature: the defect the ticket describes is no longer constructible, exactly
|
||||
as `Checked`'s constructor is the guard for the basis check rather than a test
|
||||
that can fail. What this test does add is that `Aggregation::of` is the only
|
||||
place the list is read, and that a list of two hidden dimensions is not somehow
|
||||
a third state — the reduction is emptiness, and the enum is not a count.
|
||||
|
||||
### apply_agg_over_nothing_is_absent_for_every_function
|
||||
|
||||
An empty cell must read as *absent*, not as zero — otherwise every blank
|
||||
|
||||
@@ -203,6 +203,33 @@ backend from quietly normalising or re-encoding what it is handed.
|
||||
|
||||
## `improvise-io/src/persistence/parse.rs`
|
||||
|
||||
### two_sections_naming_one_category_round_trip_to_a_stable_group_order
|
||||
|
||||
improvise-349m: `format_md(parse_md(text))` was not idempotent when a file
|
||||
carried two `## Category:` sections with the same name — the two outputs
|
||||
differed only in the order of the `>` group lines. It looked like a flake
|
||||
because only `grammar_prop_tests::generated_file_roundtrips` saw it, and only
|
||||
when the generator happened to emit a duplicate header; taking proptest's
|
||||
minimal input and running it deterministically is what turned it into a bug.
|
||||
|
||||
The fixture is the smallest shape that shows it: a first section defining a
|
||||
group by a `>` line only, and a second whose *item* names a different group that
|
||||
also gets a `>` line. Both groups must have parents, or `format_md` writes only
|
||||
one `>` line and the differing internal order is invisible in the text — which
|
||||
is the trap when shrinking this fixture further. Fault injected: reverting
|
||||
`ParsedFile::push_category` to a plain `Vec::push` fails it, and the ticket's own
|
||||
minimal input was checked against the fix as well.
|
||||
|
||||
**Stability is only half of what the merge owes, and the first version of this
|
||||
test could see only that half.** Adversarial review injected the other fault —
|
||||
`Some(_existing) => {}`, which *drops* the duplicate section outright — and the
|
||||
whole `improvise-io` suite stayed green at 2000 proptest cases while the
|
||||
fixture's `- X[G2]` and `> G2[P2]` vanished from the document without a word.
|
||||
Main's behaviour was unstable-but-lossless; a test that compares two printed
|
||||
forms cannot tell that from stable-and-lossy, because both sides of the equality
|
||||
lose the same thing. The three `contains` assertions run *before* the equality
|
||||
for exactly that reason, and they fail under the drop.
|
||||
|
||||
### parse_md_formula_category_suffix_without_space
|
||||
|
||||
Bug improvise-zgb (b). The formulas walker located the `[Category]` suffix with
|
||||
@@ -444,9 +471,10 @@ can override it. The same argument as the ambiguity below.
|
||||
nothing in the data can tell them apart. The heuristic is first-match over
|
||||
`DATE_FORMATS`, so the American reading wins — a European CSV imports with its
|
||||
days and months swapped, silently, and the derived `_Month`/`_Quarter`
|
||||
categories inherit the error. Filed as **improvise-l8rh**; pinned here so that
|
||||
changing the answer is a decision somebody makes rather than a side effect of
|
||||
reordering the table.
|
||||
categories inherit the error. **improvise-l8rh ruled this a known limit** rather
|
||||
than a defect, so this test is what makes changing the answer a decision
|
||||
somebody makes rather than a side effect of reordering the table. The argument
|
||||
is in `context/code/formula-and-io.md`.
|
||||
|
||||
The loop at the end is the precondition that makes this a statement about
|
||||
*ambiguity* rather than about one format merely fitting: the other reading has
|
||||
@@ -646,7 +674,28 @@ reopens the hole; that substitution is what this test was finally proved
|
||||
against. The negative arm is paired with a positive one, or "reject everything"
|
||||
would pass.
|
||||
|
||||
`^"WHERE"` has the identical hole and was deliberately **not** fixed here:
|
||||
pre-existing, far less exposed (it sits in one fixed position after an
|
||||
expression, where `IS` follows any identifier in expression position), and a
|
||||
grammar change with its own round-trip risk. Filed as improvise-r08u.
|
||||
`^"WHERE"` had the identical hole. It is closed now — see below.
|
||||
|
||||
### where_does_not_swallow_the_start_of_a_longer_word
|
||||
|
||||
improvise-r08u, the `WHERE` half of the same defect. `where_clause` and
|
||||
`inline_where` were ordinary rules, so pest auto-inserted `WHITESPACE` after
|
||||
`^"WHERE"` and `SUM(Rev WHEREAS x = y)` parsed as a `WHERE` on a category `AS`:
|
||||
a silently wrong filter where it should have been a syntax error.
|
||||
|
||||
The test carries **two** negative shapes because the repair has two independent
|
||||
halves, and this was found by injecting each separately. Making the rules
|
||||
compound-atomic alone already refuses `WHEREAS x = y` — once implicit whitespace
|
||||
is gone, `AS x` cannot be one identifier — so a test built only on the ticket's
|
||||
reproducer passes over a rule still spelled `sp*`, and `WHEREAS=y` is what that
|
||||
rule still reads as a filter. Faults injected: the original non-atomic rule
|
||||
(fails on the first assertion) and `sp+` relaxed to `sp*` (fails on the second).
|
||||
The positive arms are there so "refuse every WHERE" cannot pass.
|
||||
|
||||
### where_requires_a_space_after_the_keyword
|
||||
|
||||
The one spelling `sp+` costs, stated rather than discovered later:
|
||||
`WHERE|Region Name|=|East Coast|` used to parse and now does not. `IS` has
|
||||
required its space since improvise-4zl7, so this is the two keywords agreeing.
|
||||
The paired positive arm — the same text with the space — is what stops it
|
||||
degrading into "any WHERE with a quoted category fails".
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::model::Aggregation;
|
||||
use crate::ui::effect::{self, Effect};
|
||||
|
||||
use super::core::{Cmd, CmdContext};
|
||||
@@ -140,7 +141,9 @@ impl Cmd for YankCell {
|
||||
"yank"
|
||||
}
|
||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||
let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats());
|
||||
let value = ctx
|
||||
.model
|
||||
.evaluate_aggregated(&self.key, Aggregation::of(ctx.none_cats()));
|
||||
vec![
|
||||
Box::new(effect::SetYanked(value)),
|
||||
effect::set_status("Yanked"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::model::Aggregation;
|
||||
use crate::model::cell::CellValue;
|
||||
use crate::ui::app::AppMode;
|
||||
use crate::ui::effect::{self, Effect, Panel};
|
||||
@@ -112,7 +113,10 @@ impl Cmd for SearchNavigate {
|
||||
Some(k) => k,
|
||||
None => return false,
|
||||
};
|
||||
let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
|
||||
let s = match ctx
|
||||
.model
|
||||
.evaluate_aggregated(&key, Aggregation::of(ctx.none_cats()))
|
||||
{
|
||||
Some(CellValue::Number(n)) => format!("{n}"),
|
||||
Some(CellValue::Text(t)) => t,
|
||||
Some(CellValue::Error(e)) => format!("ERR:{e}"),
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use improvise_core::format::{format_f64, format_value, parse_number_format};
|
||||
use improvise_core::model::Model;
|
||||
use improvise_core::model::category::MEASURE_CATEGORY;
|
||||
use improvise_core::model::cell::CellValue;
|
||||
use improvise_core::model::record::{AggregateHash, RecordHash};
|
||||
use improvise_core::model::{Aggregation, Model};
|
||||
use improvise_core::view::layout::{AxisEntry, GridLayout, RecordCol};
|
||||
use improvise_core::view::{Axis, View};
|
||||
use improvise_core::workbook::Workbook;
|
||||
@@ -237,7 +237,9 @@ fn column_totals(
|
||||
.map(|c| {
|
||||
let total: f64 = (0..layout.row_count())
|
||||
.filter_map(|r| layout.cell_key(r, c))
|
||||
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
|
||||
.map(|key| {
|
||||
model.evaluate_aggregated_f64(&key, Aggregation::of(&layout.none_cats))
|
||||
})
|
||||
.sum();
|
||||
CellDisplay::number(format_f64(total, comma, decimals))
|
||||
})
|
||||
@@ -272,7 +274,7 @@ fn cell_display(
|
||||
app.model_state
|
||||
.workbook
|
||||
.model
|
||||
.evaluate_aggregated(&key, &layout.none_cats)
|
||||
.evaluate_aggregated(&key, Aggregation::of(&layout.none_cats))
|
||||
});
|
||||
CellDisplay {
|
||||
text: format_value(value.as_ref(), comma, decimals),
|
||||
|
||||
@@ -6,7 +6,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
use improvise_protocol::header_level_widths;
|
||||
|
||||
use crate::format::format_f64;
|
||||
use crate::model::Model;
|
||||
use crate::model::{Aggregation, Model};
|
||||
use crate::view::{AxisEntry, GridLayout};
|
||||
|
||||
/// Minimum column width — enough for short numbers/labels + 1 char gap.
|
||||
@@ -62,7 +62,7 @@ pub fn compute_col_widths(
|
||||
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
|
||||
let total: f64 = (0..layout.row_count())
|
||||
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
|
||||
.map(|key| model.evaluate_aggregated_f64(&key, Aggregation::of(&layout.none_cats)))
|
||||
.sum();
|
||||
let s = format_f64(total, fmt_comma, fmt_decimals);
|
||||
let w = s.width() as u16;
|
||||
|
||||
@@ -4,4 +4,4 @@ pub mod record;
|
||||
pub mod symbol;
|
||||
pub mod types;
|
||||
|
||||
pub use types::Model;
|
||||
pub use types::{Aggregation, Model};
|
||||
|
||||
@@ -200,6 +200,25 @@ fn eval_is(context: &CellKey, filter: &Filter) -> f64 {
|
||||
.is_some_and(|v| v == filter.item.as_str()) as i32 as f64
|
||||
}
|
||||
|
||||
/// Whether a grid hides dimensions a cell key does not name. The emptiness of
|
||||
/// the view's `Axis::None` list is the whole question — `context/code/core.md`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Aggregation {
|
||||
/// The key names every dimension, so the cell is read as stored.
|
||||
Exact,
|
||||
/// Fold every cell the key does not name.
|
||||
OverHidden,
|
||||
}
|
||||
|
||||
impl Aggregation {
|
||||
pub fn of(none_cats: &[String]) -> Self {
|
||||
match none_cats.is_empty() {
|
||||
true => Aggregation::Exact,
|
||||
false => Aggregation::OverHidden,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-data document model: categories, cells, formulas. It knows nothing
|
||||
/// about views — see [`crate::workbook::Workbook`] and core.md §10.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -476,8 +495,8 @@ impl Model {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn evaluate_aggregated(&self, key: &CellKey, none_cats: &[String]) -> Option<CellValue> {
|
||||
if none_cats.is_empty() {
|
||||
pub fn evaluate_aggregated(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
|
||||
if agg == Aggregation::Exact {
|
||||
return self.evaluate(key);
|
||||
}
|
||||
|
||||
@@ -491,8 +510,8 @@ impl Model {
|
||||
}
|
||||
|
||||
/// [`Self::evaluate_aggregated`] as `f64`, with `0.0` for an empty cell.
|
||||
pub fn evaluate_aggregated_f64(&self, key: &CellKey, none_cats: &[String]) -> f64 {
|
||||
self.evaluate_aggregated(key, none_cats)
|
||||
pub fn evaluate_aggregated_f64(&self, key: &CellKey, agg: Aggregation) -> f64 {
|
||||
self.evaluate_aggregated(key, agg)
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
@@ -815,7 +834,7 @@ mod agg_helper_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod model_tests {
|
||||
use super::Model;
|
||||
use super::{Aggregation, Model};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
|
||||
fn coord(pairs: &[(&str, &str)]) -> CellKey {
|
||||
@@ -994,7 +1013,7 @@ mod model_tests {
|
||||
|
||||
// With Date hidden, it aggregates to SUM.
|
||||
let none_cats = vec!["Date".to_string()];
|
||||
let result = m.evaluate_aggregated(&partial_key, &none_cats);
|
||||
let result = m.evaluate_aggregated(&partial_key, Aggregation::of(&none_cats));
|
||||
assert_eq!(result, Some(CellValue::Number(150.0)));
|
||||
}
|
||||
|
||||
@@ -1031,6 +1050,19 @@ mod model_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_dimension_list_reduces_to_one_of_two_states() {
|
||||
assert_eq!(Aggregation::of(&[]), Aggregation::Exact);
|
||||
assert_eq!(
|
||||
Aggregation::of(&["Date".to_string()]),
|
||||
Aggregation::OverHidden
|
||||
);
|
||||
assert_eq!(
|
||||
Aggregation::of(&["Date".to_string(), "Payee".to_string()]),
|
||||
Aggregation::OverHidden
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_aggregated_no_hidden_delegates_to_evaluate() {
|
||||
let mut m = Model::new("Test");
|
||||
@@ -1039,7 +1071,7 @@ mod model_tests {
|
||||
m.set_cell(coord(&[("Region", "East")]), CellValue::Number(42.0));
|
||||
let key = coord(&[("Region", "East")]);
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&key, &[]),
|
||||
m.evaluate_aggregated(&key, Aggregation::Exact),
|
||||
Some(CellValue::Number(42.0))
|
||||
);
|
||||
}
|
||||
@@ -1065,7 +1097,7 @@ mod model_tests {
|
||||
fn status_of(m: &Model) -> Option<CellValue> {
|
||||
m.evaluate_aggregated(
|
||||
&coord(&[("Payee", "Acme"), ("_Measure", "Status")]),
|
||||
&["Date".to_string()],
|
||||
Aggregation::of(&["Date".to_string()]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1156,14 +1188,14 @@ mod model_tests {
|
||||
|
||||
let key = coord(&[("Payee", "Acme"), ("_Measure", "Price")]);
|
||||
let none_cats = vec!["Date".to_string()];
|
||||
let result = m.evaluate_aggregated(&key, &none_cats);
|
||||
let result = m.evaluate_aggregated(&key, Aggregation::of(&none_cats));
|
||||
assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod formula_tests {
|
||||
use super::Model;
|
||||
use super::{Aggregation, Model};
|
||||
use crate::formula::parse_formula;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
|
||||
@@ -1715,12 +1747,18 @@ mod formula_tests {
|
||||
m.recompute_formulas(&none_cats);
|
||||
// Equality says X is zero…
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&coord(&[("_Measure", "IsZero")]), &none_cats),
|
||||
m.evaluate_aggregated(
|
||||
&coord(&[("_Measure", "IsZero")]),
|
||||
Aggregation::of(&none_cats)
|
||||
),
|
||||
Some(CellValue::Number(1.0))
|
||||
);
|
||||
// …so dividing by X must be div/0 in the cached path too.
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&coord(&[("_Measure", "Ratio")]), &none_cats),
|
||||
m.evaluate_aggregated(
|
||||
&coord(&[("_Measure", "Ratio")]),
|
||||
Aggregation::of(&none_cats)
|
||||
),
|
||||
Some(CellValue::Error("div/0".into()))
|
||||
);
|
||||
}
|
||||
@@ -2382,7 +2420,7 @@ mod route_then_aggregate_tests {
|
||||
|
||||
let uncached = m.evaluate(&north);
|
||||
m.recompute_formulas(&none_cats);
|
||||
let cached = m.evaluate_aggregated(&north, &none_cats);
|
||||
let cached = m.evaluate_aggregated(&north, Aggregation::of(&none_cats));
|
||||
|
||||
assert_eq!(uncached, Some(CellValue::Number(11.0)));
|
||||
assert_eq!(cached, uncached, "the two evaluators must not diverge");
|
||||
@@ -2397,7 +2435,10 @@ mod route_then_aggregate_tests {
|
||||
m.recompute_formulas(&none_cats);
|
||||
for region in ["North", "South"] {
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats),
|
||||
m.evaluate_aggregated(
|
||||
&key(&[("Region", region), (MEASURE, "Pred")]),
|
||||
Aggregation::of(&none_cats)
|
||||
),
|
||||
Some(CellValue::Number(11.0)),
|
||||
"{region}"
|
||||
);
|
||||
@@ -2428,7 +2469,10 @@ mod route_then_aggregate_tests {
|
||||
m.recompute_formulas(&none_cats);
|
||||
for (region, expected) in [("North", 1.0), ("South", 0.0)] {
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats),
|
||||
m.evaluate_aggregated(
|
||||
&key(&[("Region", region), (MEASURE, "Pred")]),
|
||||
Aggregation::of(&none_cats)
|
||||
),
|
||||
Some(CellValue::Number(expected)),
|
||||
"{region}"
|
||||
);
|
||||
@@ -2444,7 +2488,10 @@ mod route_then_aggregate_tests {
|
||||
m.recompute_formulas(&none_cats);
|
||||
for (region, expected) in [("North", 14.0), ("South", 200.0)] {
|
||||
assert_eq!(
|
||||
m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats),
|
||||
m.evaluate_aggregated(
|
||||
&key(&[("Region", region), (MEASURE, "Pred")]),
|
||||
Aggregation::of(&none_cats)
|
||||
),
|
||||
Some(CellValue::Number(expected)),
|
||||
"{region}"
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY};
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::{Aggregation, Model};
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
/// The trailing column that creates a new measure: typing a name into it
|
||||
@@ -436,7 +436,7 @@ impl GridLayout {
|
||||
for (ci, cell) in row.iter_mut().enumerate() {
|
||||
*cell = self
|
||||
.cell_key(ri, ci)
|
||||
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
|
||||
.and_then(|k| model.evaluate_aggregated(&k, Aggregation::of(&self.none_cats)))
|
||||
.is_some();
|
||||
}
|
||||
}
|
||||
@@ -494,7 +494,7 @@ impl GridLayout {
|
||||
self.records_display(row, col).unwrap_or_default()
|
||||
} else {
|
||||
self.cell_key(row, col)
|
||||
.and_then(|key| model.evaluate_aggregated(&key, &self.none_cats))
|
||||
.and_then(|key| model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats)))
|
||||
.map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ formula = { SOI ~ target ~ "=" ~ expr ~ where_clause? ~ EOI }
|
||||
// to `|Total Revenue|` name the same item.
|
||||
target = { identifier }
|
||||
|
||||
where_clause = { ^"WHERE" ~ identifier ~ "=" ~ filter_value }
|
||||
// Compound-atomic like `is_expr`, so `sp+` is the real separator: relax it and
|
||||
// `WHEREAS x = y` reads as a WHERE on a category `AS`.
|
||||
where_clause = ${ ^"WHERE" ~ sp+ ~ identifier ~ sp* ~ "=" ~ sp* ~ filter_value }
|
||||
|
||||
// ---- expressions --------------------------------------------------------
|
||||
|
||||
@@ -64,7 +66,7 @@ paren_expr = { "(" ~ expr ~ ")" }
|
||||
// Aggregates with optional inline WHERE filter inside the parens.
|
||||
agg_call = { agg_func ~ "(" ~ expr ~ inline_where? ~ ")" }
|
||||
agg_func = { ^"SUM" | ^"AVG" | ^"MIN" | ^"MAX" | ^"COUNT" }
|
||||
inline_where = { ^"WHERE" ~ identifier ~ "=" ~ filter_value }
|
||||
inline_where = ${ ^"WHERE" ~ sp+ ~ identifier ~ sp* ~ "=" ~ sp* ~ filter_value }
|
||||
|
||||
// IF(cond, then, else). Comparison is a standalone rule because comparison
|
||||
// operators are not valid in general expressions — only inside an IF condition.
|
||||
|
||||
@@ -463,6 +463,35 @@ mod tests {
|
||||
assert_eq!(filter.item, "East");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn where_does_not_swallow_the_start_of_a_longer_word() {
|
||||
assert!(parse_formula("X = SUM(Rev WHEREAS x = y)", "Cat").is_err());
|
||||
assert!(parse_formula("X = Revenue WHEREAS x = y", "Cat").is_err());
|
||||
assert!(parse_formula("X = Revenue WHEREAS=y", "Cat").is_err());
|
||||
assert!(parse_formula("X = SUM(Rev WHEREAS=y)", "Cat").is_err());
|
||||
let inline = parse_formula("X = SUM(Rev WHERE Region = East)", "Cat").unwrap();
|
||||
assert!(
|
||||
matches!(&inline.expr, Expr::Agg(_, _, Some(_))),
|
||||
"{:?}",
|
||||
inline.expr
|
||||
);
|
||||
let trailing = parse_formula("X = Revenue WHERE Region = East", "Cat").unwrap();
|
||||
assert_eq!(trailing.filter.as_ref().unwrap().category, "Region");
|
||||
}
|
||||
|
||||
/// `sp+` is the boundary, so the keyword needs a space after it exactly as
|
||||
/// `IS` does — a quoted category no longer abuts it.
|
||||
#[test]
|
||||
fn where_requires_a_space_after_the_keyword() {
|
||||
assert!(parse_formula("X = Revenue WHERE|Region Name|=|East Coast|", "Cat").is_err());
|
||||
let f = parse_formula("X = Revenue WHERE |Region Name|=|East Coast|", "Cat").unwrap();
|
||||
let filter = f.filter.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(filter.category.as_str(), filter.item.as_str()),
|
||||
("Region Name", "East Coast")
|
||||
);
|
||||
}
|
||||
|
||||
// ── IS, the coordinate predicate (improvise-4zl7) ───────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -50,6 +50,29 @@ struct ParsedFile {
|
||||
views: Vec<PView>,
|
||||
}
|
||||
|
||||
impl ParsedFile {
|
||||
/// Two `## Category:` sections naming one category are one category. The
|
||||
/// merge concatenates, so the built order is the order a writer emits.
|
||||
fn push_category(&mut self, pc: PCategory) {
|
||||
let PCategory {
|
||||
name,
|
||||
items,
|
||||
group_parents,
|
||||
} = pc;
|
||||
match self.categories.iter_mut().find(|e| e.name == name) {
|
||||
Some(existing) => {
|
||||
existing.items.extend(items);
|
||||
existing.group_parents.extend(group_parents);
|
||||
}
|
||||
None => self.categories.push(PCategory {
|
||||
name,
|
||||
items,
|
||||
group_parents,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers for walking the pest parse tree ──────────────────────────────────
|
||||
|
||||
/// Advance an iterator, returning an error if empty.
|
||||
@@ -285,7 +308,7 @@ fn walk_file(file: Pair<'_, Rule>) -> Result<ParsedFile> {
|
||||
Rule::initial_view => {
|
||||
parsed.initial_view = Some(section_title(&mut pair.into_inner(), "initial_view")?);
|
||||
}
|
||||
Rule::category_section => parsed.categories.push(walk_category_section(pair)?),
|
||||
Rule::category_section => parsed.push_category(walk_category_section(pair)?),
|
||||
Rule::formulas_section => parsed.formulas.extend(walk_formulas_section(pair)?),
|
||||
Rule::data_section => parsed.data.extend(walk_data_section(pair)?),
|
||||
Rule::view_section => parsed.views.push(walk_view_section(pair)?),
|
||||
@@ -1078,6 +1101,21 @@ mod parser_edge_cases {
|
||||
assert!(!item_names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_sections_naming_one_category_round_trip_to_a_stable_group_order() {
|
||||
let text = "v2025-04-09\n# Test\n## Category: C\n> G1[P1]\n\
|
||||
## Category: C\n- X[G2]\n> G2[P2]\n";
|
||||
let fmt = crate::persistence::format_md;
|
||||
let printed = fmt(&parse_md(text).unwrap());
|
||||
for owed in ["> G1[P1]", "> G2[P2]", "- X[G2]"] {
|
||||
assert!(
|
||||
printed.contains(owed),
|
||||
"the merge dropped {owed}:\n{printed}"
|
||||
);
|
||||
}
|
||||
assert_eq!(printed, fmt(&parse_md(&printed).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_category_with_no_items() {
|
||||
let text = "v2025-04-09\n# Test\n## Category: Empty\n## Category: Full\n- A\n";
|
||||
|
||||
@@ -297,7 +297,10 @@ mod tests {
|
||||
fn value_at(wb: &Workbook, row: usize, col: usize) -> Option<CellValue> {
|
||||
let layout = improvise_core::view::layout::GridLayout::new(&wb.model, wb.active_view());
|
||||
let key = layout.cell_key(row, col)?;
|
||||
wb.model.evaluate_aggregated(&key, &layout.none_cats)
|
||||
wb.model.evaluate_aggregated(
|
||||
&key,
|
||||
improvise_core::model::Aggregation::of(&layout.none_cats),
|
||||
)
|
||||
}
|
||||
|
||||
/// `Storage::list` is how a browser offers a document picker, and it must
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use improvise_command::ui::app::App;
|
||||
use improvise_command::ui::reduce;
|
||||
use improvise_core::model::Aggregation;
|
||||
use improvise_core::model::cell::{CellKey, CellValue};
|
||||
use improvise_core::view::Axis;
|
||||
use improvise_core::view::layout::GridLayout;
|
||||
@@ -190,7 +191,8 @@ fn the_worker_loads_projects_edits_and_persists_over_post_message() {
|
||||
fn value_at(wb: &Workbook, row: usize, col: usize) -> Option<CellValue> {
|
||||
let layout = GridLayout::new(&wb.model, wb.active_view());
|
||||
let key = layout.cell_key(row, col)?;
|
||||
wb.model.evaluate_aggregated(&key, &layout.none_cats)
|
||||
wb.model
|
||||
.evaluate_aggregated(&key, Aggregation::of(&layout.none_cats))
|
||||
}
|
||||
|
||||
/// A second worker over the same backend sees what the first one saved: what a
|
||||
|
||||
Reference in New Issue
Block a user