diff --git a/crates/workbook/schema/workbook.v1.schema.json b/crates/workbook/schema/workbook.v1.schema.json index 263f52401..9f913aae1 100644 --- a/crates/workbook/schema/workbook.v1.schema.json +++ b/crates/workbook/schema/workbook.v1.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://truecalc.dev/schema/workbook.v1.schema.json", "title": "TrueCalc Workbook v1", - "description": "Structural schema for a TrueCalc workbook document (schema spec v1). Rules that JSON Schema cannot express -- canonical key ordering, ECMAScript number formatting, spill reconstruction, duplicate-key rejection, simple-case-fold uniqueness, named-range ref canonicality, and resource limits -- are governed by the prose spec and enforced by Workbook::from_json, not here.", + "description": "Structural schema for a TrueCalc workbook document (schema spec v1). Rules that JSON Schema cannot express -- canonical key ordering, ECMAScript number formatting, spill reconstruction, duplicate-key rejection, simple-case-fold uniqueness, named-range ref canonicality, and resource limits -- are governed by the prose spec and enforced by Workbook::from_json, not here. Validate with a draft 2020-12 validator: a draft-07 validator silently ignores the `prefixItems` keyword used by `sparklineOption`, which drops all inner validation of a sparkline's option pairs and quietly accepts pairs this schema rejects.", "type": "object", "additionalProperties": false, "required": [ @@ -189,6 +189,12 @@ } } }, + { + "$ref": "#/$defs/zonedValue" + }, + { + "$ref": "#/$defs/sparklineValue" + }, { "type": "object", "additionalProperties": false, @@ -312,6 +318,169 @@ "type": "number" } } + }, + { + "$ref": "#/$defs/zonedValue" + }, + { + "$ref": "#/$defs/sparklineValue" + } + ] + }, + "zonedValue": { + "description": "A zone-aware instant, carried as a self-describing RFC-9557 string: an RFC-3339 timestamp optionally suffixed with the zone in brackets. The serializer always emits one canonical spelling -- local wall clock, a `T` separator, whole seconds, a numeric +HH:MM / -HH:MM offset, and the bracketed zone for a named IANA zone but not for a bare fixed offset. The pattern is deliberately wider, because the reader accepts every RFC-3339 spelling and normalizes it: a lower-case `t`, a space separator, `Z`/`z` for UTC, fractional seconds, and a leap second (`:60`). It is sized to the reader on purpose -- a pattern narrower than the reader would report a loadable document as malformed. Three things it still cannot express, so a string may match here and be rejected on load: calendar validity (`2026-04-31` matches), the representable instant range (an i64 nanosecond count, so roughly 1677-09-21 to 2262-04-11 -- `9999-01-01` matches), and whether a bracketed name is a zone the pinned tzdb actually knows.", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "zoned" + }, + "value": { + "type": "string", + "pattern": "^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])[Tt ]([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\\.[0-9]+)?([Zz]|[+-]([01][0-9]|2[0-3]):[0-5][0-9])(\\[[A-Za-z0-9_+:/-]+\\])?$" + } + } + }, + "sparklineValue": { + "description": "A sparkline: the parsed, validated render spec produced by SPARKLINE, carried in full because it is part of the value's storage identity.", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "sparkline" + }, + "value": { + "$ref": "#/$defs/sparklineSpec" + } + } + }, + "sparklineSpec": { + "type": "object", + "additionalProperties": false, + "required": [ + "charttype", + "data", + "options" + ], + "properties": { + "charttype": { + "description": "Lifted out of the option list into its own field; `line` when the option was omitted. Canonical lower-case only: SPARKLINE's own argument matching is case-insensitive, but the wire form is not, exactly as for option keys.", + "enum": [ + "line", + "bar", + "column", + "winloss" + ] + }, + "data": { + "description": "The points to plot, flattened row-major from the data argument. A single-point sparkline is unrepresentable -- the evaluator answers #N/A -- so there are always at least two.", + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/$defs/sparklinePoint" + } + }, + "options": { + "description": "The remaining option key/value pairs in the order given. Keys the engine does not recognise are kept rather than rejected, matching Sheets.", + "type": "array", + "items": { + "$ref": "#/$defs/sparklineOption" + } + } + } + }, + "sparklineOption": { + "description": "One [key, value] pair. Keys are stored ASCII-lower-cased, and `charttype` is never among them -- it has its own field.", + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + { + "type": "string", + "pattern": "^[^A-Z]*$", + "not": { + "const": "charttype" + } + }, + { + "$ref": "#/$defs/sparklinePoint" + } + ] + }, + "sparklinePoint": { + "description": "A sparkline data point or option value. Narrower than scalarValue: only these four value types can occupy either position.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "number" + }, + "value": { + "type": "number" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "text" + }, + "value": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "boolean" + }, + "value": { + "type": "boolean" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "value" + ], + "properties": { + "type": { + "const": "empty" + }, + "value": { + "type": "null" + } + } } ] } diff --git a/crates/workbook/src/value.rs b/crates/workbook/src/value.rs index 4603c3ce0..cf0216ab2 100644 --- a/crates/workbook/src/value.rs +++ b/crates/workbook/src/value.rs @@ -21,6 +21,12 @@ use truecalc_core::types::{SparklineChartType, SparklineSpec, SparklineValue, Zo /// array is collapsed to its scalar element before storage, schema spec /// §6), and holds only scalar values (never a nested `Array`). It appears /// only as a spill anchor's value (schema spec §5). +/// - `Sparkline` plots at least two points, and its option keys are lower-case +/// and never `charttype`. The serializer rejects a spec that breaks this and +/// the deserializer refuses one, so a spec that can be written can be read. +/// - `Zoned` is written, and must be read, as an unpadded RFC-9557 string: the +/// wire form is canonical, even where the formula-level parsers it delegates +/// to are lenient about casing and surrounding whitespace. #[derive(Debug, Clone)] pub enum Value { /// Finite IEEE-754 f64. @@ -221,6 +227,30 @@ struct SparklineSpecWire<'a>(&'a SparklineSpec); impl Serialize for SparklineSpecWire<'_> { fn serialize(&self, serializer: S) -> Result { + // Enforce the reader's invariants at the writing end too, exactly as + // the `Array` arm of `Value`'s serializer does for its own shape rules: + // a `SparklineSpec` is a public struct, so it can be hand-built in a + // state the evaluator never produces, and emitting it would yield bytes + // that neither `parse_sparkline` nor the published schema accepts — + // breaking this crate's round-trip guarantee. + if self.0.data.len() < 2 { + return Err(S::Error::custom( + "a sparkline plots at least two points; a shorter spec does not exist \ + in serialized form (the evaluator answers #N/A for one point)", + )); + } + for (key, _) in &self.0.options { + if *key != key.to_ascii_lowercase() { + return Err(S::Error::custom(format!( + "a sparkline option key must be lower-case, got {key:?}" + ))); + } + if key == "charttype" { + return Err(S::Error::custom( + "charttype is carried by the sparkline's own field, not in options", + )); + } + } let data: Vec = self.0.data.iter().map(sparkline_value_to_value).collect(); let options: Vec<(&str, Value)> = self .0 @@ -365,9 +395,7 @@ fn parse_value(raw: &serde_json::Value) -> Result { "number" => Ok(Value::Number(parse_finite_f64(payload, kind)?)), "date" => Ok(Value::Date(parse_finite_f64(payload, kind)?)), "zoned" => match payload.as_str() { - Some(s) => parse_rfc9557(s) - .map(|zi| Value::Zoned(Box::new(zi))) - .ok_or_else(|| format!("a zoned value must be a valid RFC-9557 string, got {s:?}")), + Some(s) => parse_zoned(s), None => Err("a zoned value must be a JSON string".to_string()), }, "text" => match payload.as_str() { @@ -414,6 +442,28 @@ fn parse_finite_f64(payload: &serde_json::Value, kind: &str) -> Result Result { + let zone = s + .split_once('[') + .and_then(|(_, rest)| rest.strip_suffix(']')); + if s.trim() != s || zone.is_some_and(|z| z.trim() != z) { + return Err(format!( + "a zoned value must not be padded with whitespace, got {s:?}" + )); + } + parse_rfc9557(s) + .map(|zi| Value::Zoned(Box::new(zi))) + .ok_or_else(|| format!("a zoned value must be a valid RFC-9557 string, got {s:?}")) +} + /// Parse the payload of a `sparkline` value: the full parsed spec, in the same /// shape [`SparklineSpecWire`] emits. fn parse_sparkline(payload: &serde_json::Value) -> Result { @@ -434,8 +484,20 @@ fn parse_sparkline(payload: &serde_json::Value) -> Result { let raw_chart_type = obj["charttype"] .as_str() .ok_or_else(|| "a sparkline charttype must be a JSON string".to_string())?; + // `SparklineChartType::parse` is ASCII case-insensitive because the + // *formula* level has to accept `=SPARKLINE({1,2},{"charttype","LINE"})`. + // The wire is canonical-only — as it already is for option keys below — so + // a non-canonical spelling is rejected here rather than silently + // normalized: the serializer never emits one, and accepting one would put + // a document on disk that the published schema calls malformed. let chart_type = SparklineChartType::parse(raw_chart_type) .ok_or_else(|| format!("unknown sparkline charttype {raw_chart_type:?}"))?; + if chart_type.as_str() != raw_chart_type { + return Err(format!( + "a sparkline charttype must be spelled in its canonical lower-case \ + form, got {raw_chart_type:?}" + )); + } let raw_data = obj["data"] .as_array() diff --git a/crates/workbook/tests/schema_value_variant_tests.rs b/crates/workbook/tests/schema_value_variant_tests.rs new file mode 100644 index 000000000..028a68218 --- /dev/null +++ b/crates/workbook/tests/schema_value_variant_tests.rs @@ -0,0 +1,317 @@ +//! Every `Value` variant's *serialized* form validates against the committed +//! `schema/workbook.v1.schema.json` (issue #768). +//! +//! The two branches that were missing — `zoned` and `sparkline` — are the +//! symptom; this file is the fix. A variant added to `Value` without a matching +//! schema branch fails here twice over: +//! +//! 1. [`variant_name`] matches exhaustively on `Value`, so a new variant stops +//! this test crate compiling until it is named. +//! 2. [`declared_variants`] reads the variant list out of `src/value.rs` +//! itself, so [`the_sample_set_covers_every_declared_variant`] fails until a +//! sample is constructed — and the sample is then serialized and validated +//! by [`every_variant_validates_against_the_schema`], which fails until the +//! schema describes it. +//! +//! The two gates are not fully redundant: gate 2 recognises only the tuple and +//! unit forms (`Name(..)` / `Name,`), so a struct-form variant would slip past +//! it and be caught by gate 1 alone. Gate 1 covers every shape, so nothing +//! escapes both — but gate 2 is the one that survives a careless `_ =>` arm, +//! and it does not see a struct variant. +//! +//! Samples are checked as *serializer output*, never as hand-written JSON: the +//! schema has to describe what the serializer emits, and the two drift silently +//! otherwise. + +use std::collections::BTreeSet; + +use chrono_tz::TZ_VARIANTS; +use truecalc_core::types::sparkline::{SparklineChartType, SparklineSpec, SparklineValue}; +use truecalc_core::types::zoned::{ZoneId, ZonedInstant}; +use truecalc_workbook::{Cell, EngineFlavor, Value, Workbook, Worksheet}; + +fn validator() -> jsonschema::Validator { + let text = std::fs::read_to_string("schema/workbook.v1.schema.json").unwrap(); + jsonschema::validator_for(&serde_json::from_str(&text).unwrap()).expect("schema compiles") +} + +/// The variant this value belongs to. +/// +/// Exhaustive on purpose: adding a variant to `Value` makes this `match` +/// non-exhaustive and the test crate stops compiling. +fn variant_name(value: &Value) -> &'static str { + match value { + Value::Number(_) => "Number", + Value::Text(_) => "Text", + Value::Boolean(_) => "Boolean", + Value::Error(_) => "Error", + Value::ErrorMsg(_, _) => "ErrorMsg", + Value::Empty => "Empty", + Value::Array(_) => "Array", + Value::Date(_) => "Date", + Value::Zoned(_) => "Zoned", + Value::Sparkline(_) => "Sparkline", + } +} + +/// The variant names declared by `pub enum Value`, read out of the source so +/// the sample set below cannot silently fall behind the type. +fn declared_variants() -> BTreeSet { + const SRC: &str = include_str!("../src/value.rs"); + let body = SRC + .split_once("pub enum Value {") + .expect("src/value.rs declares `pub enum Value`") + .1 + .split_once("\n}") + .expect("the enum body is closed by a brace in column 0") + .0; + body.lines() + .filter_map(|line| { + // A variant is a single-indent line `Name,` or `Name(..)`; doc + // comments and attributes fail the trailing-delimiter check. + let line = line.strip_prefix(" ")?; + let end = line.find(|c: char| !c.is_alphanumeric() && c != '_')?; + let (name, rest) = line.split_at(end); + (!name.is_empty() && rest.starts_with(['(', ','])).then(|| name.to_owned()) + }) + .collect() +} + +fn zoned(utc_nanos: i64, zone: ZoneId) -> Value { + Value::Zoned(Box::new(ZonedInstant::from_instant(utc_nanos, zone))) +} + +/// One representative instance of every `Value` variant. +fn samples() -> Vec { + vec![ + Value::Number(1.5), + Value::Text("hi".to_owned()), + Value::Boolean(true), + Value::Error("#REF!".to_owned()), + Value::ErrorMsg("#VALUE!".to_owned(), "a diagnostic".to_owned()), + Value::Empty, + Value::Array(vec![vec![Value::Number(1.0), Value::Text("a".to_owned())]]), + Value::Date(46180.5), + zoned( + 1_768_000_000_000_000_000, + ZoneId::Iana("Europe/Berlin".parse().unwrap()), + ), + Value::Sparkline(Box::new(SparklineSpec { + chart_type: SparklineChartType::Column, + data: vec![ + SparklineValue::number(1.0), + SparklineValue::Blank, + SparklineValue::Text("a".to_owned()), + SparklineValue::Bool(false), + ], + options: vec![ + ("color".to_owned(), SparklineValue::Text("red".to_owned())), + ("ymin".to_owned(), SparklineValue::number(0.0)), + ], + })), + ] +} + +/// A one-cell workbook holding `value`. The cell carries a formula because +/// `Value::Empty` is legal only as an unevaluated formula cell's result. +fn workbook_holding(value: Value) -> Workbook { + let mut wb = Workbook::new(EngineFlavor::Sheets); + let mut sheet = Worksheet::new("S"); + sheet + .cells_mut() + .insert("A1".to_owned(), Cell::with_formula("=1", value)); + wb.sheets_mut().push(sheet); + wb +} + +#[test] +fn the_sample_set_covers_every_declared_variant() { + let covered: BTreeSet = samples() + .iter() + .map(|v| variant_name(v).to_owned()) + .collect(); + assert_eq!( + covered, + declared_variants(), + "every `Value` variant needs a sample here, so that its serialized form \ + is checked against the published schema" + ); +} + +#[test] +fn every_variant_validates_against_the_schema() { + let validator = validator(); + for value in samples() { + let name = variant_name(&value).to_owned(); + let text = workbook_holding(value).to_json().unwrap(); + let json: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert!( + validator.is_valid(&json), + "the serialized form of `Value::{name}` does not validate against \ + the published schema: {text}" + ); + } +} + +#[test] +fn every_variant_round_trips_and_still_validates() { + let validator = validator(); + for value in samples() { + let name = variant_name(&value).to_owned(); + let text = workbook_holding(value).to_json().unwrap(); + let reparsed = Workbook::from_json(text.as_bytes()) + .unwrap_or_else(|e| panic!("`Value::{name}` failed to reparse: {e:?} ({text})")); + let again = reparsed.to_json().unwrap(); + assert_eq!(again, text, "`Value::{name}` did not round-trip"); + assert!( + validator.is_valid(&serde_json::from_str(&again).unwrap()), + "the re-serialized form of `Value::{name}` does not validate against \ + the published schema: {again}" + ); + } +} + +#[test] +fn every_scalar_variant_validates_inside_an_array() { + // A spill anchor's array holds scalars only — a nested array is + // unrepresentable — so the `Array` sample is the one exclusion. + let validator = validator(); + for value in samples() { + if matches!(value, Value::Array(_)) { + continue; + } + let name = variant_name(&value).to_owned(); + let row = Value::Array(vec![vec![value, Value::Number(0.0)]]); + let text = workbook_holding(row).to_json().unwrap(); + let json: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert!( + validator.is_valid(&json), + "`Value::{name}` inside a spilled array does not validate against \ + the published schema: {text}" + ); + } +} + +#[test] +fn the_zoned_branch_accepts_every_zone_in_the_pinned_tzdb() { + // The branch's string pattern is only as good as the zone names it has + // seen; check it against every name the serializer can actually emit, at + // two instants six months apart so both DST states are exercised. + let validator = validator(); + for tz in TZ_VARIANTS { + for utc_nanos in [1_767_225_600_000_000_000, 1_782_950_400_000_000_000] { + let text = workbook_holding(zoned(utc_nanos, ZoneId::Iana(tz))) + .to_json() + .unwrap(); + assert!( + validator.is_valid(&serde_json::from_str(&text).unwrap()), + "a zoned value in {} does not validate: {text}", + tz.name() + ); + } + } + // Fixed-offset zones drop the bracketed name entirely. + for minutes in [-720, -330, 0, 330, 840] { + let text = workbook_holding(zoned(0, ZoneId::Fixed(minutes))) + .to_json() + .unwrap(); + assert!( + validator.is_valid(&serde_json::from_str(&text).unwrap()), + "a fixed-offset zoned value does not validate: {text}" + ); + } +} + +/// The zoned pattern must not be narrower than the reader either: the reader +/// takes any RFC-3339 spelling and normalizes it on the way in, so a document +/// spelling an instant that way is loadable and must not be called malformed. +#[test] +fn the_zoned_branch_accepts_the_spellings_the_reader_accepts() { + let validator = validator(); + for value in [ + "2026-01-01T12:00:00Z", + "2026-01-01T12:00:00Z[UTC]", + "2026-01-01T12:00:00.5+02:00", + "2026-01-01T12:00:00+02:00[+02:00]", + // A lower-case separator, a space separator, and a leap second are all + // RFC-3339 spellings the reader normalizes on the way in. + "2026-01-01t12:00:00z", + "2026-01-01 12:00:00Z", + "2016-12-31T23:59:60Z", + // The reader's offset bound is +/-23:59, not the +/-14:00 of a real zone. + "2026-01-01T12:00:00+23:59", + "2026-01-01T12:00:00-00:00", + "2026-01-01T12:00:00+02:00[Etc/GMT+5]", + "2026-01-01T12:00:00+02:00[America/Port-au-Prince]", + ] { + let text = document_with_value(&format!(r#"{{"type":"zoned","value":"{value}"}}"#)); + assert!( + Workbook::from_json(text.as_bytes()).is_ok(), + "the reader should have accepted {value}" + ); + assert!( + validator.is_valid(&serde_json::from_str(&text).unwrap()), + "the schema should have accepted {value}" + ); + } +} + +/// A one-cell document whose only cell carries the given raw JSON value. +fn document_with_value(value: &str) -> String { + format!( + r#"{{"engine":"sheets","names":[],"sheets":[{{"cells":{{"A1":{{"formula":"=1","value":{value}}}}},"name":"S"}}],"version":"1"}}"# + ) +} + +/// The new branches must reject what the deserializer rejects — a schema +/// looser than `Workbook::from_json` mis-tells a consumer that a document it +/// cannot load is fine, which is the same defect as #768 with the sign flipped. +#[test] +fn the_new_branches_reject_what_the_deserializer_rejects() { + let bad = [ + r#"{"type":"zoned","value":"not a timestamp"}"#, + // RFC-3339 requires an offset; the reader refuses a bare wall clock. + r#"{"type":"zoned","value":"2026-01-01T12:00:00"}"#, + r#"{"type":"zoned","value":"2026-01-01T12:00:00+02:00[Europe/Berlin"}"#, + // Out-of-range fields. The pattern bounds every field it can; calendar + // validity (`2026-04-31`) and the representable instant range + // (`9999-01-01`) are the two it genuinely cannot, and the branch's + // description says so. + r#"{"type":"zoned","value":"2026-99-99T99:99:99+99:99"}"#, + r#"{"type":"zoned","value":"2026-01-01T12:00:00+24:00"}"#, + // Whitespace padding: the wire is canonical-only, so the reader refuses + // to absorb it even though the formula-level parser trims. + r#"{"type":"zoned","value":" 2026-01-01T12:00:00Z"}"#, + r#"{"type":"zoned","value":"2026-01-01T12:00:00Z\n"}"#, + r#"{"type":"zoned","value":"2026-01-01T12:00:00+02:00[ Europe/Berlin ]"}"#, + // A charttype is canonical lower-case on the wire, even though + // SPARKLINE's own argument matching is case-insensitive. + r#"{"type":"sparkline","value":{"charttype":"Line","data":[{"type":"number","value":1},{"type":"number","value":2}],"options":[]}}"#, + r#"{"type":"sparkline","value":{"charttype":"WINLOSS","data":[{"type":"number","value":1},{"type":"number","value":2}],"options":[]}}"#, + // A single data point is unrepresentable (the evaluator answers #N/A). + r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1}],"options":[]}}"#, + r#"{"type":"sparkline","value":{"charttype":"bogus","data":[{"type":"number","value":1},{"type":"number","value":2}],"options":[]}}"#, + // `options` is not optional on the wire, even when empty. + r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1},{"type":"number","value":2}]}}"#, + // Option keys are stored ASCII-lower-cased. + r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1},{"type":"number","value":2}],"options":[["COLOR",{"type":"text","value":"red"}]]}}"#, + // charttype has its own field and is never repeated in the options. + r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1},{"type":"number","value":2}],"options":[["charttype",{"type":"text","value":"bar"}]]}}"#, + // An option is a [key, value] pair, and a point is a scalar value. + r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1},{"type":"number","value":2}],"options":[["color"]]}}"#, + r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1},{"type":"date","value":2}],"options":[]}}"#, + ]; + let validator = validator(); + for value in bad { + let text = document_with_value(value); + let json: serde_json::Value = serde_json::from_str(&text).unwrap(); + assert!( + !validator.is_valid(&json), + "the schema should have rejected {value}" + ); + assert!( + Workbook::from_json(text.as_bytes()).is_err(), + "the deserializer should have rejected {value}" + ); + } +} diff --git a/crates/workbook/tests/sparkline_value_tests.rs b/crates/workbook/tests/sparkline_value_tests.rs index 8719836d8..9b085aeda 100644 --- a/crates/workbook/tests/sparkline_value_tests.rs +++ b/crates/workbook/tests/sparkline_value_tests.rs @@ -138,3 +138,53 @@ fn malformed_specs_are_rejected_on_decode() { ); } } + +#[test] +fn a_non_canonical_charttype_is_rejected_on_decode() { + // `SPARKLINE`'s own argument matching is case-insensitive, so + // `{"charttype","LINE"}` evaluates; the wire form is canonical-only, as it + // already is for option keys. Accepting a non-canonical spelling here would + // load a document the published schema calls malformed. + for charttype in ["Line", "LINE", "WinLoss", "Bar"] { + let json = format!( + r#"{{"type":"sparkline","value":{{"charttype":"{charttype}","data":[{{"type":"number","value":1.0}},{{"type":"number","value":2.0}}],"options":[]}}}}"# + ); + assert!( + serde_json::from_str::(&json).is_err(), + "should have been rejected: {json}" + ); + } +} + +/// `SparklineSpec` is a public struct, so a spec the evaluator can never +/// produce is constructible. Encoding one would emit bytes that neither the +/// decoder nor the published schema accepts, so the encoder refuses — the same +/// guard `Value::Array` applies to its own shape rules. +#[test] +fn malformed_specs_are_rejected_on_encode() { + let bad = [ + // `parse_data` answers #REF! for no points and #N/A for one, so neither + // exists in serialized form. + spec(SparklineChartType::Line, Vec::new(), Vec::new()), + spec(SparklineChartType::Line, nums(&[1.0]), Vec::new()), + spec( + SparklineChartType::Line, + nums(&[1.0, 2.0]), + vec![("COLOR".to_owned(), SparklineValue::Text("red".to_owned()))], + ), + spec( + SparklineChartType::Line, + nums(&[1.0, 2.0]), + vec![( + "charttype".to_owned(), + SparklineValue::Text("bar".to_owned()), + )], + ), + ]; + for value in bad { + assert!( + serde_json::to_string(&value).is_err(), + "should have been rejected: {value:?}" + ); + } +}