e9a43ce657
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
960 lines
31 KiB
Rust
960 lines
31 KiB
Rust
//! `Eager` against the evaluator, and the harness against wrong backends.
|
|
//! Evidence: `context/test-evidence/improvise-incremental.md`.
|
|
|
|
use improvise_core::formula::ast::AggFunc;
|
|
use improvise_core::formula::parse_formula;
|
|
use improvise_core::model::cell::{CellKey, CellValue};
|
|
use improvise_core::model::{Aggregation, Model};
|
|
use improvise_core::view::{Axis, View};
|
|
use improvise_incremental::{
|
|
CellDelta, DeltaValue, Derived, Divergence, Eager, Edit, Maintained, Probe, Reading,
|
|
cell_delta, differential, differential_in, drawn_probes, probes, same_value,
|
|
};
|
|
use proptest::prelude::*;
|
|
use std::path::Path;
|
|
|
|
fn key(region: &str, product: &str, measure: &str) -> CellKey {
|
|
CellKey::new(vec![
|
|
("Region".into(), region.into()),
|
|
("Product".into(), product.into()),
|
|
("_Measure".into(), measure.into()),
|
|
])
|
|
}
|
|
|
|
fn partial(product: &str, measure: &str) -> CellKey {
|
|
CellKey::new(vec![
|
|
("Product".into(), product.into()),
|
|
("_Measure".into(), measure.into()),
|
|
])
|
|
}
|
|
|
|
fn num(n: f64) -> Option<CellValue> {
|
|
Some(CellValue::Number(n))
|
|
}
|
|
|
|
fn text(s: &str) -> Option<CellValue> {
|
|
Some(CellValue::Text(s.into()))
|
|
}
|
|
|
|
fn mixed() -> Option<CellValue> {
|
|
Some(CellValue::Error("mixed".into()))
|
|
}
|
|
|
|
/// Region is the None category. Revenue and Cost are numbers; Note is text,
|
|
/// unanimous under A and conflicting under B.
|
|
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
|
|
}
|
|
|
|
fn hand_eager() -> Eager {
|
|
Eager::new(hand_model(), vec!["Region".into()])
|
|
}
|
|
|
|
fn hand_edits() -> Vec<Edit> {
|
|
vec![
|
|
Edit::SetCell(key("East", "A", "Revenue"), CellValue::Number(20.0)),
|
|
Edit::SetMeasureAgg {
|
|
measure: "Revenue".into(),
|
|
func: AggFunc::Max,
|
|
},
|
|
Edit::ClearCell(key("West", "A", "Revenue")),
|
|
Edit::SetCell(key("West", "B", "Revenue"), CellValue::Text("oops".into())),
|
|
Edit::AddFormula(parse_formula("Double = Revenue * 2", "_Measure").unwrap()),
|
|
Edit::RemoveFormula {
|
|
target: "Profit".into(),
|
|
target_category: "_Measure".into(),
|
|
},
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn eager_raw_fold_sums_by_default() {
|
|
assert_eq!(hand_eager().raw_fold(&partial("A", "Revenue")), num(15.0));
|
|
}
|
|
|
|
#[test]
|
|
fn eager_raw_fold_follows_the_measures_aggregation() {
|
|
let mut eager = hand_eager();
|
|
eager.apply(&Edit::SetMeasureAgg {
|
|
measure: "Revenue".into(),
|
|
func: AggFunc::Max,
|
|
});
|
|
assert_eq!(eager.raw_fold(&partial("A", "Revenue")), num(10.0));
|
|
assert_eq!(eager.raw_fold(&partial("A", "Cost")), num(6.0));
|
|
}
|
|
|
|
#[test]
|
|
fn eager_raw_fold_of_text_is_unanimous_or_mixed() {
|
|
let eager = hand_eager();
|
|
assert_eq!(eager.raw_fold(&partial("A", "Note")), text("n"));
|
|
assert_eq!(eager.raw_fold(&partial("B", "Note")), mixed());
|
|
}
|
|
|
|
#[test]
|
|
fn eager_value_reads_a_formula_exactly_and_folded() {
|
|
let eager = hand_eager();
|
|
assert_eq!(
|
|
eager.value(&key("East", "A", "Profit"), Aggregation::Exact),
|
|
num(6.0)
|
|
);
|
|
assert_eq!(
|
|
eager.value(&partial("A", "Profit"), Aggregation::OverHidden),
|
|
num(9.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn eager_follows_an_edit_to_the_cell_a_fold_reads() {
|
|
let mut eager = hand_eager();
|
|
eager.apply(&Edit::SetCell(
|
|
key("East", "A", "Revenue"),
|
|
CellValue::Number(20.0),
|
|
));
|
|
assert_eq!(eager.raw_fold(&partial("A", "Revenue")), num(25.0));
|
|
assert_eq!(
|
|
eager.value(&partial("A", "Profit"), Aggregation::OverHidden),
|
|
num(19.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn eager_numbers_among_text_are_mixed() {
|
|
let mut eager = hand_eager();
|
|
eager.apply(&Edit::SetCell(
|
|
key("West", "B", "Revenue"),
|
|
CellValue::Text("oops".into()),
|
|
));
|
|
assert_eq!(eager.raw_fold(&partial("B", "Revenue")), mixed());
|
|
}
|
|
|
|
type Doc = (String, Model, Vec<String>, Vec<View>);
|
|
|
|
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();
|
|
out.push((
|
|
"hidden".into(),
|
|
hidden.model,
|
|
vec!["_Measure".into()],
|
|
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.
|
|
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
|
|
}
|
|
|
|
#[test]
|
|
fn eager_matches_the_evaluator_on_the_shipped_documents() {
|
|
for (name, model, none, views) in corpus() {
|
|
let eager = Eager::new(model.clone(), none.clone());
|
|
let mut direct = model.clone();
|
|
direct.recompute_formulas(&none);
|
|
let mut asked = probes(eager.model());
|
|
assert!(!asked.is_empty(), "{name}: no probes");
|
|
asked.extend(drawn_probes(eager.model(), &views));
|
|
for probe in asked {
|
|
let want = match &probe {
|
|
Probe::Value(k, a) => direct.evaluate_aggregated(k, *a),
|
|
Probe::RawFold(k) if direct.formula_for(k).is_some() => None,
|
|
Probe::RawFold(k) => direct.evaluate_aggregated(k, Aggregation::OverHidden),
|
|
Probe::Covered(k, r) => direct.evaluate_covering(k, r.agg, &|c, i| r.covers(c, i)),
|
|
Probe::Total(k, over, r) => {
|
|
direct.evaluate_spanning(k, over, r.agg, &|c, i| r.covers(c, i))
|
|
}
|
|
Probe::Occupancy(p, r) => {
|
|
let drawn = |(c, i): &(String, String)| r.covers(c, i);
|
|
let stored = direct.data.iter_cells().map(|(k, _)| k);
|
|
let n = stored.filter(|k| k.matches_partial(&p.0) && k.0.iter().all(drawn));
|
|
Some(CellValue::Number(n.count() as f64))
|
|
}
|
|
};
|
|
assert_eq!(probe.ask(&eager), want, "{name}: {probe:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn eager_agrees_with_itself_over_every_corpus_edit_sequence() {
|
|
let mut files_done: Vec<String> = Vec::new();
|
|
for (name, model, none, views) in corpus() {
|
|
let file = name.split(':').next().unwrap().to_string();
|
|
if files_done.contains(&file) {
|
|
continue;
|
|
}
|
|
files_done.push(file);
|
|
let mut edits = edits_for(&model);
|
|
// Probes grow with the cells squared-ish, and a debug build pays for it.
|
|
if model.data.iter_cells().count() > 40 {
|
|
edits.truncate(4);
|
|
}
|
|
let report = differential_in(
|
|
&model,
|
|
&views,
|
|
&none,
|
|
Eager::new(model.clone(), none.clone()),
|
|
&edits,
|
|
)
|
|
.unwrap_or_else(|d| panic!("{name}: {d}"));
|
|
assert_eq!(report.steps, edits.len() + 1, "{name}");
|
|
assert!(report.probes > 0, "{name}");
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn coastal() -> CellKey {
|
|
key("Coastal", "A", "Revenue")
|
|
}
|
|
|
|
/// A wrong backend: an `Eager` told every edit, answering one thing wrongly.
|
|
#[derive(Clone, Copy)]
|
|
enum Fault {
|
|
IgnoresAggregation,
|
|
IgnoresMeasureAggregation,
|
|
GoesStaleAfterTwoEdits,
|
|
IgnoresClear,
|
|
WrongFoldedFormula,
|
|
IgnoresRemoveFormula,
|
|
WrongWhenTwoAreDropped,
|
|
RawFoldReturnsFormula,
|
|
WrongOnAGroup,
|
|
}
|
|
|
|
struct Rival {
|
|
inner: Eager,
|
|
fault: Fault,
|
|
seen: usize,
|
|
}
|
|
|
|
impl Rival {
|
|
fn over(inner: Eager, fault: Fault) -> Self {
|
|
Rival {
|
|
inner,
|
|
fault,
|
|
seen: 0,
|
|
}
|
|
}
|
|
|
|
fn of(fault: Fault) -> Self {
|
|
Self::over(hand_eager(), fault)
|
|
}
|
|
}
|
|
|
|
impl Derived for Rival {
|
|
fn value(&self, k: &CellKey, agg: Aggregation) -> Option<CellValue> {
|
|
let wrong = match self.fault {
|
|
Fault::WrongFoldedFormula => {
|
|
*k == partial("A", "Profit") && agg == Aggregation::OverHidden
|
|
}
|
|
Fault::WrongWhenTwoAreDropped => *k == only_revenue(),
|
|
Fault::WrongOnAGroup => *k == coastal() && agg == Aggregation::Exact,
|
|
_ => false,
|
|
};
|
|
match (wrong, self.fault) {
|
|
(true, _) => num(-999.0),
|
|
(_, Fault::IgnoresAggregation) => self.inner.value(k, Aggregation::Exact),
|
|
_ => self.inner.value(k, agg),
|
|
}
|
|
}
|
|
|
|
fn raw_fold(&self, p: &CellKey) -> Option<CellValue> {
|
|
match self.fault {
|
|
Fault::WrongWhenTwoAreDropped if *p == only_revenue() => num(-999.0),
|
|
Fault::RawFoldReturnsFormula => self.inner.value(p, Aggregation::OverHidden),
|
|
_ => self.inner.raw_fold(p),
|
|
}
|
|
}
|
|
|
|
fn covered(&self, k: &CellKey, r: &Reading) -> Option<CellValue> {
|
|
self.inner.covered(k, r)
|
|
}
|
|
|
|
fn total(&self, k: &CellKey, over: &[String], r: &Reading) -> Option<CellValue> {
|
|
self.inner.total(k, over, r)
|
|
}
|
|
|
|
fn occupancy(&self, p: &CellKey, r: &Reading) -> usize {
|
|
self.inner.occupancy(p, r)
|
|
}
|
|
}
|
|
|
|
impl Maintained for Rival {
|
|
fn apply(&mut self, edit: &Edit) {
|
|
self.seen += 1;
|
|
let skip = match (self.fault, edit) {
|
|
(Fault::IgnoresMeasureAggregation, Edit::SetMeasureAgg { .. }) => true,
|
|
(Fault::GoesStaleAfterTwoEdits, _) => self.seen > 2,
|
|
(Fault::IgnoresClear, Edit::ClearCell(_)) => true,
|
|
(Fault::IgnoresRemoveFormula, Edit::RemoveFormula { .. }) => true,
|
|
_ => false,
|
|
};
|
|
if !skip {
|
|
self.inner.apply(edit);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn only_revenue() -> CellKey {
|
|
CellKey::new(vec![("_Measure".into(), "Revenue".into())])
|
|
}
|
|
|
|
fn measure_of(probe: &Probe) -> Option<&str> {
|
|
match probe {
|
|
Probe::Value(k, _)
|
|
| Probe::RawFold(k)
|
|
| Probe::Covered(k, _)
|
|
| Probe::Total(k, _, _)
|
|
| Probe::Occupancy(k, _) => k.get("_Measure"),
|
|
}
|
|
}
|
|
|
|
fn run_rival(fault: Fault) -> Result<improvise_incremental::Report, Divergence> {
|
|
differential(
|
|
&hand_model(),
|
|
&["Region".to_string()],
|
|
Rival::of(fault),
|
|
&hand_edits(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_passes_a_backend_that_is_right() {
|
|
let report = differential(
|
|
&hand_model(),
|
|
&["Region".to_string()],
|
|
hand_eager(),
|
|
&hand_edits(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(report.steps, hand_edits().len() + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_backend_that_ignores_the_aggregation() {
|
|
let d = run_rival(Fault::IgnoresAggregation).unwrap_err();
|
|
assert_eq!(d.step, 0);
|
|
assert_eq!(
|
|
*d.probe,
|
|
Probe::Value(key("East", "A", "Profit"), Aggregation::OverHidden)
|
|
);
|
|
assert_eq!((d.eager, d.candidate), (None, num(6.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_backend_that_ignores_the_measure_aggregation() {
|
|
let d = run_rival(Fault::IgnoresMeasureAggregation).unwrap_err();
|
|
assert_eq!(d.step, 2, "{d}");
|
|
assert_eq!(measure_of(&d.probe), Some("Revenue"), "{d}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_backend_that_goes_stale_after_two_edits() {
|
|
let d = run_rival(Fault::GoesStaleAfterTwoEdits).unwrap_err();
|
|
assert_eq!(d.step, 3, "{d}");
|
|
assert_eq!(measure_of(&d.probe), Some("Revenue"), "{d}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_backend_that_ignores_a_clear() {
|
|
let d = run_rival(Fault::IgnoresClear).unwrap_err();
|
|
assert_eq!(d.step, 3, "{d}");
|
|
assert_eq!(measure_of(&d.probe), Some("Revenue"), "{d}");
|
|
assert!(d.to_string().starts_with("divergence at step 3:"), "{d}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_wrong_folded_formula_value() {
|
|
let d = run_rival(Fault::WrongFoldedFormula).unwrap_err();
|
|
assert_eq!(d.step, 0);
|
|
assert_eq!(
|
|
*d.probe,
|
|
Probe::Value(partial("A", "Profit"), Aggregation::OverHidden)
|
|
);
|
|
assert_eq!((d.eager, d.candidate), (num(9.0), num(-999.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_backend_that_keeps_answering_for_a_removed_formula() {
|
|
let d = run_rival(Fault::IgnoresRemoveFormula).unwrap_err();
|
|
assert_eq!(d.step, hand_edits().len(), "{d}");
|
|
assert_eq!(measure_of(&d.probe), Some("Profit"), "{d}");
|
|
assert!(d.eager.is_none() && d.candidate.is_some(), "{d}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_fault_that_only_shows_after_two_coordinates_are_dropped() {
|
|
let d = run_rival(Fault::WrongWhenTwoAreDropped).unwrap_err();
|
|
assert_eq!(d.step, 0);
|
|
assert_eq!(*d.probe, Probe::Value(only_revenue(), Aggregation::Exact));
|
|
assert_eq!(d.candidate, num(-999.0));
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_raw_fold_that_returns_a_formula() {
|
|
let d = run_rival(Fault::RawFoldReturnsFormula).unwrap_err();
|
|
assert_eq!(d.step, 0);
|
|
assert!(matches!(*d.probe, Probe::RawFold(_)), "{d}");
|
|
assert_eq!(measure_of(&d.probe), Some("Profit"), "{d}");
|
|
assert!(d.eager.is_none() && d.candidate.is_some(), "{d}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_wrong_group_fold() {
|
|
let eager = Eager::new(grouped_model(), vec![]);
|
|
let d = differential(
|
|
&grouped_model(),
|
|
&[],
|
|
Rival::over(eager, Fault::WrongOnAGroup),
|
|
&[],
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(*d.probe, Probe::Value(coastal(), Aggregation::Exact));
|
|
assert_eq!((d.eager, d.candidate), (num(15.0), num(-999.0)));
|
|
}
|
|
|
|
#[test]
|
|
fn eager_folds_a_group_it_is_asked_about() {
|
|
let eager = Eager::new(grouped_model(), vec![]);
|
|
assert_eq!(eager.value(&coastal(), Aggregation::Exact), num(15.0));
|
|
}
|
|
|
|
#[test]
|
|
fn eager_raw_fold_is_never_a_formula() {
|
|
let eager = hand_eager();
|
|
assert_eq!(
|
|
eager.value(&partial("A", "Profit"), Aggregation::OverHidden),
|
|
num(9.0)
|
|
);
|
|
assert_eq!(eager.raw_fold(&partial("A", "Profit")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn eager_add_and_remove_formula_change_what_a_key_reads() {
|
|
let mut eager = hand_eager();
|
|
let k = key("East", "A", "Double");
|
|
assert_eq!(eager.value(&k, Aggregation::Exact), None);
|
|
eager.apply(&Edit::AddFormula(
|
|
parse_formula("Double = Revenue * 2", "_Measure").unwrap(),
|
|
));
|
|
assert_eq!(eager.value(&k, Aggregation::Exact), num(20.0));
|
|
eager.apply(&Edit::RemoveFormula {
|
|
target: "Double".into(),
|
|
target_category: "_Measure".into(),
|
|
});
|
|
assert_eq!(eager.value(&k, Aggregation::Exact), None);
|
|
}
|
|
|
|
#[test]
|
|
fn eager_clear_cell_leaves_the_key_absent() {
|
|
let mut eager = hand_eager();
|
|
let k = key("East", "A", "Revenue");
|
|
eager.apply(&Edit::ClearCell(k.clone()));
|
|
assert_eq!(eager.value(&k, Aggregation::Exact), None);
|
|
assert_eq!(eager.model().get_cell(&k), None);
|
|
}
|
|
|
|
#[test]
|
|
fn same_value_follows_the_float_rules_and_is_exact_elsewhere() {
|
|
let n = |x: f64| num(x);
|
|
assert!(same_value(&n(f64::NAN), &n(f64::NAN)));
|
|
assert!(same_value(&n(-0.0), &n(0.0)));
|
|
assert!(!same_value(&n(1.0), &n(1.000_000_000_000_000_2)));
|
|
assert!(!same_value(&n(1.0), &n(f64::NAN)));
|
|
assert!(same_value(&text("a"), &text("a")));
|
|
assert!(!same_value(&text("a"), &text("b")));
|
|
let err = |s: &str| Some(CellValue::Error(s.into()));
|
|
assert!(same_value(&err("mixed"), &err("mixed")));
|
|
assert!(!same_value(&err("mixed"), &err("circular")));
|
|
assert!(!same_value(&None, &n(0.0)));
|
|
assert!(same_value(&None, &None));
|
|
}
|
|
|
|
#[test]
|
|
fn probes_cover_folds_formulas_measures_and_groups() {
|
|
let hand = probes(&hand_model());
|
|
let has = |p: Probe| hand.contains(&p);
|
|
assert!(has(Probe::RawFold(partial("A", "Revenue"))));
|
|
assert!(has(Probe::Value(
|
|
key("East", "A", "Revenue"),
|
|
Aggregation::Exact
|
|
)));
|
|
assert!(has(Probe::Value(
|
|
partial("A", "Profit"),
|
|
Aggregation::Exact
|
|
)));
|
|
assert!(has(Probe::Value(
|
|
partial("A", "Profit"),
|
|
Aggregation::OverHidden
|
|
)));
|
|
assert!(has(Probe::Value(only_revenue(), Aggregation::OverHidden)));
|
|
assert!(has(Probe::RawFold(CellKey::new(vec![]))));
|
|
let grouped = probes(&grouped_model());
|
|
assert!(grouped.contains(&Probe::Value(coastal(), Aggregation::Exact)));
|
|
}
|
|
|
|
#[test]
|
|
fn an_edit_names_its_key_as_two_value_probes_and_a_fold() {
|
|
let k = key("East", "A", "Revenue");
|
|
let asked = improvise_incremental::edit_probes(&Edit::ClearCell(k.clone()));
|
|
assert!(asked.contains(&Probe::Value(k.clone(), Aggregation::Exact)));
|
|
assert!(asked.contains(&Probe::Value(k.clone(), Aggregation::OverHidden)));
|
|
assert!(asked.contains(&Probe::RawFold(k)));
|
|
let none = improvise_incremental::edit_probes(&Edit::SetMeasureAgg {
|
|
measure: "Revenue".into(),
|
|
func: AggFunc::Max,
|
|
});
|
|
assert!(none.is_empty());
|
|
}
|
|
|
|
/// An `Eager` rebuilt from scratch on every edit: maintaining one in place
|
|
/// must give the same answers as building it afresh.
|
|
struct Fresh {
|
|
model: Model,
|
|
none: Vec<String>,
|
|
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());
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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())),
|
|
]
|
|
}
|
|
|
|
/// The store as a Z-set, every cell at weight one.
|
|
fn snapshot(model: &Model) -> CellDelta {
|
|
model
|
|
.data
|
|
.iter_cells()
|
|
.map(|(k, v)| ((k, DeltaValue::new(v)), 1))
|
|
.collect()
|
|
}
|
|
|
|
/// Values the delta must treat as the float rules do: both zeros, several NaNs.
|
|
fn delta_value() -> impl Strategy<Value = CellValue> {
|
|
prop_oneof![
|
|
(-2i32..3).prop_map(|n| CellValue::Number(f64::from(n))),
|
|
Just(CellValue::Number(-0.0)),
|
|
Just(CellValue::Number(f64::NAN)),
|
|
Just(CellValue::Number(-f64::NAN)),
|
|
Just(CellValue::Number(f64::from_bits(0x7ff0_0000_0000_0001))),
|
|
prop::sample::select(vec!["n", "x"]).prop_map(|t| CellValue::Text(t.into())),
|
|
]
|
|
}
|
|
|
|
fn cell_edit() -> impl Strategy<Value = Edit> {
|
|
prop_oneof![
|
|
(any_key(), delta_value()).prop_map(|(k, v)| Edit::SetCell(k, v)),
|
|
any_key().prop_map(Edit::ClearCell),
|
|
]
|
|
}
|
|
|
|
proptest! {
|
|
#[test]
|
|
fn the_cell_delta_of_an_edit_is_the_store_after_minus_the_store_before(
|
|
edits in prop::collection::vec(cell_edit(), 0..12)
|
|
) {
|
|
let mut model = hand_model();
|
|
model.data.record_changes();
|
|
for edit in &edits {
|
|
let before = snapshot(&model);
|
|
edit.apply(&mut model);
|
|
let delta = cell_delta(&model.data.drain_changes());
|
|
prop_assert_eq!(delta, &snapshot(&model) - &before, "after {:?}", edit);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}),
|
|
]
|
|
}
|
|
|
|
proptest! {
|
|
#[test]
|
|
fn a_maintained_eager_equals_a_fresh_one_over_random_edit_sequences(
|
|
edits in prop::collection::vec(any_edit(), 0..4)
|
|
) {
|
|
let model = hand_model();
|
|
let none = vec!["Region".to_string()];
|
|
let fresh = Fresh {
|
|
model: model.clone(),
|
|
none: none.clone(),
|
|
eager: Eager::new(model.clone(), none.clone()),
|
|
};
|
|
let run = differential(&model, &none, fresh, &edits);
|
|
prop_assert!(run.is_ok(), "{}", run.unwrap_err());
|
|
}
|
|
}
|
|
|
|
/// Six categories, four of them None in the one view: the cell it draws
|
|
/// drops four coordinates of every stored key.
|
|
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
|
|
}
|
|
|
|
fn wide_cell() -> CellKey {
|
|
CellKey::new(vec![
|
|
("C0".into(), "a".into()),
|
|
("_Measure".into(), "Revenue".into()),
|
|
])
|
|
}
|
|
|
|
struct WrongAt(Eager, CellKey);
|
|
|
|
impl Derived for WrongAt {
|
|
fn value(&self, k: &CellKey, agg: Aggregation) -> Option<CellValue> {
|
|
if *k == self.1 {
|
|
num(-999.0)
|
|
} else {
|
|
self.0.value(k, agg)
|
|
}
|
|
}
|
|
fn raw_fold(&self, p: &CellKey) -> Option<CellValue> {
|
|
self.0.raw_fold(p)
|
|
}
|
|
|
|
fn covered(&self, k: &CellKey, r: &Reading) -> Option<CellValue> {
|
|
self.0.covered(k, r)
|
|
}
|
|
|
|
fn total(&self, k: &CellKey, over: &[String], r: &Reading) -> Option<CellValue> {
|
|
self.0.total(k, over, r)
|
|
}
|
|
|
|
fn occupancy(&self, p: &CellKey, r: &Reading) -> usize {
|
|
self.0.occupancy(p, r)
|
|
}
|
|
}
|
|
|
|
impl Maintained for WrongAt {
|
|
fn apply(&mut self, edit: &Edit) {
|
|
self.0.apply(edit);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_checks_a_cell_a_view_draws_that_drops_four_coordinates() {
|
|
let wb = wide_workbook();
|
|
let none: Vec<String> = ["C1", "C2", "C3", "C4"].map(String::from).to_vec();
|
|
let rival = WrongAt(Eager::new(wb.model.clone(), none.clone()), wide_cell());
|
|
let views: Vec<_> = wb.views.values().cloned().collect();
|
|
let d = differential_in(&wb.model, &views, &none, rival, &[]).unwrap_err();
|
|
assert_eq!(d.step, 0);
|
|
assert_eq!(*d.probe, Probe::Value(wide_cell(), Aggregation::Exact));
|
|
}
|
|
|
|
/// 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.
|
|
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
|
|
}
|
|
|
|
/// A backend that reads the view wrongly in exactly one way.
|
|
#[derive(Clone, Copy)]
|
|
enum Blindness {
|
|
CoversIgnoreHidden,
|
|
TotalsIgnoreHidden,
|
|
TotalsDoNotSpan,
|
|
OccupancyCountsHidden,
|
|
}
|
|
|
|
struct Blind(Eager, Blindness);
|
|
|
|
impl Derived for Blind {
|
|
fn value(&self, k: &CellKey, agg: Aggregation) -> Option<CellValue> {
|
|
self.0.value(k, agg)
|
|
}
|
|
fn raw_fold(&self, p: &CellKey) -> Option<CellValue> {
|
|
self.0.raw_fold(p)
|
|
}
|
|
fn covered(&self, k: &CellKey, r: &Reading) -> Option<CellValue> {
|
|
match self.1 {
|
|
Blindness::CoversIgnoreHidden => self.0.covered(k, &Reading::new(r.agg, [])),
|
|
_ => self.0.covered(k, r),
|
|
}
|
|
}
|
|
fn total(&self, k: &CellKey, over: &[String], r: &Reading) -> Option<CellValue> {
|
|
match self.1 {
|
|
Blindness::TotalsIgnoreHidden => self.0.total(k, over, &Reading::new(r.agg, [])),
|
|
Blindness::TotalsDoNotSpan => self.0.covered(k, r),
|
|
_ => self.0.total(k, over, r),
|
|
}
|
|
}
|
|
fn occupancy(&self, p: &CellKey, r: &Reading) -> usize {
|
|
match self.1 {
|
|
Blindness::OccupancyCountsHidden => self.0.occupancy(p, &Reading::new(r.agg, [])),
|
|
_ => self.0.occupancy(p, r),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Maintained for Blind {
|
|
fn apply(&mut self, edit: &Edit) {
|
|
self.0.apply(edit);
|
|
}
|
|
}
|
|
|
|
fn run_blind(fault: Option<Blindness>) -> Result<improvise_incremental::Report, Divergence> {
|
|
let wb = hidden_workbook();
|
|
let none = vec!["_Measure".to_string()];
|
|
let eager = Eager::new(wb.model.clone(), none.clone());
|
|
let views: Vec<_> = wb.views.values().cloned().collect();
|
|
let edit = Edit::SetCell(key("West", "A", "Revenue"), CellValue::Number(6.0));
|
|
match fault {
|
|
Some(f) => differential_in(&wb.model, &views, &none, Blind(eager, f), &[edit]),
|
|
None => differential_in(&wb.model, &views, &none, eager, &[edit]),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_passes_a_view_reader_that_is_right() {
|
|
run_blind(None).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_fold_that_covers_a_hidden_item() {
|
|
let d = run_blind(Some(Blindness::CoversIgnoreHidden)).unwrap_err();
|
|
assert!(matches!(*d.probe, Probe::Covered(..)), "{d}");
|
|
assert_eq!((d.eager, d.candidate), (num(5.0), num(15.0)), "Coastal/A");
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_total_that_counts_a_hidden_item() {
|
|
let d = run_blind(Some(Blindness::TotalsIgnoreHidden)).unwrap_err();
|
|
assert!(matches!(*d.probe, Probe::Total(..)), "{d}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_a_total_that_does_not_span_its_rows() {
|
|
let d = run_blind(Some(Blindness::TotalsDoNotSpan)).unwrap_err();
|
|
assert!(matches!(*d.probe, Probe::Total(..)), "{d}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_harness_catches_an_occupancy_that_counts_a_hidden_item() {
|
|
let d = run_blind(Some(Blindness::OccupancyCountsHidden)).unwrap_err();
|
|
assert!(matches!(*d.probe, Probe::Occupancy(..)), "{d}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_view_is_drawn_as_cells_totals_and_occupancy() {
|
|
let wb = hidden_workbook();
|
|
let views: Vec<_> = wb.views.values().cloned().collect();
|
|
let drawn = drawn_probes(&wb.model, &views);
|
|
let kinds = [
|
|
drawn.iter().any(|p| matches!(p, Probe::Covered(..))),
|
|
drawn.iter().any(|p| matches!(p, Probe::Total(..))),
|
|
drawn.iter().any(|p| matches!(p, Probe::Occupancy(..))),
|
|
];
|
|
assert_eq!(kinds, [true; 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn probing_a_key_of_twenty_six_coordinates_is_not_exponential() {
|
|
let coords = (0..26)
|
|
.map(|i| (format!("C{i:02}"), "a".to_string()))
|
|
.collect();
|
|
let mut m = Model::new("deep");
|
|
m.set_cell(CellKey::new(coords), CellValue::Number(1.0));
|
|
let started = std::time::Instant::now();
|
|
let n = probes(&m).len();
|
|
assert!(n > 0);
|
|
assert!(
|
|
started.elapsed().as_millis() < 250,
|
|
"{:?}",
|
|
started.elapsed()
|
|
);
|
|
}
|