Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 55850841d3 Release 0.14.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:30 -04:00
Brandon ThomasandClaude Opus 5.5 1f73f62c4f Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:28 -04:00
Brandon Thomas fb1e59ff61 Merge remote-tracking branch 'origin/main' into land-1030b 2026-10-30 23:16:31 -04:00
Brandon ThomasandClaude Opus 5.5 8f59487cbe Release 0.14.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:39 -04:00
Brandon ThomasandClaude Opus 5.5 71d4e2311d Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:37 -04:00
Brandon Thomas 213fe1150e Review fixes: one Depth table for the BINOM, BETA and HARMEAN notes; summarise the function-parity header 2026-10-30 23:07:11 -04:00
Brandon Thomas 0db7d97f2e Merge PR #3008
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 4d1233d0a2 Merge PR #3007
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 07c8201786 Merge PR #3006
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 31d5cc5b41 Merge PR #3003
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas c0fcc7de16 Merge PR #3002
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:06:47 -04:00
Brandon Thomas 5390e230d8 Merge PR #3001
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas c9eb797d8b Merge PR #3000
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:06:46 -04:00
Brandon Thomas 5441691ca3 Merge PR #2999
# Conflicts:
#	crates/functions/src/stat/tests.rs
#	docs/function-parity.md
2026-10-30 23:04:26 -04:00
Brandon Thomas d3f54debd9 Merge PR #2998
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:04:10 -04:00
Brandon Thomas 64ea0cb69c Merge PR #2997
# Conflicts:
#	crates/functions/src/text.rs
#	docs/function-parity.md
2026-10-30 23:04:01 -04:00
Brandon Thomas f887ecf1cf Merge PR #2996
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas ae598a74fe Merge PR #2995
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:41 -04:00
Brandon Thomas bc0fffbe97 Merge PR #2994
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:40 -04:00
Brandon Thomas 1ebef5a694 Merge PR #2992
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:39 -04:00
Brandon Thomas f812c53814 Merge PR #2991
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas b775b3ce32 Merge PR #2990
# Conflicts:
#	docs/function-parity.md
2026-10-30 23:03:38 -04:00
Brandon Thomas 76aed738e3 Merge PR #2989 2026-10-30 23:03:37 -04:00
Brandon Thomas a44d3d5ee5 Merge PR #2982 2026-10-30 23:03:36 -04:00
Brandon Thomas 2bf5dbac84 Merge PR #2983 2026-10-30 23:03:36 -04:00
Brandon Thomas c269ff483d Merge PR #2993 2026-10-30 23:03:35 -04:00
Brandon ThomasandClaude Opus 5.5 15f6297001 Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:24 -04:00
Harrison Booth 006b7962bd Preserve F distribution tail accuracy 2026-10-30 21:14:19 -05:00
Harrison Booth 7ea3a4c2c3 Merge upstream main into harmonic mean branch 2026-10-30 20:59:56 -05:00
Harrison Booth cae0c9abdb Merge main into function updates 2026-10-30 20:59:41 -05:00
Harrison Booth ed81d7e34e Merge upstream main into binomial distribution branch 2026-10-30 20:59:39 -05:00
Harrison Booth 8f7dff0d04 Merge main into function updates 2026-10-30 20:59:31 -05:00
Harrison Booth 7b7e734524 Merge main into function updates 2026-10-30 20:59:20 -05:00
Harrison Booth 6d5ab57f84 Merge upstream main into Weibull distribution branch 2026-10-30 20:59:18 -05:00
Harrison Booth a04629576d Merge main into function updates 2026-10-30 20:59:12 -05:00
Harrison Booth 3174020a70 Merge main into function updates 2026-10-30 20:59:02 -05:00
Harrison Booth 781149a593 Merge main into function updates 2026-10-30 20:58:53 -05:00
Harrison Booth 322298b202 Merge upstream main into gamma distribution branch 2026-10-30 20:58:48 -05:00
Harrison Booth 11d6ff1b79 Merge main into function updates 2026-10-30 20:58:38 -05:00
Harrison Booth eefa7c4a54 Merge main into function updates 2026-10-30 20:58:24 -05:00
Harrison Booth 45939a8a41 Merge remote-tracking branch 'upstream/main' into gc-build-g718x
# Conflicts:
#	docs/function-parity.md
2026-10-30 20:56:57 -05:00
Harrison Booth 70da8bb3ae Cover strict upper tail in F inverse 2026-10-30 20:53:15 -05:00
Harrison Booth a0da53f576 Match T DIST zero degree errors by mode 2026-10-30 20:53:00 -05:00
Harrison Booth ca44a5d68f Avoid overflow in t density normalization 2026-10-30 20:52:05 -05:00
Harrison Booth 3b61d660e9 Return NUM for invalid T distribution degrees 2026-10-30 20:51:47 -05:00
Harrison Booth cca8a58bfa Stabilize F distribution CDF at extreme degrees 2026-10-30 20:51:07 -05:00
Harrison Booth d3cd393dba G719: return NA for empty harmonic mean input 2026-10-30 20:25:09 -05:00
Harrison Booth 8985685833 G718: match beta density endpoint errors 2026-10-30 20:24:21 -05:00
Harrison Booth 962a5df841 G717: cap binomial trial counts 2026-10-30 20:23:32 -05:00
Harrison Booth fea4c03ca2 G709: use stable tail at F median 2026-10-30 20:18:56 -05:00
Harrison Booth 1d9096e5ec G709: stabilize F upper tail at extreme degrees 2026-10-30 20:05:47 -05:00
Harrison Booth 8e740d97e1 G707: stabilize decimal boundary cases 2026-10-30 19:58:17 -05:00
Harrison Booth a9bacdca4c G711: keep T.DIST validation isolated 2026-10-30 19:56:36 -05:00
Cédric Legrand 53d6de3c16 docs(releasing): M13: correct outdated prerequisites 2026-10-31 01:49:44 +01:00
Harrison Booth 6aa2cb8223 G709: invert upper F probabilities through complementary tail 2026-10-30 19:49:32 -05:00
Harrison Booth 83e6408b71 G707: align signed output boundary regression 2026-10-30 19:47:30 -05:00
Harrison Booth 1b1224c67a G713: simplify zero-shape boundary 2026-10-30 19:42:50 -05:00
Harrison Booth 213b76af10 Expand bit shift boundary regressions 2026-10-30 19:39:26 -05:00
Harrison Booth 4e5c86d842 G711: preserve small density term at huge degrees 2026-10-30 19:39:05 -05:00
Harrison Booth ad69d4cbdd G710: update zero-degree test error 2026-10-30 19:39:00 -05:00
Harrison Booth 32dbf6a224 G705: fix single-character case conversions 2026-10-30 19:37:03 -05:00
Harrison Booth 11b54ebe40 G708: retain dollar sign when rounding to zero 2026-10-30 19:36:23 -05:00
Harrison Booth 9e710199b1 Move base source parity note to history 2026-10-30 19:36:06 -05:00
Harrison Booth 5ee65a8005 Tighten UNICHAR regressions and parity history 2026-10-30 19:35:50 -05:00
Harrison Booth 48c6b57060 G714: return zero Weibull density at zero 2026-10-30 19:34:59 -05:00
Harrison Booth ec2efe3246 G713: correct gamma density at zero 2026-10-30 19:34:36 -05:00
Harrison Booth 5ed6a1b9c7 G707: enforce fixed and dollar output limits 2026-10-30 19:34:32 -05:00
Harrison Booth 5d89b9efdf Move bit shift parity note to revision history 2026-10-30 19:34:19 -05:00
Harrison Booth f1ff38504a G712: return division error for zero T inverse probability 2026-10-30 19:34:12 -05:00
Harrison Booth 0435ff50f9 G711: stabilize large-degree t density 2026-10-30 19:33:50 -05:00
Harrison Booth 550085f8b8 G710: return division error for zero T degrees 2026-10-30 19:33:10 -05:00
Harrison Booth 3a27743a38 Move places parity note to revision history 2026-10-30 19:32:48 -05:00
Harrison Booth dc691d52db G709: accept F distribution boundary degrees 2026-10-30 19:32:22 -05:00
Harrison Booth bd8cf4e14a G706: avoid binary noise in fixed decimal text 2026-10-30 19:32:19 -05:00
Harrison Booth dc75555ee9 G705: match Excel single-character casing 2026-10-30 19:30:32 -05:00
Harrison Booth 4d725e6bc1 Reject Unicode noncharacters in UNICHAR 2026-10-30 19:24:46 -05:00
Harrison Booth 9fccec28af Handle zero-valued oversized bit shifts 2026-10-30 19:24:22 -05:00
Harrison Booth 52d317c8ff Validate places for negative base conversions 2026-10-30 19:23:51 -05:00
Harrison Booth 00359ee93a Merge current main into G701 branch 2026-10-30 19:23:09 -05:00
Harrison Booth c895acf1f7 Fix base conversion whitespace validation 2026-10-30 19:22:39 -05:00
Dylan M. Taylor 1f4c67ef0a fix(flatpak): allow reading launcher version pins (#2985)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:32 -04:00
Michael HatzandClaude Opus 5.5 8cf8425745 Print checkbox test: check that the unchecked box is outlined
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:52:22 +01:00
NetterSilverandClaude Haiku 5.5 59a8b14d4d Print structured refs to columns starting with @ in double brackets
Found while testing crates/formula/src/printer.rs:164 (write_struct_ref, single column without specifiers)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 22:26:49 +00:00
Michael HatzandClaude Opus 5.5 8f93ba2e9c Print checkbox cells as boxes, not TRUE/FALSE (#2972)
A checkbox cell drew a box on screen but printed and exported to PDF
its value as TRUE or FALSE. The print path now draws the box as the
grid does: white with a grey outline when unchecked, green with a
white tick when checked. Other values in a checkbox cell still print
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:14:20 +01:00
15 changed files with 445 additions and 76 deletions
Generated
+16 -16
View File
@@ -1936,7 +1936,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft" name = "gridcraft"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1963,7 +1963,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-calc" name = "gridcraft-calc"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
"gridcraft-formula", "gridcraft-formula",
@@ -1975,7 +1975,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-chart" name = "gridcraft-chart"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-calc", "gridcraft-calc",
"gridcraft-core", "gridcraft-core",
@@ -1986,7 +1986,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-cli" name = "gridcraft-cli"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-engine", "gridcraft-engine",
"gridcraft-mcp", "gridcraft-mcp",
@@ -1998,7 +1998,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-core" name = "gridcraft-core"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"js-sys", "js-sys",
@@ -2009,7 +2009,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-engine" name = "gridcraft-engine"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-calc", "gridcraft-calc",
"gridcraft-chart", "gridcraft-chart",
@@ -2031,7 +2031,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-formula" name = "gridcraft-formula"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
"serde", "serde",
@@ -2040,7 +2040,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-functions" name = "gridcraft-functions"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
"regex", "regex",
@@ -2048,7 +2048,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-mcp" name = "gridcraft-mcp"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-engine", "gridcraft-engine",
"log", "log",
@@ -2057,7 +2057,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-model" name = "gridcraft-model"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
"gridcraft-formula", "gridcraft-formula",
@@ -2069,18 +2069,18 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-numfmt" name = "gridcraft-numfmt"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
] ]
[[package]] [[package]]
name = "gridcraft-pdf" name = "gridcraft-pdf"
version = "0.13.0" version = "0.14.2"
[[package]] [[package]]
name = "gridcraft-ui-egui" name = "gridcraft-ui-egui"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"craft-ui", "craft-ui",
"craft-update", "craft-update",
@@ -2105,7 +2105,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-web" name = "gridcraft-web"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -2122,7 +2122,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-xlsx" name = "gridcraft-xlsx"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"aes", "aes",
"gridcraft-core", "gridcraft-core",
@@ -6403,7 +6403,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.13.0" version = "0.14.2"
dependencies = [ dependencies = [
"gridcraft-engine", "gridcraft-engine",
"serde_json", "serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package] [workspace.package]
version = "0.13.0" version = "0.14.2"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.95" rust-version = "1.95"
+36 -2
View File
@@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture; use super::objects::draw_cell_picture;
use super::text::{TextPlacement, draw_text}; use super::text::{TextPlacement, draw_text};
use super::{ use super::{
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook, BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value,
split_sheet, Workbook, split_sheet,
}; };
// ---------------------------------------------------------------- drawing // ---------------------------------------------------------------- drawing
@@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|| merges.iter().any(|m| m.start == *c) || merges.iter().any(|m| m.start == *c)
|| !sh.value(*c).is_empty() || !sh.value(*c).is_empty()
|| l.style.borders != Borders::default() || l.style.borders != Borders::default()
|| l.style.num_fmt.as_str() == "checkbox"
}) })
.collect(); .collect();
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) { let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
@@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
} }
if let Some(picture) = sh.cell_pictures.get(c) { if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k); draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else if let Some(on) = checkbox_state(l, &sh.value(*c)) {
draw_checkbox(pg, (x, y, w, h), b.k, on);
} else { } else {
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) }); draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
} }
@@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
pg.restore(); pg.restore();
} }
/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values
/// print as text.
fn checkbox_state(l: &Look, v: &Value) -> Option<bool> {
if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
return None;
}
match v {
Value::Bool(on) => Some(*on),
Value::Empty => Some(false),
_ => None,
}
}
/// A checkbox centred in the cell: white with a grey outline, or green with a white tick.
fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) {
let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k);
if s <= 0.0 {
return;
}
let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0);
let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]];
if on {
let green = [0x10, 0x7C, 0x41];
pg.polygon(&corners, Some(green), Some((green, 0.75 * k)));
let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]];
pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]);
} else {
pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k)));
}
}
pub(super) struct PendingPdfLink { pub(super) struct PendingPdfLink {
pub(super) page: usize, pub(super) page: usize,
pub(super) sheet: usize, pub(super) sheet: usize,
+16
View File
@@ -565,3 +565,19 @@ fn rotated_wrapped_text_prints_rotated() {
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
} }
#[test]
fn checkbox_cells_print_as_boxes_not_true_false() {
// #2972: a checkbox cell printed its value as TRUE or FALSE.
let mut s = s();
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap();
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE");
// Only the plain TRUE in C1 prints as text; the checked box is a filled box.
let text = String::from_utf8_lossy(&pdf);
assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE");
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
}
+2 -2
View File
@@ -175,8 +175,8 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or("")); let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
} }
(Some(c), None) if s.specifiers.is_empty() => { (Some(c), None) if s.specifiers.is_empty() => {
// Excel writes a name with spaces at its edges in the double-bracket form. // Names with spaces at their edges, or starting with `@` (this-row shorthand), use the double-bracket form.
if c.trim() == c { if c.trim() == c && !c.starts_with('@') {
let _ = write!(out, "[{}]", esc(c)); let _ = write!(out, "[{}]", esc(c));
} else { } else {
let _ = write!(out, "[[{}]]", esc(c)); let _ = write!(out, "[[{}]]", esc(c));
+9
View File
@@ -558,3 +558,12 @@ fn reference_shapes_print_unchanged() -> Result<(), Box<dyn std::error::Error>>
} }
Ok(()) Ok(())
} }
#[test]
fn column_starting_with_at_round_trips() {
for name in ["@a", "a@b"] {
let s = StructRef { table: "T".into(), specifiers: vec![], col_start: Some(name.into()), col_end: None };
let text = format!("={}", printer::struct_ref(&s));
assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}");
}
}
+56 -7
View File
@@ -25,7 +25,7 @@ fn base_source(a: &[Arg]) -> R<String> {
Ok(number_to_text(n)) Ok(number_to_text(n))
} }
Value::Empty => Ok(String::new()), Value::Empty => Ok(String::new()),
v => Ok(v.to_text()?.trim().to_string()), v => Ok(v.to_text()?.to_string()),
} }
} }
@@ -62,6 +62,15 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
if v < min || v > max { if v < min || v > max {
return Err(CellError::Num); return Err(CellError::Num);
} }
let places = places
.map(|p| {
let p = p.trunc();
if !(1.0..=10.0).contains(&p) {
return Err(CellError::Num);
}
Ok(p as usize)
})
.transpose()?;
if v < 0 { if v < 0 {
let u = v + (1i64 << bits); let u = v + (1i64 << bits);
return text_val(to_radix(u, radix)); return text_val(to_radix(u, radix));
@@ -70,11 +79,6 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
match places { match places {
None => text_val(s), None => text_val(s),
Some(p) => { Some(p) => {
let p = p.trunc();
if !(1.0..=10.0).contains(&p) {
return Err(CellError::Num);
}
let p = p as usize;
if s.len() > p { if s.len() > p {
return Err(CellError::Num); return Err(CellError::Num);
} }
@@ -97,7 +101,13 @@ fn to_radix(mut v: i64, radix: u32) -> String {
} }
fn places_arg(a: &[Arg]) -> R<Option<f64>> { fn places_arg(a: &[Arg]) -> R<Option<f64>> {
if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) } if !has(a, 1) {
return Ok(None);
}
if matches!(a.get(1).map(scalar), Some(Value::Bool(_))) {
return Err(CellError::Value);
}
Ok(Some(num(a, 1)?))
} }
fn convert_base(a: &[Arg], from: u32, to: u32) -> R<Value> { fn convert_base(a: &[Arg], from: u32, to: u32) -> R<Value> {
@@ -175,6 +185,9 @@ fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
fn shift(a: &[Arg], left: bool) -> R<Value> { fn shift(a: &[Arg], left: bool) -> R<Value> {
let n = bit_operand(a, 0)?; let n = bit_operand(a, 0)?;
let s = num(a, 1)?.trunc(); let s = num(a, 1)?.trunc();
if n == 0 {
return Ok(Value::Number(0.0));
}
if s.abs() > 53.0 { if s.abs() > 53.0 {
return Err(CellError::Num); return Err(CellError::Num);
} }
@@ -402,6 +415,12 @@ mod tests {
is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777"); is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777");
is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001"); is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001");
is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100"); is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100");
is_err(ev("DEC2BIN", vec![n(-1.0), n(0.0)]), CellError::Num);
is_err(ev("DEC2HEX", vec![n(-1.0), n(11.0)]), CellError::Num);
is_err(ev("DEC2OCT", vec![n(-1.0), n(-1.0)]), CellError::Num);
is_err(ev("DEC2BIN", vec![n(1.0), crate::Arg::val(Value::Bool(true))]), CellError::Value);
is_text(ev("DEC2BIN", vec![n(-1.0), n(1.0)]), "1111111111");
is_text(ev("DEC2HEX", vec![n(-1.0), n(10.0)]), "FFFFFFFFFF");
is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num); is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num);
is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064"); is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064");
is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA"); is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA");
@@ -426,6 +445,21 @@ mod tests {
is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040"); is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040");
is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B"); is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B");
is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num); is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num);
for (f, input) in [
("BIN2DEC", " 10"),
("BIN2DEC", "10 "),
("BIN2DEC", "\t10"),
("BIN2HEX", " 10"),
("BIN2OCT", "10 "),
("HEX2DEC", " 10"),
("HEX2BIN", "10 "),
("HEX2OCT", "\t10"),
("OCT2DEC", " 10"),
("OCT2BIN", "10 "),
("OCT2HEX", "\t10"),
] {
is_err(ev(f, vec![t(input)]), CellError::Num);
}
is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num); is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num);
is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num); is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num);
is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value); is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value);
@@ -446,6 +480,21 @@ mod tests {
is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num); is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num);
is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num); is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num);
is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num); is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num);
for f in ["BITLSHIFT", "BITRSHIFT"] {
for shift in [-100.0, -54.0, -53.0, 53.0, 54.0, 100.0] {
close(ev(f, vec![n(0.0), n(shift)]), 0.0);
}
for shift in [-100.0, -54.0, 54.0, 100.0] {
is_err(ev(f, vec![n(1.0), n(shift)]), CellError::Num);
}
if f == "BITLSHIFT" {
is_err(ev(f, vec![n(1.0), n(53.0)]), CellError::Num);
} else {
close(ev(f, vec![n(1.0), n(53.0)]), 0.0);
}
is_err(ev(f, vec![n(0.0), t("bad")]), CellError::Value);
}
is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num);
} }
#[test] #[test]
+4 -1
View File
@@ -420,7 +420,10 @@ pub(super) fn geomean(v: &[f64]) -> R<f64> {
} }
pub(super) fn harmean(v: &[f64]) -> R<f64> { pub(super) fn harmean(v: &[f64]) -> R<f64> {
if v.is_empty() || v.iter().any(|&x| x <= 0.0) { if v.is_empty() {
return Err(CellError::NA);
}
if v.iter().any(|&x| x <= 0.0) {
return Err(CellError::Num); return Err(CellError::Num);
} }
Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::<f64>()) Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::<f64>())
+40 -14
View File
@@ -22,7 +22,14 @@ fn t_cdf(t: f64, v: f64) -> f64 {
} }
fn t_pdf(t: f64, v: f64) -> f64 { fn t_pdf(t: f64, v: f64) -> f64 {
(ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp() let z = v / 2.0;
let log_gamma_ratio = if z >= 16.0 {
let inv_z = 1.0 / z;
0.5 * z.ln() - inv_z / 8.0 + inv_z.powi(3) / 192.0 - inv_z.powi(5) / 640.0 + 17.0 * inv_z.powi(7) / 14_336.0
} else {
ln_gamma(z + 0.5) - ln_gamma(z)
};
(log_gamma_ratio - 0.5 * (v.ln() + std::f64::consts::PI.ln()) - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp()
} }
/// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. /// t ≥ 0 with P(T > t) = q, q in (0, 0.5].
@@ -51,6 +58,12 @@ fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 {
if x <= 0.0 { if x <= 0.0 {
return 0.0; return 0.0;
} }
if x == 1.0 && d1 == d2 {
return 0.5;
}
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
return reg_gamma_q(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
}
reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0) reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0)
} }
@@ -58,6 +71,10 @@ fn f_upper(x: f64, d1: f64, d2: f64) -> f64 {
if x <= 0.0 { if x <= 0.0 {
return 1.0; return 1.0;
} }
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
// With a much larger numerator degree, the scaled beta tail converges to a gamma tail.
return reg_gamma_p(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
}
reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0) reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0)
} }
@@ -74,6 +91,8 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 {
((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp() ((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp()
} }
const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0;
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 { fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
if k < 0.0 || k > n { if k < 0.0 || k > n {
return 0.0; return 0.0;
@@ -176,8 +195,14 @@ fn df_arg(a: &[Arg], i: usize) -> R<f64> {
pub(super) fn t_dist(a: &[Arg], _: C) -> R<Value> { pub(super) fn t_dist(a: &[Arg], _: C) -> R<Value> {
let x = num(a, 0)?; let x = num(a, 0)?;
let v = df_arg(a, 1)?; let v = num(a, 1)?.trunc();
let cum = boolean(a, 2)?; let cum = boolean(a, 2)?;
if v == 0.0 {
return Err(if cum { CellError::Num } else { CellError::Div0 });
}
if v < 0.0 {
return Err(CellError::Num);
}
num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) })
} }
@@ -209,6 +234,9 @@ pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R<Value> {
pub(super) fn t_inv(a: &[Arg], _: C) -> R<Value> { pub(super) fn t_inv(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 0)?; let p = num(a, 0)?;
let v = df_arg(a, 1)?; let v = df_arg(a, 1)?;
if p == 0.0 {
return Err(CellError::Div0);
}
if !(p > 0.0 && p < 1.0) { if !(p > 0.0 && p < 1.0) {
return Err(CellError::Num); return Err(CellError::Num);
} }
@@ -347,7 +375,7 @@ fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> {
let x = num(a, 0)?; let x = num(a, 0)?;
let d1 = num(a, 1)?.trunc(); let d1 = num(a, 1)?.trunc();
let d2 = num(a, 2)?.trunc(); let d2 = num(a, 2)?.trunc();
if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 { if d1 < 1.0 || d2 < 1.0 || d1 > 1e10 || d2 > 1e10 {
return Err(CellError::Num); return Err(CellError::Num);
} }
Ok((x, d1, d2)) Ok((x, d1, d2))
@@ -378,7 +406,8 @@ pub(super) fn f_inv(a: &[Arg], _: C) -> R<Value> {
if p == 0.0 { if p == 0.0 {
return num_val(0.0); return num_val(0.0);
} }
num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60)) let value = if p < 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) };
num_val(value)
} }
pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R<Value> { pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R<Value> {
@@ -410,7 +439,7 @@ pub(super) fn binom_dist(a: &[Arg], _: C) -> R<Value> {
let n = num(a, 1)?.trunc(); let n = num(a, 1)?.trunc();
let p = num(a, 2)?; let p = num(a, 2)?;
let cum = boolean(a, 3)?; let cum = boolean(a, 3)?;
if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) { if k < 0.0 || n > MAX_BINOM_TRIALS || k > n || !(0.0..=1.0).contains(&p) {
return Err(CellError::Num); return Err(CellError::Num);
} }
num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) }) num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) })
@@ -421,7 +450,7 @@ pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
let p = num(a, 1)?; let p = num(a, 1)?;
let s1 = num(a, 2)?.trunc(); let s1 = num(a, 2)?.trunc();
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { if !(0.0..=MAX_BINOM_TRIALS).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
return Err(CellError::Num); return Err(CellError::Num);
} }
if s2 - s1 > 10_000.0 { if s2 - s1 > 10_000.0 {
@@ -499,13 +528,7 @@ pub(super) fn gamma_dist(a: &[Arg], _: C) -> R<Value> {
return num_val(reg_gamma_p(al, x / be)); return num_val(reg_gamma_p(al, x / be));
} }
if x == 0.0 { if x == 0.0 {
return if al < 1.0 { return if al <= 1.0 { Err(CellError::Num) } else { num_val(0.0) };
Err(CellError::Num)
} else if al == 1.0 {
num_val(1.0 / be)
} else {
num_val(0.0)
};
} }
num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp()) num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp())
} }
@@ -548,7 +571,7 @@ pub(super) fn beta_dist(a: &[Arg], _: C) -> R<Value> {
if cum { if cum {
return num_val(reg_inc_beta(z, al, be)); return num_val(reg_inc_beta(z, al, be));
} }
if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) { if (z == 0.0 && al <= 1.0) || (z == 1.0 && be <= 1.0) {
return Err(CellError::Num); return Err(CellError::Num);
} }
let lp = if z == 0.0 || z == 1.0 { let lp = if z == 0.0 || z == 1.0 {
@@ -608,6 +631,9 @@ pub(super) fn weibull_dist(a: &[Arg], _: C) -> R<Value> {
if x < 0.0 || al <= 0.0 || be <= 0.0 { if x < 0.0 || al <= 0.0 || be <= 0.0 {
return Err(CellError::Num); return Err(CellError::Num);
} }
if x == 0.0 {
return num_val(0.0);
}
let t = (x / be).powf(al); let t = (x / be).powf(al);
num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() }) num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() })
} }
+102
View File
@@ -151,6 +151,19 @@ fn spread() {
let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0];
close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9);
close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9);
let text_values = arr(vec![vec![tv("1"), tv("2")]]);
let bool_values = arr(vec![vec![Value::Bool(true), Value::Bool(false)]]);
let mixed_values = arr(vec![vec![nv(2.0), tv("ignored"), Value::Bool(true)]]);
is_err(ev("HARMEAN", vec![av(text_values.clone())]), CellError::NA);
is_err(ev("HARMEAN", vec![av(bool_values.clone())]), CellError::NA);
is_err(ev("HARMEAN", vec![rf(text_values)]), CellError::NA);
is_err(ev("HARMEAN", vec![rf(bool_values)]), CellError::NA);
close(ev("HARMEAN", vec![av(mixed_values.clone())]), 2.0);
close(ev("HARMEAN", vec![rf(mixed_values)]), 2.0);
close(ev("HARMEAN", vec![b(true)]), 1.0);
close(ev("HARMEAN", vec![n(2.0), n(4.0)]), 8.0 / 3.0);
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(0.0)]]))]), CellError::Num);
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(-1.0)]]))]), CellError::Num);
is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num);
close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9);
is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num);
@@ -360,6 +373,7 @@ fn normal() {
#[test] #[test]
fn t_chi_f() { fn t_chi_f() {
close_tol(ev("T.DIST", vec![n(0.0), n(6e307), b(false)]), 0.3989422804, 1e-10);
close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7);
close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5);
close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7);
@@ -397,6 +411,7 @@ fn t_chi_f() {
close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7);
close_tol(ev("F.INV", vec![n(0.99), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7);
@@ -404,6 +419,48 @@ fn t_chi_f() {
is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num);
} }
#[test]
fn f_distributions_accept_ten_billion_degrees() {
close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0);
close_tol(ev("F.DIST", vec![n(0.01), n(1_000_000_000.0), n(1.0), b(true)]), 1.52397e-23, 1e-28);
close_tol(ev("F.DIST", vec![n(1.0), n(10_000_000_000.0), n(10_000_000_000.0), b(true)]), 0.5, 0.0);
close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8);
close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6);
close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0);
is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num);
is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num);
}
#[test]
fn t_dist_zero_degrees_error_depends_on_mode() {
for df in [0.0, 0.9, -0.1] {
is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0);
is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num);
}
is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num);
close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
close_tol(ev("T.DIST", vec![n(0.0), n(1.0), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
}
#[test]
fn t_density_is_stable_for_large_degrees() {
close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12);
close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12);
close_tol(ev("T.DIST", vec![n(1.0), n(10_000_000_000_000_000.0), b(false)]), 0.24197072451914337, 1e-12);
}
#[test]
fn t_inv_zero_probability_returns_div0() {
is_err(ev("T.INV", vec![n(-0.1), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(0.0), n(1.0)]), CellError::Div0);
close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7);
is_err(ev("T.INV", vec![n(1.0), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(1.1), n(1.0)]), CellError::Num);
is_err(ev("T.INV", vec![n(0.0), n(0.0)]), CellError::Num);
is_err(ev("T.INV.2T", vec![n(0.0), n(1.0)]), CellError::Num);
}
#[test] #[test]
fn binom_inv_huge_trials_returns() { fn binom_inv_huge_trials_returns() {
// Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end.
@@ -427,7 +484,15 @@ fn discrete() {
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12);
close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.0), n(0.0), b(false)]), 0.0);
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.9), n(0.0), b(false)]), 0.0);
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.0), n(0.0), b(false)]), CellError::Num);
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.9), n(0.0), b(false)]), CellError::Num);
is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num);
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.0), n(0.0), n(1.0)]), 0.0);
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.9), n(0.0), n(1.0)]), 0.0);
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.0), n(0.0), n(1.0)]), CellError::Num);
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.9), n(0.0), n(1.0)]), CellError::Num);
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6);
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6);
close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0);
@@ -466,6 +531,19 @@ fn continuous() {
is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num);
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7);
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7);
is_err(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(0.0), n(0.5), n(2.0), b(false)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(1.0), n(2.0), n(0.5), b(false)]), CellError::Num);
close(ev("BETA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0);
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(2.0), b(false)]), 0.0);
close(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(true)]), 0.0);
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0);
is_err(ev("BETA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false), n(2.0), n(6.0)]), CellError::Num);
is_err(ev("BETA.DIST", vec![n(6.0), n(2.0), n(1.0), b(false), n(2.0), n(6.0)]), CellError::Num);
close(ev("BETA.DIST", vec![n(4.0), n(1.0), n(1.0), b(false), n(2.0), n(6.0)]), 0.25);
close(ev("BETA.DIST", vec![n(2.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 0.0);
close(ev("BETA.DIST", vec![n(6.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 1.0);
close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7);
close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
@@ -479,6 +557,30 @@ fn continuous() {
close_tol(r[0][1].clone(), 0.841344746, 1e-8); close_tol(r[0][1].clone(), 0.841344746, 1e-8);
} }
#[test]
fn gamma_density_at_zero_shape_one_is_num() {
for scale in [0.1, 0.9, 1.0, 1.9, 2.0, 10_000_000_000.0] {
is_err(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(scale), b(false)]), CellError::Num);
}
is_err(ev("GAMMA.DIST", vec![n(0.0), n(0.5), n(1.0), b(false)]), CellError::Num);
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(2.0), b(true)]), 0.0, 0.0);
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false)]), 0.5 * (-1.0f64).exp(), 1e-15);
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
is_err(ev("GAMMA.DIST", vec![n(-0.1), n(1.0), n(2.0), b(false)]), CellError::Num);
}
#[test]
fn weibull_density_at_zero_is_zero() {
for shape in [0.5, 1.0, 2.0] {
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(false)]), 0.0, 0.0);
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(true)]), 0.0, 0.0);
}
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), (-1.0f64).exp(), 1e-15);
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
is_err(ev("WEIBULL.DIST", vec![n(-0.1), n(1.0), n(1.0), b(false)]), CellError::Num);
}
#[test] #[test]
fn aggregate_helper() { fn aggregate_helper() {
let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)];
+113 -12
View File
@@ -111,12 +111,31 @@ fn len(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(text(a, 0)?.chars().count() as f64) num_val(text(a, 0)?.chars().count() as f64)
} }
fn lower_char(ch: char) -> char {
match ch.to_lowercase().next() {
Some(lower) => lower,
None => ch,
}
}
fn upper_char(ch: char) -> char {
let mut uppercase = ch.to_uppercase();
match uppercase.next() {
Some(upper) if uppercase.next().is_none() => upper,
_ => ch,
}
}
fn proper_upper_char(ch: char) -> char {
if ch == '\u{130}' { 'I' } else { upper_char(ch) }
}
fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> { fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(text(a, 0)?.to_lowercase()) text_val(text(a, 0)?.chars().map(lower_char).collect::<String>())
} }
fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> { fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
text_val(text(a, 0)?.to_uppercase()) text_val(text(a, 0)?.chars().map(upper_char).collect::<String>())
} }
fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> { fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
@@ -126,9 +145,9 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
for ch in s.chars() { for ch in s.chars() {
if ch.is_alphabetic() { if ch.is_alphabetic() {
if prev_letter { if prev_letter {
out.extend(ch.to_lowercase()); out.push(lower_char(ch));
} else { } else {
out.extend(ch.to_uppercase()); out.push(proper_upper_char(ch));
} }
prev_letter = true; prev_letter = true;
} else { } else {
@@ -281,7 +300,11 @@ fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
if !(1.0..=1_114_111.0).contains(&n) { if !(1.0..=1_114_111.0).contains(&n) {
return Err(CellError::Value); return Err(CellError::Value);
} }
match char::from_u32(n as u32) { let cp = n as u32;
if (0xFDD0..=0xFDEF).contains(&cp) || cp & 0xFFFE == 0xFFFE {
return Err(CellError::NA);
}
match char::from_u32(cp) {
Some(c) => text_val(c.to_string()), Some(c) => text_val(c.to_string()),
None => Err(CellError::NA), None => Err(CellError::NA),
} }
@@ -350,10 +373,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
/// Formats `x` (already rounded) with `decimals` places and optional thousands separators. /// Formats `x` (already rounded) with `decimals` places and optional thousands separators.
fn format_fixed(x: f64, decimals: i64, commas: bool) -> String { fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
let d = decimals.max(0) as usize; let d = decimals.max(0) as usize;
let s = format!("{:.*}", d, x.abs()); let s = x.abs().to_string();
let (int_part, frac) = match s.split_once('.') { let (int_part, frac) = match s.split_once('.') {
Some((i, f)) => (i.to_string(), Some(f.to_string())), Some((i, f)) => (i, f),
None => (s.clone(), None), None => (s.as_str(), ""),
}; };
let int_part = if commas { let int_part = if commas {
let b = int_part.as_bytes(); let b = int_part.as_bytes();
@@ -366,12 +389,14 @@ fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
} }
o o
} else { } else {
int_part int_part.to_string()
}; };
let mut out = int_part; let mut out = int_part;
if let Some(f) = frac { if d > 0 {
let frac_len = frac.len().min(d);
out.push('.'); out.push('.');
out.push_str(&f); out.push_str(&frac[..frac_len]);
out.extend(std::iter::repeat_n('0', d - frac_len));
} }
out out
} }
@@ -394,13 +419,21 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (r, d) = rounded(a, 2.0)?; let (r, d) = rounded(a, 2.0)?;
let no_commas = opt_bool(a, 2, false)?; let no_commas = opt_bool(a, 2, false)?;
let body = format_fixed(r, d, !no_commas); let body = format_fixed(r, d, !no_commas);
if body.len() + if r < 0.0 { 1 } else { 0 } > 255 {
return Err(CellError::Value);
}
text_val(if r < 0.0 { format!("-{body}") } else { body }) text_val(if r < 0.0 { format!("-{body}") } else { body })
} }
fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> { fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let (r, d) = rounded(a, 2.0)?; let (r, d) = rounded(a, 2.0)?;
let negative = r < 0.0 || (r == 0.0 && d >= 0 && num(a, 0)? < 0.0);
let body = format_fixed(r, d, true); let body = format_fixed(r, d, true);
text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") }) let out = if negative { format!("(${body})") } else { format!("${body}") };
if out.len() > 254 {
return Err(CellError::Value);
}
text_val(out)
} }
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
@@ -1123,6 +1156,20 @@ mod tests {
fn case_and_cleanup() { fn case_and_cleanup() {
is_text(ev("UPPER", vec![t("abc")]), "ABC"); is_text(ev("UPPER", vec![t("abc")]), "ABC");
is_text(ev("LOWER", vec![t("ABC")]), "abc"); is_text(ev("LOWER", vec![t("ABC")]), "abc");
is_text(ev("UPPER", vec![t("Straße")]), "STRAßE");
is_text(ev("LOWER", vec![t("İ")]), "i");
is_text(ev("PROPER", vec![t("İSTANBUL")]), "Istanbul");
is_text(ev("PROPER", vec![t("ß")]), "ß");
is_text(ev("PROPER", vec![t("ÉCOLE")]), "École");
let proper = ev("PROPER", vec![t("İSTANBUL Straße")]);
is_text(proper.clone(), "Istanbul Straße");
assert_eq!(
match proper {
Value::Text(s) => s.chars().count(),
_ => 0,
},
"Istanbul Straße".chars().count()
);
is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title"); is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title");
is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S"); is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S");
is_text(ev("TRIM", vec![t(" a b c ")]), "a b c"); is_text(ev("TRIM", vec![t(" a b c ")]), "a b c");
@@ -1181,8 +1228,15 @@ mod tests {
close(ev("CODE", vec![t("€")]), 128.0); close(ev("CODE", vec![t("€")]), 128.0);
is_err(ev("CODE", vec![t("")]), CellError::Value); is_err(ev("CODE", vec![t("")]), CellError::Value);
is_text(ev("UNICHAR", vec![n(9731.0)]), "☃"); is_text(ev("UNICHAR", vec![n(9731.0)]), "☃");
for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] {
is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA);
}
for (cp, expected) in [(0xFDCF, "\u{FDCF}"), (0xFDF0, "\u{FDF0}"), (0xFFFD, "\u{FFFD}"), (0x1FFFD, "\u{1FFFD}")] {
is_text(ev("UNICHAR", vec![n(f64::from(cp))]), expected);
}
close(ev("UNICODE", vec![t("☃")]), 9731.0); close(ev("UNICODE", vec![t("☃")]), 9731.0);
is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value); is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value);
is_err(ev("UNICHAR", vec![n(1_114_112.0)]), CellError::Value);
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA); is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
} }
@@ -1237,10 +1291,57 @@ mod tests {
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1"); is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00"); is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)"); is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
let fixed_16 = format!("1.1{}", "0".repeat(15));
let dollar_16 = format!("$1.1{}", "0".repeat(15));
let fixed_20 = format!("1.005{}", "0".repeat(17));
let dollar_20 = format!("$1.005{}", "0".repeat(17));
let fixed_127 = format!("1.1{}", "0".repeat(126));
let dollar_127 = format!("$1.1{}", "0".repeat(126));
is_text(ev("FIXED", vec![n(1.1), n(16.0)]), &fixed_16);
is_text(ev("DOLLAR", vec![n(1.1), n(16.0)]), &dollar_16);
is_text(ev("FIXED", vec![n(1.005), n(20.0)]), &fixed_20);
is_text(ev("DOLLAR", vec![n(1.005), n(20.0)]), &dollar_20);
is_text(ev("FIXED", vec![n(1.1), n(127.0)]), &fixed_127);
is_text(ev("DOLLAR", vec![n(1.1), n(127.0)]), &dollar_127);
is_text(ev("T", vec![t("x")]), "x"); is_text(ev("T", vec![t("x")]), "x");
is_text(ev("T", vec![n(1.0)]), ""); is_text(ev("T", vec![n(1.0)]), "");
} }
#[test]
fn fixed_and_dollar_output_limits() {
assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255));
is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value);
assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.starts_with('-')));
is_err(ev("FIXED", vec![n(-1e191), n(0.0)]), CellError::Value);
assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255));
is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(0.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1.23456789e187), n(2.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1.23456789e188), n(2.0)]), CellError::Value);
is_err(ev("DOLLAR", vec![n(-1.23456789e186), n(2.0)]), CellError::Value);
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254));
is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value);
}
#[test]
fn dollar_keeps_negative_sign_when_nonnegative_precision_rounds_to_zero() {
is_text(ev("DOLLAR", vec![n(-0.0001), n(0.0)]), "($0)");
is_text(ev("DOLLAR", vec![n(-0.0001), n(2.0)]), "($0.00)");
is_text(ev("DOLLAR", vec![n(0.0), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-0.0), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(0.0001), n(0.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-0.0001), n(-1.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-4.0), n(-1.0)]), "$0");
is_text(ev("DOLLAR", vec![n(-5.0), n(-1.0)]), "($10)");
is_text(ev("DOLLAR", vec![n(-0.4), n(0.0)]), "($0)");
is_text(ev("DOLLAR", vec![n(-0.5), n(0.0)]), "($1)");
is_text(ev("FIXED", vec![n(-0.0001), n(0.0)]), "0");
}
#[test] #[test]
fn before_after() { fn before_after() {
is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red"); is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red");
+24 -1
View File
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas # Function parity: worksheet functions and formulas
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-27 · **Change:** minor (ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals, and the other IM* differences from Excel are listed; earlier: the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -98,6 +98,12 @@ This improves series preparation; it does not change the model-fitting or season
## Depth (estimated) ## Depth (estimated)
| Function | Excel parity note |
|---|---|
| BINOM.DIST / BINOM.DIST.RANGE | Trial count is truncated, then must be at most 2,147,483,646; larger trials return `#NUM!`. |
| BETA.DIST | Density mode returns `#NUM!` at the lower or upper interval endpoint when the corresponding shape parameter is less than or equal to 1; cumulative endpoint results remain 0 and 1. |
| HARMEAN | Returns `#N/A` when arrays and references contain no numeric observations; zero and negative numeric observations still return `#NUM!`. |
**~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many **~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many
assertions each, but there is no conformance suite against Excel-computed results, and user assertions each, but there is no conformance suite against Excel-computed results, and user
reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range
@@ -124,6 +130,23 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid |
| 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel |
| 2026-10-31 | minor | BITLSHIFT and BITRSHIFT return `0` for zero operands shifted beyond 53 bits in either direction; nonzero operands retain the `#NUM!` limit, matching Excel |
| 2026-10-31 | minor | UNICHAR returns `#N/A` for U+FDD0–U+FDEF and U+FFFE/U+FFFF on every plane; adjacent valid characters and existing invalid-range/surrogate errors remain unchanged, matching Excel |
| 2026-10-31 | minor | UPPER, LOWER and PROPER use single-character casing: `UPPER("Straße")` keeps ß, `LOWER("İ")` returns i, and `PROPER("İSTANBUL")` returns Istanbul, matching Windows Excel 16.0 build 20527 in LCID 1033 |
| 2026-10-31 | minor | FIXED and DOLLAR format the rounded number's shortest decimal representation before padding requested fractional zeroes, so 1.1 at 16 places and 1.005 at 20 places do not expose binary noise; measured in Windows Excel 16.0 build 20527, LCID 1033 |
| 2026-10-31 | minor | FIXED accepts output through 255 characters; DOLLAR accepts output through 254. Windows Excel 16.0 build 20527 (LCID 1033) confirmed the distinct cutoffs with grouped and ungrouped values, signs, currency layout and decimal places |
| 2026-10-31 | minor | DOLLAR uses the input sign for negative amounts that round to zero at nonnegative decimal places (`DOLLAR(-0.0001,0)` is `($0)`); when negative decimal places round to zero, Excel returns `$0`. Exact zero and negative zero stay unsigned; FIXED still returns `0` for `FIXED(-0.0001,0)` |
| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; F.INV uses the complementary tail, with a gamma-tail limit when numerator degrees dominate, to preserve inverse accuracy; values above the limit remain invalid |
| 2026-10-31 | minor | T.DIST returns `#DIV/0!` in density mode and `#NUM!` in cumulative mode when degrees truncate to zero; negative integer degrees remain `#NUM!` |
| 2026-10-31 | minor | Student-t density uses an asymptotic log-gamma ratio for large degrees of freedom, avoiding cancellation while preserving bounded work; checked against Excel at 100 million and 10 billion degrees |
| 2026-10-31 | minor | T.INV probability zero returns `#DIV/0!`, while other out-of-range probabilities and invalid degrees remain `#NUM!`; T.INV.2T keeps its separate boundary behavior |
| 2026-10-31 | minor | GAMMA.DIST density at x=0 and shape 1 returns `#NUM!` for all positive scales; the cumulative branch and other shape boundaries are unchanged |
| 2026-10-31 | minor | WEIBULL.DIST returns zero at x=0 in both density and cumulative modes, including shape below one; negative x remains `#NUM!` |
| 2026-10-31 | minor | BINOM.DIST and BINOM.DIST.RANGE reject trial counts above 2,147,483,646 after truncation |
| 2026-10-31 | minor | BETA.DIST density returns `#NUM!` at the lower endpoint with alpha less than or equal to 1 and upper endpoint with beta less than or equal to 1; cumulative behavior is unchanged |
| 2026-10-31 | minor | HARMEAN returns `#N/A` when array or reference inputs contain no numbers; nonpositive numeric observations remain `#NUM!` |
| 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change | | 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change |
| 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open | | 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open |
| 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` | | 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` |
+19 -19
View File
@@ -19,25 +19,24 @@ This is GridCraft's implementation of the shared [release playbook](release-play
| Web app dir `apps/gridcraft-web` | `packaging/web/package.sh` | | Web app dir `apps/gridcraft-web` | `packaging/web/package.sh` |
| App colour `#1f9d55` (ink `#147a40`), **proposed** | `assets/app-icon/README.md`, metainfo `<branding>` | | App colour `#1f9d55` (ink `#147a40`), **proposed** | `assets/app-icon/README.md`, metainfo `<branding>` |
## Prerequisites still owned by the app crates ## Application release prerequisites
The pipeline references crates that don't exist yet; the first release run fails until they do: The `apps/gridcraft` and `apps/gridcraft-web` crates are workspace members, and
`apps/gridcraft-cli` supports `--version`. The `cargo xtask` alias is defined in
`.cargo/config.toml`. The current app-specific release setup includes:
- **`apps/gridcraft`** (the GUI binary `gridcraft`) and **`apps/gridcraft-web`** (trunk - **Windows resources:** `apps/gridcraft/build.rs` embeds the icon and version information with
site) must be workspace members. `apps/gridcraft-cli` exists and supports `--version`. `winresource` (instructions at the top of the file). `package.ps1` sets
- **Windows resources:** move `packaging/windows/app-build.rs.in` to `apps/gridcraft/build.rs` `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon fails, and it checks that
and add `winresource = "0.1"` under `[build-dependencies]` (instructions at the top of the `gridcraft.exe` is a GUI-subsystem binary. `apps/gridcraft/src/main.rs` sets
file). `package.ps1` sets `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon `windows_subsystem = "windows"` for release builds.
fails, and it checks that `gridcraft.exe` is a GUI-subsystem binary, so `main.rs` needs - **Version information:** `gridcraft --version`, `gridcraft-cli --version` and the About dialog
`#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]`. use `env!("CARGO_PKG_VERSION")` from `[workspace.package]`. `packaging/env.sh` also exports
- **Build info:** CI exports `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE`; read them `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE` for artifact metadata.
with `option_env!` (fallback: "dev build") for `--version` and *Help › About GridCraft*. - **Runtime icon:** `apps/gridcraft/src/main.rs` embeds
- **Runtime icon:** embed `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and
`hicolor/256x256/apps/ai.storyteller.gridcraft.png` (Windows/Linux) as the window icon, and `hicolor/256x256/apps/ai.storyteller.gridcraft.png` (Windows/Linux) as the window icon, and
use `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`. uses `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`.
- **`cargo xtask` alias:** the docs use `cargo xtask …`, which needs
`.cargo/config.toml` with `[alias] xtask = "run --package xtask --"`. Until it exists, use
`cargo run -p xtask -- …`. The workflows already call `cargo run -p xtask` directly.
## Cutting a release ## Cutting a release
@@ -289,7 +288,8 @@ row in `ATTRIBUTION.md` (`cargo xtask assets`).
installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner. installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner.
- `.github/workflows/freebsd.yml`: FreeBSD build + tests (manual run from `release`). - `.github/workflows/freebsd.yml`: FreeBSD build + tests (manual run from `release`).
**Only the `release` branch builds.** No build workflow runs on `main` or pull requests; every **CI and release builds.** `.github/workflows/ci.yml` runs the shared `cargo xtask ci` gate on
build job checks `github.ref == 'refs/heads/release'`, so a manual run from another branch is every push to `main` and every pull request. Release artifacts are built on `release` pushes or
skipped. Only `packaging-lint.yml` (no build, no secrets) runs on `main` and PRs. manually; manual runs from other branches are dry runs as described above. `packaging-lint.yml`
also runs on `main` and PRs.
- Locally: `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm, parity check). - Locally: `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm, parity check).
+2 -1
View File
@@ -1,6 +1,6 @@
# UI parity # UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -194,6 +194,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text |
| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl | | 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl |
| 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit | | 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit |
| 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift | | 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift |
@@ -41,6 +41,11 @@ finish-args:
- --filesystem=xdg-documents - --filesystem=xdg-documents
- --filesystem=xdg-download - --filesystem=xdg-download
# Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted. # Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted.
# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs
# on cups-client's unstripped tools, in whichever module comes first after they're installed. The
# app's own binaries come from the release tarball, already stripped.
build-options:
no-debuginfo: true
modules: modules:
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
# Linked statically against this release's libcups, so a runtime update can't break them; # Linked statically against this release's libcups, so a runtime update can't break them;