581934886d
# Conflicts: # context/test-evidence/formula-and-io.md # crates/improvise-io/src/import/wizard.rs # src/main.rs
2049 lines
105 KiB
Markdown
2049 lines
105 KiB
Markdown
# Test evidence — `improvise-formula` and `improvise-io`
|
||
|
||
What each of these tests is evidence *of*: the bug it reproduced, the fault
|
||
that was injected, and why it can fail. Keyed by exact test name — a rename
|
||
orphans the entry, and `scripts/check-comment-runs.py --check-evidence`
|
||
catches that. The mechanism arguments are in
|
||
`context/code/formula-and-io.md`.
|
||
|
||
**`###` is reserved for a name that really is an annotated test.** A fixture,
|
||
strategy or shared harness whose narrative is worth keeping gets a `####`
|
||
subordinate heading that says so. The checker as written accepts any `fn`, so a
|
||
helper under `###` would pass today and fail the moment that is tightened.
|
||
|
||
---
|
||
|
||
## `improvise-formula/src/parser.rs`
|
||
|
||
### each_primary_alternative_builds_its_own_expr
|
||
|
||
The anti-clamp companion to making the four `Rule` dispatches in `parser.rs`
|
||
exhaustive. Converting an `if rule == Rule::x { .. } else if .. { .. } else
|
||
{ .. }` chain into a `match` is mechanical, and a mechanical conversion can
|
||
**transpose two arms and still compile** — the compiler checks that every rule
|
||
is named, never that it is named against the right body. This test walks each
|
||
`primary` alternative (`number`, `agg_call`, `if_expr`, `paren_expr`, `is_expr`,
|
||
`ref_expr`), plus `unary_minus`, and the three `filter_value` alternatives that
|
||
share the other dispatch, and asserts each reaches its own `Expr` or its own
|
||
unquoting. Proved discriminating by swapping the `string` and `pipe_quoted`
|
||
arms: the test fails, and nothing else in the 85-test crate suite does.
|
||
|
||
Why the exhaustiveness is worth its noise, and how the defect was demonstrated,
|
||
are in `context/code/formula-and-io.md`, "The tree walkers are infallible by
|
||
construction" — including the injection trap, that an *atomic* new rule is
|
||
caught by accident in `first_inner` and only a **non-atomic** one reaches the
|
||
silent path.
|
||
|
||
### a_comparison_the_walker_does_not_know_is_not_ge
|
||
|
||
The `cmp_op` half of improvise-492x. `parse_cmp_op` ended `_ => BinOp::Ge`, so
|
||
a `<>` alternative added to `cmp_op` in `formula.pest` would have parsed as
|
||
`>=`: a filter that silently means something else. Same shape and same fix as
|
||
the aggregate half above; the token `<>` is chosen because it is the spelling a
|
||
future grammar would most plausibly add.
|
||
|
||
### an_aggregate_the_walker_does_not_know_is_not_count
|
||
|
||
improvise-492x, one of three beads that are the same defect — with
|
||
improvise-vyrx (`eval_bool_cached` wildcarding `BinOp` where `eval_bool` lists
|
||
it) and improvise-4lgj (the `.improv` pest walker's `_ => {}` arms silently
|
||
skipping unhandled rules). design-principles §2 states the rule they all break:
|
||
prefer an exhaustive match, so that adding a variant produces a compile error
|
||
that names every site needing an update. Each of the three is a place where a
|
||
wildcard turned that compile error into a **silent** wrong answer.
|
||
|
||
Here the wildcard was `_ => AggFunc::Count`. Adding `| ^"MEDIAN"` to `agg_func`
|
||
in `formula.pest` would have made `MEDIAN(Revenue)` parse and evaluate as
|
||
`COUNT(Revenue)` — a plausible number, no error, nothing to notice.
|
||
|
||
**A compile error is not available and that is a property of the input, not a
|
||
choice.** `agg_func` is one pest rule matching five string alternatives, so
|
||
`pest_derive` generates no per-alternative `Rule` variant for Rust to match
|
||
exhaustively over; the walker's input is a `&str`. So the refusal is the next
|
||
best thing and it is the idiom this file already uses for exactly this
|
||
condition: `GRAMMAR_INVARIANT`, whose text is "parser out of sync with
|
||
formula.pest". The module's own header comment — "tree walkers: infallible,
|
||
because pest validated the pair first" — is what makes a panic here the honest
|
||
shape rather than a `Result` threaded through every walker for a state no user
|
||
input can reach.
|
||
|
||
The test injects the ticket's own fault: it calls `parse_agg_func("MEDIAN")`
|
||
directly and requires a panic. On the pre-fix code that call returns
|
||
`AggFunc::Count` and the test fails. Its companion,
|
||
`every_grammar_aggregate_and_comparison_is_spelled_out`, is the anti-clamp
|
||
direction — a walker that panicked on *everything* would satisfy the two
|
||
`should_panic` tests, so the five aggregates and six comparisons are asserted to
|
||
still map to what the grammar says they mean.
|
||
|
||
### every_grammar_aggregate_and_comparison_is_spelled_out
|
||
|
||
The anti-clamp half of improvise-492x — see
|
||
`an_aggregate_the_walker_does_not_know_is_not_count`. It also stands as the
|
||
list that must be edited when `formula.pest` gains an alternative: the walker
|
||
refuses the new token, and this test is where the accepted set is written down
|
||
in Rust rather than only in the grammar.
|
||
|
||
### parse_sum_with_inline_where_filter
|
||
|
||
Regression: a `WHERE` inside the aggregate's parens must parse as the
|
||
aggregate's *inline* filter, not as a top-level `WHERE` clause on the formula.
|
||
The two land in different places on the AST — `Expr::Agg(_, _, Some(filter))`
|
||
against `Formula::filter` — and the difference is silent at parse time.
|
||
|
||
### hyphen_in_bare_identifier_reference
|
||
|
||
Regression. The hand-rolled tokenizer that preceded the pest grammar did not
|
||
allow `-` inside bare identifiers, so a reference to a persistence-legal name
|
||
like `east-coast` was silently parsed as `east - coast`: a **subtraction of two
|
||
unrelated `Expr::Ref`s**, which evaluates rather than erroring. The pest grammar
|
||
mirrors `improv.pest`'s `bare_name`, which does allow `-`.
|
||
|
||
### hyphen_in_bare_identifier_inside_aggregate
|
||
|
||
The same fix through the aggregate path, because `SUM(east-coast)` reaches the
|
||
identifier rule by a different route.
|
||
|
||
### pipe_quoted_target_is_unquoted_like_a_reference
|
||
|
||
Regression, and a data bug rather than a cosmetic one. The tree walker took the
|
||
target's **raw span**, so a pipe-quoted target came back with its delimiters
|
||
still attached while an `Expr::Ref` for the very same name came back unquoted.
|
||
Downstream, `Model::add_formula` then created a `_Measure` item literally named
|
||
`|My Target|`, which never matched the user's item `My Target`: the formula
|
||
wrote into a ghost column and nothing referring to it by name resolved.
|
||
|
||
The test asserts both halves — the stripped target *and* that the reference
|
||
spelled identically names the same item — because asserting only the target
|
||
would pass under a walker that unquoted both wrongly in the same way.
|
||
|
||
Why no existing test caught it: the writer emits `Formula::raw`, not `target`,
|
||
so the file text is identical either way and every round-trip test was blind to
|
||
it. See `context/code/formula-and-io.md` § "A formula target is unquoted".
|
||
|
||
### pipe_quoted_target_applies_escapes
|
||
|
||
The escape table applies to the target too, not only to `Expr::Ref` — the
|
||
companion that stops the fix above from being "strip the outer pipes".
|
||
|
||
### bare_target_is_unchanged
|
||
|
||
The negative arm: a bare target has nothing to strip. Without it, "always strip
|
||
the first and last character" would pass the two tests above.
|
||
|
||
---
|
||
|
||
## `improvise-formula/src/quote.rs`
|
||
|
||
### unescaping_inverts_quoting
|
||
|
||
The reading half inverts the writing half over every escape both grammars
|
||
define, plus the `\X` passthrough neither of them assigns a meaning to. The
|
||
`"42"` case is there because `pipe_quote` is unconditional: a `.improv` text
|
||
cell holding `42` must not read back as a number.
|
||
|
||
### a_trailing_lone_backslash_is_kept
|
||
|
||
Before improvise-i2m5 the unescape table existed **twice** — here (as
|
||
`parser.rs::unquote_pipe`) and as
|
||
`improvise-io/src/persistence/quoting.rs::unescape_pipe`. The two agreed on
|
||
every input either grammar's `pipe_inner` can produce, and differed *only* on a
|
||
trailing lone backslash, which `pipe_inner` cannot emit: io returned it,
|
||
`parser.rs` panicked. This pins the surviving answer for that unreachable input,
|
||
so the surviving function is total.
|
||
|
||
### a_quoted_name_round_trips_through_the_parser
|
||
|
||
The property the whole module exists for: a name `quote_name` spells parses, and
|
||
the parser hands back the name that went in — as a **target** and as a
|
||
**reference**, which after the target-unquoting fix are the same rule. The
|
||
fixture list is the interesting part: keywords (`WHERE`, `SUM`), a leading digit
|
||
(`9lives`), the escapes, and the empty name.
|
||
|
||
### a_keyword_is_quoted_even_though_it_looks_bare
|
||
|
||
Pins the keyword guard, which is deliberately stricter than the grammar
|
||
requires and is **user-confirmed** — do not revert it as over-caution. The
|
||
argument (PEG rule ordering, and `## Formulas` carrying formula text verbatim)
|
||
is in `context/code/formula-and-io.md`.
|
||
|
||
---
|
||
|
||
## `improvise-io/src/storage.rs`
|
||
|
||
### a_shared_backend_is_a_backend_and_a_clone_is_the_same_one
|
||
|
||
improvise-hmws. A worker must hold the concrete backend — for the four async
|
||
methods the trait does not carry — *and* hand the `App` a `Storage`. Before the
|
||
blanket `impl Storage for Rc<T>`, `Rc<OpfsFs>` was not a `Storage` and could not
|
||
be made one outside this crate (orphan rule), so `improvise-worker-server` wrote
|
||
a forwarding newtype, and so would every worker after it.
|
||
|
||
**The regression guard is the signature**: this does not compile without the
|
||
impl. What it asserts *beyond* compiling is the part a newtype could still get
|
||
wrong — that the clone the `App` holds and the handle the message loop keeps are
|
||
one backend and not two.
|
||
|
||
### memory_fs_satisfies_the_storage_contract
|
||
|
||
### physical_fs_satisfies_the_storage_contract
|
||
|
||
Both run `contract::storage_contract` at a **non-empty `dir`**, which is the
|
||
whole point of `dir` being a parameter: at `Path::new("")` a backend that
|
||
answers `list` in the wrong path shape passes, because joining is the identity
|
||
there. `OpfsFs` did exactly that and the disagreement was invisible until
|
||
improvise-vhcz (see `context/code/formula-and-io.md`).
|
||
|
||
---
|
||
|
||
## `improvise-io/src/persistence/mod.rs`
|
||
|
||
#### The shared harness: `load_corrupt_gzip`
|
||
|
||
Not a test, and so deliberately not a `###` heading — the harness the two
|
||
gzip-corruption tests below share. It asserts two **preconditions** before
|
||
handing the damaged bytes to the loader: that the corruption actually changed
|
||
the bytes, and that the result still looks like a gzip document by its magic
|
||
bytes. Without the second, a corruption that also broke the routing would make
|
||
the loader fail for a different reason and the test would pass while proving
|
||
nothing.
|
||
|
||
### a_truncated_gzip_document_fails_as_an_unexpected_eof
|
||
|
||
A `.gz` document cut short — the disk filled, or the process died mid-save. The
|
||
**kind** is asserted (`io::ErrorKind::UnexpectedEof`), not merely that an error
|
||
occurred, because "an error occurred" would also pass if the loader had stopped
|
||
decompressing altogether and choked on the compressed bytes as text.
|
||
|
||
### a_gzip_document_with_a_flipped_byte_fails_its_checksum
|
||
|
||
The other way a `.gz` arrives damaged, and it is a different kind
|
||
(`InvalidInput`), which is the reason to test both: one test against one
|
||
corruption cannot tell "the decoder refuses damaged input" from "the decoder
|
||
refuses short input". The first is *incomplete*, the second is *wrong* — "you
|
||
lost the tail" versus "you cannot trust any of it".
|
||
|
||
The byte flipped is in the **CRC32 field**, the first four of the eight-byte
|
||
gzip trailer, and not in the DEFLATE payload (Copilot, PR #22). A payload byte
|
||
also yields `InvalidInput`, but it can do so for malformed compressed data
|
||
rather than for the mismatch the test is named after, and the name would then be
|
||
a guess about which. Corrupting the trailer leaves the stream decodable, so the
|
||
only thing left to fail is the checksum — hence the assertion on the *source*
|
||
error's message, since anyhow's `to_string` shows only the outermost context.
|
||
|
||
### a_valid_gzip_of_invalid_utf8_is_reported_as_not_utf8
|
||
|
||
The third failure, and the one that made the other two worth keeping apart in
|
||
the code as well as in the tests (Copilot, PR #22). A gzip stream can be
|
||
perfectly well-formed and still hold bytes that are not UTF-8; `read_to_string`
|
||
decodes as it decompresses, so a single `Context` around it called that document
|
||
*unreadable gzip* — a wrong diagnosis of a document that decompressed without
|
||
complaint.
|
||
|
||
The precondition asserts the payload really is invalid UTF-8; without it the
|
||
test would also pass on a document that merely failed to decompress, which is
|
||
the case the other two already own. It also asserts the message does **not**
|
||
mention gzip: the gzip stream was fine, and blaming it is the bug.
|
||
|
||
#### The fixture: `storage_fixture`
|
||
|
||
Not a test. A workbook with a category, items, a formula and data — enough that
|
||
a round-trip exercises every section of the format. Every gzip test above and
|
||
every storage round-trip below reuses it, so that "the document survived" is a
|
||
claim about a real document rather than about an empty one.
|
||
|
||
### round_trips_through_memory_storage
|
||
|
||
improvise-6mq: persistence goes through `Storage`, so a workbook can be saved and
|
||
loaded with **no filesystem at all** — no `tempfile`, no disk. This is the shape
|
||
the browser backend plugs into.
|
||
|
||
### gzip_round_trips_through_memory_storage
|
||
|
||
The `.gz` suffix selects gzip regardless of backend: a property of the document
|
||
*name*, not of the filesystem.
|
||
|
||
### physical_and_memory_backends_write_identical_bytes
|
||
|
||
Swapping the backend must not change the bytes written. This is what stops a
|
||
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
|
||
`raw.rfind(" [")`, so a hand-written line with no space before the bracket —
|
||
`- Total = Food + Gas[Type]` — was not recognised and the formula was **silently
|
||
retargeted to `_Measure`**. The suffix is now part of the grammar
|
||
(`formula_line`) and walked structurally.
|
||
|
||
### parse_md_formula_ending_in_bracketed_pipe_name_keeps_raw
|
||
|
||
Companion to improvise-zgb: a formula *ending* in a pipe-quoted identifier that
|
||
contains `" ["` must not have the quoted name mistaken for a category suffix. It
|
||
stays a `_Measure` formula with its raw text intact. Without this arm, "look for
|
||
the last `[`" would pass the test above.
|
||
|
||
### unparseable_number_token_yields_error_not_zero
|
||
|
||
Bug improvise-4yc. The `Rule::number` arm used `as_str().parse().unwrap_or(0.0)`,
|
||
so any numeric token the grammar admitted but `f64::from_str` rejected silently
|
||
became `Number(0.0)` — a fabricated value in a spreadsheet.
|
||
|
||
No token the *current* grammar admits actually fails `f64` parsing (overflow
|
||
saturates to inf), so the test exercises `parse_number_cell` **directly**, with
|
||
the forms the grammar could plausibly drift toward admitting. That is the point:
|
||
the guard exists for the drift, not for today.
|
||
|
||
### malformed_numbers_in_data_never_silently_zero
|
||
|
||
The same bug through the grammar, covering all three routings: `--5` fails the
|
||
`number` rule entirely and becomes `bare_value` → `Text`; `3.14.15` matches the
|
||
`3.14` prefix, commits, and the rest fails → a whole-file parse error naming the
|
||
text; `1e999` is admitted and saturates to `+inf`. None of the three is `0.0`.
|
||
|
||
### parse_error_names_line_number_and_contents
|
||
|
||
Bug improvise-6kj: parse failures were wrapped as `Parse error: {e}`, leaning on
|
||
pest's raw rendering instead of stating the location in plain words. A user
|
||
fixing a hand-edited file needs the line number and the offending line.
|
||
|
||
### roundtrip_preserves_an_awkward_category_name
|
||
|
||
improvise-pczv as a property rather than a list. `quote_header` decides which
|
||
names the header line cannot carry, and that decision is a small set of clauses
|
||
— so the property is what earns its place: for *any* name, save-then-load must
|
||
give the name back. It runs against `populated_model` rather than a fresh one
|
||
because a workbook with no data cannot show a coordinate losing its category,
|
||
which is the silent half of the bug.
|
||
|
||
#### Why `populated_model` and not a fresh workbook
|
||
|
||
Not a test — the fixture the property above runs against. It already holds
|
||
categories, items and cells, and the *reason* is the evidence: a
|
||
default-constructed workbook makes the round-trip test vacuous for the
|
||
data-coordinate half, because there is no coordinate to lose its category.
|
||
|
||
#### The strategy: `awkward_name`
|
||
|
||
Not a test — the input strategy the property above draws from. The generated
|
||
shapes are the whole-line contract's stress points: edge whitespace, embedded
|
||
line breaks, pipes, and text that looks like another section marker
|
||
(`\n## Data`).
|
||
|
||
---
|
||
|
||
## `improvise-io/src/persistence/format.rs`
|
||
|
||
### virtual_views_are_not_written
|
||
|
||
improvise-60z. `_Records` and `_Drill` are scratch views the app builds on
|
||
demand. They put `_Index`/`_Dim` on axes, and those categories are never written
|
||
to the file — so persisting the views produces axis lines referring to
|
||
categories the loader will not find. The test also asserts a real view *is*
|
||
written, so "write no views at all" cannot pass it.
|
||
|
||
---
|
||
|
||
## `improvise-io/src/persistence/roundtrip_tests.rs`
|
||
|
||
### initial_view_skips_a_virtual_active_view
|
||
|
||
improvise-60z's writing half: saving while in records mode must not leave
|
||
`Initial View: _Records` pointing at a view the file no longer contains. It
|
||
asserts the loaded workbook's active view **exists in the loaded workbook**, not
|
||
merely that the string changed.
|
||
|
||
### virtual_views_in_an_existing_file_are_dropped_on_load
|
||
|
||
The reading half: a file written by an older build may already carry the scratch
|
||
views, and loading one must not reinstate them.
|
||
|
||
### filter_axis_round_trips_with_its_selection
|
||
|
||
improvise-dz8. `Axis::Filter` arises only inside the `_Drill` view, which is
|
||
never written, so this is defensive — but the alternative to supporting it in
|
||
the format is **silently writing a Filter axis as something else**.
|
||
|
||
### parse_md_round_trips_formula_category_with_special_chars
|
||
|
||
Bug improvise-zgb (a). `rfind(" [")` mis-split a suffix whose category name
|
||
itself contains `" ["`: the writer emitted `[Margin [%]]` unquoted and the parser
|
||
then split inside the name, producing garbage raw text and a category called
|
||
`%]`. Category names in the suffix are now pipe-quoted on write and parsed via
|
||
the grammar's `name` rule.
|
||
|
||
### a_keyword_named_category_is_pipe_quoted
|
||
|
||
improvise-i2m5. `.improv` now spells names through
|
||
`improvise_formula::quote_name`, which pipe-quotes a formula keyword. Before the
|
||
switch, io's own copy of the rule had no keyword guard and wrote
|
||
`## Category: SUM`, `- WHERE` and `SUM=WHERE = 1` bare — that was the **only**
|
||
output difference the whole persistence suite showed.
|
||
|
||
The names round-trip either way (`improv.pest` accepts `pipe_quoted` wherever a
|
||
`name` goes), so this pins the *spelling*, not the survival. It also pins the
|
||
header exception: `category_section` takes the whole line and never goes through
|
||
`quote_name` on either side, so only *references* are quoted.
|
||
|
||
### a_keyword_named_category_round_trips_everywhere_it_appears
|
||
|
||
The round trip the quoting exists for, at every position a keyword-named
|
||
category or item reaches the file: the axis line, the page selection,
|
||
`hidden:`, the category section and the data coordinates. A guard that covered
|
||
only some of them would pass a single-position test.
|
||
|
||
### a_formula_targeting_a_keyword_named_measure_round_trips
|
||
|
||
Why the keyword guard is worth its cosmetic cost even though bare keywords
|
||
parse: a `## Formulas` line carries formula text **verbatim**, so a name written
|
||
by `format_md` is later read by `parse_formula`. The two grammars share one
|
||
namespace, and `quote_name` is the one rule spelling names for both.
|
||
|
||
### a_category_name_the_header_line_cannot_carry_round_trips
|
||
|
||
improvise-pczv. A section header was the one name position that never went
|
||
through a quoting rule, so a name the header *line* could not carry was written
|
||
raw into it. The table `NAMES_THE_LINE_CANNOT_CARRY` is the whole rule, and each
|
||
entry is a different failure:
|
||
|
||
- `Region\nName` — the newline ended the header and the rest became a stray
|
||
line: the file would not parse at all.
|
||
- `Region\n## Data` — worse: the remainder looked like another section marker.
|
||
- `"Region "` / `" Region"` — the reader trims, so the category came back
|
||
renamed while every data coordinate still named the untrimmed original:
|
||
**silent orphaning**.
|
||
- `""` and `"|Region|"` — would be taken for a quoted name and unquoted.
|
||
|
||
This is reachable and not theoretical — see
|
||
`a_csv_header_with_an_embedded_newline_survives_a_save`.
|
||
|
||
### a_model_and_view_name_the_header_line_cannot_carry_round_trip
|
||
|
||
The same rule at the other three header positions: the model title, the view
|
||
title, and the `Initial View:` reference that must still resolve to the view.
|
||
|
||
### an_ordinary_spaced_header_stays_bare
|
||
|
||
The negative arm, and the one that stops `quote_header` from quietly becoming
|
||
`quote_name`. The header carries its name as the whole line — that is the
|
||
contract — and quoting a name the line carries fine would rewrite the header of
|
||
every existing document for nothing. It also asserts that a *reference* to the
|
||
same name is still quoted, so the two rules stay distinguishable.
|
||
|
||
### pipes_that_do_not_close_the_header_line_are_part_of_the_name
|
||
|
||
The one genuinely ambiguous line, decided by the grammar's `&NEWLINE`:
|
||
`## Category: |a| and |b|` is a category called `|a| and |b|`. Without the
|
||
lookahead the pipe-quoted arm would match `a` and **silently drop the rest of
|
||
the line**.
|
||
|
||
### a_header_written_by_an_older_build_still_loads
|
||
|
||
A header written before improvise-pczv is a bare line and must still load as one
|
||
— spaces, pipes in the middle and all. The compatibility direction of the same
|
||
grammar change.
|
||
|
||
### a_csv_header_with_an_embedded_newline_survives_a_save
|
||
|
||
The bug as a user meets it, and the reason improvise-pczv is a corruption bug
|
||
rather than tidiness. An RFC 4180 CSV may quote a header field containing a
|
||
newline; nothing on the import path forbids it, so the category carried one —
|
||
and the `.improv` file the user then saved **could not be reopened**. The test
|
||
asserts the precondition (the import still admits the header verbatim) so it
|
||
cannot pass by the import having quietly started rejecting it.
|
||
|
||
### an_item_name_the_item_line_cannot_carry_round_trips
|
||
|
||
improvise-odbc. `bank-info.improv` in the repo root failed to load at
|
||
`- JORDAN MARTINEZ` (line 293) with `expected pipe_quoted`. The ticket named two
|
||
readings — a stale file, or a writer/reader gap in which improvise emits an item
|
||
line the grammar rejects — and said to decide by asking whether improvise can
|
||
*write* that line today.
|
||
|
||
It cannot. `item_list`'s `name` is `pipe_quoted | bare_name`, and `bare_name` is
|
||
`[A-Za-z_][A-Za-z0-9_-]*` with no space; `quote_name` mirrors it character for
|
||
character, so every one of those item names is emitted pipe-quoted and read
|
||
straight back. The file was hand-edited in `c48a5cd` — the `v2025-04-09`
|
||
migration quoted the `Date` items and left the `Entity` ones bare — and never
|
||
round-tripped. So the reading is "stale file", and the standing finding was that
|
||
nothing exercised it. This test is what that finding turned into: the item names
|
||
from the file that motivated the ticket, driven through the writer and back. It
|
||
asserts both directions — the spaced name is quoted, the bare-legal name stays
|
||
bare.
|
||
|
||
### a_bare_item_name_with_a_space_is_rejected
|
||
|
||
The other half of the same finding: the exact line the file carries is *not*
|
||
something the reader accepts, so the file really was broken rather than the
|
||
error message being misleading. Asserting the failure keeps the test above
|
||
honest — without it, "the writer quotes it" would be consistent with the reader
|
||
also accepting the unquoted form. The trailing precondition (the same line
|
||
quoted *does* parse) stops it passing because of some unrelated syntax error.
|
||
|
||
### prune_empty_survives_the_roundtrip
|
||
|
||
improvise-qu4a. `View::prune_empty` was never written to disk: `format.rs` wrote
|
||
`format:`, `hidden:` and `collapsed:` for a view and nothing else, and
|
||
`improv.pest`'s `view_entry` had no `prune` alternative. Pressing `P`, saving
|
||
and reopening brought every pruned row and column back — and the toggle marks
|
||
the document dirty, so the user is invited to save the setting that is about to
|
||
be discarded.
|
||
|
||
The reason nothing caught it is the shape worth remembering: it is **invisible
|
||
to `fmt(parse(x)) == fmt(parse(fmt(parse(x))))`, because both sides drop it**
|
||
(`doctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share)`). So this
|
||
test asserts the **flag**, never the output's stability. Fault injected: delete
|
||
the two-line `w!` in `format_md`; the test fails at the `prune: true` assertion,
|
||
and the two controls below still pass — which is exactly why they are controls
|
||
and not the evidence.
|
||
|
||
#### a_view_that_does_not_prune_writes_no_prune_line
|
||
|
||
The control, stated separately because it is the direction the default supplies.
|
||
A writer that emitted `prune: true` unconditionally passes the test above and
|
||
fails this one. Not evidence of the bug; evidence the fix is not one-directional.
|
||
|
||
#### a_file_without_a_prune_line_does_not_prune
|
||
|
||
The compatibility reading, decided rather than defaulted: a file written before
|
||
the line existed means `false`, which is what happens today. `improv.pest`
|
||
spells `prune: true|false` rather than making the line's presence the boolean,
|
||
so the grammar-walking generator can emit `prune: false` and it round-trips to
|
||
`false` through a writer that omits it.
|
||
|
||
### a_file_naming_its_own_views_gains_no_default
|
||
|
||
improvise-m4rv. `Workbook::new` seeds a `Default` view and `on_category_added`
|
||
assigns every parsed category into **every** existing view, so a file naming
|
||
only `Plan` parsed into *two* views — the second auto-laid-out and never in the
|
||
file. Two consequences, the second worse: the shipped demo's view panel showed a
|
||
phantom row **at index 0**, so the first `V`+`Enter` a visitor pressed took them
|
||
off the curated view; and open-and-save with no edits rewrote the user's
|
||
document, appending the whole `## View: Default` block permanently.
|
||
|
||
`examples/demo.improv` was unaffected only because its view *is* named
|
||
`Default`, which is why the bug survived the shipped-document tests.
|
||
|
||
Fault injected: remove the `drop_unnamed_default` call in `build_workbook`; the
|
||
test fails with `["Default", "Plan"]`. The fix is narrow on purpose — it drops
|
||
`Default` only when the file named **at least one** view and none of them was
|
||
`Default`, so the two cases in the control below are untouched.
|
||
|
||
#### a_file_that_names_default_itself_keeps_it_alongside_its_siblings
|
||
|
||
The control the fix must not break, and **the second view is the whole point**.
|
||
This entry replaces a vacuous one, found by review injecting
|
||
`default_is_a_phantom = true` — the over-broad "always drop Default" — and
|
||
watching the control **pass**. It passed because `Workbook::delete_view` refuses
|
||
the last view: with `Default` alone in the map the drop was refused by a guard
|
||
one level away from the condition under test, so the test answered *does
|
||
`delete_view` refuse the last view* where it meant *does `drop_unnamed_default`
|
||
decline to fire*. Naming `Default` **and** `Other` gives the map two entries, so
|
||
the wrong condition really does delete and the test really can fail. (Two
|
||
pre-existing tests, `parse_md_round_trips_multiple_views` and
|
||
`parse_md_order_independent_new_view_before_categories`, also catch it — the
|
||
repo was covered, but not by the test that claimed to be the control.)
|
||
|
||
#### a_file_naming_no_view_keeps_the_seeded_default
|
||
|
||
The legacy reading, kept separate and **deliberately weaker**: a file with no
|
||
`## View` section at all still opens on the seeded view. It cannot be made
|
||
discriminating the way its neighbour was — with no view named there is only ever
|
||
one in the map, so `delete_view`'s last-view refusal masks any condition. It is
|
||
a statement of behaviour, not evidence of the fix.
|
||
|
||
#### a_category_named_prune_is_not_read_as_the_prune_flag
|
||
|
||
`prune: true` and `prune: row` differ only in the value, and `view_entry` tries
|
||
`prune_line` **before** `axis_line` — so a category literally named `prune`
|
||
round-trips only because `bool_kw` is `"true" | "false"` and fails on an axis
|
||
keyword. Safety rests on the disjointness of two keyword sets, which nothing
|
||
else checks, which is why the test exists.
|
||
|
||
**Its first version was vacuous, and the injection is what showed it.** Feeding
|
||
the rejected presence-style design — `prune_line = { "prune: " ~ rest_of_line }`,
|
||
which swallows `prune: row` whole — left the suite green, because the fixture put
|
||
the category on `Row` and `Workbook::add_category` → `View::on_category_added`
|
||
**re-derives** `Row` for the first regular category on reload. The axis matched
|
||
by reinvention rather than by being read. The fixture now uses `Page` plus a page
|
||
selection, neither of which any default supplies, and the same injection fails
|
||
it.
|
||
|
||
### the_aggregation_section_is_written_in_a_stable_order
|
||
|
||
improvise-ppru's companion. `Model::measure_agg` is a `HashMap`, and an index
|
||
whose iteration order is hash order is not an order (gotcha 18) — writing it
|
||
unsorted would give a different `.improv` file per process, the same class of
|
||
defect as `analyze_records`. The test pins the three lines in name order and
|
||
then asserts `format(parse(text)) == text`. Fault injected: drop the
|
||
`sort_by_key`; the assertion fails on most runs, which is itself the argument
|
||
for pinning it rather than trusting a single green run.
|
||
|
||
### a_removed_measures_aggregation_is_not_written
|
||
|
||
Found by **review of this branch**, not by a ticket, and it is improvise-qx1z's
|
||
own defect one layer down: `Model::remove_item` removed the item and its cells
|
||
and left `measure_agg` holding an entry for a measure that no longer exists.
|
||
|
||
It was harmless while improvise-ppru was unfixed, because nothing persisted the
|
||
map — the stale entry evaporated on reload. **Persisting it made it durable**, so
|
||
the document grew a `## Aggregation` line naming a measure it does not have, and
|
||
re-adding that measure brought back an aggregation nobody asked for. A fix that
|
||
introduces a format for state that outlives what it names is how a latent bug
|
||
becomes a permanent one; the sweep had to ship with the writer.
|
||
|
||
The fixture carries **two** measures and removes one, so the assertion separates
|
||
"forget this measure" from "clear the map" — the same shape
|
||
`on_item_removed_forgets_a_hidden_item` needs for the view half. Fault injected:
|
||
delete the `measure_agg.remove` line; the test fails on the written text before
|
||
it ever reaches the reader.
|
||
|
||
### an_interleaved_category_keeps_its_item_order
|
||
|
||
improvise-3yv3. `format_md` collected every ungrouped item into `bare` and
|
||
emitted that whole list *before* any grouped item, so a category built as
|
||
`[grouped, ungrouped]` came back `[ungrouped, grouped]`. Grid axis ordering
|
||
consumes item insertion order (gotcha 18), so reopening a document silently
|
||
rearranged its rows and columns.
|
||
|
||
The fixture interleaves deliberately — `a, b, g1[G], c, g2[G]` — because the
|
||
ticket's own two-item reproduction cannot tell "writes grouped items last" from
|
||
"writes each run in place": with one item of each kind, *both* orders are a
|
||
single bare run followed by a single grouped one. The test asserts the four
|
||
emitted lines, not just the parsed order, so a writer that happened to re-sort
|
||
correctly by accident still fails. Fault injected: restore the two-vector split;
|
||
the line assertion fails with `["- a, b, c", "- g1[G]", "- g2[G]"]`.
|
||
|
||
### a_cell_with_no_coordinates_round_trips
|
||
|
||
improvise-pnf5, the P1. A cell keyed by the empty `CellKey` is a legal model
|
||
state — it is what `AddRecordRow` leaves while a records row is unfinished, and
|
||
it is reachable from the shipped `r` then `o` keys. `format_md` wrote it as
|
||
`" = 0"` and `coord_list` required at least one coordinate, so **saving in
|
||
records mode overwrote a good document with one the application could not
|
||
reopen**. `CleanEmptyRecords` runs on leaving records mode, not before `Save`.
|
||
|
||
The fix is a spelling rather than a discard, because the ticket is explicit that
|
||
legitimate data must not be dropped: `data_coords = _{ (coord_list ~ " ")? }`
|
||
gives the scalar cell the line `= 42`. It is unambiguous under PEG because
|
||
`coord` needs a `name`, and `bare_name` cannot start with `=`, so the optional
|
||
group fails cleanly and the `"= "` literal matches.
|
||
|
||
Fault injected: revert the writer's `match` to the old `"{} = {}"`; the test
|
||
fails at the `\n= 42\n` assertion, and `parse_md` then rejects its own writer's
|
||
output. The trailing `format_md(&back) == text` is what makes this a round trip
|
||
rather than a parse check.
|
||
|
||
#### slash_in_hidden_name_survives_roundtrip
|
||
|
||
**Not a bug.** improvise-nvq4 (external report) claimed a `/` in an item or
|
||
group name corrupts the `hidden:`/`collapsed:` line, whose format is
|
||
`Cat/Item`. It does not reproduce: `hidden_line` reads two `name`s, a `name` is
|
||
`pipe_quoted | bare_name`, and `bare_name` excludes `/` — so `quote_name`
|
||
already writes `hidden: C/|A/B|` and `extract_name_pair` reads it back. The test
|
||
is kept as a **passing control** over both maps, so the claim cannot be re-filed
|
||
without someone first watching this pass.
|
||
|
||
---
|
||
|
||
## `improvise-io/src/persistence/export.rs`
|
||
|
||
### export_csv_quotes_commas_per_rfc4180
|
||
|
||
Bug improvise-1cz. `export_csv` joined labels and values with bare commas — no
|
||
RFC 4180 quoting — and ignored the view's number format. Under a comma-grouping
|
||
format a value of 1200 renders as `1,200` and a label like `Smith, Jr.` splits
|
||
into two fields, **corrupting every row**. The test reads the file back with a
|
||
real CSV reader and asserts the field *count*, which is the thing a string
|
||
`contains` check cannot see.
|
||
|
||
**Strengthened by improvise-711n.** The assertion on the value was
|
||
`records[1][1].starts_with("1,200")`, and the fixture's format is `,.2f` — so
|
||
the check could not tell `1,200` from `1,200.00` and sat green while
|
||
`parse_number_format` threw the `.2` away. It is now an exact equality. Reverting
|
||
the `format.rs` fix fails it with `left: "1,200"`, `right: "1,200.00"`, which is
|
||
the cross-crate demonstration that the format bug reached a user's exported file
|
||
and not only the screen.
|
||
|
||
|
||
---
|
||
|
||
## `improvise-io/src/import/analyzer.rs`
|
||
|
||
### detect_short_year_format
|
||
|
||
Two-digit years are ambiguous with the four-digit format, so `%m/%d/%Y` matches
|
||
first. Expected, not a defect: the wizard shows the detected format and the user
|
||
can override it. The same argument as the ambiguity below.
|
||
|
||
### an_ambiguous_date_is_read_american_first
|
||
|
||
`01/02/2025` and `02/03/2025` parse under `%m/%d/%Y` *and* `%d/%m/%Y`, and
|
||
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. **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
|
||
to fit too, or there is nothing to resolve.
|
||
|
||
### a_day_first_only_value_settles_the_ambiguity
|
||
|
||
Where the two readings genuinely can be told apart, the unambiguous value
|
||
decides — first match still, but `%m/%d/%Y` now fails. Without this, "always
|
||
American" would pass the test above.
|
||
|
||
### the_distinct_values_are_in_first_seen_order
|
||
|
||
improvise-7yx, found while pinning the date heuristic. `analyze_records` built
|
||
its distinct values by collecting into a `HashSet` and iterating it, so the order
|
||
was different in every process. This is gotcha 18's bug in a second place, and it
|
||
reaches further than a tidy-up: `build_model` adds items in exactly this order,
|
||
`IndexMap` preserves insertion order, and persistence relies on it — so the
|
||
**same CSV imported twice produced two different `.improv` files** and two
|
||
different row orders on screen.
|
||
|
||
The answer is first-seen order: deterministic, and the order the records
|
||
themselves are in, which is the one a user can predict. Five runs is enough to
|
||
see it, because each `HashSet` gets its own `RandomState`; the pre-fix code
|
||
produced five different orders here.
|
||
|
||
### the_detected_date_format_does_not_depend_on_the_hash_seed
|
||
|
||
The consequence of the same bug that a user would have found hardest to explain.
|
||
`detect_date_format` tests only the first ten samples, so when the sample order
|
||
was a hash order, *which* ten it saw varied — and with it the detected format,
|
||
whenever the eleventh-and-later values are the ones that rule a format out.
|
||
|
||
Twelve values are ambiguous and one (`25/12/2025`) is day-first only. In hash
|
||
order it landed inside the first ten about half the time, so the column imported
|
||
as `%d/%m/%Y` on some runs and `%m/%d/%Y` on others — every date in it off by the
|
||
difference. In first-seen order it is last, deliberately, and the answer is
|
||
**stable**, which is the property asserted.
|
||
|
||
**The expected value moved with improvise-jcfs, and the reason is worth keeping.**
|
||
This entry used to say the answer was `%m/%d/%Y` and that the ten-sample
|
||
truncation "is still a real limit". That is exactly backwards as a test: the
|
||
fixture was built so the disambiguating value sits *outside* the window, so the
|
||
assertion was pinning the truncation as correct behaviour — a test written to
|
||
prove determinism had, as a side effect, made the neighbouring defect a
|
||
specification. With the window gone the twelfth value is seen and the answer is
|
||
`%d/%m/%Y`. Nothing about hash-seed independence changed; only the constant did.
|
||
The general form: **a fixture chosen to exercise bug A can silently enshrine
|
||
bug B**, and the tell is that the expected value is surprising for a reason the
|
||
entry does not explain.
|
||
|
||
### a_disambiguating_date_decides_however_many_values_precede_it
|
||
|
||
improvise-jcfs. `detect_date_format` took `samples.into_iter().take(10)`, so a
|
||
date that can only be read day-first was never examined if it appeared after the
|
||
tenth *distinct* value. `%m/%d/%Y` precedes `%d/%m/%Y` in `DATE_FORMATS`, so the
|
||
column silently imported month-first and every derived `_Month`/`_Quarter` value
|
||
was wrong. The cap is removed; `all()` short-circuits on the first failure, so a
|
||
format that does not fit costs one parse either way.
|
||
|
||
**Fault injected:** `take(10)` restored. The test fails, as does
|
||
`the_detected_date_format_does_not_depend_on_the_hash_seed`. The leading values
|
||
are asserted to parse under *both* readings first, so this is a statement about
|
||
a value outside the old window deciding, not about one format merely fitting —
|
||
the same precondition `an_ambiguous_date_is_read_american_first` uses.
|
||
|
||
**The rival fix is what shaped this test**
|
||
(`doctrine(inject_the_rival_fix_not_only_the_bug)`). A first version used a
|
||
single twelve-value fixture, and `take(100)` — raising the cap instead of
|
||
removing it — passed the entire suite. That is not the rule: the rule is that
|
||
detection does not depend on how many values there are, and a fixed fixture can
|
||
only ever pin one side of some cap. So the test runs the same shape at **11 and
|
||
500** leading values, an order of magnitude apart. Any `take(N)` fails one of
|
||
them for every N, because the two leads straddle whatever N a rival picks — and
|
||
the failure message names which lead hid the decider.
|
||
|
||
The 500-value arm costs microseconds: `all()` abandons `%m/%d/%Y` at the last
|
||
element and `%d/%m/%Y` succeeds, so it is two passes over 501 short strings.
|
||
|
||
### a_blank_value_does_not_demote_a_numeric_column
|
||
|
||
improvise-l1f5. `all_numeric` was `values.iter().all(|v| v.is_number())` over
|
||
*every* value including `Value::Null`, and `parse_csv_field` maps a blank CSV
|
||
field to `Null`. So one blank cell in a thousand-row Revenue column proposed
|
||
`Label` instead of `Measure`, and on a default import the numbers then arrived as
|
||
**category item names** rather than as cell data. Inference now runs over the
|
||
values that are present.
|
||
|
||
**Fault injected:** both `all_` bindings restored to the full `values` slice.
|
||
This test and its string sibling fail; `an_all_null_column_is_a_label` does not,
|
||
which is correct — it is the control.
|
||
|
||
### a_blank_value_does_not_demote_a_string_column
|
||
|
||
The same defect one branch over, and the reason the fix touched two lines rather
|
||
than one. `all_string` had the identical shape, so a Region column with one blank
|
||
became a `Label`: not merely a wrong proposal, but a column whose
|
||
`distinct_values` were then computed over a slice the branch had already
|
||
rejected. Fixing only the numeric half would have left a bug with the same cause,
|
||
the same shape and no entry here.
|
||
|
||
### an_all_null_column_is_a_label
|
||
|
||
Stated because `all()` is vacuously true over an empty slice: with the `Null`s
|
||
filtered out, a column that is entirely blank would satisfy `all_numeric` *and*
|
||
`all_string` and be proposed `Measure` on the strength of no evidence at all.
|
||
The `!present.is_empty()` guard is what makes it fall through to `Label`, and
|
||
this test is the only thing that names that as a decision.
|
||
|
||
**Fault injected:** the `!present.is_empty() &&` conjunct dropped from both
|
||
bindings. Only this test fails. It needed its own injection: under the *l1f5*
|
||
fault (both `all_` bindings back over the full `values`) it **passes**, because
|
||
an all-null column was `Label` before the fix too — so the injection that proves
|
||
its neighbours discriminating says nothing about this one
|
||
(`doctrine(an_injection_that_does_not_fail_names_an_untested_path)`).
|
||
|
||
**The rival fix was injected too**, and it is what justifies fixing both `all_`
|
||
bindings rather than the numeric one the ticket named: `v.is_number() ||
|
||
v.is_null()` over the full slice repairs the Revenue case exactly as well and
|
||
leaves the Region case broken. `a_blank_value_does_not_demote_a_string_column`
|
||
is the input that separates them.
|
||
|
||
---
|
||
|
||
## `improvise-io/src/import/csv_parser.rs`
|
||
|
||
### short_rows_pad_missing_trailing_columns_with_null
|
||
|
||
Bug improvise-k8i. The reader is configured `.flexible(true)`, so rows shorter
|
||
than the header parsed without error — but the missing trailing columns were
|
||
simply **absent** from the record map. No error, no warning, and field-kind
|
||
analysis saw skewed value counts. Short rows must keep every header column,
|
||
missing ones as `Null`.
|
||
|
||
### short_rows_produce_warning_naming_column_and_count
|
||
|
||
Companion to improvise-k8i: the warning must name the affected column and say how
|
||
many rows were short. A warning that said only "some rows were short" would pass
|
||
a weaker test and be useless to the user.
|
||
|
||
### full_rows_produce_no_warnings
|
||
|
||
The negative arm: padding must not fire on a complete file.
|
||
|
||
### a_leading_utf8_bom_is_not_part_of_the_first_header
|
||
|
||
A header name becomes a **category name** and survives into the `.improv` file,
|
||
so a byte-order mark riding along invisibly is silent corruption rather than
|
||
cosmetics. Excel writes one; the `csv` crate strips a *leading* one, so the first
|
||
header is `Region` and not `\u{feff}Region`.
|
||
|
||
The control at the end is what makes this an assertion rather than a tautology:
|
||
a BOM in the *second* header is not leading, is not stripped, and shows up in the
|
||
key — so the check demonstrably can see a BOM where there is one.
|
||
|
||
### crlf_line_endings_leave_no_carriage_return_in_the_data
|
||
|
||
The `\r` belongs to the terminator, not to the last field of the row: a `Revenue`
|
||
of `1\r` would be a string rather than a number, and every row would import as
|
||
text.
|
||
|
||
The control: a **lone** `\r` is itself a record terminator to this reader, so
|
||
`Ea\rst` splits one row into two. That is what a stray carriage return does when
|
||
it is not part of a CRLF pair, and it is why "two records, clean values" is a
|
||
real observation rather than a coincidence.
|
||
|
||
Since improvise-8jk6.4 the parser yields text, so the assertion is that
|
||
`Revenue` is the text `1`. A kept `\r` would make it `1\r`, which fails that
|
||
assertion, and which `number_in` would not read as a number where a measure
|
||
wanted one.
|
||
|
||
### an_empty_file_is_no_records_and_no_warnings
|
||
|
||
An empty file is no records and no complaint. The wizard's own preconditions
|
||
(`wizard.rs::validation_error`) are what refuse to build a model from nothing;
|
||
the parser's job is to say truthfully that there was nothing there.
|
||
|
||
### a_header_with_no_rows_is_no_records
|
||
|
||
The same answer, and the case a user actually hits — an export filtered down to
|
||
nothing.
|
||
|
||
### a_single_column_file_yields_one_field_per_record
|
||
|
||
The degenerate shape for everything downstream that splits a row into
|
||
coordinates and a measure: one field, present, and **not** mistaken for a short
|
||
row.
|
||
|
||
### a_nonfinite_numeric_token_stays_text_rather_than_becoming_zero
|
||
|
||
improvise-jc95, rated P1. `serde_json::Number::from_f64` returns `None` for NaN
|
||
and the infinities — JSON has no spelling for them — and `parse_csv_field`
|
||
answered that `None` with `.unwrap_or(serde_json::Number::from(0))`. So `NaN`,
|
||
`inf`, `-inf` and any overflowing literal such as `1e999` imported as a **real
|
||
measured zero**: not a blank, not an error, a number that sums and averages and
|
||
is indistinguishable from data. The token now falls through to the existing
|
||
string arm, which is what an unparseable field already does.
|
||
|
||
**Fault injected:** the `unwrap_or` restored. The test fails on the first token.
|
||
The precondition inside the loop asserts each token really does parse as a
|
||
non-finite `f64`, so a future `f64` parser that rejected `inf` outright would
|
||
fail the precondition rather than pass the test vacuously.
|
||
|
||
The consequence downstream is deliberate and worth stating: a column carrying
|
||
`NaN` is no longer all-numeric, so the analyzer proposes it as a Category or
|
||
Label rather than a Measure. That is the honest answer — the column is not
|
||
numeric — and it is visible in the wizard, where a fabricated zero was not.
|
||
|
||
Since improvise-8jk6.4 the numeric rule is `csv_parser::number_in`, applied
|
||
through `typed` only where a number is wanted, and the parser itself yields
|
||
text. So this test asserts that `typed` leaves each token as text. The
|
||
`unwrap_or` fault would be injected into `number_in` now.
|
||
|
||
### an_ordinary_float_is_still_a_number
|
||
|
||
The control for the entry above. Guarding non-finiteness by deleting the float
|
||
branch entirely would also pass that test, so this one pins that `1000.50` is
|
||
still a `Number` and that `0` — the value the bug invented — is still reachable
|
||
when the file really says so. Since improvise-8jk6.4 both go through `typed`.
|
||
|
||
### a_repeated_header_is_renamed_rather_than_overwriting_its_twin
|
||
|
||
improvise-5hfh. Headers key a `serde_json::Map`, so `A,B,A` inserted the third
|
||
column's value over the first column's under one key: the record kept two fields
|
||
where the file had three, with no error and no warning, and the analyzer then
|
||
saw a column that did not exist in the source. Later occurrences are now renamed
|
||
`A_2`, `A_3`, … and each rename is reported.
|
||
|
||
**Fault injected:** `disambiguate_headers` short-circuited to `(raw, vec![])`.
|
||
The test fails on `map["A_2"]` being absent, and so does
|
||
`duplicate_csv_headers_not_silent`.
|
||
|
||
The warning names the original, the 1-based column and the new name, because a
|
||
user's next question is which of two identically-named columns became `A_2`.
|
||
Note the enabled reproduction asserts the **observable** outcome first — three
|
||
fields, values `1`, `2`, `3` in order — and only then that a warning mentions
|
||
it; its original form asserted prose alone, against a module that emitted no
|
||
warnings at all and so had no wording convention to match.
|
||
|
||
### a_rename_skips_a_suffix_the_file_already_claims
|
||
|
||
The repair's own failure mode. `A,A_2,A` renaming the third column to `A_2`
|
||
would collide with the real second column and reintroduce exactly the overwrite
|
||
the fix exists to prevent — a bug strictly worse than the original, because the
|
||
warning would say the data was preserved. The candidate suffix is checked
|
||
against every name the file claims, so the third column becomes `A_3`.
|
||
|
||
### distinct_headers_are_not_renamed_and_warn_nothing
|
||
|
||
The negative arm: an ordinary file must not acquire suffixes or warnings.
|
||
Without it, renaming every column would satisfy the two tests above.
|
||
|
||
### a_row_wider_than_the_header_is_reported
|
||
|
||
The half of improvise-k8i that was not closed with it. `.flexible(true)` also
|
||
permits a row *longer* than the header, and the insert loop guards with
|
||
`headers.get(i)`, which returns `None` — so every field past the header width was
|
||
dropped with no error, no warning and no record of it. The short-row half got
|
||
padding and a per-column warning in f0b9227; the long-row half got nothing,
|
||
because the ticket's title said "row widths mismatch" and only one direction was
|
||
reproduced.
|
||
|
||
The values are **still dropped** — preserving them means inventing column names,
|
||
which changes the imported model's shape and is a wizard decision rather than a
|
||
parser one. What changed is that it is no longer silent: the warning counts the
|
||
affected rows, names the header width and gives the widest row seen, which is
|
||
enough for a user to tell a stray delimiter from a genuinely different file.
|
||
|
||
**Fault injected:** the `record.len() > headers.len()` counter disabled. Both
|
||
this test and `long_csv_row_is_not_silently_dropped` fail.
|
||
|
||
---
|
||
|
||
## `improvise-io/src/import/wizard.rs`
|
||
|
||
### wizard_previous_reverses_each_advance
|
||
|
||
Bug improvise-907 (from improvise-o1c): no step bound a back key, so reaching a
|
||
later step and wanting to change a field kind meant Esc — discarding every
|
||
proposal toggle, formula and name. `previous` walks back through the path
|
||
**actually taken**, including the steps a conditional transition skipped on the
|
||
way forward, which is why it is a history stack and not a reverse table.
|
||
|
||
### wizard_build_parks_the_workbook_on_done
|
||
|
||
Bug improvise-907 (from improvise-c6j): `advance` mapped `NameModel` → `Done`
|
||
but nothing ever called it there, so `Done` was reachable **only from tests**.
|
||
`build` now parks the workbook on `Done` for confirmation.
|
||
|
||
### wizard_build_failure_stays_on_name_model
|
||
|
||
A build that fails despite the earlier gates leaves the user on `NameModel` with
|
||
the error, rather than moving to a `Done` step with nothing to confirm.
|
||
|
||
### wizard_blocks_advance_when_over_category_limit
|
||
|
||
Bug improvise-mzv: with more than `MAX_CATEGORIES` (12 then, 24 since improvise-felo.1) accepted Category
|
||
proposals, the wizard let the user advance through every remaining step and
|
||
`build_model()` only failed at the final confirm. Advancing past
|
||
`ReviewProposals` must be blocked, with a message telling the user to mark
|
||
fields as Measure or toggle them off — the actions still available on that step.
|
||
|
||
### wizard_blocks_advance_when_no_category_accepted
|
||
|
||
Bug improvise-907 (from improvise-gfq): `build_model` raised "At least one
|
||
category must be accepted" only on Enter at `NameModel`, the final step. An
|
||
import whose fields all classify as Measure walked the user through
|
||
`DefineFormulas` and `NameModel` before refusing. The gate belongs at
|
||
`ReviewProposals`, where `c` can still change a field's kind — the same treatment
|
||
improvise-mzv gave the too-many-categories case.
|
||
|
||
### build_model_null_category_value_is_missing_not_the_item_null
|
||
|
||
improvise-ych. A category value of JSON `null` used to be stringified by the
|
||
`v.to_string()` fallback, **inventing an item literally named "null"** and
|
||
placing the record under it. A CSV empty cell parses to `Value::Null` too, so
|
||
every blank column value in a CSV import grew a phantom "null" item. Null means
|
||
*absent*: the record must be skipped and counted, exactly as if the key were
|
||
missing.
|
||
|
||
**The skip is superseded by improvise-8jk6.2.** Null still means absent, and
|
||
still invents no item. But an absent coordinate no longer drops the record: it
|
||
keeps its cell without that coordinate. So the test now asserts both cells
|
||
(`region=East` and the bare `_Measure=revenue`) and no warning. The rival that
|
||
restores the skip (a present-but-blank category returns the record as
|
||
unplaceable) fails here, beside
|
||
`a_record_with_no_value_for_a_category_is_kept_without_that_coordinate`.
|
||
|
||
### build_model_literal_string_null_is_a_real_category_item
|
||
|
||
The other side of improvise-ych: the *string* `"null"` is an ordinary value — a
|
||
region really called "null" — and must survive as its own item. Only
|
||
`Value::Null` means absent. Without this arm, "drop anything that stringifies to
|
||
null" would pass the test above.
|
||
|
||
### build_model_empty_string_category_value_is_missing
|
||
|
||
improvise-ych, third arm. An empty string is the JSON spelling of a blank cell —
|
||
the CSV parser already turns a blank field into `Value::Null`, so the two inputs
|
||
must agree. An item with an empty name is unnameable in the grid, so it is
|
||
treated as missing rather than created. Since improvise-8jk6.2 "missing"
|
||
means the record keeps its cell without a `region` coordinate. It is no longer
|
||
skipped, and the same skip-restoring rival fails here.
|
||
|
||
### build_model_sums_records_that_share_every_coordinate
|
||
|
||
improvise-2cw9, rated P1. Each record called `set_cell` directly, so two source
|
||
rows with identical category values — `East,10` then `East,20` — were **one**
|
||
cell and the second overwrote the first. A sum over the import read 20 where the
|
||
file said 30, with no error and no warning, and nothing downstream could tell
|
||
that a row had existed.
|
||
|
||
**The policy is a decision, and the ticket deliberately left it open**: preserve,
|
||
aggregate, or reject. The model cannot preserve — a cell is named by its
|
||
coordinate tuple and there is no second address for a second record, so the only
|
||
lossless options are aggregating or inventing a row-identity category, and the
|
||
latter changes the shape of every imported document. Summing is what a pivot
|
||
table does to repeated records and is `measure_agg`'s own default, so it is the
|
||
answer here; the warning is what keeps it from being silent, because it is *not*
|
||
right for a measure the user will later set to `Avg` (that value is now
|
||
pre-summed). If that case matters, the follow-up is a wizard step that asks, not
|
||
a change to this default.
|
||
|
||
**Fault injected:** the accumulate arm changed to `total = val; contributors = 1`
|
||
— last-row-wins again. This test fails on `Some(30.0)`, as does
|
||
`repeated_import_coordinates_not_silently_overwritten`.
|
||
|
||
The `West` cell is asserted alongside, because a repair that summed *every* cell
|
||
into one, or that dropped the singleton path, would otherwise pass. The warning
|
||
is asserted **not** to name `West`, so the collision report cannot be a list of
|
||
every cell in the document.
|
||
|
||
### build_model_writes_cells_in_first_seen_order
|
||
|
||
Not a reported bug — the hazard the 2cw9 fix introduced. Accumulating means a
|
||
map keyed by `CellKey`, and gotcha 18 is exactly this: `Model::set_cell` writes
|
||
into an `IndexMap` whose order persistence depends on, so writing the cells in a
|
||
`HashMap`'s iteration order would have given the same CSV a different `.improv`
|
||
file per process. That is improvise-7yx's bug, in a third place, introduced by
|
||
the fix for a different one. The `HashMap` here only deduplicates; the order is
|
||
carried by a `Vec` beside it.
|
||
|
||
**Fault injected:** `cells.sort_by_key(|c| c.key.to_string())` before the write
|
||
loop — a *deterministic* reordering, which is the weaker fault and still caught.
|
||
Five iterations, because each `HashMap` gets its own `RandomState` and one run
|
||
of a hash order can happen to be right.
|
||
|
||
**Two insertion histories, per `doctrine(an_order_test_needs_two_histories)`.**
|
||
A single-history order test cannot tell "listed its own insertion order" from
|
||
"emitted one canonical order that happens to match this fixture" — a writer that
|
||
always sorted, or always emitted the order of some other structure, is
|
||
indistinguishable from the right one against one input. The same six regions are
|
||
imported forwards and backwards and each run must list *its own* order.
|
||
|
||
Reaching the second direction needed care, because an injection that fails at
|
||
iteration 1 leaves iteration 2 untested (the corollary in
|
||
`doctrine(a_precondition_derived_from_its_own_expectation_guards_nothing)`). The
|
||
loop was therefore re-ordered to `[backward, forward]` under the sort fault and
|
||
observed failing on the backward history specifically, with
|
||
`left: ["alpha", …]` (sorted) against `right: ["charlie", …]` (backward).
|
||
|
||
### every_summed_cell_is_reported_in_full
|
||
|
||
This replaces `a_long_collision_list_is_truncated_with_a_count`, which pinned
|
||
2cw9's three-then-"and N more" warning. After the Sum choice, the Confirm step
|
||
still showed "…; and 28 more". The user's rule is that a message they need is
|
||
never truncated, so each summed cell is now its own warning, and the step wraps
|
||
and scrolls them. It was red at compile time against the old `Option<String>`
|
||
signature, then red against a `Vec` that still held one elided sentence. The
|
||
rival **elided after three** fails it.
|
||
|
||
### improvise-5j9's skip warning, retired by improvise-8jk6.2
|
||
|
||
improvise-5j9: skipping a record for a missing category value used to be silent,
|
||
so a 1000-row import could drop rows with no user-visible sign. The warning had
|
||
to name the count, the total, and the offending field(s); a bare "some records
|
||
were skipped" is not actionable. Its test,
|
||
`build_model_warns_about_records_skipped_for_missing_category`, was **deleted**
|
||
by improvise-8jk6.2. A record missing a category value is now kept without that
|
||
coordinate, so there is no skip left to report. Its successor is
|
||
`a_record_with_no_value_for_a_category_is_kept_without_that_coordinate`, which
|
||
asserts no warning at all. `build_model_record_with_missing_category_value_skipped`
|
||
became `build_model_record_with_missing_category_field_is_kept` and now asserts
|
||
the kept cell rather than only the East one.
|
||
|
||
### is_does_not_swallow_the_start_of_a_longer_word
|
||
|
||
Adversarial review of PR #54, low severity and fixed anyway. `is_expr` was
|
||
`identifier ~ ^"IS" ~ filter_value`, so `Region ISLAND` parsed as
|
||
`Is(Region, "LAND")` and `Region isotope` as `Is(Region, "otope")` — inputs that
|
||
were **parse errors** before the production existed, now silently-wrong
|
||
formulas. Gotcha 34's whole theme is that a silently wrong parse costs more than
|
||
a refusal.
|
||
|
||
The repair went through two wrong shapes before the right one, and both are
|
||
worth keeping. A negative lookahead `^"IS" ~ !(ASCII_ALPHANUMERIC | …)` in an
|
||
ordinary rule **cannot work**: pest auto-inserts `WHITESPACE` between sequence
|
||
elements, so it tested the character *after* the space — rejecting
|
||
`Region IS East` while still accepting `Region ISLAND`, i.e. exactly backwards.
|
||
Making `is_expr` compound-atomic (`$`) stops the auto-insertion and forces the
|
||
separator to be written out as `sp+`.
|
||
|
||
At which point **the lookahead was redundant and was removed**, which only
|
||
re-injecting the fault revealed: deleting `!bare_char` left the suite green,
|
||
because `sp+` after `IS` already refuses `ISLAND` — `IS` matches, then `sp+`
|
||
demands whitespace and finds `L`. A guard nobody has seen fail is
|
||
indistinguishable from one that always passes
|
||
(`doctrine(check_the_checker)`), so it went. **`sp+` is now the mechanism**, and
|
||
relaxing it to `sp*` — the obvious "let people write `Region IS|East|`" change —
|
||
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"` 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".
|
||
|
||
### the_grammar_is_parsed_once_however_many_files_the_strategy_yields
|
||
|
||
improvise-8shu. `improvise-io`'s lib binary was the run's outlier — 1.42s here
|
||
against a 0.01s median, measured at 1.40–1.45s, 1.50s and 3.70–5.24s on three
|
||
machines. The ticket's diagnosis was that the grammar walk had no size bound,
|
||
so a few deeply nested documents dominated. **That was wrong**, and measuring
|
||
before fixing is the only reason it did not ship as the fix: instrumenting the
|
||
two halves separately gave `load_grammar()` ×1000 = **2.50s** and the walk
|
||
itself ×1000 = **15.7ms**. The walk was 0.6% of the cost. `improv_file()`
|
||
called `load_grammar()` inside its `prop_map`, so `pest_meta` re-parsed
|
||
`improv.pest` once for every generated case — 1000 times across the two
|
||
properties, plus shrink attempts.
|
||
|
||
The strategy's input space was never the problem and is unchanged: the same
|
||
entropy range, the same 0..N repetitions, the same properties over groups,
|
||
formulas, views, hidden items and pipe quoting. The grammar is now read once
|
||
through a `OnceLock`.
|
||
|
||
The guard counts *parses* rather than calls, which is what makes it
|
||
order-independent: other tests in the binary reach `grammar()` too, and the
|
||
count stays 1 however many of them run first. It drives `improv_file()` itself
|
||
through a `TestRunner` rather than re-implementing what the strategy does, so
|
||
a future edit that reverts the call site is what it catches. Fault injected:
|
||
`Gen::new(grammar(), …)` restored to `let rules = load_grammar();` — the test
|
||
fails with `left: 64, right: 1`.
|
||
|
||
Case counts went 500 → 256 in the same file, which is the default
|
||
`context/design-principles.md` §7 prescribes and never below it; that is worth
|
||
a further 0.39s → 0.21s but it is not the fix, and reporting it as one would
|
||
be the mistake this entry exists to record. Net: 1.42s → 0.21s, and
|
||
`improvise-io` is no longer the run's longest binary.
|
||
|
||
### parser::generator::the_grammar_is_parsed_once_however_many_formulas_the_strategies_yield
|
||
|
||
improvise-i5z6 is improvise-8shu's twin, in `improvise-formula`. Both
|
||
generator strategies, `formula_string()` and `expr_string()`, called
|
||
`load_grammar()` inside their `prop_map`. So `pest_meta` re-parsed
|
||
`formula.pest` for every generated case: three 256-case properties plus
|
||
shrinks. The `improvise-io` fix never reached this crate, which has its own
|
||
copy of the generator.
|
||
|
||
All figures were measured on an Apple M4 Max (16 cores) under heavy parallel
|
||
load (load average 15–33), because an unloaded window was not available. The
|
||
ratios are more trustworthy than the seconds.
|
||
|
||
- **Before:** the lib binary was the **longest binary in all three workspace
|
||
runs**, at 0.91s, 1.02s and 1.95s. The runner-up was 0.50–0.92s, and a fourth
|
||
run gave 3.13s. The median binary reports `0.00s` at two decimals, so the
|
||
ratio to the median is not defined. By any finite reading it is two orders
|
||
of magnitude above the median. Alone, the binary took 0.84–0.98s over five
|
||
runs. The three generator properties alone took 0.94–1.03s, and the other 82
|
||
tests together took 0.01s.
|
||
- **After:** the binary alone takes 0.06–0.07s over five runs, and
|
||
0.06–0.09s inside the workspace run. `headless_cli`, which spawns processes,
|
||
is now the longest binary at 0.49–0.75s.
|
||
|
||
The guard copies 8shu's shape. It counts **parses**, not calls, so it does not
|
||
depend on test order. It drives both strategies through a `TestRunner` rather
|
||
than re-implementing them. Red before the fix with `left: 64, right: 1`.
|
||
|
||
Rivals, one per strategy: restoring `let rules = load_grammar();` in either
|
||
`formula_string` or `expr_string` alone fails the test with `left: 33`. So the
|
||
two strategies are guarded independently.
|
||
|
||
The case count stays 256. It is already the default that
|
||
`context/design-principles.md` §7 prescribes, and it was never the cost.
|
||
|
||
### export_csv_omits_the_rows_the_view_prunes
|
||
|
||
improvise-iot6. `render_csv` built its layout with `GridLayout::new`, which does
|
||
**not** apply `prune_empty` — only `GridLayout::with_frozen_records` does. So
|
||
`:export` in the TUI and the CLI export path both wrote rows the grid does not
|
||
draw. User-visible, in production, and shipping for as long as the two
|
||
constructors have differed.
|
||
|
||
This is the **fourth** instance of the same trap; the third was
|
||
`project::grid_in`, fixed in PR #78, and this fix is that one line for line.
|
||
No fault needed injecting: on the unfixed tree the test fails with
|
||
|
||
> CSV wrote a row the grid does not draw: left `["Food", "Gas", "Rent"]`,
|
||
> right `["Food", "Gas"]`
|
||
|
||
**Why the fixture is discriminating, which is the whole difficulty here.** The
|
||
sibling property written for improvise-t6db passed on the buggy tree in its
|
||
first version, because it made its two views differ by turning pruning *off* —
|
||
plenty of differing config, and vacuous, since with nothing to prune both code
|
||
paths agree. So this fixture does two things that one cannot fake:
|
||
|
||
- it contains a row that is **actually empty** (`Rent`, an item of `Type` that
|
||
no cell names) and leaves `prune_empty` **ON**;
|
||
- it asserts the precondition in both directions before exporting anything —
|
||
`with_frozen_records` draws 2 rows and `new` holds 3. If a future change makes
|
||
pruning a no-op, or makes the two constructors agree, the precondition fails
|
||
and says so rather than the assertion passing vacuously.
|
||
|
||
None of `export.rs`'s three pre-existing tests mentions pruning at all, which is
|
||
exactly why this shipped.
|
||
|
||
**Both axes, because `prune_empty` prunes both.** A first version of this
|
||
fixture had a single `Month`, so it demonstrated the row half and left the
|
||
column half asserted by nothing -- the same defect one axis over. `Feb` is now
|
||
the empty column, the precondition is a pair on each layout ((2, 1) drawn
|
||
against (3, 2) unpruned), and the CSV header is asserted to carry `Jan` alone.
|
||
|
||
### persistence::parse::parser_edge_cases::parse_duplicate_categories
|
||
|
||
Found by an external Grok Bot adversarial review (improvise-jkxr), and the
|
||
finding was verified by injection rather than taken on report.
|
||
|
||
The test parses two `## Category: Type` sections — `- A` then `- B` — and
|
||
asserted only `!item_names.is_empty()`. That cannot fail for the defect the
|
||
test's name and placement imply. `ParsedFile::push_category` concatenates on a
|
||
duplicate section name (`invariant(duplicate_category_sections_merge_in_pass_one)`),
|
||
and a merge that kept *either* half alone still leaves the list non-empty.
|
||
|
||
**Fault injected:** `existing.items.extend(items)` → `existing.items = items`,
|
||
so a second section *replaces* the first instead of merging. The old assertion
|
||
**passed**. The strengthened assertion fails with `left: ["B"], right: ["A", "B"]`.
|
||
Only a total wipe of both sections could ever have failed the original.
|
||
|
||
The assertion is now `assert_eq!(item_names, ["A", "B"])`, which pins order as
|
||
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.
|
||
|
||
### a_label_category_survives_the_round_trip_as_a_label
|
||
|
||
`CategoryKind::Label` had no spelling in `.improv` at all: `format_md` wrote
|
||
every category as a bare `## Category: Name` and `build_category` read every
|
||
section back through `Workbook::add_category`. The kind was dropped on the way
|
||
out *and* on the way in, so the loss was symmetric and no round-trip equality
|
||
test could see it — the same shape the `Formula::format` entry above records.
|
||
Reproduced at `d46ec59`: a workbook with `Region` (regular) and `Note` (label)
|
||
came back with `Note.kind == Regular`.
|
||
|
||
The test asserts the **parsed** `Category::kind`, never the emitted line, for
|
||
that reason. Its second assertion — that `Region` still reads back `Regular` —
|
||
is the control: a reader that made every category a `Label` satisfies the first
|
||
assertion and fails this one.
|
||
|
||
Faults injected, both against the fixed tree:
|
||
|
||
- **Writer drops the line** (`if false` around `w!(out, "kind: label")`). Fails
|
||
`left: Regular right: Label`, and takes
|
||
`a_document_at_the_category_limit_plus_a_label_reloads` with it.
|
||
- **Reader ignores the kind it parsed** (`build_category` calls `add_category`
|
||
unconditionally, discarding `pc.kind`). Same two failures. This is the
|
||
discriminating one: the file text is *correct* in this variant, so a test
|
||
comparing emitted output passes.
|
||
|
||
### a_document_at_the_category_limit_plus_a_label_reloads
|
||
|
||
The P1 half, and the reason improvise-dy3j was not merely a lost flag. `Label`
|
||
is outside `MAX_CATEGORIES` (12 then) by design — `Model::add_label_category` has no
|
||
limit check, and the import wizard's `proposed_category_count` scores a label
|
||
as zero — so a document with twelve categories and one label is legally
|
||
constructible and the wizard will build one from any CSV carrying a free-text
|
||
column. Every label came back `Regular` on load, the thirteenth category was
|
||
refused, and `parse_md` returned
|
||
`Err("Maximum of 12 categories reached")`: **the document was permanently
|
||
unloadable.** Reproduced at `d46ec59` with exactly that error.
|
||
|
||
The second assertion (`regular_category_names().len() == crate::model::MAX_CATEGORIES`) is what keeps the
|
||
first honest. A "fix" that simply raised or removed the limit would let the
|
||
document load while putting a high-cardinality label into the pivot count, and
|
||
this assertion fails on it.
|
||
|
||
### a_category_section_with_no_kind_line_loads_as_regular
|
||
|
||
The absent case: every `.improv` file written before this commit carries no
|
||
`kind:` line, and must still load. Without it, a walker that defaulted
|
||
`PCategory::kind` to `Label` would pass both tests above.
|
||
|
||
The input is hand-written text rather than a round trip, deliberately — a round
|
||
trip cannot exhibit an absent line, because the writer under test is the thing
|
||
that would have written it.
|
||
|
||
### a_loaded_label_category_is_not_on_a_pivot_axis
|
||
|
||
The end-to-end check that the two halves of improvise-dy3j/cj4u agree: a label
|
||
is off every pivot axis in every view after a save and a load, including a view
|
||
created after the label.
|
||
|
||
Worth stating what it does and does not witness. It is **not** a second witness
|
||
for the kind: `format_md` writes each view's axes explicitly, so `Note: none`
|
||
carries the seating across the file whatever the kind does. What it witnesses
|
||
is the *seating* — with the `improvise-cj4u` fix reverted, `create_view`
|
||
pivots `Note` onto Column in the source workbook, the file faithfully records
|
||
`Note: column`, and this test fails. It was written while cj4u was still open
|
||
and failed then, which is how that was established rather than assumed.
|
||
|
||
### import::wizard::tests::build_model_warns_about_values_that_do_not_match_the_date_format
|
||
|
||
improvise-yss5, and the ticket said plainly it had **not** been reproduced. It
|
||
is real and it is narrower than it reads. `extract_date_component` returns
|
||
`None` for a value the detected format cannot parse, and the caller's `if let
|
||
Some(..)` simply does not push the derived coordinate — so the row silently
|
||
leaves the derived `Date_Month`/`Date_Year` category with nothing reported, the
|
||
same shape improvise-2cw9/jc95/5hfh closed elsewhere in this path.
|
||
|
||
**Through the shipped CLI and TUI wizard it is unreachable**, and the reason is
|
||
worth writing down because it is a property of one function.
|
||
`detect_date_format` accepts a format only when **every** distinct non-empty
|
||
sample parses, and `extract_date_component` then makes the identical
|
||
`NaiveDate::parse_from_str` call — so a mismatch requires the format to come
|
||
from somewhere other than detection over the same values. That door is
|
||
`ImportPipeline`'s `pub proposals`: a library caller may set `date_format`
|
||
itself, and the test does. `a_column_of_mixed_date_formats_is_not_proposed_as_a_time_category`
|
||
guards the reachability claim rather than leaving it as prose, per
|
||
`doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated)`.
|
||
|
||
The fixture derives **two** columns (Month and Year) from one `Date` whose three
|
||
values include two the format rejects, and the assertion is on the full warning
|
||
vector rather than on a substring — so "one located warning per derived column"
|
||
is separable from "one line saying some dates did not parse".
|
||
|
||
Three rivals, each caught at a different assertion:
|
||
|
||
- **Dropping the `count > 0` filter** — warn unconditionally — passed the entire
|
||
suite before `extracting_a_date_component_from_values_that_all_parse_warns_about_nothing`
|
||
was added, because no existing test enabled date extraction *and* asserted
|
||
silence. That is the one-directional fault
|
||
`context/design-principles.md` §7 names: a check that only asserts the warning
|
||
appears is satisfied by a writer that always warns. It now fails there.
|
||
- **Tallying into one counter instead of one per extraction** fails the vector
|
||
comparison, which a single-derived-column fixture could not see.
|
||
- **Weakening `detect_date_format` from `all` to `any`** fails the reachability
|
||
guard here and three analyzer tests, which is the evidence that guard is
|
||
load-bearing and not decoration.
|
||
|
||
The fix reports and does not relocate: the row still lands with the coordinates
|
||
it does have, exactly as before. Changing where such a row is placed is a
|
||
behaviour change the ticket did not ask for.
|
||
|
||
### import::wizard::tests::a_row_kept_without_a_category_is_charged_to_the_date_tally
|
||
|
||
**Renamed from `a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally`
|
||
by improvise-8jk6.2.** Its premise went with the skip. A row missing `Region`
|
||
now lands, so its unparseable `Date` *is* a derived column's loss, and it is
|
||
charged once to each of `Date_Month` and `Date_Year`. The test asserts the
|
||
landed cell (`Date=2025-02-05, _Measure=Amount = 200`, with no `Region` and no
|
||
derived part) and exactly those two warnings. The history below is kept
|
||
because the rule it established (charge only landed rows) still holds. Every
|
||
object row now lands.
|
||
|
||
improvise-yss5, found by ChatGPT Codex reviewing PR #150 at `af7fae0e` and
|
||
correct. The first fix incremented `unparsed_dates` while walking a record's
|
||
categories, **before the record was known to survive that walk**. A row whose
|
||
`Date` is unparseable and whose *later* required category is missing is skipped
|
||
entirely — it never lands in the imported model — yet it had already been
|
||
charged to the derived column's tally. So the warning counted a row nothing else
|
||
in the import reports, and its value depended on the **order** the categories
|
||
happen to be walked in.
|
||
|
||
The mismatches are now collected per record and merged only after
|
||
`missing.is_none()`. The fixture only discriminates while `Date` precedes the
|
||
missing category, so the test asserts that ordering explicitly rather than
|
||
relying on it — a later change to proposal order would otherwise make the test
|
||
pass by walking the missing category first and never reaching the bad date.
|
||
|
||
Before the fix it reported three warnings where one is correct, naming
|
||
`Date_Month` and `Date_Year` for a row that does not exist in the model.
|
||
Injecting the rival — merging the per-record tally *before* the `missing` check
|
||
rather than after — reproduces exactly that and fails here.
|
||
|
||
This is the second time in this ticket that the interesting question was *which
|
||
rows the count is over*. The first was one warning per derived column rather
|
||
than one overall; this is one warning per **landed** row rather than per
|
||
attempted one.
|
||
|
||
### export_writes_a_collapsed_group_as_its_own_total_row
|
||
|
||
improvise-6qet (`context/code/core.md` §14). An export writes what the grid draws
|
||
(improvise-iot6), and the grid now draws a collapsed group as one row whose cell
|
||
folds the group, so the CSV body is exactly `["Q1,7.00"]` (Jan 3 + Feb 4). Decided
|
||
rather than defaulted: the alternative — expanding the group in the export — would
|
||
reintroduce the iot6 divergence between what the user sees and what they export.
|
||
On the pre-6qet tree the body is empty (the group had no row, and pruning left
|
||
nothing), so the test fails there.
|
||
|
||
## Legacy JSON documents (improvise-6yvg, improvise-tt22)
|
||
|
||
`a_legacy_json_workbook_loads_its_numbers_bit_for_bit` (improvise-6yvg) pinned
|
||
that the legacy JSON load path read numbers exactly. improvise-tt22 removed that
|
||
path, so the test went with it. Its other role was to witness `improvise-io`'s own
|
||
`float_roundtrip` declaration. The manifest rule in
|
||
`every_crate_that_can_parse_json_enables_float_roundtrip` (`tests/json_numbers.rs`)
|
||
still covers that, because `improvise-io` keeps a normal `serde_json`
|
||
dependency for its import types (`context/test-evidence/tooling.md`).
|
||
|
||
### `a_legacy_json_workbook_is_refused_by_name`
|
||
|
||
improvise-tt22, on the user's direction on PR #210: `.improv` text is the only
|
||
native format. A document starting with `{` used to be deserialized as a
|
||
`Workbook`. The test writes a JSON workbook in the shape the old loader read,
|
||
both plain and gzipped, and asserts the error names "legacy JSON" and says "no
|
||
longer supported".
|
||
|
||
**Red at `52c6a05`**: `Failed to deserialize workbook: missing field
|
||
`next_category_id``. That is a parse error, which is exactly what the ticket said
|
||
the refusal must not be. A complete legacy document instead loaded successfully,
|
||
which the since-deleted bit-for-bit test above demonstrated.
|
||
|
||
**Rivals, each run:**
|
||
|
||
- **Deleting the refusal.** The text falls through to `parse_md`, and the test
|
||
fails on `legacy.improv` with "Parse error at line 1, column 1 (expected
|
||
version_line)". A parse error does not name the format.
|
||
- **Refusing only when the path is not gzipped.** The plain case passes and the
|
||
test fails on `legacy.improv.gz` with the same grammar error. So the gzip
|
||
case is what makes the check prove it runs on decompressed text.
|
||
|
||
## Colliding records (improvise-cgqt)
|
||
|
||
The user's decision on improvise-cgqt: when records collide on every
|
||
coordinate, the wizard asks whether to sum them, keep them separate, or go
|
||
back. The fixture `colliding_records` has **two** colliding tuples of
|
||
different sizes (three records and two), interleaved with a record that
|
||
collides with nothing. A one-collision fixture cannot tell a per-tuple
|
||
occurrence counter from a global row number, and a fixture with only one group
|
||
cannot tell a filter from no filter. Every rival below was injected into
|
||
`wizard.rs`, the io suite run, and the file restored.
|
||
|
||
### collisions_are_known_before_the_model_is_built
|
||
|
||
Red before the feature: `collisions()` was a stub returning nothing. It asserts
|
||
the structured result exactly: two groups, first-seen order, the coordinates in
|
||
build order, and the 1-based record numbers `[1, 3, 5]` and `[2, 6]`. The rival
|
||
**every tuple reported, colliding or not** (the `retain` removed) fails it and
|
||
thirty-odd other tests.
|
||
|
||
### summing_duplicates_adds_their_values_into_one_cell
|
||
|
||
A characterization of 2cw9's sum, now chosen explicitly. It was green before the
|
||
feature, and its role is to be the **other half** of the Sum/Separate
|
||
discrimination: it asserts three cells (70, 6, 7) and no extra category, where
|
||
Separate must give six cells. It was first red for a test bug: `CellKey`
|
||
displays with braces, which the expected strings lacked.
|
||
|
||
### keeping_duplicates_separate_keeps_every_record_distinct
|
||
|
||
Red before the feature. It asserts all six cells with their occurrence numbers,
|
||
the items `1, 2, 3` in order, the category on `Axis::None`, and the
|
||
confirmation warning. The rivals each fail it:
|
||
|
||
- **occurrence always 1** (no per-tuple counter). The records re-collide and sum.
|
||
- **a global row number** (the counter keyed by an empty tuple). The items run
|
||
`1..6`.
|
||
- **Separate treated as Sum.**
|
||
- **the category left on its default axis.** It lands on `Page`, which would
|
||
show only occurrence 1 in the default grid.
|
||
|
||
### an_unchosen_policy_refuses_to_build_colliding_records
|
||
|
||
The rival **Refuse treated as Sum** fails it. This is what keeps a caller that
|
||
never asked, like `ImportJsonHeadless` or a future path, from summing silently.
|
||
|
||
### a_policy_is_moot_when_nothing_collides
|
||
|
||
Separate and Refuse must add nothing and refuse nothing when nothing collides.
|
||
It was green against the stub, because the stub ignored the policy entirely.
|
||
It guards against a Separate that always adds its category.
|
||
|
||
### the_duplicates_step_is_absent_when_no_records_collide
|
||
|
||
Green against the stub, which never showed the step, so its evidence is the
|
||
rival. **step always shown** fails it along with eight existing wizard walks.
|
||
Its first version looped `while step != NameModel` and **hung** under that
|
||
rival, because advancing from the step with `Refuse` is blocked. It is now
|
||
bounded to four advances, so the rival fails it rather than stalling the suite.
|
||
|
||
### keeping_duplicates_separate_is_refused_at_the_category_cap
|
||
|
||
A full set of categories plus a collision, which is the shape of the reported
|
||
import. The fixture is `category_records(MAX_CATEGORIES)`, so it tracks the cap
|
||
(12 when the test was written, 24 since improvise-felo.1). It asserts option (b)
|
||
is disabled with a reason naming the cap (`reason.contains(&cap)`, where `cap`
|
||
is `MAX_CATEGORIES` spelled out) and Skip, that choosing it leaves
|
||
the step and the policy (`Refuse`) untouched, and that the message is the
|
||
reason. The rival **cap checked with `>`** fails it.
|
||
|
||
### choosing_separate_asks_for_a_name_then_moves_on
|
||
|
||
Also `choosing_go_back_returns_to_the_step_before`, `back_retraces_the_duplicates_steps`,
|
||
`a_name_that_is_already_a_category_is_refused` and
|
||
`the_suggested_name_avoids_a_header_that_already_uses_it`. The cursor sits at
|
||
**1 and 2**, never only 0, so "the option under the cursor" and "the first
|
||
option" are different. The rival **chooser ignores the cursor** fails six of
|
||
them. **suggestion ignores headers** and **name check accepts a taken name**
|
||
each fail their own test.
|
||
|
||
### records_with_disjoint_measures_on_one_tuple_do_not_collide
|
||
|
||
improvise-9bit, found by the adversarial review of PR #304 at `92233cff`.
|
||
`collisions_among` keyed on the tuple alone, so `{East, revenue}` and
|
||
`{East, cost}` were reported as colliding although no cell sums. Under the
|
||
`Refuse` default, a sparse-measure CSV that imported cleanly before was refused.
|
||
It was red before the fix: `collisions()` returned the East group. The rival
|
||
**tuple-only rule** fails it, and fails
|
||
`only_the_records_that_share_a_cell_are_listed_and_numbered` too.
|
||
|
||
### a_record_with_no_measure_collides_with_nothing
|
||
|
||
The rule the reviewer's injection `if false && measures.is_empty()` showed was
|
||
unguarded. It asserts records `[1, 3]` with the measureless `#2` excluded, and
|
||
that the two measureless West records form no collision at all. It was green
|
||
before the fix, since the rule already held, so its evidence is the rival:
|
||
**a measureless record counts as writing** fails it.
|
||
|
||
### only_the_records_that_share_a_cell_are_listed_and_numbered
|
||
|
||
A tuple with one `cost` record and two `revenue` records. Only `#2` and `#3`
|
||
are listed. Under Separate, the `cost` record keeps occurrence `1`, beside the
|
||
first `revenue` record, and the second `revenue` record gets `2`. The rivals
|
||
**tuple-only rule** and **a non-colliding record numbered past 1** each fail it.
|
||
|
||
### the_name_check_covers_categories_derived_from_dates
|
||
|
||
improvise-4klp, filed by the same review. The name step's check against
|
||
date-derived categories was untested. The test extracts `when_Year` and types
|
||
that name, which must be refused. It was green on arrival, so its evidence is
|
||
the reviewer's rival, **date-derived names dropped from the taken names**,
|
||
which fails it. That fault would have let `add_category` reuse the existing
|
||
category, and every cell key would then name `when_Year` twice.
|
||
|
||
### a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group
|
||
|
||
The user's ruling on improvise-felo.6. Three spellings of one group given two
|
||
folds, each refused as `Refusal::FoldConflict` naming the view and the group,
|
||
plus a category given two depths. Rival: a second fold for a group silently
|
||
wins. Only this test fails. Its control is
|
||
`a_collapsed_group_inside_an_expanded_region_loads`: a DIFFERENT group
|
||
collapsed inside an expanded region, and the same line twice, both load.
|
||
|
||
### a_cycle_among_group_parents_is_refused_naming_it
|
||
|
||
`> Q1-2025[|2025|]` and `> |2025|[Q1-2025]`. Rival: the cycle check disabled;
|
||
only this test fails. The fixture's first version spelled `2024-01` bare, which
|
||
the grammar refuses (a bare name starts with a letter), so every test on it
|
||
passed or failed for the parse error, `a_malformed_depth_is_a_parse_error`
|
||
vacuously. It now carries a control that parses.
|
||
|
||
### an_axis_line_for_a_category_named_expanded_is_still_an_axis_line
|
||
|
||
`axis_line` is tried before `expanded_line`, so the writer must never spell a
|
||
category depth bare. Rival: the writer writing `expanded: Cat` for depth 1. This
|
||
test fails: the category `row` at depth 1 reads back as an axis line for a
|
||
category named `expanded`.
|
||
|
||
### an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines
|
||
|
||
"Everything opens closed, including old files." Every group of the file opens
|
||
closed, the one its `collapsed:` line names and the three it does not, and the
|
||
line is written back. Rival: the old open default; this test fails with the
|
||
other eleven in that rival's list.
|
||
|
||
### a_date_column_imports_as_one_category_of_months_in_quarters_in_years
|
||
|
||
improvise-8jk6.3, the user's "dates should usually be one category". The
|
||
fixture has two days of one month, so that month is one summed cell, and
|
||
January of the next year, so a month must stay unique across years. Rivals, each
|
||
run against the `date_hierarchy` module, and each caught here:
|
||
- The split as the default shape.
|
||
- Levels taken finest first, so the year hangs under the month.
|
||
- Groups registered without their parents.
|
||
- The raw dates still added beside the months.
|
||
- A hierarchy counted as `1 + components` slots.
|
||
|
||
### a_level_left_unchosen_is_skipped_not_emptied
|
||
|
||
Year + Month nests months straight into years, and Quarter alone is a flat
|
||
category of quarters. Caught the finest-first rival and the raw-dates rival.
|
||
|
||
### an_imported_date_hierarchy_opens_folded_to_its_years
|
||
|
||
The import sets nothing: the hierarchy opens folded, one row per year, because
|
||
every group starts closed (improvise-felo.6). The 2024 row is the fold of its
|
||
months, 111. Caught three of the rivals above.
|
||
|
||
### an_unparsed_date_keeps_its_value_and_is_warned_about
|
||
|
||
A format that reads none of the values keeps each raw value as an ungrouped
|
||
item, and the build says so. Its first version passed on the base commit: the
|
||
shape was ignored there and the SPLIT's warning happened to contain the same
|
||
words. It now also asserts there is no `Date_Year`. Rival: the unparsed value
|
||
not counted; this test fails.
|
||
|
||
### a_headless_date_import_splits_when_told_to
|
||
|
||
`--split-date` is the wizard's `s`, headless. It failed first as an unknown
|
||
argument. Rival: the flag parsed and ignored; this test fails, and
|
||
`a_headless_date_import_is_one_hierarchical_category` does not, which is the
|
||
point of having both.
|
||
|
||
### every_fold_the_api_can_build_reads_back_from_the_file_it_writes
|
||
|
||
ChatGPT Codex, P2 on #317 at `e09ec752`: `GroupFold::Expanded(usize)` and
|
||
`View::set_group_fold` are public, so `Expanded(0)` could be built, written as
|
||
`expanded(0): …` and then refused by the grammar that wrote it. Confirmed by
|
||
reading: the writer formatted any `usize`, and `depth_number` starts at `1`.
|
||
The fix makes the state unrepresentable (`Expanded(NonZeroUsize)`), so the
|
||
regression guard is the type, and the old failing input no longer compiles.
|
||
This test covers every fold the API CAN build (`Collapsed` and depths 1 to 3,
|
||
beside a category depth), each saved and read back.
|
||
|
||
### a_record_with_no_value_for_a_category_is_kept_without_that_coordinate
|
||
|
||
improvise-8jk6.2, reported by the user from the wizard: "Skipped 6 of 342
|
||
records missing category values: Settlement Date (6)". `Plan::place` returned
|
||
`Placement::Missing` for a category with no value, so the build dropped the
|
||
whole record, measures and all. The fixture has one complete record, one with
|
||
`"Settlement Date": null` and one with `""`, for two different payees. At
|
||
`e65d87fd`'s parent the build stored only the first cell. Now the stored cells
|
||
are exactly the three records, the two blanks with no date coordinate, and
|
||
there is no warning. `Settlement Date` holds only its one real item, so neither
|
||
`"null"` nor `""` became one. Rival R1, a record with a present-but-blank
|
||
category treated as unplaceable, fails this test and the two improvise-ych
|
||
tests.
|
||
|
||
### files_with_different_headers_import_as_one_sparse_model
|
||
|
||
improvise-lpzu. code-maat's `age.csv` (`entity,age-months`) and `coupling.csv`
|
||
(`entity,coupled,degree,average-revs`) merged through `merge_csvs_from`. Before
|
||
the fix, the age records had no `coupled` key, so they took the same `Missing`
|
||
path and **every age record was dropped**. The build stored the four coupling
|
||
cells and nothing else. The test pins the proposals (each column's kind,
|
||
judged over its present values), all six stored cells sorted, no empty-named
|
||
item in any category, and no warning. Rival R2, a record whose file lacks a
|
||
category column skipped, fails it along with the two CLI folder tests, the
|
||
projection test and the per-file view test.
|
||
|
||
### a_label_column_a_record_lacks_is_no_coordinate_not_an_empty_item
|
||
|
||
improvise-lpzu, the label half. Labels were attached with
|
||
`scalar_name(..).unwrap_or_default()`, so an age record, whose file has no
|
||
`coupled`, stored `coupled=` as a coordinate, and `coupled` grew an item named
|
||
`""`. Before the fix the first sorted cell was
|
||
`File=age, _Measure=age-months, coupled=, entity=src/a.rs = 2`. Rival R3
|
||
(`unwrap_or_default` restored) fails it.
|
||
|
||
### a_folder_expands_to_its_csv_files_in_name_order_and_skips_the_rest
|
||
|
||
improvise-lpzu. The folder holds three CSVs written out of name order, one
|
||
with an upper-case `.CSV`, plus a `.txt`, an `.improv` (the output a second run
|
||
finds) and a CSV in *another* folder. Only the three are returned, sorted.
|
||
`MemoryFs` stores in a `HashMap`, so the order comes from `Storage::list`'s
|
||
contract and not from insertion. Rival R5 (the extension filter dropped) and
|
||
rival R6 (the listing passed through a `HashSet`) each fail it.
|
||
|
||
### a_folder_with_no_csv_files_is_refused_by_name
|
||
|
||
improvise-lpzu. Both an empty folder and a folder holding only non-CSV files
|
||
must refuse with `no CSV files in folder maat`. Importing nothing would write
|
||
an empty document. Rival R4 (the `ensure!` removed) fails it, and so does R5,
|
||
because the text-only folder then "has" files.
|
||
|
||
### files_and_folders_expand_in_argument_order
|
||
|
||
improvise-lpzu. `[z.csv, b/, a.csv]` must expand to
|
||
`[z.csv, b/one.csv, b/two.csv, a.csv]`. Argument order is kept, and a folder
|
||
is sorted within itself. A fixture listing only folders, or files in name
|
||
order, could not tell "argument order" from "sort everything". R6 fails it.
|
||
|
||
### two_files_with_one_stem_union_their_columns
|
||
|
||
improvise-8jk6.12 (3). `a/x.csv` (`entity,one`) and `b/x.csv` (`entity,two`) are
|
||
one `File` item, `x`, whose columns are `entity, one, two`. Rival F3 (the
|
||
reviewer's fault: clear the stem's columns before each file) leaves
|
||
`entity, two`, and the `x` view would then hide the measure `one` that half its
|
||
records carry.
|
||
|
||
### files_with_different_headers_merge_without_inventing_the_missing_columns
|
||
|
||
improvise-lpzu, at the parser. Each merged record carries exactly its own
|
||
file's keys plus `File`: no key, `null` or `0` for a column another file has.
|
||
Each `SourceFile` lists its file's columns in header order. The per-file views
|
||
are built from those lists.
|
||
|
||
### a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid
|
||
|
||
improvise-8jk6.2's downstream question, asked of the real layout rather than
|
||
argued. Bolt has no `Date`:
|
||
|
||
- With `Date` on Column or on Page, the grid draws only Acme's `10`.
|
||
- With `Date` on None, it draws both `10` and `20`.
|
||
- In records mode, Bolt's row reads `["", "Bolt", "", "20", ""]`, a blank
|
||
`Date` cell.
|
||
- The Data line is `Payee=Bolt, _Measure=Amount = 20`, and it round-trips.
|
||
- A drill on `Payee=Bolt` reaches the record.
|
||
|
||
This is a characterization of the model's existing sparse semantics. It found
|
||
no bug. Its purpose is to make the Row/Column answer ("invisible") a stated
|
||
fact the user can overrule, rather than an accident. R2 fails it.
|
||
|
||
### a_merge_gives_each_file_a_view_projected_to_its_columns
|
||
|
||
improvise-lpzu, the user's "one view filtered by the file column and
|
||
projected to the columns in that file". The test asserts:
|
||
|
||
- the view order (`Default`, then the files in read order), with `Default`
|
||
still active;
|
||
- `File` on Filter at the file's item;
|
||
- the file's own categories seated, and the other file's on None;
|
||
- `_Measure` on Page at the file's first measure, with the other file's
|
||
measures hidden;
|
||
- the drawn values.
|
||
|
||
The age view draws `[2, 7]`. The coupling view draws `[5, 3]`, because its
|
||
first measure in model order is `average-revs`: serde_json's map orders keys
|
||
alphabetically. Failed at `bb505f8f`'s parent, where no view existed. Rivals
|
||
R7 (File on None), R9 (every category seated) and R10 (nothing hidden) each
|
||
fail it.
|
||
|
||
### a_files_view_holds_only_that_files_records
|
||
|
||
improvise-lpzu. The code-maat fixture cannot show that the *filter* works,
|
||
because its files share no measure, so the measure page alone would isolate
|
||
them. Two quarterly files with the same `Region,Revenue` schema can: without
|
||
the filter, `q1` would draw `30` and `3`. Rival R7 fails it. R9 and R10 do
|
||
not, and are not meant to.
|
||
|
||
### a_files_view_name_skips_names_already_held
|
||
|
||
improvise-lpzu. Two stems collide with names already held: `Default`, the
|
||
merged view, and `_Drill`, a virtual view that is never written and is dropped
|
||
on load. The first becomes `Default 2` and the second `_Drill 2`, and each
|
||
still filters to its own item. Rival R8 (the virtual names not checked) fails
|
||
it: the file's view would have been saved as `_Drill` and lost.
|
||
|
||
### per_file_views_survive_a_round_trip_and_repeat_byte_for_byte
|
||
|
||
improvise-lpzu. Two builds from two independently written `MemoryFs` give
|
||
identical `.improv` text, and `format(parse(text)) == text`. The reloaded
|
||
document still has the three views in order, `File: filter` at the file's item,
|
||
and the same drawn values. This is the first persisted, non-virtual use of
|
||
`Axis::Filter` (gotcha 27). R7 and R9 fail it.
|
||
|
||
### a_csv_column_read_as_a_category_keeps_its_text_and_as_a_measure_its_number
|
||
|
||
improvise-8jk6.4, at the pipeline. It checks three things in one fixture,
|
||
one per reader of a CSV value:
|
||
|
||
- analysis still proposes the numeric-looking `commit` as a Measure (typed);
|
||
- forced to Category, its items are `00565796` and `220944e2` (text);
|
||
- `lines` is stored as the number 5 (typed).
|
||
|
||
The rivals, each run:
|
||
|
||
- **R1**, a category named from the typed value, fails it on the items.
|
||
- **R2**, a CSV measure read with `as_f64` on its text, fails it on the cell.
|
||
- **R3**, CSV analysed without typing, fails it on the proposal.
|
||
|
||
R2 and R3 also fail the five lpzu merge tests in this module and five
|
||
headless imports. That is how much of the suite leans on CSV measures reading
|
||
as numbers.
|
||
|
||
This test uses `from_csv` and so cannot compile on the base. The base failure
|
||
for 8jk6.4 is the headless test
|
||
`a_csv_column_forced_to_category_keeps_its_text_verbatim`.
|
||
|
||
### a_formula_that_does_not_parse_stops_the_build_and_is_named
|
||
|
||
improvise-8jk6.5. On the base the build returned `Ok`, keeping `Good` and
|
||
silently dropping `Bad`. Two formulas fail to parse here, so the test proves
|
||
every failure is named. **R5**, naming only the first, fails it on `Worse`,
|
||
and since improvise-8jk6.17 the headless
|
||
`an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing`
|
||
sees R5 too (it passes two bad formulas). It also asserts `Good` is not named, so a
|
||
message that lists every formula cannot pass. **R4** (drop, as the base did)
|
||
fails it.
|
||
|
||
### confirming_a_formula_that_does_not_parse_keeps_it_out_and_the_text_in
|
||
|
||
improvise-8jk6.5, the wizard half. On the base `confirm_formula` pushed
|
||
`Bad = revenue +` into the list, which failed on `pipeline.formulas`. The test
|
||
asserts the text is still in the buffer and the editor still open. A fix that
|
||
refused but cleared the buffer would throw away what the user typed, the same
|
||
hazard improvise-v4hg fixed in the TUI formula minibuffer. **R6**, confirming
|
||
regardless, fails it. The headless suite cannot see R6.
|
||
|
||
### a_build_that_keeps_no_record_refuses_and_says_why
|
||
|
||
improvise-8jk6.6. On the base both halves got `At least one category must be
|
||
accepted`, a refusal right only by accident. It asserts the exact message for
|
||
the two cases `nothing_to_import` distinguishes: all skipped, and no records
|
||
at all.
|
||
|
||
- **R7**, the check removed from the build, fails it.
|
||
- **R9**, `any` swapped for `all`, also fails it, on the empty source: `all`
|
||
is vacuously true over no records.
|
||
|
||
### the_review_step_says_when_no_record_can_be_imported
|
||
|
||
improvise-8jk6.6, the wizard half (gotcha 22: the check lives on the step
|
||
where the user can still choose another array path). On the base the step
|
||
refused with `No categories: … Press c to cycle a field's kind`, which offers
|
||
a key that has no field to act on. **R8**, the step without the check, fails
|
||
it. The build test does not see R8.
|
||
|
||
### a_header_only_file_keeps_its_file_item_its_columns_and_its_view
|
||
|
||
The ChatGPT Codex P2 on PR #314 at `4e68372c`, confirmed by the adversarial
|
||
review. `age.csv` (one row), a header-only `coupling.csv` and `rev.csv` (one
|
||
row). At `005b7a76` the test failed at the first assertion: `File` held only
|
||
`age` and `rev`, so the `coupling` view filtered to an item that did not exist,
|
||
and `coupled`, `degree` and `average-revs` were not in the model. Now `File`
|
||
reads `age, coupling, rev` in file order, the three columns exist (as Labels),
|
||
and the `coupling` view is a zero-row records view filtered to `coupling`, while
|
||
the `age` view still draws `2`. Rival F5 (sources not seeded) and F6 (unseen
|
||
columns not proposed) each fail it, and so do F7 and F8 below.
|
||
|
||
### a_file_with_no_measure_gets_a_records_view_not_another_files_measure
|
||
|
||
improvise-8jk6.13, from the review of PR #314. `a.csv` (`entity,x`) and
|
||
`tags.csv` (`entity,tag`). At `005b7a76` the `tags` view had `_Measure` on Page
|
||
with no selection, so the layout paged onto `x`, a measure the same view hides.
|
||
The test failed there, with `row_count() == 1`: `a`'s record, drawn under
|
||
`File=tags`'s view. Now the view has no `_Measure` page, is a records layout,
|
||
and draws zero rows, while `a`'s view is still a pivot. Rival F7 (the old
|
||
paging) fails it, and F8 (the File selection lost when `records_steps` moves
|
||
`File` off Filter) fails it with the same `1`. F8 is the mistake the first
|
||
version of the fix made.
|
||
|
||
### a_files_view_seats_the_date_parts_of_its_own_date_column
|
||
|
||
improvise-8jk6.12 (1). The reviewer replaced the date-parts clause of
|
||
`source_view`'s `own` with `false` and every test passed, because no folder
|
||
fixture extracted a date part. Here `abs-churn.csv` has a `date` column,
|
||
extracted to `date_Month`, and `age.csv` does not. `date_Month` is seated in the
|
||
`abs-churn` view and on None in the `age` view. Rival F1 (the reviewer's fault)
|
||
puts it on None in both and fails the first assertion.
|
||
|
||
### two_files_whose_names_collide_in_one_batch_keep_a_view_each
|
||
|
||
improvise-8jk6.12 (2). `Default.csv` and `Default 2.csv`. The first takes
|
||
`Default 2`, so the second must take `Default 2 2`. Without the same-batch
|
||
check both are named `Default 2`, and `IndexMap::insert` silently replaces the
|
||
first, leaving two views where three are owed. The test asserts all three names
|
||
with their filters. Rival F2 (the reviewer's fault) fails it.
|
||
|
||
### a_whitespace_only_category_value_is_no_coordinate
|
||
|
||
improvise-8jk6.12 (4). A `region` of `" "` is kept as a record with no
|
||
`region` coordinate, and `region` holds only `East`. The trim in `scalar_name`
|
||
predates this PR (improvise-ych), but the sparse-record rule now rests on it:
|
||
without it the value becomes an item named `" "`. Rival F4 (the reviewer's
|
||
fault) fails it.
|
||
|
||
### a_raw_date_spelled_like_a_generated_item_is_refused
|
||
|
||
improvise-d0n6 (Codex P2 on PR #322). A `%Y-%m-%d` column imported Year > Month
|
||
with `2024-01-15` and a raw `2024-01`. Before the refusal the build succeeded,
|
||
put both records on the grouped item `2024-01` (summing them) and warned that
|
||
the raw value was "kept ungrouped", which was false. Fault injected: removing
|
||
the `spelling_clash` call makes this test fail with `unwrap_err()` on an `Ok`.
|
||
|
||
### a_raw_date_spelled_like_a_generated_group_is_refused
|
||
|
||
improvise-d0n6. The same with a raw `2024`, a generated group. Removing the
|
||
`spelling_clash` call fails it the same way. The control,
|
||
`a_raw_date_spelled_like_nothing_generated_is_kept_ungrouped`, passes either
|
||
way, so a blanket refusal of every unparsed value fails it.
|
||
|
||
### the_date_step_will_not_advance_over_a_spelling_clash
|
||
|
||
improvise-d0n6 (Copilot on PR #322, and the adversarial review's B1). The
|
||
spelling-clash refusal sat only in the build, so a wizard with a raw `2024` in
|
||
a `%Y-%m-%d` column passed ConfigureDates and failed on NameModel, three steps
|
||
from the `s` that fixes it. Before the check joined `validation_error` the
|
||
test failed with the step already `DefineFormulas`; removing that call again
|
||
reproduces it. It also toggles to the split shape and requires the step to
|
||
advance, so a check that blocks forever fails.
|
||
|
||
### split_date_naming_an_unknown_or_non_date_field_is_refused
|
||
|
||
improvise-tpm8 class. `--split-date Nope` and `--split-date Revenue` (a
|
||
measure) exited 0 and did nothing. Both now exit 1 naming the flag and the
|
||
field; removing the check in `run_headless_import` fails both rows.
|
||
|
||
### an_axis_naming_a_category_the_import_did_not_build_is_refused
|
||
|
||
improvise-8jk6.3 review. `--extract Date:Month --axis Date_Month:column` used
|
||
to build one `Date` category and drop the `--axis` for the vanished
|
||
`Date_Month`, exit 0. It now exits 1 naming the category and `--split-date`.
|
||
This also refuses any `--axis` naming an absent category, which was silently
|
||
ignored before this PR as well.
|
||
|
||
### a_folder_of_only_header_only_files_keeps_no_record_and_refuses
|
||
|
||
improvise-8jk6.6 meeting PR #314's rule that a header-only file is still a
|
||
file. Written after merging `main` at `c91cc055`, where the two rules first
|
||
met. The file seeds give the pipeline a `File` Category proposal even with no
|
||
records. So without `nothing_to_import` the build succeeds and saves `File`
|
||
items and empty views: the category check alone cannot refuse. The `setup`
|
||
assertion proves that seeded proposal exists, so the test is not passing only
|
||
because there was no category. **R7**, the check removed from the build, fails
|
||
it. The user confirmed the refusal on PR #321: "Refuse (as #321 does)".
|
||
|
||
### an_absent_axis_category_that_is_no_date_part_names_what_was_built
|
||
|
||
improvise-8jk6.3 review (B2). The README's own recipe passed
|
||
`--axis Measure:column`, a silent no-op because the category is `_Measure`; the
|
||
first refusal blamed dates for it. The test uses a non-date absent name and
|
||
asserts the message lists the built categories and carries no date hint.
|
||
Fault injected: making the hint unconditional fails it.
|
||
|
||
### the_readmes_import_recipe_runs
|
||
|
||
improvise-8jk6.3 review (B2). Runs the README's recipe as printed. Fault
|
||
injected: restoring `--axis Measure:column` in the README fails it with the
|
||
refusal.
|
||
|
||
### split_date_on_a_time_field_with_no_date_format_is_refused
|
||
|
||
improvise-8jk6.3 review. `--time Revenue --split-date Revenue` made the field a
|
||
time category with no date format and the split was a silent no-op. Without
|
||
the `date_format.is_some()` condition the import exits 0 and this fails.
|