Files
improvise/context/test-evidence/formula-and-io.md
T
Edward Langley a5d240b0d4 docs(formula): move the quoting and grammar arguments into context/
improvise-formula's comments carried three arguments worth keeping and
several restatements of the code. The arguments move to
context/code/formula-and-io.md (pipe quoting's one home, the keyword guard
and why PEG rule ordering is not a rule, the unquoted formula target, and
why a new grammar production needs generator coverage) and the bug
narratives to context/test-evidence/formula-and-io.md, keyed by test name.

No behaviour change: comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:49:34 -07:00

28 KiB

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.


improvise-formula/src/parser.rs

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::Refs, 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

load_corrupt_gzip

Not a test — the harness the two gzip-corruption tests 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.

storage_fixture

A workbook with a category, items, a formula and data — enough that a round-trip exercises every section of the format. The gzip tests reuse 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

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.

populated_model

A workbook that already holds categories, items and cells. Named here because the reason it is not a default-constructed one is the evidence: a default-constructed workbook makes the round-trip test vacuous for the data-coordinate half.

awkward_name

The strategy behind the property above. 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.


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.


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. Filed as improvise-l8rh; pinned here so that changing the answer is a decision somebody makes rather than a side effect of reordering the table.

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: the answer is %m/%d/%Y stably, which is the property asserted. The ten-sample truncation itself is unchanged and is still a real limit.


improvise-io/src/import/csv_parser.rs

short_rows_pad_missing_trailing_columns_with_null

Bug improvise-k8i. The reader is configured .flexible(true), so rows shorter than the header parsed without error — but the missing trailing columns were simply absent from the record map. No error, no warning, and field-kind analysis saw skewed value counts. Short rows must keep every header column, missing ones as Null.

short_rows_produce_warning_naming_column_and_count

Companion to improvise-k8i: the warning must name the affected column and say how many rows were short. A warning that said only "some rows were short" would pass a weaker test and be useless to the user.

full_rows_produce_no_warnings

The negative arm: padding must not fire on a complete file.

a_leading_utf8_bom_is_not_part_of_the_first_header

A header name becomes a category name and survives into the .improv file, so a byte-order mark riding along invisibly is silent corruption rather than cosmetics. Excel writes one; the csv crate strips a leading one, so the first header is Region and not \u{feff}Region.

The control at the end is what makes this an assertion rather than a tautology: a BOM in the second header is not leading, is not stripped, and shows up in the key — so the check demonstrably can see a BOM where there is one.

crlf_line_endings_leave_no_carriage_return_in_the_data

The \r belongs to the terminator, not to the last field of the row: a Revenue of 1\r would be a string rather than a number, and every row would import as text.

The control: a lone \r is itself a record terminator to this reader, so Ea\rst splits one row into two. That is what a stray carriage return does when it is not part of a CRLF pair, and it is why "two records, clean values" is a real observation rather than a coincidence.

an_empty_file_is_no_records_and_no_warnings

An empty file is no records and no complaint. The wizard's own preconditions (wizard.rs::validation_error) are what refuse to build a model from nothing; the parser's job is to say truthfully that there was nothing there.

a_header_with_no_rows_is_no_records

The same answer, and the case a user actually hits — an export filtered down to nothing.

a_single_column_file_yields_one_field_per_record

The degenerate shape for everything downstream that splits a row into coordinates and a measure: one field, present, and not mistaken for a short row.


improvise-io/src/import/wizard.rs

wizard_previous_reverses_each_advance

Bug improvise-907 (from improvise-o1c): no step bound a back key, so reaching a later step and wanting to change a field kind meant Esc — discarding every proposal toggle, formula and name. previous walks back through the path actually taken, including the steps a conditional transition skipped on the way forward, which is why it is a history stack and not a reverse table.

wizard_build_parks_the_workbook_on_done

Bug improvise-907 (from improvise-c6j): advance mapped NameModel → Done but nothing ever called it there, so Done was reachable only from tests. build now parks the workbook on Done for confirmation.

wizard_build_failure_stays_on_name_model

A build that fails despite the earlier gates leaves the user on NameModel with the error, rather than moving to a Done step with nothing to confirm.

wizard_blocks_advance_when_over_category_limit

Bug improvise-mzv: with more than MAX_CATEGORIES (12) accepted Category proposals, the wizard let the user advance through every remaining step and build_model() only failed at the final confirm. Advancing past ReviewProposals must be blocked, with a message telling the user to mark fields as Measure or toggle them off — the actions still available on that step.

wizard_blocks_advance_when_no_category_accepted

Bug improvise-907 (from improvise-gfq): build_model raised "At least one category must be accepted" only on Enter at NameModel, the final step. An import whose fields all classify as Measure walked the user through DefineFormulas and NameModel before refusing. The gate belongs at ReviewProposals, where c can still change a field's kind — the same treatment improvise-mzv gave the too-many-categories case.

build_model_null_category_value_is_missing_not_the_item_null

improvise-ych. A category value of JSON null used to be stringified by the v.to_string() fallback, inventing an item literally named "null" and placing the record under it. A CSV empty cell parses to Value::Null too, so every blank column value in a CSV import grew a phantom "null" item. Null means absent: the record must be skipped and counted, exactly as if the key were missing.

build_model_literal_string_null_is_a_real_category_item

The other side of improvise-ych: the string "null" is an ordinary value — a region really called "null" — and must survive as its own item. Only Value::Null means absent. Without this arm, "drop anything that stringifies to null" would pass the test above.

build_model_empty_string_category_value_is_missing

improvise-ych, third arm. An empty string is the JSON spelling of a blank cell — the CSV parser already turns a blank field into Value::Null, so the two inputs must agree. An item with an empty name is unnameable in the grid, so it is treated as missing rather than created.

build_model_warns_about_records_skipped_for_missing_category

improvise-5j9: skipping a record for a missing category value used to be silent, so a 1000-row import could drop rows with no user-visible sign. The warning must name the count, the total, and the offending field(s) — a bare "some records were skipped" is not actionable.