feat(google)!: SPARKLINE — parse and validate the in-cell chart - #770
Merged
Conversation
Contributor
Test Coverage by Category
✓ = 100% passing · ⚠ = known deviation · The ~79,424 total counts formula evaluations (each conformance row and each property case = 1). GitHub Checks reports 3,731 Rust test functions: 2,897 unit + 159 property functions (shown as cases above) + 675 conformance/integration. |
hhimanshu
force-pushed
the
feat/766-sparkline-code
branch
from
July 28, 2026 02:48
4161cec to
1fe5112
Compare
SPARKLINE(data, [options]) now parses and validates its arguments and
produces a render spec; drawing stays with the consumer. Every behaviour
below is a row of the Google Sheets conformance fixtures, not a guess.
Value model: a new `Value::Sparkline(Box<SparklineSpec>)` variant, because
Sheets models the result as a value kind of its own — `TYPE()` reports the
undocumented code 128 (outside the documented 1/2/4/16/64 set) and
`ISERROR()` is FALSE. A sparkline read back out of a cell is still a
sparkline: `=TYPE(Data!K1)` is 128, so referencing is not a coercion point.
Sheets keeps two notions of sameness, and the engine reproduces both:
- `=` reports ANY two sparklines equal, whatever they plot, whatever
their charttype or options. So `PartialEq` on the core value ignores
the payload, like `ErrorMsg` does, and a sparkline outranks every
scalar in ordering (`>1`, `>"zzzz"`, `>TRUE` are all TRUE) while two
sparklines are mutually equal (`<`/`>` FALSE, `>=` TRUE).
- `COUNTUNIQUE` nonetheless counts two different sparklines as 2 and two
identical ones as 1. The spec is therefore retained, keyed on in
COUNTUNIQUE, used as storage identity in the workbook layer (recalc
both writes a cell back only when the value differs and tests
convergence by value equality, so a coarser identity would strand a
changed chart's stale spec), and carried in full by every
serialization — including back *in* through the WASM and MCP variable
decoders, so an emitted sparkline handed back as a variable is still a
sparkline rather than silently becoming empty.
All three decoders accept exactly what the engine can emit and nothing
wider. Every guard in all three is covered by a test verified to fail when
that guard is removed — 21 guards individually knocked out and restored,
including the two `pair.len() != 2` checks whose absence would index out of
bounds on caller-supplied JSON rather than reject it.
The comparison-alias functions EQ/NE/GT/GTE/LT/LTE are backed by a second
comparison path in the operator module; both paths now answer identically,
pinned by a test that compares each alias against its operator.
Coercion runs through three seams — `to_number` rejects with #VALUE!,
`to_string_val` reads "" and `to_bool` reads false — but individual
functions carry hand-carved arms that override their seam (`N`, `TEXT`, the
`TO_*` family, the `&` operator, the aggregates). The module doc does not
try to enumerate them: google.tsv is the record and the only authority, and
the doc says so. `DOLLAR` is #VALUE! while `TO_DOLLARS` is "" — that pair
is why nothing here is inferred by analogy.
Aggregates skip a sparkline as if the argument were absent, and answer the
same however it arrived — directly or from a cell in a range. SUM, PRODUCT,
MAX, MIN, MAXA, MINA, COUNT and SUMSQ all answer 0 when a sparkline is the
only thing in scope; AVERAGE and AVERAGEA answer #DIV/0!, which is the row
proving the argument list genuinely empties rather than gaining a zero. An
explicitly empty array argument outranks that: `=MAX(SPARKLINE(...),{})` is
#REF! while `=MAX(SPARKLINE(...),{"a"})` is 0. MAX's numberless-array
`#REF!` and MAXA/MINA's `#N/A` are still reached by every input that does
not involve a sparkline: a 360-case differential against origin/main over
text, boolean, empty, mixed and nested arrays for
MAX/MIN/MAXA/MINA/SUM/PRODUCT/AVERAGE/COUNT is byte-identical.
Validation splits into three error classes:
#N/A arity/shape of `data` — no arguments, a scalar instead of a
range, a single value
#REF! structural malformation — an empty array, options that are not
key/value pairs
#VALUE! a bad option *value* — an unrecognised charttype
An unrecognised option *key* is not an error: Sheets ignores it, so it is
kept in the spec rather than rejected, which is what lets a workbook
written against a newer option set still evaluate. Kept keys participate in
COUNTUNIQUE's key exactly as recognised ones do. Option keys and charttype
values match case-insensitively, a non-text option key is accepted, 2-D
data flattens row-major, a genuine blank cell inside the source range is a
data point, and `bar` given a third value renders — all recorded.
Carried across all four value representations: the core enum, the workbook
wire value (canonical JSON `{"type":"sparkline","value":{charttype,data,
options}}`), the WASM `EvalResult` (a typed `SparklineSpecResult`) and the
MCP JSON surfaces, each with a decode path back.
The shared TSV runner also learns to skip rows that read sheet-qualified
references (`=SUM(Data!K1:K2)`). It evaluates each row standalone with no
workbook behind it, so such a reference resolves to empty — which does not
only fail the row, it makes one *pass for the wrong reason* whenever an
empty read happens to match the recorded value (`=SUM(Data!K1:K1)` is 0
either way). In the two runners that affects only google.tsv; the
per-function coverage scan applies the same guard, where it also drops
workbook.tsv's 24 sheet-qualified rows from the credit scan — harmless
(every function they mention is credited by many other rows) and
deliberate, since an accidental empty-vs-empty match is not evidence of
coverage.
Because that adds a second silent skip category to a runner whose silent
skipping is already tracked as a defect, both runners now print a per-file
breakdown — total rows, enforced, and skipped by reason:
google.tsv: 128 rows — 83 enforced, 29 skipped (no recorded expected
value), 16 skipped (reads authored cells)
nextest captures a passing test's stdout, so the `ci` profile carries a
`success-output` override for the conformance binary — without it the
accounting would be invisible under the exact command CI runs, which is the
only place it matters. It is scoped by `binary_id`, not binary name, so a
future crate adding a `tests/conformance.rs` does not inherit it silently.
The reason labels describe the whole bucket rather than SPARKLINE's case:
text.tsv's 46 rows there are 41 with a genuinely empty recorded value plus
5 whose recorded value is whitespace and is skipped by a pre-existing
`trim()`. This is a narrow mitigation, not the fix core#767 asks for — the
rows are still skipped, they merely announce themselves.
Tests live in separate files per repo convention: crates/core/tests/
sparkline.rs (47 engine-level cases, each citing its fixture row),
crates/workbook/tests/sparkline_value_tests.rs (serialization, storage
identity, decode rejection), crates/wasm/tests/sparkline_round_trip.rs and
crates/mcp/tests/sparkline_variables.rs (emit-then-read-back, plus decoder
rejection on both surfaces).
google.tsv is a blocking conformance gate, covering 69 of the function's
114 rows there: 29 record an empty expected value and 16 read authored
input cells. A 115th recorded row is a known engine divergence and sits in
bugs.tsv instead — `=MIN(SPARKLINE({1,2,3}),{})` is #REF! in Sheets and 0
here, because MIN has no empty-array rule at all (`=MIN({})` is 0 on main,
`=MAX({})` is #REF!). That gap predates sparklines and fixing it would move
MIN for inputs unrelated to this work. The skipped set is exactly the rows where a sparkline renders,
projects to empty text, or arrives through a range — so the conformance
suite alone would not catch a regression of the text seam or of range
delivery. crates/core/tests/sparkline.rs is what covers them, deliberately,
the range rows against a seeded resolver.
BREAKING CHANGE: `truecalc_core::Value` and `truecalc_workbook::Value` gain
a `Sparkline` variant. Neither enum is `#[non_exhaustive]`, so any
downstream exhaustive `match` on a cell value must add an arm.
Refs #766, #767
The coverage guard requires every registered function to have fixture rows, and the "Check fixture / code separation" CI job rejects any PR touching both `fixtures/google_sheets/*.tsv` and code. Those two rules deadlock for a new function: code first leaves SPARKLINE registered with no rows and fails this test; fixtures first leaves 103 rows for a function the engine does not have and fails the conformance run. Add SPARKLINE to the existing pending-fixture-verification set to break the tie for exactly one merge. Unlike QUERY, which is there because its rows do not exist yet, SPARKLINE's rows are pipeline-verified and land in the immediately following fixtures-only PR — the entry is removed there, so the guard enforces SPARKLINE from that point on. Refs #766
hhimanshu
force-pushed
the
feat/766-sparkline-code
branch
from
July 28, 2026 03:28
1fe5112 to
085b015
Compare
Contributor
Test Coverage by Category
✓ = 100% passing · ⚠ = known deviation · The ~79,424 total counts formula evaluations (each conformance row and each property case = 1). GitHub Checks reports 3,732 Rust test functions: 2,897 unit + 159 property functions (shown as cases above) + 676 conformance/integration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #766 (closed by the follow-up fixtures PR, which adds the conformance rows)
Summary
SPARKLINE(data, [options])— Google Sheets' in-cell chart function. The engine parses and validates its arguments and produces a render spec; drawing stays with the consumer.Valueenums intruecalc-coreandtruecalc-workbook. Neither is#[non_exhaustive], so downstream exhaustivematches will not compile. The commit carries a!and aBREAKING CHANGE:footer, so release-plz should compute 7.0.0 from the just-released 6.1.0.The value model, and why it is not the obvious one
SPARKLINEreturns something that is not a number, string, bool, date or error.TYPE()reports 128 — outside the documented 1/2/4/16/64 set — so Sheets models it as a distinct value kind, not a specially-formatted string.The variant follows
ErrorMsg's shape (a variant carrying a validated payload) but not its identity treatment, and the two crates deliberately disagree:truecalc_core::Value=truecalc_workbook::ValueCOUNTUNIQUE-grade notionThat split is deliberate and load-bearing.
recalc.rs::apply_changeswrites a recomputed cell back onlyif old != new; under coarse equality, a cell whose sparkline changed would compare equal to its stale stored value and silently keep — and export — the old spec. A reviewer independently found a second site with the same exposure: the fixed-point loop's convergence check would declare premature convergence.This was not the first design. The original built identity over the spec, on the strength of
=SPARKLINE({1,2,3})=SPARKLINE({1,2,3})→TRUE. That row was missing its control.=SPARKLINE({1,2,3})=SPARKLINE({9,9,9})is alsoTRUE— every sparkline equals every other.COUNTUNIQUEis what actually distinguishes them (2 for different, 1 for identical), so the spec must still be carried, for a completely different reason than the one first recorded.Coercion is asymmetric, and the asymmetry is not derivable
Note
&errors whileCONCATENATEsucceeds, and=SPARKLINE(...)=""isFALSEwhileEXACT(...,"")isTRUE. Both are genuine Sheets asymmetries, reproduced rather than smoothed over.The sharpest pair:
Near-identical names, opposite answers. No reading of either implementation produces that — which is why the module carries a deliberately short coercion note: the three coercion seams and what each yields, a pointer to
google.tsvas the only authority, and one rule — find the row; if there is no row, probe it. Every predictive claim was removed after three successive attempts to summarise engine behaviour in a comment turned out wrong.Errors
data(no args, scalar, single value)#N/A#REF!#VALUE!Also in this change
Value::Emptysilently, soTYPE(x)returned 1 instead of 128. Both now decode, with guards matching the workbook decoder exactly, so all three decoders share one contract.text.tsvhas 46 rows asserting nothing — pre-existing, unrelated, previously uncounted. See test(conformance): silently skipped rows assert nothing — 46 in text.tsv alone, and one pair passed for the wrong reason #767.0, and an unresolved cross-sheet read also yields0. Documented as a mitigation, not test(conformance): silently skipped rows assert nothing — 46 in text.tsv alone, and one pair passed for the wrong reason #767's fix.How to test
Fastest repro — the pair that decided the value model:
Or run the suite:
cargo test -p truecalc-core --test sparkline(46 tests, each citing its fixture row), andcargo test -p truecalc-core --test conformance -- --nocaptureto see the new per-file tally.Review
Five independent review rounds, each by a reviewer that did not write the code. Every finding fixed:
EQ()returnedFALSEwhere=returnedTRUE. Sheets' comparison-alias functions run through a separate comparison implementation that had no sparkline arm. The contract every serialization decision rested on held through one path and was inverted through the other.PRODUCTskipped only in array position, not as a direct argument — inconsistent with every other aggregate and with itself.TO_DOLLARS/TO_PURE_NUMBER/TO_DATEkept the opposite answer from their probed siblings, in the same module, with character-identicalmatchshapes.MAXAreturned#N/Awhere Sheets returns0.PRODUCTchanged no non-sparkline behaviour (30 inputs diffed againstorigin/mainvia purpose-built probe binaries), that the decoders survive malformed input including a 10,000-element array and 2,000-deep nesting, and thatHash/PartialEqagree within each crate.Ground truth
103 rows, every value observed in live Google Sheets — grown from 26 across five probe batches, each triggered by a review finding that could not be settled by reasoning. The rows arrive in the follow-up fixtures PR (CI hard-fails a PR touching both fixtures and code).
Of the 103: 65 enforced, 29 skipped (no text projection), 9 skipped (authored cells) — stated because an earlier version of this work overstated the gate's coverage.
Test plan
cargo test --workspace— 88 suites, 0 failurescargo clippy --workspace -- -D warnings— clean (CI's exact form)wasm-packbuilds greencargo fmt --check/clippy --all-targetsdirty onmainalready — confirmed pre-existing, no new dirt from this diffRelated
zonedandsparklinebranches🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.