//! Golden values for the built-in functions: each `tests/data/*_goldens.tsv` //! lists a formula over literals, the expected value (from LibreOffice //! 25.8 headless, regenerated by `scripts/goldens/.py`, or from the //! Excel/Gnumeric/Numbers/Quantrix documentation where LibreOffice lacks the //! function), and its source. Each formula runs through BOTH the scalar //! evaluator and the dataflow engine. use improv_core_model::{Measure, MeasureId, MeasureKind, Model, Name, ValueType}; use improv_engine::{dataflow::evaluate, scalar, CellValue}; use std::collections::HashMap; /// Every golden file's `(name, contents)`. fn golden_files() -> Vec<(String, String)> { let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data"); let mut out: Vec<_> = std::fs::read_dir(dir) .expect("tests/data") .map(|e| e.expect("dir entry").path()) .filter(|p| p.to_string_lossy().ends_with("_goldens.tsv")) // Reductions read datasets, not literals (tests/reductions_*.rs). .filter(|p| !p.to_string_lossy().ends_with("_reduce_goldens.tsv")) .map(|p| { let name = p.file_name().unwrap().to_string_lossy().into_owned(); (name, std::fs::read_to_string(&p).expect("golden file")) }) .collect(); out.sort(); assert!(out.iter().any(|(n, _)| n == "p1_goldens.tsv")); out } /// Every golden row as `(file:line, formula, expected JSON, source)`. fn golden_rows() -> Vec<(String, String, String, String)> { let mut rows = Vec::new(); for (file, text) in golden_files() { for (n, line) in text.lines().enumerate().skip(1) { let mut cols = line.splitn(3, '\t'); if let (Some(f), Some(w), Some(s)) = (cols.next(), cols.next(), cols.next()) { rows.push((format!("{file}:{}", n + 1), f.into(), w.into(), s.into())); } } } rows } /// The expected column: a JSON number, string, bool, `{"error": "#NUM!"}`, /// `{"date": "2025-01-31T00:00:00Z"}`, or `{"approx": x, "tol": t}` for an /// iterative solver's result (RATE, YIELD) within `t` relative. fn expected(j: &serde_json::Value) -> CellValue { match j { serde_json::Value::Object(o) if o.contains_key("approx") => { CellValue::num(o["approx"].as_f64().unwrap()) } serde_json::Value::Number(n) => CellValue::num(n.as_f64().unwrap()), serde_json::Value::String(s) => CellValue::Text(s.clone()), serde_json::Value::Bool(b) => CellValue::Bool(*b), serde_json::Value::Object(o) if o.contains_key("date") => { let s = o["date"].as_str().unwrap(); let t = chrono::DateTime::parse_from_rfc3339(s) .unwrap_or_else(|e| panic!("bad date {s}: {e}")); CellValue::Date(t.timestamp_millis()) } serde_json::Value::Object(o) => { let code = o["error"].as_str().unwrap(); let kind = improv_core_model::ValueErrorKind::from_code(code) .unwrap_or_else(|| panic!("unknown error {code}")); CellValue::Err(kind.error_type().unwrap()) } other => panic!("bad expected value {other}"), } } /// How far a number may be from the golden: 1e-12 relative (transcendental /// functions may differ from LibreOffice's in the last bits), or an /// `approx` row's `tol` relative to `max(|x|, 1)` (a solver's root near 0 is /// only known to the solver's absolute tolerance). fn tolerance(j: &serde_json::Value, want: &CellValue) -> f64 { match ( j.get("tol").and_then(serde_json::Value::as_f64), want.as_num(), ) { (Some(t), Some(x)) => t * x.abs().max(1.0), (_, Some(x)) => 1e-12 * x.abs().max(1e-300), _ => 0.0, } } /// Equal, or numbers within `tol`. fn close(got: &CellValue, want: &CellValue, tol: f64) -> bool { match (got.as_num(), want.as_num()) { (Some(a), Some(b)) => a == b || (a - b).abs() <= tol, _ => got == want, } } #[test] fn builtins_match_the_goldens() { let model = Model::new(); let mut failures = Vec::new(); let mut checked = 0; for (at, formula, want, source) in golden_rows() { let json: serde_json::Value = serde_json::from_str(&want).unwrap_or_else(|e| panic!("{at}: bad JSON {want:?}: {e}")); let want = expected(&json); let tol = tolerance(&json, &want); let expr = match improv_core_model::parse_expr(&model, &formula) { Ok(f) => f.expr, Err(e) => { failures.push(format!("{at}: {formula}: parse error {e}")); continue; } }; let got = scalar::eval(&expr, &HashMap::new()); match got { Ok(Some(v)) if close(&v, &want, tol) => {} other => { failures.push(format!( "{at}: {formula} = {other:?}, want {want} ({source})" )); continue; } } checked += 1; } assert!(checked > 500, "only {checked} goldens checked"); assert!( failures.is_empty(), "{} of {} goldens fail:\n{}", failures.len(), checked + failures.len(), failures.join("\n") ); } /// The dataflow gives the same values as the scalar evaluator for every /// golden (one dimensionless model, a measure per formula). #[test] fn dataflow_matches_scalar_on_the_goldens() { let mut model = Model::new(); let mut want = HashMap::new(); for (n, (_, formula, _, _)) in golden_rows().iter().enumerate() { let Ok(f) = improv_core_model::parse_expr(&model, formula) else { continue; }; let Ok(Some(v)) = scalar::eval(&f.expr, &HashMap::new()) else { continue; }; let Ok(typed) = improv_engine::compiler::infer( &improv_engine::compiler::CompileContext::new(&model.measures), MeasureId(0), &f.expr, ) else { continue; }; let id = MeasureId(1000 + n as u32); model.add_measure(Measure { id, name: Name(format!("G{n}")), value_type: typed.ty.value_type, categories: vec![], kind: MeasureKind::Derived(f), description: None, }); want.insert(id, v); } let ids: Vec = want.keys().copied().collect(); let out = evaluate(&model, &ids).expect("evaluate"); for (id, v) in &want { let got = out.get(id).and_then(|m| m.get(&Vec::new())); assert_eq!(got, Some(v), "{}", model.measures[id].name.0); } assert!(want.len() > 500); let _ = ValueType::Number; }