fix(core): a non-finite cell value crosses the wire, in the spelling the format already uses (improvise-55v2)
serde_json writes a non-finite f64 as null, and null does not read back as one,
so CellValue::Number(INFINITY) serialized to {"Number":null} and the frame
carrying it was unreadable. The websocket reader skips an unparseable frame and
the worker logs a console error, so one poisoned cell silently dropped a whole
batch — and to_string returned Ok, so the send site could not notice either.
The ticket offered two answers and the format had already chosen. Measured
before changing anything: inf, -inf and nan all round-trip losslessly through
.improv — format_number writes them, the grammar's bare_value accepts them, the
walker maps them back. So refusing them before sending would make a legal saved
document unprojectable to a browser, which is a new failure rather than a fix.
It also settles the ticket's second location: resolve.rs's typed() accepting inf
is correct, not half a defect.
Number now serializes through an untagged adaptor: a JSON number when finite,
one of the three tokens when not. A finite cell's bytes are unchanged, and a
JSON integer still reads as a number rather than as a token.
The tokens are one home — nonfinite_token/parse_nonfinite beside CellValue,
called by the adaptor and by persistence/{format,parse}.rs. Three copies stood
before and a fourth was about to be written. format_f64 is deliberately not
folded in: it is the display layer and these three are a format.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
@@ -36,15 +36,10 @@ fn agg_keyword(agg: &AggFunc) -> &'static str {
|
||||
}
|
||||
|
||||
fn format_number(n: f64) -> String {
|
||||
if n.is_infinite() {
|
||||
return if n.is_sign_positive() {
|
||||
"inf".to_string()
|
||||
} else {
|
||||
"-inf".to_string()
|
||||
};
|
||||
}
|
||||
if n.is_nan() {
|
||||
return "nan".to_string();
|
||||
// The token is the model's, not this file's: the wire spells it the same
|
||||
// way and a second copy is a second answer (improvise-55v2).
|
||||
if let Some(token) = crate::model::cell::nonfinite_token(n) {
|
||||
return token.to_string();
|
||||
}
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
format!("{}", n as i64)
|
||||
|
||||
@@ -366,3 +366,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nonfinite_agreement {
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::workbook::Workbook;
|
||||
|
||||
fn key() -> CellKey {
|
||||
CellKey::new(vec![
|
||||
("R".to_string(), "a".to_string()),
|
||||
("_Measure".to_string(), "V".to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonfinite_cell_is_spelled_the_same_on_disk_and_on_the_wire() {
|
||||
for v in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
|
||||
let mut wb = Workbook::new("P");
|
||||
wb.model.add_category("R").expect("under the limit");
|
||||
wb.model
|
||||
.category_mut("R")
|
||||
.expect("just added")
|
||||
.add_item("a");
|
||||
wb.model
|
||||
.category_mut("_Measure")
|
||||
.expect("always exists")
|
||||
.add_item("V");
|
||||
wb.model.set_cell(key(), CellValue::Number(v));
|
||||
|
||||
let text = super::format_md(&wb);
|
||||
let on_disk = text
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("R=a, _Measure=V = "))
|
||||
.expect("the data line");
|
||||
let on_wire = serde_json::to_value(CellValue::Number(v))
|
||||
.expect("serializes")
|
||||
.get("Number")
|
||||
.and_then(|n| n.as_str().map(String::from))
|
||||
.expect("a non-finite value is a token on the wire");
|
||||
assert_eq!(on_disk, on_wire, "one spelling, two carriers");
|
||||
|
||||
let back = super::parse_md(&text).expect("reparses");
|
||||
match back.model.data.get(&key()) {
|
||||
Some(CellValue::Number(n)) if v.is_nan() => assert!(n.is_nan()),
|
||||
Some(CellValue::Number(n)) => assert_eq!(*n, v),
|
||||
other => panic!("{v} reparsed as {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,12 +265,13 @@ fn walk_cell_value(value_pair: Pair<'_, Rule>) -> Result<CellValue> {
|
||||
let inner = next(&mut value_pair.into_inner(), "pipe_quoted")?;
|
||||
CellValue::Text(unescape_pipe_body(inner.as_str()))
|
||||
}
|
||||
Rule::bare_value => match value_pair.as_str().trim() {
|
||||
"inf" => CellValue::Number(f64::INFINITY),
|
||||
"-inf" => CellValue::Number(f64::NEG_INFINITY),
|
||||
"nan" => CellValue::Number(f64::NAN),
|
||||
s => CellValue::Text(s.to_string()),
|
||||
},
|
||||
Rule::bare_value => {
|
||||
let s = value_pair.as_str().trim();
|
||||
match crate::model::cell::parse_nonfinite(s) {
|
||||
Some(n) => CellValue::Number(n),
|
||||
None => CellValue::Text(s.to_string()),
|
||||
}
|
||||
}
|
||||
_ => CellValue::Text(value_pair.as_str().to_string()),
|
||||
};
|
||||
Ok(value)
|
||||
|
||||
Reference in New Issue
Block a user