//! The array functions against LibreOffice: every dataset of //! `tests/data/array_reduce_goldens.tsv` (regenerated by //! `scripts/goldens/array_reduce.py`) becomes a model with category T (one //! item per row), optionally G (one item per group), the landing category K, //! and one input measure per column. Each golden formula, e.g. //! `SORTBY(X, Y OVER T, -1 INTO K)`, is a derived measure evaluated by the //! dataflow engine; its k-th result must land at K's k-th item, and K's //! items past the end of the list must have no cell. A formula without OVER //! (`TEXTSPLIT(C, "," INTO K)`) has one list per item of T. use improv_core_model::{ CategoryId, Coordinate, ItemId, Measure, MeasureId, MeasureKind, Model, Name, Value, ValueError, ValueErrorKind, ValueType, }; use improv_engine::{compiler::derived_shape, dataflow::evaluate, CellValue}; use serde_json::Value as J; const T: CategoryId = CategoryId(1); const G: CategoryId = CategoryId(2); const K: CategoryId = CategoryId(3); fn item(c: CategoryId, i: usize) -> ItemId { ItemId(100 * c.0 + i as u32) } fn error_kind(j: &J) -> Option { let code = j.get("error")?.as_str()?; Some(ValueErrorKind::from_code(code).unwrap_or_else(|| panic!("unknown error {code}"))) } fn input(j: &J) -> Value { match j { J::Bool(b) => Value::Boolean(*b), J::Number(n) => Value::Number(n.as_f64().unwrap()), J::String(s) => Value::Text(s.clone()), other => Value::Error(ValueError::new(error_kind(other).expect("an error object"))), } } /// A golden cell: a number, text, an error object, or null for no cell. fn expected(j: &J) -> Option { Some(match j { J::Null => return None, J::Number(n) => CellValue::num(n.as_f64().unwrap()), J::String(s) => CellValue::Text(s.clone()), other => CellValue::Err( error_kind(other) .and_then(|k| k.error_type()) .expect("error code"), ), }) } /// The dataset's model: categories T, (G,) K; one input measure per column. fn model(data: &J) -> Model { let mut m = Model::new(); let count = |k: &str| data.get(k).and_then(J::as_u64).map(|n| n as usize); let n_g = count("G"); for (c, name, n) in [(T, "T", count("T")), (G, "G", n_g), (K, "K", count("K"))] { let Some(n) = n else { continue }; m.add_category(c, name); for i in 0..n { m.add_item(item(c, i), c, format!("{name}{i}")); } } for (k, (name, groups)) in data["m"].as_object().unwrap().iter().enumerate() { let id = MeasureId(10 + k as u32); let mut vt = ValueType::Number; for (g, vals) in groups.as_array().unwrap().iter().enumerate() { for (i, v) in vals.as_array().unwrap().iter().enumerate() { let v = input(v); vt = match &v { Value::Text(_) => ValueType::Text, Value::Boolean(_) => ValueType::Boolean, _ => vt, }; let mut pairs = vec![(T, item(T, i))]; if n_g.is_some() { pairs.push((G, item(G, g))); } m.set_input(id, Coordinate::from_pairs(pairs), v); } } let mut cats = vec![T]; cats.extend(n_g.map(|_| G)); m.add_measure(Measure { id, name: Name(name.clone()), value_type: vt, categories: cats, kind: MeasureKind::Input, description: None, }); } m } /// Equal, or within 1e-12 relative (the fits may differ from LibreOffice's /// in the last bits). fn close(got: &CellValue, want: &CellValue) -> bool { match (got.as_num(), want.as_num()) { (Some(a), Some(b)) => a == b || (a - b).abs() <= 1e-12 * b.abs().max(1e-300), _ => got == want, } } /// A golden row: (line, formula, expected per group, source). type Case = (usize, String, J, String); #[test] fn array_functions_match_libreoffice() { let text = include_str!("data/array_reduce_goldens.tsv"); let mut datasets: Vec<(String, J, Vec)> = Vec::new(); for (n, line) in text.lines().enumerate().skip(1) { let cols: Vec<&str> = line.split('\t').collect(); if let Some(name) = cols[0].strip_prefix('@') { datasets.push(( name.into(), serde_json::from_str(cols[1]).unwrap(), Vec::new(), )); continue; } let ds = datasets.last_mut().expect("a dataset line first"); assert_eq!(cols[0], ds.0, "line {}", n + 1); let want = serde_json::from_str(cols[2]).unwrap(); ds.2.push((n + 1, cols[1].into(), want, cols[3].into())); } let (mut failures, mut checked, mut missing, mut full) = (Vec::new(), 0, 0, 0); for (name, data, cases) in &datasets { let mut m = model(data); let mut targets = Vec::new(); for (k, (line, formula, want, src)) in cases.iter().enumerate() { let id = MeasureId(1000 + k as u32); let text = format!("R{k} = {formula}"); let f = match improv_core_model::parse_formula(&m, &text) { Ok(f) => f.formula, Err(e) => { failures.push(format!("{line}: {formula}: parse error {e}")); continue; } }; let (vt, cats) = match derived_shape(&m, id, &format!("R{k}"), &f) { Ok(s) => s, Err(e) => { failures.push(format!("{line}: {formula}: compile error {e}")); continue; } }; m.add_measure(Measure { id, name: Name(format!("R{k}")), value_type: vt, categories: cats, kind: MeasureKind::Derived(f), description: None, }); targets.push((id, *line, formula, want, src)); } let ids: Vec = targets.iter().map(|t| t.0).collect(); let out = evaluate(&m, &ids).unwrap_or_else(|e| panic!("dataset {name}: {e}")); // The group a list belongs to: an item of G, or of T without OVER. let group_cat = if data.get("G").is_some() { Some(G) } else { None }; for (id, line, formula, want, src) in targets { let res = out.get(&id).cloned().unwrap_or_default(); let gc = if formula.contains(" OVER ") { group_cat } else { Some(T) }; let lists = want.as_array().unwrap(); let mut seen = 0; for (g, list) in lists.iter().enumerate() { for (i, w) in list.as_array().unwrap().iter().enumerate() { let mut key: Vec<(u32, u32)> = vec![(K.0, item(K, i).0)]; key.extend(gc.map(|c| (c.0, item(c, g).0))); key.sort_unstable(); let want = expected(w); let got = res.get(&key); seen += usize::from(got.is_some()); missing += usize::from(want.is_none()); let ok = match (got, &want) { (Some(g), Some(want)) => close(g, want), (None, None) => true, _ => false, }; checked += 1; if !ok { failures.push(format!( "{line}: {formula} at {key:?} = {got:?}, want {want:?} ({src})" )); } } } // No cells beyond K's items (a longer list is cut). if res.len() != seen { failures.push(format!( "{line}: {formula}: {} cells, want {seen}", res.len() )); } // A list that fills K (dataset A's SORTBYs have 8 values for // K's 6 items: the last two are dropped). full += lists .iter() .filter(|l| l.as_array().unwrap().iter().all(|w| !w.is_null())) .count(); } } // Both shapes are covered: lists shorter than K and lists cut to K. assert!(missing > 50 && full > 10, "missing {missing}, full {full}"); assert!(checked > 300, "only {checked} cells checked"); assert!( failures.is_empty(), "{} of {checked} cells fail:\n{}", failures.len(), failures.join("\n") ); } /// `TEXTSPLIT` without OVER: each cell of the measure is its own group, so /// the result ranges over the measure's categories and K. A K shorter than /// a list drops the rest; a longer one leaves its later items without cells. #[test] fn textsplit_without_over_splits_each_cell() { let data = serde_json::json!({"T": 2, "K": 2, "m": {"C": [["a,b,c", "z"]]}}); let mut m = model(&data); let f = improv_core_model::parse_formula(&m, "P = TEXTSPLIT(C, \",\" INTO K)") .unwrap() .formula; let (vt, cats) = derived_shape(&m, MeasureId(99), "P", &f).unwrap(); assert_eq!((vt, cats.clone()), (ValueType::Text, vec![T, K])); m.add_measure(Measure { id: MeasureId(99), name: Name("P".into()), value_type: vt, categories: cats, kind: MeasureKind::Derived(f), description: None, }); let out = &evaluate(&m, &[MeasureId(99)]).unwrap()[&MeasureId(99)]; let at = |t: usize, k: usize| { let mut key = vec![(T.0, item(T, t).0), (K.0, item(K, k).0)]; key.sort_unstable(); out.get(&key).cloned() }; let text = |s: &str| Some(CellValue::Text(s.into())); // "a,b,c" has three parts for K's two items: "c" is dropped. assert_eq!((at(0, 0), at(0, 1)), (text("a"), text("b"))); // "z" has one: K's second item has no cell. assert_eq!((at(1, 0), at(1, 1)), (text("z"), None)); assert_eq!(out.len(), 3); }