Files
improvise/context/test-evidence/formula-and-io.md
T
Claude (8jk63-fix) 581934886d Merge remote-tracking branch 'gh/main' into feat/8jk6.3-date-hierarchy
# Conflicts:
#	context/test-evidence/formula-and-io.md
#	crates/improvise-io/src/import/wizard.rs
#	src/main.rs
2026-10-02 22:00:32 -07:00

2049 lines
105 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.