10f63cdef9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
472 lines
17 KiB
Rust
472 lines
17 KiB
Rust
//! 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<String>, Vec<View>);
|
|
|
|
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<Doc> {
|
|
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<String>> = Vec::new();
|
|
for (name, view) in &wb.views {
|
|
let none: Vec<String> = 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<Edit> {
|
|
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<String> {
|
|
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<Value = CellValue> {
|
|
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<Value = Edit> {
|
|
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<String>,
|
|
pub eager: Eager,
|
|
}
|
|
|
|
impl Derived for Fresh {
|
|
fn value(&self, k: &CellKey, agg: Aggregation) -> Option<CellValue> {
|
|
self.eager.value(k, agg)
|
|
}
|
|
|
|
fn raw_fold(&self, p: &CellKey) -> Option<CellValue> {
|
|
self.eager.raw_fold(p)
|
|
}
|
|
|
|
fn covered(&self, k: &CellKey, r: &Reading) -> Option<CellValue> {
|
|
self.eager.covered(k, r)
|
|
}
|
|
|
|
fn total(&self, k: &CellKey, over: &[String], r: &Reading) -> Option<CellValue> {
|
|
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<Value = CellKey> {
|
|
(
|
|
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<Value = CellValue> {
|
|
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<Value = Edit> {
|
|
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,
|
|
}),
|
|
]
|
|
}
|