diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index d29956cbc2..5c8098dfbb 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -531,6 +531,10 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option< let num = |s: &String| make_number(s).unwrap_or(0.0); match c { Calculate::None => None, + // A product of no values is 0, as an empty sum, average, minimum or maximum is, not the + // empty product 1: a blank quantity × price row reads 0, and a total over the rows + // doesn't count it as 1. (The form's `values` leaves out a field that has no value.) + Calculate::Simple { op: CalcOp::Product, fields } if fields.iter().all(|f| values(f).is_empty()) => Some("0".into()), Calculate::Simple { op, fields } => { let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); let v = match op { diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 79c5c7be02..5e8c4f085f 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -360,6 +360,35 @@ fn dates_times_and_masks() { assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); } +#[test] +fn a_calculation_over_fields_without_values_is_0_for_every_operation() { + // A blank row: neither field has a value, so the form's lookup returns none. + let vals = |name: &str| -> Vec { if name == "a" { vec!["2".into()] } else { vec![] } }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + for op in CalcOp::ALL { + assert_eq!(simple(op, &["qty", "price"]), Some("0".into()), "{op:?} of empty fields"); + assert_eq!(simple(op, &[]), Some("0".into()), "{op:?} of no fields"); + } + assert_eq!(simple(CalcOp::Product, &["a", "qty"]), Some("2".into()), "a field with no value is left out"); +} + +/// A drop-down's blank option is often a single space, the value it starts with. That is a value +/// worth 0, so a row whose quantity is still blank reads 0 × price = 0. Read as "no value", it +/// would be left out and the row would read the price alone. +#[test] +fn a_blank_option_of_a_single_space_is_a_value_worth_0() { + let vals = |name: &str| -> Vec { + match name { + "qty" => vec![" ".into()], + "price" => vec!["605".into()], + _ => vec![], + } + }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + assert_eq!(simple(CalcOp::Product, &["qty", "price"]), Some("0".into())); + assert_eq!(simple(CalcOp::Product, &["qty", "none"]), Some("0".into()), "a blank quantity on a row without a price"); +} + #[test] fn validation_and_calculation() { let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index a96a519d8c..f252927f05 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1943,6 +1943,59 @@ fn missing_fields_and_built_names_are_flagged() { assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read"); } +/// An order form's rows, as a public one lays them out: an editable drop-down quantity whose blank +/// option, and starting value, is a single space, a prefilled price, the row's product and a total +/// over the rows. A blank quantity is 0, so its row is 0; a quantity cleared to nothing on a row +/// without a price leaves the product nothing to multiply, and it is 0 too, not 1. +#[test] +fn an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows() { + let tx = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [250 {y} 350 {}] /P 3 0 R {rest} >>", y + 20) + }; + let qty = |name: &str, y: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 393216 /T ({name}) /Opt [( ) (1) (2) (3)] /V ( ) /Rect [50 {y} 150 {}] /P 3 0 R >>", + y + 20 + ) + }; + let prd = |row: u32| { + format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"PRD\", new Array \\(\"QTY {row}\", \"price {row}\"\\)\\);) >> >>") + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + qty("QTY 0", 700), + tx("price 0", 700, "/V (605)"), + tx("Total 0", 700, &format!("/V (0) {}", prd(0))), + qty("QTY 1", 660), + tx("price 1", 660, "/V (780)"), + tx("Total 1", 660, &format!("/V (0) {}", prd(1))), + qty("QTY 2", 620), + tx("price 2", 620, ""), + tx("Total 2", 620, &format!("/V (0) {}", prd(2))), + tx( + "Total", + 580, + r#"/V (0) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array \("Total 0", "Total 1", "Total 2"\)\);) >> >>"#, + ), + ]; + let mut doc = document(&objs); + let totals = |doc: &Document| { + let all = fields(doc); + ["Total 0", "Total 1", "Total 2", "Total"].map(|n| field(&all, n).value.first().cloned().unwrap_or_default()) + }; + recalculate(&mut doc).unwrap(); + assert_eq!(totals(&doc), ["0", "0", "0", "0"], "every quantity blank: a single space is worth 0"); + set_value(&mut doc, "QTY 0", &FieldValue::Text("2".into())).unwrap(); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "only the chosen row"); + set_value(&mut doc, "QTY 2", &FieldValue::Text(String::new())).unwrap(); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "a quantity cleared on a row without a price"); + let doc = reopen(&doc); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "kept across a save"); +} + /// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an /// entry repeated, a reference to nothing and a number. The order reads around them, and setting /// the order writes `/CO` afresh. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b6b31849f..7d3249ddb7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4770,8 +4770,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] -notes = "SUM, PRD, AVG, MIN, MAX over fields and groups" +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af/tests.rs::a_calculation_over_fields_without_values_is_0_for_every_operation", "crates/forms/src/af/tests.rs::a_blank_option_of_a_single_space_is_a_value_worth_0", "crates/forms/src/tests.rs::an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows"] +notes = "SUM, PRD, AVG, MIN, MAX over fields and groups; fields without a value are left out, and when none of the listed fields has one, every operation gives 0 (a product too, not 1); a drop-down's blank option of a single space is a value worth 0" [[feature]] id = "form.simplified-notation"