//! Fixtures the integration tests share: the shipped corpus and the hand-built models. #![allow(dead_code)] use improvise_core::formula::ast::AggFunc; use improvise_core::formula::parse_formula; use improvise_core::model::Aggregation; use improvise_core::model::Model; use improvise_core::model::cell::{CellKey, CellValue}; use improvise_core::view::{Axis, View}; use improvise_incremental::{Derived, Eager, Edit, Evaluation, Incremental, Maintained, Reading}; use proptest::prelude::*; use std::path::Path; pub type Doc = (String, Model, Vec, Vec); pub fn key(region: &str, product: &str, measure: &str) -> CellKey { CellKey::new(vec![ ("Region".into(), region.into()), ("Product".into(), product.into()), ("_Measure".into(), measure.into()), ]) } /// Region is the None category. Revenue and Cost are numbers; Note is text, /// unanimous under A and conflicting under B. pub fn hand_model() -> Model { let mut m = Model::new("hand"); for (cat, items) in [("Region", ["East", "West"]), ("Product", ["A", "B"])] { m.add_category(cat).unwrap(); for item in items { m.category_mut(cat).unwrap().add_item(item); } } for measure in ["Revenue", "Cost", "Note"] { m.category_mut("_Measure").unwrap().add_item(measure); } let rows = [ ("East", "A", 10.0, 4.0, "n", "n"), ("West", "A", 5.0, 2.0, "n", "n"), ("East", "B", 7.0, 3.0, "x", "x"), ("West", "B", 1.0, 1.0, "y", "y"), ]; for (r, p, rev, cost, note, _) in rows { m.set_cell(key(r, p, "Revenue"), CellValue::Number(rev)); m.set_cell(key(r, p, "Cost"), CellValue::Number(cost)); m.set_cell(key(r, p, "Note"), CellValue::Text(note.into())); } m.add_formula(parse_formula("Profit = Revenue - Cost", "_Measure").unwrap()); m } pub fn corpus() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let mut out = Vec::new(); for file in ["examples/demo.improv", "web/demo.improv"] { let wb = improvise_io::persistence::load(&root.join(file)).unwrap(); let mut seen: Vec> = Vec::new(); for (name, view) in &wb.views { let none: Vec = view .categories_on(Axis::None) .into_iter() .map(String::from) .collect(); if !seen.contains(&none) { seen.push(none.clone()); let views = wb.views.values().cloned().collect(); out.push((format!("{file}:{name}"), wb.model.clone(), none, views)); } } } out.push(("hand".into(), hand_model(), vec!["Region".into()], vec![])); let hidden = hidden_workbook(); let views = hidden.views.values().cloned().collect(); let none = none_of(&hidden); out.push(("hidden".into(), hidden.model, none, views)); out } /// Edits built from the model's own first cell: overwrite, text, clear, a new /// record, a new aggregation, and the first formula removed then restored. pub fn edits_for(model: &Model) -> Vec { let Some((first, _)) = model.data.iter_cells().next() else { return vec![]; }; let (last_cat, _) = first.0.last().unwrap().clone(); let measure = first.get("_Measure").map(String::from); let fresh = first.clone().with(last_cat, "Zz-new"); let mut edits = vec![ Edit::SetCell(first.clone(), CellValue::Number(7.5)), Edit::SetCell(first.clone(), CellValue::Text("t".into())), Edit::SetCell(fresh.clone(), CellValue::Number(3.0)), Edit::ClearCell(first), ]; edits.extend(measure.map(|m| Edit::SetMeasureAgg { measure: m, func: AggFunc::Max, })); if let Some(f) = model.formulas().first() { edits.push(Edit::RemoveFormula { target: f.target.clone(), target_category: f.target_category.clone(), }); edits.push(Edit::AddFormula(f.clone())); } edits.push(Edit::ClearCell(fresh)); edits } pub fn grouped_model() -> Model { let mut m = Model::new("grouped"); m.add_category("Region").unwrap(); m.add_category("Product").unwrap(); m.category_mut("Region") .unwrap() .add_group(improvise_core::model::category::Group::new("Coastal")); let region = m.category_mut("Region").unwrap(); region.add_item_in_group("East", "Coastal"); region.add_item_in_group("West", "Coastal"); region.add_item("Inland"); m.category_mut("Product").unwrap().add_item("A"); m.category_mut("_Measure").unwrap().add_item("Revenue"); for (r, v) in [("East", 10.0), ("West", 5.0), ("Inland", 2.0)] { m.set_cell(key(r, "A", "Revenue"), CellValue::Number(v)); } m } pub fn coastal() -> CellKey { key("Coastal", "A", "Revenue") } /// Six categories, four of them None in the one view: the cell it draws /// drops four coordinates of every stored key. pub fn wide_workbook() -> improvise_core::workbook::Workbook { let mut wb = improvise_core::workbook::Workbook::new("wide"); for c in ["C0", "C1", "C2", "C3", "C4"] { wb.add_category(c).unwrap(); wb.add_item(c, "a").unwrap(); } wb.add_item("C0", "b").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); for (c0, v) in [("a", 1.0), ("b", 2.0)] { let k = CellKey::new(vec![ ("C0".into(), c0.into()), ("C1".into(), "a".into()), ("C2".into(), "a".into()), ("C3".into(), "a".into()), ("C4".into(), "a".into()), ("_Measure".into(), "Revenue".into()), ]); wb.model.set_cell(k, CellValue::Number(v)); } let view = wb.active_view_mut(); view.set_axis("C0", Axis::Row); view.set_axis("_Measure", Axis::Column); for c in ["C1", "C2", "C3", "C4"] { view.set_axis(c, Axis::None); } wb } /// The None categories of the active view, virtual ones included: the set an /// `Eager` standing for that view is built with. pub fn none_of(wb: &improvise_core::workbook::Workbook) -> Vec { let none = wb.active_view().categories_on(Axis::None); none.into_iter().map(String::from).collect() } /// East is hidden and Coastal is collapsed (a group is, by default), so the Coastal row folds West only; /// Inland has a cell under A only, so B's column holds just Coastal's. pub fn hidden_workbook() -> improvise_core::workbook::Workbook { let mut wb = improvise_core::workbook::Workbook::new("hidden"); for c in ["Region", "Product"] { wb.add_category(c).unwrap(); } wb.add_item_in_group("Region", "East", "Coastal").unwrap(); wb.add_item_in_group("Region", "West", "Coastal").unwrap(); wb.add_item("Region", "Inland").unwrap(); wb.add_item("Product", "A").unwrap(); wb.add_item("Product", "B").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); let cells = [ ("East", "A", 10.0), ("West", "A", 5.0), ("East", "B", 20.0), ("West", "B", 1.0), ("Inland", "A", 2.0), ]; for (r, p, v) in cells { wb.model .set_cell(key(r, p, "Revenue"), CellValue::Number(v)); } let view = wb.active_view_mut(); view.set_axis("Region", Axis::Row); view.set_axis("Product", Axis::Column); view.set_axis("_Measure", Axis::None); view.hide_item("Region", "East"); wb } /// Nothing is hidden and Margin is a ratio: the pooled total of Margin is /// Revenue over Cost summed across the rows, which no single row's fold gives. pub fn ratio_workbook() -> improvise_core::workbook::Workbook { let mut wb = improvise_core::workbook::Workbook::new("ratio"); for c in ["Region", "Product"] { wb.add_category(c).unwrap(); } wb.add_item("Region", "East").unwrap(); wb.add_item("Region", "West").unwrap(); wb.add_item("Product", "A").unwrap(); for m in ["Revenue", "Cost"] { wb.add_item("_Measure", m).unwrap(); } for (r, rev, cost) in [("East", 10.0, 4.0), ("West", 5.0, 2.0)] { wb.model .set_cell(key(r, "A", "Revenue"), CellValue::Number(rev)); wb.model .set_cell(key(r, "A", "Cost"), CellValue::Number(cost)); } wb.model .add_formula(parse_formula("Margin = Revenue / Cost", "_Measure").unwrap()); let view = wb.active_view_mut(); view.set_axis("Region", Axis::Row); view.set_axis("_Measure", Axis::Column); view.set_axis("Product", Axis::None); wb } /// [`hidden_workbook`] drawn with the measure on the columns and Product folded: its cells /// carry a measure, so the measure's aggregation reaches every row, total and collapsed group. pub fn hidden_counted_workbook() -> improvise_core::workbook::Workbook { let mut wb = hidden_workbook(); let view = wb.active_view_mut(); view.set_axis("Product", Axis::None); view.set_axis("_Measure", Axis::Column); wb } /// Formulas the random tests add and remove, as `(text, target category)`: chains, a cycle, /// aggregates over expressions, filters, a ratio, and two that target Region. pub const FORMULA_POOL: [(&str, &str); 21] = [ ("Profit = Revenue - Cost", "_Measure"), ("Margin = Profit / Revenue", "_Measure"), ("Weighted = SUM(Revenue * Units)", "_Measure"), ("Share = Revenue / SUM(Revenue)", "_Measure"), ("Bonus = Revenue * 0.1 WHERE Region = East", "_Measure"), ("Flag = IF(Revenue > Cost, 1, 0)", "_Measure"), ("Loop = Loop + 1", "_Measure"), ("Ping = Pong + 1", "_Measure"), ("Pong = Ping + 1", "_Measure"), ("Peak = MAX(Revenue WHERE Product = A)", "_Measure"), ("Mix = Revenue * (Region IS East) + Cost", "_Measure"), ("Cnt = COUNT(Revenue)", "_Measure"), ("Inverse = 1 / Units", "_Measure"), ("Both = Profit + Cnt", "_Measure"), ("Total = East + West", "Region"), ("EastRev = Revenue", "Region"), ("Recip = Units ^ -1", "_Measure"), ("Pa = A + Revenue", "_Measure"), ("NegZero = -0", "_Measure"), ("Flip = Units * -1", "_Measure"), ("Negate = -Cost", "_Measure"), ]; pub fn formula_edit(index: usize) -> Edit { let (raw, category) = FORMULA_POOL[index]; Edit::AddFormula(parse_formula(raw, category).unwrap()) } pub fn unformula_edit(index: usize) -> Edit { let (raw, category) = FORMULA_POOL[index]; Edit::RemoveFormula { target: parse_formula(raw, category).unwrap().target, target_category: category.into(), } } /// Two regions by one product by three measures, with `Profit` and `Margin`, drawn with /// Region on the rows and the measure on the columns. pub fn formula_workbook() -> improvise_core::workbook::Workbook { let mut wb = improvise_core::workbook::Workbook::new("formulas"); for c in ["Region", "Product"] { wb.add_category(c).unwrap(); } for r in ["East", "West"] { wb.add_item("Region", r).unwrap(); } wb.add_item("Product", "A").unwrap(); for m in ["Revenue", "Cost", "Units"] { wb.add_item("_Measure", m).unwrap(); } let mut n = 1.0; for r in ["East", "West"] { for (m, scale) in [("Revenue", 10.0), ("Cost", 4.0), ("Units", 2.0)] { wb.model .set_cell(key(r, "A", m), CellValue::Number(scale * n)); n += 1.0; } } let profit = parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(); wb.model.add_formula(profit); let margin = parse_formula("Margin = Profit / Revenue", "_Measure").unwrap(); wb.model.add_formula(margin); let view = wb.active_view_mut(); view.set_axis("Region", Axis::Row); view.set_axis("_Measure", Axis::Column); view.set_axis("Product", Axis::None); wb } pub fn bump(region: &str, product: &str, measure: &str, to: f64) -> Edit { Edit::SetCell(key(region, product, measure), CellValue::Number(to)) } /// Both backends told every edit; their models, formula cache included, must print alike. pub fn same_model( model: &Model, none: &[String], edits: &[Edit], by: Evaluation, ) -> Result<(), String> { let mut eager = Eager::new(model.clone(), none.to_vec()); let mut incremental = Incremental::new(model.clone(), none.to_vec()).with_evaluation(by); for edit in edits { eager.apply(edit); incremental.apply(edit); } let (want, got) = ( format!("{:?}", eager.model()), format!("{:?}", incremental.model()), ); match want == got { true => Ok(()), false => Err(format!( "after {edits:?}:\n eager {want}\n incremental {got}" )), } } fn any_formula_value() -> impl Strategy { prop_oneof![ // No +0.0 beside -0.0: eager's Min and Max of both zeros is platform-defined. prop_oneof![-2i32..0, 1i32..4].prop_map(|n| CellValue::Number(f64::from(n))), Just(CellValue::Number(-0.0)), Just(CellValue::Number(f64::NAN)), prop::sample::select(vec![0.1, 1e16, -1e16]).prop_map(CellValue::Number), Just(CellValue::Text("t".into())), ] } /// An edit to the formula fixture: a cell written or cleared (a region and a product the /// fixture lacks included), a formula from the pool added or removed, or a new aggregation. pub fn any_formula_edit() -> impl Strategy { let regions = prop::sample::select(vec!["East", "West", "North"]); let products = prop::sample::select(vec!["A", "B", "C"]); let measures = prop::sample::select(vec!["Revenue", "Cost", "Units", "Loop", "Pong", "Pa"]); let at = (regions, products, measures); let funcs = vec![ AggFunc::Sum, AggFunc::Avg, AggFunc::Min, AggFunc::Max, AggFunc::Count, ]; let aggregated = ( prop::sample::select(vec!["Revenue", "Cost", "Units"]), prop::sample::select(funcs), ); prop_oneof![ 3 => (at.clone(), any_formula_value()).prop_map(|((r, p, m), v)| Edit::SetCell(key(r, p, m), v)), 2 => at.prop_map(|(r, p, m)| Edit::ClearCell(key(r, p, m))), 2 => (0..FORMULA_POOL.len()).prop_map(formula_edit), 1 => (0..FORMULA_POOL.len()).prop_map(unformula_edit), 1 => aggregated.prop_map(|(m, func)| Edit::SetMeasureAgg { measure: m.into(), func }), ] } /// The same edits told to a backend that evaluates in dependency order and to one that runs /// eager's passes: models, cache and zero signs must print alike after every edit. pub fn same_orders(model: &Model, none: &[String], edits: &[Edit]) -> Result<(), String> { let side = |by| Incremental::new(model.clone(), none.to_vec()).with_evaluation(by); let (mut ordered, mut plain) = (side(Evaluation::Ordered), side(Evaluation::Plain)); for (n, edit) in edits.iter().enumerate() { ordered.apply(edit); plain.apply(edit); let (want, got) = ( format!("{:?}", plain.model()), format!("{:?}", ordered.model()), ); if want != got { let told = &edits[..=n]; return Err(format!("after {told:?}:\n plain {want}\n ordered {got}")); } } Ok(()) } /// An `Eager` rebuilt from scratch on every edit: maintaining one in place /// must give the same answers as building it afresh. pub struct Fresh { pub model: Model, pub none: Vec, pub eager: Eager, } impl Derived for Fresh { fn value(&self, k: &CellKey, agg: Aggregation) -> Option { self.eager.value(k, agg) } fn raw_fold(&self, p: &CellKey) -> Option { self.eager.raw_fold(p) } fn covered(&self, k: &CellKey, r: &Reading) -> Option { self.eager.covered(k, r) } fn total(&self, k: &CellKey, over: &[String], r: &Reading) -> Option { self.eager.total(k, over, r) } fn occupancy(&self, p: &CellKey, r: &Reading) -> usize { self.eager.occupancy(p, r) } } impl Maintained for Fresh { fn apply(&mut self, edit: &Edit) { edit.apply(&mut self.model); self.eager = Eager::new(self.model.clone(), self.none.clone()); } } pub fn any_key() -> impl Strategy { ( prop::sample::select(vec!["East", "West"]), prop::sample::select(vec!["A", "B"]), prop::sample::select(vec!["Revenue", "Cost", "Note"]), ) .prop_map(|(r, p, m)| key(r, p, m)) } pub fn any_value() -> impl Strategy { prop_oneof![ (-4i32..5).prop_map(|n| CellValue::Number(f64::from(n))), Just(CellValue::Number(-0.0)), prop::sample::select(vec!["n", "x"]).prop_map(|t| CellValue::Text(t.into())), ] } pub fn any_edit() -> impl Strategy { prop_oneof![ (any_key(), any_value()).prop_map(|(k, v)| Edit::SetCell(k, v)), any_key().prop_map(Edit::ClearCell), prop::sample::select(vec!["Profit = Revenue - Cost", "Double = Revenue * 2"]) .prop_map(|raw| Edit::AddFormula(parse_formula(raw, "_Measure").unwrap())), prop::sample::select(vec!["Profit", "Double"]).prop_map(|t| Edit::RemoveFormula { target: t.into(), target_category: "_Measure".into(), }), ( prop::sample::select(vec!["Revenue", "Cost"]), prop::sample::select(vec![AggFunc::Sum, AggFunc::Max, AggFunc::Min, AggFunc::Avg]), ) .prop_map(|(m, func)| Edit::SetMeasureAgg { measure: m.into(), func, }), ] }