1a359b4ae6
A collapsed group is written to CSV as its own total row, because an export writes what the grid draws (improvise-iot6). core.md §14 carries the argument; the evidence entries record each rival and where it failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
1437 lines
74 KiB
Markdown
1437 lines
74 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.
|
||
|
||
### 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.
|
||
|
||
### 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.
|
||
|
||
### 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) 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.
|
||
|
||
### 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.
|
||
|
||
### 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).
|
||
|
||
### a_long_collision_list_is_truncated_with_a_count
|
||
|
||
The warning is rendered into a one-line status bar. A file with ten thousand
|
||
duplicate coordinates would otherwise compose a warning naming every one. Three
|
||
cells are named and the rest counted; the empty case returns `None` rather than
|
||
a warning that says nothing happened.
|
||
|
||
### build_model_warns_about_records_skipped_for_missing_category
|
||
|
||
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 must
|
||
name the count, the total, and the offending field(s) — a bare "some records were
|
||
skipped" is not actionable.
|
||
|
||
### 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.
|
||
|
||
### 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` 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() == 12`) 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_dropped_for_a_missing_category_is_not_charged_to_the_date_tally
|
||
|
||
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.
|