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")
}
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",
}
}
fn declared_variants() -> BTreeSet<String> {
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| {
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)))
}
fn samples() -> Vec<Value> {
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)),
],
})),
]
}
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<String> = 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() {
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() {
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()
);
}
}
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}"
);
}
}
#[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]",
"2026-01-01t12:00:00z",
"2026-01-01 12:00:00Z",
"2016-12-31T23:59:60Z",
"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}"
);
}
}
fn document_with_value(value: &str) -> String {
format!(
r#"{{"engine":"sheets","names":[],"sheets":[{{"cells":{{"A1":{{"formula":"=1","value":{value}}}}},"name":"S"}}],"version":"1"}}"#
)
}
#[test]
fn the_new_branches_reject_what_the_deserializer_rejects() {
let bad = [
r#"{"type":"zoned","value":"not a timestamp"}"#,
r#"{"type":"zoned","value":"2026-01-01T12:00:00"}"#,
r#"{"type":"zoned","value":"2026-01-01T12:00:00+02:00[Europe/Berlin"}"#,
r#"{"type":"zoned","value":"2026-99-99T99:99:99+99:99"}"#,
r#"{"type":"zoned","value":"2026-01-01T12:00:00+24:00"}"#,
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 ]"}"#,
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":[]}}"#,
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":[]}}"#,
r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1},{"type":"number","value":2}]}}"#,
r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1},{"type":"number","value":2}],"options":[["COLOR",{"type":"text","value":"red"}]]}}"#,
r#"{"type":"sparkline","value":{"charttype":"line","data":[{"type":"number","value":1},{"type":"number","value":2}],"options":[["charttype",{"type":"text","value":"bar"}]]}}"#,
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}"
);
}
}