//! The financial reductions end to end, against //! `tests/data/financial_reduce.tsv` (LibreOffice 25.8 over a column //! of flows, or GNU Oleo's FMRR; regenerated by //! `scripts/goldens/financial_reduce.py`). //! //! Each case is one model: a Time category with an item per period, a Group //! category when the case has several groups, and the input measures CF //! (flows), Day (dates as DateTime) and Serial (the same dates as numbers). //! The case's formula defines `R`, evaluated by the dataflow engine. use improv_core_model::{ CategoryId, Coordinate, ItemId, Measure, MeasureId, MeasureKind, Model, Name, Value, ValueError, ValueErrorKind, ValueType, }; use improv_engine::{dataflow::evaluate, CellValue}; use std::collections::BTreeMap; const TIME: CategoryId = CategoryId(1); const GROUP: CategoryId = CategoryId(2); const CF: MeasureId = MeasureId(100); const DAY: MeasureId = MeasureId(101); const SERIAL: MeasureId = MeasureId(102); const R: MeasureId = MeasureId(200); /// 1899-12-30, spreadsheet serial date 0. const SERIAL_EPOCH_MS: i64 = -2_209_161_600_000; /// A group: flows (`None` = no cell), dates, expected JSON, source. type Group = (Vec>, Vec, serde_json::Value, String); fn value(j: &serde_json::Value) -> Option { match j { serde_json::Value::Null => None, serde_json::Value::Number(n) => Some(Value::Number(n.as_f64().unwrap())), serde_json::Value::Object(o) => { let kind = ValueErrorKind::from_code(o["error"].as_str().unwrap()).unwrap(); Some(Value::Error(ValueError::new(kind))) } other => panic!("bad flow {other}"), } } fn measure(m: &mut Model, id: MeasureId, name: &str, vt: ValueType, cats: &[CategoryId]) { m.add_measure(Measure { id, name: Name(name.into()), value_type: vt, categories: cats.to_vec(), kind: MeasureKind::Input, description: None, }); } /// The model of one case, evaluated: `R` per group. fn run(formula: &str, groups: &[Group]) -> Vec> { let mut m = Model::new(); m.add_category(TIME, "Time"); let periods = groups.iter().map(|g| g.0.len()).max().unwrap(); for t in 0..periods as u32 { m.add_item(ItemId(10 + t), TIME, format!("p{t}")); } let several = groups.len() > 1; let cats: &[CategoryId] = if several { &[TIME, GROUP] } else { &[TIME] }; if several { m.add_category(GROUP, "Group"); for g in 0..groups.len() as u32 { m.add_item(ItemId(1000 + g), GROUP, format!("g{g}")); } } measure(&mut m, CF, "CF", ValueType::Number, cats); measure(&mut m, DAY, "Day", ValueType::DateTime, cats); measure(&mut m, SERIAL, "Serial", ValueType::Number, cats); for (g, (flows, dates, _, _)) in groups.iter().enumerate() { let at = |t: usize| { let mut c = vec![(TIME, ItemId(10 + t as u32))]; if several { c.push((GROUP, ItemId(1000 + g as u32))); } Coordinate::from_pairs(c) }; for (t, v) in flows.iter().enumerate() { if let Some(v) = v { m.set_input(CF, at(t), v.clone()); } } for (t, d) in dates.iter().enumerate() { let ms = SERIAL_EPOCH_MS + (d * 86_400_000.0).round() as i64; let dt = chrono::DateTime::from_timestamp_millis(ms).unwrap(); m.set_input(DAY, at(t), Value::DateTime(dt)); m.set_input(SERIAL, at(t), Value::Number(*d)); } } let f = improv_core_model::parse_formula(&m, &format!("R = {formula}")) .unwrap_or_else(|e| panic!("{formula}: {e}")); m.add_measure(Measure { id: R, name: f.target, value_type: ValueType::Number, categories: if several { vec![GROUP] } else { vec![] }, kind: MeasureKind::Derived(f.formula), description: None, }); let out = evaluate(&m, &[R]).unwrap_or_else(|e| panic!("{formula}: {e}")); let r = &out[&R]; (0..groups.len() as u32) .map(|g| { let key = if several { vec![(2, 1000 + g)] } else { vec![] }; r.get(&key).cloned() }) .collect() } /// The expected value and the tolerance (relative to `max(|x|, 1)` for an /// `approx` row, else 1e-12 relative). fn expected(j: &serde_json::Value) -> (CellValue, f64) { match j { serde_json::Value::Number(n) => { let x = n.as_f64().unwrap(); (CellValue::num(x), 1e-12 * x.abs()) } serde_json::Value::Object(o) if o.contains_key("approx") => { let x = o["approx"].as_f64().unwrap(); ( CellValue::num(x), o["tol"].as_f64().unwrap() * x.abs().max(1.0), ) } serde_json::Value::Object(o) => { let kind = ValueErrorKind::from_code(o["error"].as_str().unwrap()).unwrap(); (CellValue::Err(kind.error_type().unwrap()), 0.0) } other => panic!("bad expected value {other}"), } } #[test] fn financial_reductions_match_the_goldens() { let text = include_str!("data/financial_reduce.tsv"); // case -> (formula, groups in order) let mut cases: BTreeMap)> = BTreeMap::new(); for line in text.lines().skip(1) { let c: Vec<&str> = line.split('\t').collect(); let [case, _group, formula, flows, dates, want, source] = c[..] else { panic!("bad row {line:?}"); }; let flows: Vec = serde_json::from_str(flows).unwrap(); let dates: Vec = serde_json::from_str(dates).unwrap(); let entry = cases .entry(case.parse().unwrap()) .or_insert_with(|| (formula.to_string(), Vec::new())); entry.1.push(( flows.iter().map(value).collect(), dates, serde_json::from_str(want).unwrap(), source.to_string(), )); } let mut failures = Vec::new(); let mut checked = 0; for (case, (formula, groups)) in &cases { for (g, got) in run(formula, groups).into_iter().enumerate() { let (want, tol) = expected(&groups[g].2); let ok = match (got.as_ref().and_then(CellValue::as_num), want.as_num()) { (Some(a), Some(b)) => (a - b).abs() <= tol, _ => got.as_ref() == Some(&want), }; if !ok { failures.push(format!( "case {case} group {g}: {formula} = {got:?}, want {want:?} ({})", groups[g].3 )); } checked += 1; } } assert!(checked >= 60, "only {checked} goldens checked"); assert!( failures.is_empty(), "{} of {checked} fail:\n{}", failures.len(), failures.join("\n") ); }