From be5eb3db6dd66bd24a5fe80a479e49f8a124db71 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 22:27:25 +0200 Subject: [PATCH 1/2] Numfmt: one built-in format table, no dead render arms, fewer allocations (#817) S1: the built-in format table lives only in gridcraft-xlsx (file formats do not depend on numfmt); numfmt's unused copy and its exports are removed, xlsx gets tests pinning its lookup rules. S3: drop the render_number arm that printed what the fallback prints; literal text is appended to the output instead of built as a String per call. O3: render_number keeps pieces, text-before and literal replacements in one buffer (Parts), fill_int writes digits left to right, Section stores the date flags (sub-second digits, calendar, AM/PM) computed at parse time, render_date writes into one buffer. O4: significant_digits formats into a stack buffer; format_general_fit re-rounds the 15 digits per width into reused strings. An equality test compares format_value, TEXT() and format_general_fit with digests recorded from the previous implementation. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 + crates/numfmt/src/builtin.rs | 56 --- crates/numfmt/src/decimal.rs | 193 ++++++--- crates/numfmt/src/lib.rs | 2 - crates/numfmt/src/parse.rs | 43 +- crates/numfmt/src/render.rs | 334 +++++++++------ crates/numfmt/src/tests.rs | 31 +- crates/numfmt/tests/golden/codes.txt | 152 +++++++ crates/numfmt/tests/golden/general.txt | 555 +++++++++++++++++++++++++ crates/numfmt/tests/render_equality.rs | 225 ++++++++++ crates/xlsx/src/tables.rs | 23 + 11 files changed, 1345 insertions(+), 271 deletions(-) delete mode 100644 crates/numfmt/src/builtin.rs create mode 100644 crates/numfmt/tests/golden/codes.txt create mode 100644 crates/numfmt/tests/golden/general.txt create mode 100644 crates/numfmt/tests/render_equality.rs diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b38..99489dc57b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Number formatting: one table of built-in formats (xlsx); numbers, dates and General render with one scratch buffer instead of per-token strings; output unchanged, checked against digests recorded from the old code (#817) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Numfmt cleanup: one built-in format table (gridcraft-xlsx), fewer allocations rendering numbers, dates and General, equality test against the previous output (#817); progress log row | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/crates/numfmt/src/builtin.rs b/crates/numfmt/src/builtin.rs deleted file mode 100644 index ed03627f74..0000000000 --- a/crates/numfmt/src/builtin.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! Built-in number formats (ECMA-376 §18.8.30, en-US). - -const BUILTINS: [(u32, &str); 36] = [ - (0, "General"), - (1, "0"), - (2, "0.00"), - (3, "#,##0"), - (4, "#,##0.00"), - (5, "\"$\"#,##0_);\\(\"$\"#,##0\\)"), - (6, "\"$\"#,##0_);[Red]\\(\"$\"#,##0\\)"), - (7, "\"$\"#,##0.00_);\\(\"$\"#,##0.00\\)"), - (8, "\"$\"#,##0.00_);[Red]\\(\"$\"#,##0.00\\)"), - (9, "0%"), - (10, "0.00%"), - (11, "0.00E+00"), - (12, "# ?/?"), - (13, "# ??/??"), - (14, "m/d/yyyy"), - (15, "d-mmm-yy"), - (16, "d-mmm"), - (17, "mmm-yy"), - (18, "h:mm AM/PM"), - (19, "h:mm:ss AM/PM"), - (20, "h:mm"), - (21, "h:mm:ss"), - (22, "m/d/yyyy h:mm"), - (37, "#,##0 ;(#,##0)"), - (38, "#,##0 ;[Red](#,##0)"), - (39, "#,##0.00;(#,##0.00)"), - (40, "#,##0.00;[Red](#,##0.00)"), - (41, "_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"), - (42, "_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"), - (43, "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)"), - (44, "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), - (45, "mm:ss"), - (46, "[h]:mm:ss"), - (47, "mmss.0"), - (48, "##0.0E+0"), - (49, "@"), -]; - -/// Built-in format code for an id (0..=49, en-US), e.g. 14 => "m/d/yyyy". Locale-specific ids -/// (23–36) and unknown ids return `None`. -pub fn builtin_format(id: u32) -> Option<&'static str> { - BUILTINS.iter().find(|(i, _)| *i == id).map(|(_, c)| *c) -} - -/// Id of a built-in format code. Matching ignores case and backslash escapes. -pub fn builtin_id(code: &str) -> Option { - if let Some((id, _)) = BUILTINS.iter().find(|(_, c)| *c == code) { - return Some(*id); - } - let norm = |s: &str| s.chars().filter(|&c| c != '\\').flat_map(char::to_lowercase).collect::(); - let n = norm(code); - BUILTINS.iter().find(|(_, c)| norm(c) == n).map(|(id, _)| *id) -} diff --git a/crates/numfmt/src/decimal.rs b/crates/numfmt/src/decimal.rs index 67071d4e05..5248eaf240 100644 --- a/crates/numfmt/src/decimal.rs +++ b/crates/numfmt/src/decimal.rs @@ -4,6 +4,8 @@ //! digits and then rounded half away from zero on that decimal string. That makes 2.675 with //! "0.00" show 2.68 and 1.005 show 1.01, as in Excel, even though neither is exact in binary. +use std::fmt::Write as _; + /// Most decimals we ever compute; placeholders beyond this are padded with zeros. pub(crate) const MAX_DECIMALS: usize = 400; @@ -21,7 +23,8 @@ pub(crate) fn decimal_digits(a: f64) -> ([u8; 15], i32) { } // 16 digits, rounded. Unless the 16th is a 5 it decides the 15th exactly (4 or less rounds // down, 6 or more up), so one format serves almost every number. - let (d16, exp) = significant_digits(a, 16); + let mut d16 = [0u8; 16]; + let exp = significant_digits(a, &mut d16); for (slot, d) in digits.iter_mut().zip(&d16) { *slot = *d; } @@ -30,11 +33,8 @@ pub(crate) fn decimal_digits(a: f64) -> ([u8; 15], i32) { 5 if is_exact_tie(a) => (digits, exp), 5 => { // Not a tie: round the exact value (the 16-digit rounding above may be a double rounding). - let (d15, exp15) = significant_digits(a, 15); let mut out = [0u8; 15]; - for (slot, d) in out.iter_mut().zip(&d15) { - *slot = *d; - } + let exp15 = significant_digits(a, &mut out); (out, exp15) } _ => { @@ -59,12 +59,54 @@ pub(crate) fn decimal_digits(a: f64) -> ([u8; 15], i32) { } } -/// `n` significant digits of `a` (rounded half to even on the exact binary value) and the power of -/// ten of the first. -fn significant_digits(a: f64, n: usize) -> (Vec, i32) { - let s = format!("{:.*e}", n.saturating_sub(1), a); - let (mant, exp) = s.split_once('e').unwrap_or((s.as_str(), "0")); - (mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').collect(), exp.parse().unwrap_or(0)) +/// Writes `out.len()` significant digits of `a` (rounded half to even on the exact binary value) +/// into `out` and returns the power of ten of the first. +/// +/// Up to 32 digits are formatted in a stack buffer; more (the 800 of the tie check) use a string. +fn significant_digits(a: f64, out: &mut [u8]) -> i32 { + fn parse(s: &str, out: &mut [u8]) -> i32 { + let (mant, exp) = s.split_once('e').unwrap_or((s, "0")); + let digits = mant.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0'); + for (slot, d) in out.iter_mut().zip(digits) { + *slot = d; + } + exp.parse().unwrap_or(0) + } + let prec = out.len().saturating_sub(1); + if out.len() <= 32 { + let mut buf = StackStr::default(); + if write!(buf, "{:.*e}", prec, a).is_ok() { + return parse(buf.as_str(), out); + } + } + parse(&format!("{:.*e}", prec, a), out) +} + +/// A fixed-size string buffer for short number formatting; a write that does not fit fails. +struct StackStr { + buf: [u8; 64], + len: usize, +} + +impl Default for StackStr { + fn default() -> Self { + StackStr { buf: [0; 64], len: 0 } + } +} + +impl StackStr { + fn as_str(&self) -> &str { + self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("") + } +} + +impl std::fmt::Write for StackStr { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let end = self.len.checked_add(s.len()).ok_or(std::fmt::Error)?; + self.buf.get_mut(self.len..end).ok_or(std::fmt::Error)?.copy_from_slice(s.as_bytes()); + self.len = end; + Ok(()) + } } /// True when `a`'s exact decimal expansion has a 5 as its 16th significant digit and only zeros after. @@ -72,27 +114,59 @@ fn is_exact_tie(a: f64) -> bool { let is_tie = |ds: &[u8]| ds.get(15) == Some(&5) && ds.get(16..).is_some_and(|t| t.iter().all(|&d| d == 0)); // A cheap screen first (30 digits), then the exact expansion: a double has at most 767 // significant digits and Rust prints every one of them. - is_tie(&significant_digits(a, 30).0) && is_tie(&significant_digits(a, 800).0) + let mut screen = [0u8; 30]; + significant_digits(a, &mut screen); + if !is_tie(&screen) { + return false; + } + let mut exact = vec![0u8; 800]; + significant_digits(a, &mut exact); + is_tie(&exact) } /// Rounds the number `digits × 10^exp` (see [`decimal_digits`]) to `decimals` places, half away /// from zero. Returns the integer digits ("" when the integer part is zero) and exactly /// `decimals` fraction digits. pub(crate) fn fixed_from_digits(digits: &[u8; 15], exp: i32, decimals: usize) -> (String, String) { + let (mut int, mut frac) = (String::new(), String::new()); + fixed_into(digits, exp, decimals, &mut int, &mut frac); + (int, frac) +} + +/// [`fixed_from_digits`] into caller-owned strings (cleared first), so a loop can reuse them. +/// +/// The rounded digits are `lead` (at most 16, a carry can add a leading 1) followed by `zeros` +/// zero digits (placeholders beyond the 15 known digits), which is all a `Vec` of them held. +fn fixed_into(digits: &[u8; 15], exp: i32, decimals: usize, int: &mut String, frac: &mut String) { + int.clear(); + frac.clear(); let decimals_calc = decimals.min(MAX_DECIMALS); let keep = exp as i64 + decimals_calc as i64 + 1; - let mut m: Vec = if keep <= 0 { - if keep == 0 && digits.first().copied().unwrap_or(0) >= 5 { vec![1] } else { Vec::new() } + let mut head = [0u8; 16]; + let mut head_len = 0usize; + let mut zeros = 0usize; + if keep <= 0 { + if keep == 0 && digits.first().copied().unwrap_or(0) >= 5 { + if let Some(h) = head.first_mut() { + *h = 1; + } + head_len = 1; + } } else if keep as usize >= digits.len() { - let mut v = digits.to_vec(); - v.resize(keep as usize, 0); - v + for (h, d) in head.iter_mut().zip(digits) { + *h = *d; + } + head_len = digits.len(); + zeros = keep as usize - digits.len(); } else { let keep = keep as usize; - let mut v = digits.get(..keep).map(<[u8]>::to_vec).unwrap_or_default(); + for (h, d) in head.iter_mut().zip(digits.iter().take(keep)) { + *h = *d; + } + head_len = keep; if digits.get(keep).copied().unwrap_or(0) >= 5 { let mut carry = true; - for d in v.iter_mut().rev() { + for d in head.iter_mut().take(keep).rev() { if *d == 9 { *d = 0; } else { @@ -102,26 +176,36 @@ pub(crate) fn fixed_from_digits(digits: &[u8; 15], exp: i32, decimals: usize) -> } } if carry { - v.insert(0, 1); + head.copy_within(0..keep, 1); + if let Some(h) = head.first_mut() { + *h = 1; + } + head_len += 1; } } - v + } + let total = head_len + zeros; + let mut lead = head.iter().take(head_len).take_while(|&&d| d == 0).count(); + if lead == head_len { + lead = total; // all zeros + } + let len = total - lead; + // Digit `k` of the rounded number after its leading zeros. + let digit_at = |k: usize| { + let pos = lead + k; + if pos < head_len { head.get(pos).map_or('0', |&d| char::from(b'0' + d)) } else { '0' } }; - let lead = m.iter().take_while(|&&d| d == 0).count(); - m.drain(..lead); - let to_s = |ds: &[u8]| ds.iter().map(|d| char::from(b'0' + d)).collect::(); - let (int, mut frac) = if m.len() <= decimals_calc { - let mut f = "0".repeat(decimals_calc - m.len()); - f.push_str(&to_s(&m)); - (String::new(), f) + if len <= decimals_calc { + frac.extend(std::iter::repeat_n('0', decimals_calc - len)); + frac.extend((0..len).map(digit_at)); } else { - let split = m.len() - decimals_calc; - (to_s(m.get(..split).unwrap_or(&[])), to_s(m.get(split..).unwrap_or(&[]))) - }; + let split = len - decimals_calc; + int.extend((0..split).map(digit_at)); + frac.extend((split..len).map(digit_at)); + } if decimals > decimals_calc { - frac.push_str(&"0".repeat(decimals - decimals_calc)); + frac.extend(std::iter::repeat_n('0', decimals - decimals_calc)); } - (int, frac) } /// Rounds a non-negative number to `decimals` places (see [`fixed_from_digits`]). @@ -145,21 +229,25 @@ pub fn format_general_fit(n: f64, max_chars: usize) -> Option { let sign = if n < 0.0 { "-" } else { "" }; let (digits, x) = decimal_digits(n.abs()); let int_len = sign.len() + (x.max(0) as usize) + 1; - // Fixed notation for magnitudes from 1E-4 up to what fits. + // Fixed notation for magnitudes from 1E-4 up to what fits. The 15 digits are computed once; + // each width only re-rounds them, into strings that are reused. + let (mut i, mut f, mut s) = (String::new(), String::new(), String::new()); if x >= -4 && int_len <= max_chars { let avail = max_chars - int_len; - let mut decimals = if avail >= 2 { avail - 1 } else { 0 }; + // Zeros past the 400th decimal are trimmed below anyway, so a huge width needs no more. + let mut decimals = (if avail >= 2 { avail - 1 } else { 0 }).min(MAX_DECIMALS); loop { - let (i, f) = fixed_from_digits(&digits, x, decimals); - let f = f.trim_end_matches('0'); - if i.is_empty() && f.is_empty() { + fixed_into(&digits, x, decimals, &mut i, &mut f); + let ft = f.trim_end_matches('0'); + if i.is_empty() && ft.is_empty() { break; // rounded away to zero: use scientific } - let mut s = String::from(sign); + s.clear(); + s.push_str(sign); s.push_str(if i.is_empty() { "0" } else { &i }); - if !f.is_empty() { + if !ft.is_empty() { s.push('.'); - s.push_str(f); + s.push_str(ft); } if s.chars().count() <= max_chars { return Some(s); @@ -172,19 +260,20 @@ pub fn format_general_fit(n: f64, max_chars: usize) -> Option { } for md in (0..=max_chars.min(14)).rev() { let mut e = x; - let (mut mi, mut mf) = fixed_from_digits(&digits, 0, md); - if mi.len() > 1 { + fixed_into(&digits, 0, md, &mut i, &mut f); + if i.len() > 1 { e += 1; - (mi, mf) = fixed_from_digits(&digits, -1, md); + fixed_into(&digits, -1, md, &mut i, &mut f); } - let mf = mf.trim_end_matches('0'); - let mut s = String::from(sign); - s.push_str(if mi.is_empty() { "0" } else { &mi }); - if !mf.is_empty() { + let ft = f.trim_end_matches('0'); + s.clear(); + s.push_str(sign); + s.push_str(if i.is_empty() { "0" } else { &i }); + if !ft.is_empty() { s.push('.'); - s.push_str(mf); + s.push_str(ft); } - s.push_str(&format!("E{}{:02}", if e < 0 { '-' } else { '+' }, e.unsigned_abs())); + let _ = write!(s, "E{}{:02}", if e < 0 { '-' } else { '+' }, e.unsigned_abs()); if s.chars().count() <= max_chars { return Some(s); } @@ -208,6 +297,10 @@ mod tests { assert_eq!(fixed(0.0005, 3), ("".into(), "001".into())); assert_eq!(fixed(1e20, 0).0, "100000000000000000000"); assert_eq!(fixed(0.0, 2), ("".into(), "00".into())); + // Past 400 decimals only zeros are added; huge exponents keep all their digits. + assert_eq!(fixed(1.5, 450), ("1".into(), format!("5{}", "0".repeat(449)))); + assert_eq!(fixed(1e300, 1), (format!("1{}", "0".repeat(300)), "0".into())); + assert_eq!(fixed(123456789012345678.0, 0).0, "123456789012346000"); } #[test] diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 674e84446f..cf614ca523 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -3,7 +3,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] -mod builtin; mod decimal; mod parse; mod render; @@ -12,7 +11,6 @@ use std::sync::Arc; use gridcraft_core::{CellError, DateSystem, Value}; -pub use builtin::{builtin_format, builtin_id}; pub use decimal::format_general_fit; use parse::{SecKind, Section, Tok}; diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index e54f04c9fa..86c53f7e5d 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -54,19 +54,29 @@ pub(crate) enum Tok { } impl Tok { - /// The text this token shows when it has no special meaning in its section. - pub(crate) fn literal_text(&self) -> String { + /// Appends the text this token shows when it has no special meaning in its section. + pub(crate) fn push_literal(&self, out: &mut String) { match self { - Tok::Lit(s) | Tok::FixedDenom(s) => s.clone(), - Tok::Digit(c) => c.to_string(), - Tok::Point => ".".into(), - Tok::Comma => ",".into(), - Tok::Percent => "%".into(), - Tok::Exp { upper, plus } => format!("{}{}", if *upper { 'E' } else { 'e' }, if *plus { '+' } else { '-' }), - Tok::Slash => "/".into(), - Tok::Fill(_) | Tok::At | Tok::General | Tok::Date(_) => String::new(), + Tok::Lit(s) | Tok::FixedDenom(s) => out.push_str(s), + Tok::Digit(c) => out.push(*c), + Tok::Point => out.push('.'), + Tok::Comma => out.push(','), + Tok::Percent => out.push('%'), + Tok::Exp { upper, plus } => { + out.push(if *upper { 'E' } else { 'e' }); + out.push(if *plus { '+' } else { '-' }); + } + Tok::Slash => out.push('/'), + Tok::Fill(_) | Tok::At | Tok::General | Tok::Date(_) => {} } } + + /// The text this token shows when it has no special meaning in its section. + pub(crate) fn literal_text(&self) -> String { + let mut s = String::new(); + self.push_literal(&mut s); + s + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -122,6 +132,13 @@ pub(crate) struct Section { pub fraction: bool, pub currency: bool, pub fill: bool, + /// Date sections: the most digits of any `.0`/`.00`/`.000` part (at most 3), which decides how + /// the time of day is rounded. + pub sub_digits: u8, + /// Date sections: has a year, month or day part, so the serial's date is needed. + pub calendar: bool, + /// Date sections: has `AM/PM` or `A/P`, so hours show on the 12-hour clock. + pub ampm: bool, } /// Splits a code into sections at unquoted, unescaped, unbracketed semicolons. @@ -442,6 +459,9 @@ fn analyze(raw: Raw) -> Section { fraction: false, currency, fill: toks.iter().any(|t| matches!(t, Tok::Fill(_))), + sub_digits: 0, + calendar: false, + ampm: false, }; if is_date { sec.kind = SecKind::Date; @@ -480,6 +500,9 @@ fn analyze(raw: Raw) -> Section { } i += 1; } + sec.sub_digits = out.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3); + sec.calendar = out.iter().any(|t| matches!(t, Tok::Date(d) if d.is_calendar())); + sec.ampm = out.iter().any(|t| matches!(t, Tok::Date(DatePart::AmPm | DatePart::AP(..)))); sec.toks = out; return sec; } diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 7befe591ab..0f4d9abdb4 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -1,5 +1,7 @@ //! Rendering a value with a parsed section. +use std::fmt::Write as _; + use gridcraft_core::DateSystem; use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial}; @@ -22,6 +24,10 @@ impl Out { self.fill = Some((c, self.text.len())); } } + /// Appends what `t` shows when it has no special meaning (no temporary string). + fn push_tok(&mut self, t: &Tok) { + t.push_literal(&mut self.text); + } pub(crate) fn prepend(&mut self, s: &str) { self.text.insert_str(0, s); if let Some((_, p)) = &mut self.fill { @@ -37,7 +43,7 @@ pub(crate) fn render_text(sec: &Section, text: &str) -> Out { match t { Tok::At => out.push(text), Tok::Fill(c) => out.fill(*c), - other => out.push(&other.literal_text()), + other => out.push_tok(other), } } out @@ -50,7 +56,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { match t { Tok::General => out.push(&format_general_fit(v, width).unwrap_or_else(|| "#".repeat(width.clamp(1, 11)))), Tok::Fill(c) => out.fill(*c), - other => out.push(&other.literal_text()), + other => out.push_tok(other), } } out @@ -61,8 +67,7 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) { return None; } - let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3); - let unit: i64 = 10i64.pow(3 - sub_digits as u32); + let unit: i64 = 10i64.pow(3 - u32::from(sec.sub_digits.min(3))); let mut day = v.floor() as i64; let mut ms = (((v - v.floor()) * 86_400_000.0) / unit as f64).round() as i64 * unit; if ms >= 86_400_000 { @@ -70,38 +75,33 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option day += 1; } let total_ms = day.saturating_mul(86_400_000).saturating_add(ms); - let needs_date = sec.toks.iter().any(|t| matches!(t, Tok::Date(d) if d.is_calendar())); - let dt = if needs_date { datetime_from_serial(sys, day as f64)? } else { datetime_from_serial(sys, 0.0).unwrap_or_default() }; - let ampm = sec.toks.iter().any(|t| matches!(t, Tok::Date(DatePart::AmPm | DatePart::AP(..)))); + let dt = if sec.calendar { datetime_from_serial(sys, day as f64)? } else { datetime_from_serial(sys, 0.0).unwrap_or_default() }; + let ampm = sec.ampm; let hour = ms / 3_600_000; let minute = (ms / 60_000) % 60; let second = (ms / 1000) % 60; let milli = ms % 1000; - let pad = |n: i64, w: u8| if w >= 2 { format!("{:0w$}", n, w = w.min(20) as usize) } else { n.to_string() }; let month_name = MONTHS.get((dt.month as usize).wrapping_sub(1)).copied().unwrap_or(""); let day_name = WEEKDAYS.get(dt.weekday as usize).copied().unwrap_or(""); let mut out = Out::default(); for t in &sec.toks { match t { Tok::Date(d) => { - let s = match *d { + let text = &mut out.text; + match *d { DatePart::Year(n) => { - if n <= 2 { - format!("{:02}", dt.year.rem_euclid(100)) - } else { - format!("{:04}", dt.year) - } + let _ = if n <= 2 { write!(text, "{:02}", dt.year.rem_euclid(100)) } else { write!(text, "{:04}", dt.year) }; } DatePart::Month(n) => match n { - 1 | 2 => pad(dt.month as i64, n), - 3 => month_name.chars().take(3).collect(), - 5 => month_name.chars().take(1).collect(), - _ => month_name.to_string(), + 1 | 2 => push_padded(text, dt.month as i64, n), + 3 => text.extend(month_name.chars().take(3)), + 5 => text.extend(month_name.chars().take(1)), + _ => text.push_str(month_name), }, DatePart::Day(n) => match n { - 1 | 2 => pad(dt.day as i64, n), - 3 => day_name.chars().take(3).collect(), - _ => day_name.to_string(), + 1 | 2 => push_padded(text, dt.day as i64, n), + 3 => text.extend(day_name.chars().take(3)), + _ => text.push_str(day_name), }, DatePart::Hour(n) => { let h = if ampm { @@ -110,78 +110,168 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option } else { hour }; - pad(h, n) + push_padded(text, h, n); } - DatePart::Minute(n) => pad(minute, n), - DatePart::Second(n) => pad(second, n), + DatePart::Minute(n) => push_padded(text, minute, n), + DatePart::Second(n) => push_padded(text, second, n), DatePart::SubSec(n) => { - let mut s = format!(".{:03}", milli); - s.truncate(1 + (n as usize).min(3)); - if n > 3 { - s.push_str(&"0".repeat(n as usize - 3)); + // `.` and the first `n` (at most 3) of the three millisecond digits, then + // zeros for the digits beyond milliseconds. + text.push('.'); + for (k, div) in [100, 10, 1].into_iter().enumerate() { + if k < n as usize { + text.push(char::from(b'0' + (milli / div % 10) as u8)); + } + } + for _ in 3..n { + text.push('0'); } - s } - DatePart::ElapsedH(n) => pad(total_ms / 3_600_000, n), - DatePart::ElapsedM(n) => pad(total_ms / 60_000, n), - DatePart::ElapsedS(n) => pad(total_ms / 1000, n), - DatePart::AmPm => (if hour >= 12 { "PM" } else { "AM" }).to_string(), - DatePart::AP(a, p) => (if hour >= 12 { p } else { a }).to_string(), - }; - out.push(&s); + DatePart::ElapsedH(n) => push_padded(text, total_ms / 3_600_000, n), + DatePart::ElapsedM(n) => push_padded(text, total_ms / 60_000, n), + DatePart::ElapsedS(n) => push_padded(text, total_ms / 1000, n), + DatePart::AmPm => text.push_str(if hour >= 12 { "PM" } else { "AM" }), + DatePart::AP(a, p) => text.push(if hour >= 12 { p } else { a }), + } } Tok::Fill(c) => out.fill(*c), - other => out.push(&other.literal_text()), + other => out.push_tok(other), } } Some(out) } +/// Appends `n`, zero padded to `w` digits (at most 20) when `w` is 2 or more. +fn push_padded(out: &mut String, n: i64, w: u8) { + let _ = if w >= 2 { write!(out, "{:0w$}", n, w = w.min(20) as usize) } else { write!(out, "{n}") }; +} + +/// Scratch text of one number rendering: every token's own output (`piece`, replaces the token's +/// text), the text inserted ahead of a token (`before`) and the replacement of a literal +/// (`lit_spaces`: fractions blank the literals of a hidden part) live in ONE buffer, addressed +/// by byte ranges, instead of one string per token. Writing a piece again appends the new text +/// and points the slot at it. +struct Parts { + buf: String, + slots: Vec, +} + +#[derive(Clone, Copy, Default)] +struct Slot { + piece: (usize, usize), + before: (usize, usize), + /// `Some(k)`: show `k` spaces instead of the literal. + lit_spaces: Option, +} + +impl Parts { + fn new(n: usize) -> Parts { + Parts { buf: String::new(), slots: vec![Slot::default(); n + 1] } + } + /// Start of a piece written straight into the buffer; finish it with [`Parts::end_piece`]. + fn mark(&self) -> usize { + self.buf.len() + } + fn end_piece(&mut self, ti: usize, start: usize) { + let end = self.buf.len(); + if let Some(sl) = self.slots.get_mut(ti) { + sl.piece = (start, end); + } + } + fn set_piece(&mut self, ti: usize, text: &str) { + let start = self.mark(); + self.buf.push_str(text); + self.end_piece(ti, start); + } + fn set_spaces(&mut self, ti: usize, k: usize) { + let start = self.mark(); + self.buf.extend(std::iter::repeat_n(' ', k)); + self.end_piece(ti, start); + } + fn set_before(&mut self, ti: usize, text: &str) { + let start = self.mark(); + self.buf.push_str(text); + let end = self.buf.len(); + if let Some(sl) = self.slots.get_mut(ti) { + sl.before = (start, end); + } + } + fn set_lit_spaces(&mut self, ti: usize, k: usize) { + if let Some(sl) = self.slots.get_mut(ti) { + sl.lit_spaces = Some(k); + } + } + fn text(&self, (a, b): (usize, usize)) -> &str { + self.buf.get(a..b).unwrap_or("") + } + fn piece(&self, ti: usize) -> &str { + self.slots.get(ti).map_or("", |sl| self.text(sl.piece)) + } + fn before(&self, ti: usize) -> &str { + self.slots.get(ti).map_or("", |sl| self.text(sl.before)) + } +} + /// Assigns integer digits right-to-left to the placeholders at `idxs`; the leftmost placeholder /// takes all remaining digits. `force_zero` shows `0` in the units placeholder when there are no /// digits. A blank `?` pads with a space, and so does the thousands separator it would carry. -fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, pieces: &mut [String]) { - let ds: Vec = digits.chars().collect(); +/// +/// `digits` is ASCII (digit strings made by this crate), so bytes are characters. Each +/// placeholder's text is written left to right: a digit, then the separator that follows it when +/// its position counted from the right is a multiple of 3. +fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, parts: &mut Parts) { + let ds = digits.as_bytes(); let mut rem = ds.len(); let mut pos = 0usize; let count = idxs.len(); + let sep = |p: usize| thousands && p > 0 && p.is_multiple_of(3); for (k, &ti) in idxs.iter().rev().enumerate() { let leftmost = k + 1 == count; let ph = match toks.get(ti) { Some(Tok::Digit(c)) => *c, _ => '0', }; - let mut rev = String::new(); - let push_digit = |c: char, rev: &mut String, pos: &mut usize| { - if thousands && *pos > 0 && pos.is_multiple_of(3) { - rev.push(','); - } - rev.push(c); - *pos += 1; - }; + let start = parts.mark(); let take = if leftmost { rem } else { rem.min(1) }; if take == 0 { match ph { - '0' => push_digit('0', &mut rev, &mut pos), - _ if force_zero && k == 0 => push_digit('0', &mut rev, &mut pos), + '0' => { + parts.buf.push('0'); + if sep(pos) { + parts.buf.push(','); + } + pos += 1; + } + _ if force_zero && k == 0 => { + parts.buf.push('0'); + if sep(pos) { + parts.buf.push(','); + } + pos += 1; + } '?' => { - if thousands && pos > 0 && pos.is_multiple_of(3) { - rev.push(' '); + parts.buf.push(' '); + if sep(pos) { + parts.buf.push(' '); } - rev.push(' '); pos += 1; } _ => {} } } else { - for _ in 0..take { - rem -= 1; - push_digit(ds.get(rem).copied().unwrap_or('0'), &mut rev, &mut pos); + // The `take` digits just left of `rem`, most significant first; the digit `s` places + // from the right end of the run sits at position `pos + s`. + for s in (0..take).rev() { + let d = ds.get(rem.saturating_sub(1 + s)).copied().unwrap_or(b'0'); + parts.buf.push(char::from(d)); + if sep(pos + s) { + parts.buf.push(','); + } } + rem -= take; + pos += take; } - if let Some(p) = pieces.get_mut(ti) { - *p = rev.chars().rev().collect(); - } + parts.end_piece(ti, start); } } @@ -201,26 +291,26 @@ fn with_commas(digits: &str, thousands: bool) -> String { } /// Fraction-digit placeholders: trailing zeros show as `0`, nothing (`#`) or a space (`?`). -fn fill_frac(toks: &[Tok], idxs: &[usize], frac: &str, pieces: &mut [String]) { - let fd: Vec = frac.chars().collect(); - let last_sig = fd.iter().rposition(|&c| c != '0'); +/// `frac` is ASCII digits. +fn fill_frac(toks: &[Tok], idxs: &[usize], frac: &str, parts: &mut Parts) { + let fd = frac.as_bytes(); + let last_sig = fd.iter().rposition(|&c| c != b'0'); for (i, &ti) in idxs.iter().enumerate() { let ph = match toks.get(ti) { Some(Tok::Digit(c)) => *c, _ => '0', }; - let s = if last_sig.is_some_and(|l| i <= l) { - fd.get(i).copied().unwrap_or('0').to_string() + let start = parts.mark(); + if last_sig.is_some_and(|l| i <= l) { + parts.buf.push(char::from(fd.get(i).copied().unwrap_or(b'0'))); } else { match ph { - '0' => "0".into(), - '?' => " ".into(), - _ => String::new(), + '0' => parts.buf.push('0'), + '?' => parts.buf.push(' '), + _ => {} } - }; - if let Some(p) = pieces.get_mut(ti) { - *p = s; } + parts.end_piece(ti, start); } } @@ -242,14 +332,11 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { } let toks = &sec.toks; let n = toks.len(); - // Pieces replace the token's own output; `before` is inserted ahead of a token; `lits` - // replaces a literal's text (fractions blank the literals of a hidden part). - let mut pieces: Vec = vec![String::new(); n]; - let mut before: Vec = vec![String::new(); n + 1]; - let mut lits: Vec> = Vec::new(); + // Pieces replace the token's own output; `before` is inserted ahead of a token; literal + // replacements blank the literals of a hidden fraction part (see [`Parts`]). + let mut parts = Parts::new(n); if sec.fraction { - lits = vec![None; n]; - render_fraction(sec, x, &mut pieces, &mut lits); + render_fraction(sec, x, &mut parts); } else { let point = toks.iter().position(|t| matches!(t, Tok::Point)); let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. })); @@ -283,59 +370,56 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { if int_s.is_empty() && frac_s.chars().all(|c| c == '0') { e = 0; } - let mut head = String::from(if upper { "E" } else { "e" }); + let start = parts.mark(); + parts.buf.push(if upper { 'E' } else { 'e' }); if e < 0 { - head.push('-'); + parts.buf.push('-'); } else if plus { - head.push('+'); + parts.buf.push('+'); } let width = exp_idx.iter().filter(|&&i| matches!(toks.get(i), Some(Tok::Digit('0')))).count().min(20); - let digits = format!("{:0w$}", e.unsigned_abs(), w = width); match exp_idx.first() { Some(&first) => { - if let Some(p) = pieces.get_mut(e_at) { - *p = head; - } - if let Some(p) = pieces.get_mut(first) { - *p = digits; - } + parts.end_piece(e_at, start); + let start = parts.mark(); + let _ = write!(parts.buf, "{:0w$}", e.unsigned_abs(), w = width); + parts.end_piece(first, start); } None => { - if let Some(p) = pieces.get_mut(e_at) { - *p = head + &digits; - } + let _ = write!(parts.buf, "{:0w$}", e.unsigned_abs(), w = width); + parts.end_piece(e_at, start); } } (int_s, frac_s) } else { fixed(x, frac_idx.len()) }; - fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces); + fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut parts); if int_idx.is_empty() && !int_s.is_empty() && let Some(p) = point.or(exp) - && let Some(b) = before.get_mut(p) { - *b = with_commas(&int_s, sec.thousands); + parts.set_before(p, &with_commas(&int_s, sec.thousands)); } - fill_frac(toks, &frac_idx, &frac_s, &mut pieces); - if let Some(p) = point - && let Some(s) = pieces.get_mut(p) - { - *s = ".".into(); + fill_frac(toks, &frac_idx, &frac_s, &mut parts); + if let Some(p) = point { + parts.set_piece(p, "."); } } let mut out = Out::default(); for (i, t) in toks.iter().enumerate() { - if let Some(b) = before.get(i) { - out.push(b); - } + out.push(parts.before(i)); match t { - Tok::Lit(s) => out.push(lits.get(i).and_then(Option::as_deref).unwrap_or(s)), + Tok::Lit(s) => match parts.slots.get(i).and_then(|sl| sl.lit_spaces) { + Some(k) => out.text.extend(std::iter::repeat_n(' ', k)), + None => out.push(s), + }, Tok::Percent => out.push("%"), Tok::Fill(c) => out.fill(*c), - Tok::At | Tok::General | Tok::Date(_) | Tok::Comma => {} - _ => out.push(pieces.get(i).map(String::as_str).unwrap_or("")), + // Every other token shows its piece. `At` (`0@`) is reachable here, `Comma`, `General` + // and `Date` are not (analysis turns commas into literals and routes sections with + // dates or General elsewhere); none of them is ever given a piece, so they print "". + _ => out.push(parts.piece(i)), } } out @@ -390,22 +474,14 @@ fn shown_width(t: &Tok) -> usize { } /// Replaces the literals in `range` with spaces (`spaces`) or nothing; placeholders and the bar -/// in it get the same treatment through `pieces`. -fn blank_range(toks: &[Tok], range: std::ops::Range, spaces: bool, pieces: &mut [String], lits: &mut [Option]) { +/// in it get the same treatment as pieces. +fn blank_range(toks: &[Tok], range: std::ops::Range, spaces: bool, parts: &mut Parts) { for i in range { let Some(t) = toks.get(i) else { break }; - let s = if spaces { " ".repeat(shown_width(t)) } else { String::new() }; + let width = if spaces { shown_width(t) } else { 0 }; match t { - Tok::Lit(_) => { - if let Some(l) = lits.get_mut(i) { - *l = Some(s); - } - } - Tok::Digit(_) | Tok::Slash | Tok::FixedDenom(_) => { - if let Some(p) = pieces.get_mut(i) { - *p = s; - } - } + Tok::Lit(_) => parts.set_lit_spaces(i, width), + Tok::Digit(_) | Tok::Slash | Tok::FixedDenom(_) => parts.set_spaces(i, width), _ => {} } } @@ -419,7 +495,7 @@ fn blank_range(toks: &[Tok], range: std::ops::Range, spaces: bool, pieces /// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and /// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear /// (or become spaces when any part has a `?`) and the whole part shows at least `0`. -fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option]) { +fn render_fraction(sec: &Section, x: f64, parts: &mut Parts) { let toks = &sec.toks; let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_))); let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return }; @@ -467,31 +543,27 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() }; let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0'))); if has_whole && num == 0 && !num_zero_ph { - fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces); + fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, parts); let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx); - blank_range(toks, sep_start..den_end, spaces, pieces, lits); + blank_range(toks, sep_start..den_end, spaces, parts); return; } if has_whole { // A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one. let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#'))); let force = whole_s.is_empty() && num == 0 && !units_hash; - fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces); - let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit()))); + fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, parts); + let whole_shown = whole_idx.iter().any(|&i| parts.piece(i).chars().any(|c| c.is_ascii_digit())); if !whole_shown { let spaces = has_q(&whole_idx) || has_q(&num_idx); - blank_range(toks, sep_start..num_start, spaces, pieces, lits); + blank_range(toks, sep_start..num_start, spaces, parts); } } - fill_int(toks, &num_idx, &num.to_string(), false, true, pieces); - if let Some(p) = pieces.get_mut(slash) { - *p = "/".into(); - } + fill_int(toks, &num_idx, &num.to_string(), false, true, parts); + parts.set_piece(slash, "/"); let den_s = den.to_string(); if fixed_den.is_some() { - if let Some(p) = pieces.get_mut(den_start) { - *p = den_s; - } + parts.set_piece(den_start, &den_s); } else { // Denominator digits are zero padded to the `0` placeholders, then left aligned; `?` // pads with spaces after them. @@ -514,9 +586,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt } else { s }; - if let Some(p) = pieces.get_mut(ti) { - *p = s; - } + parts.set_piece(ti, &s); } } } diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 6c2306c4fc..324a0965d2 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -2,6 +2,9 @@ use super::*; const S: DateSystem = DateSystem::D1900; +/// Built-in format 44 (accounting with a currency symbol, two decimals). +const ACCOUNTING_2DP: &str = "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"; + fn f(code: &str, n: f64) -> String { format_value(&Value::Number(n), &NumberFormat::parse(code), S).text } @@ -208,7 +211,7 @@ fn literals_and_fill() { assert_eq!(f("(0)", 5.0), "(5)"); let r = full("*-0", Value::Number(5.0)); assert_eq!((r.text.as_str(), r.fill), ("5", Some(('-', 0)))); - let acct = builtin_format(44).unwrap(); + let acct = ACCOUNTING_2DP; let r = full(acct, Value::Number(0.0)); assert_eq!((r.text.as_str(), r.fill), (" $- ", Some((' ', 2)))); let r = full(acct, Value::Number(1234.5)); @@ -294,7 +297,7 @@ fn kinds() { assert_eq!(NumberFormat::parse("0.00").kind(), FormatKind::Number); assert_eq!(NumberFormat::parse("#,##0.00;[Red](#,##0.00)").kind(), FormatKind::Number); assert_eq!(NumberFormat::parse("\"$\"#,##0.00").kind(), FormatKind::Currency); - assert_eq!(NumberFormat::parse(builtin_format(44).unwrap()).kind(), FormatKind::Accounting); + assert_eq!(NumberFormat::parse(ACCOUNTING_2DP).kind(), FormatKind::Accounting); assert_eq!(NumberFormat::parse("m/d/yyyy").kind(), FormatKind::Date); assert_eq!(NumberFormat::parse("h:mm AM/PM").kind(), FormatKind::Time); assert_eq!(NumberFormat::parse("0%").kind(), FormatKind::Percentage); @@ -306,25 +309,11 @@ fn kinds() { } #[test] -fn builtins() { - assert_eq!(builtin_format(0), Some("General")); - assert_eq!(builtin_format(14), Some("m/d/yyyy")); - assert_eq!(builtin_format(49), Some("@")); - assert_eq!(builtin_format(30), None); - assert_eq!(builtin_format(50), None); - assert_eq!(builtin_id("m/d/yyyy"), Some(14)); - assert_eq!(builtin_id("0.00%"), Some(10)); - assert_eq!(builtin_id("GENERAL"), Some(0)); - assert_eq!(builtin_id("[h]:mm:ss"), Some(46)); - assert_eq!(builtin_id("#,##0.00_);(#,##0.00)"), None); - for id in 0..=60 { - if let Some(code) = builtin_format(id) { - assert_eq!(builtin_id(code), Some(id)); - } - } - assert_eq!(f(builtin_format(5).unwrap(), -1234.0), "($1,234)"); - assert_eq!(f(builtin_format(12).unwrap(), 0.5), " 1/2"); - assert_eq!(f(builtin_format(47).unwrap(), 61.2 / 86400.0), "0101.2"); +fn builtin_codes_render() { + // Built-in ids 5, 12 and 47 (the id tables live with the file formats, in gridcraft-xlsx). + assert_eq!(f("\"$\"#,##0_);\\(\"$\"#,##0\\)", -1234.0), "($1,234)"); + assert_eq!(f("# ?/?", 0.5), " 1/2"); + assert_eq!(f("mmss.0", 61.2 / 86400.0), "0101.2"); } #[test] diff --git a/crates/numfmt/tests/golden/codes.txt b/crates/numfmt/tests/golden/codes.txt new file mode 100644 index 0000000000..c26cd09b7c --- /dev/null +++ b/crates/numfmt/tests/golden/codes.txt @@ -0,0 +1,152 @@ +1427c0507a69d2eb 79 "0.0000000000000000000000000" +fb07df5f50253a81 79 "#,###,###,###,###,###,###,###,##0.00" +c886ef7cc0381aca 79 "0.0000000000000000000000000E+00" +16878498a86d6270 79 "# ?????/?????" +25810fe3aa348757 79 "General" +27262e94f7f567fb 79 "0" +badf5806706d1eb9 79 "0.00" +356d81eb81b5c1ce 79 "#,##0" +47c31019ea98abf6 79 "#,##0.00" +0bea5e28b3073d86 79 "#.##" +381a3c37517d921f 79 "0.0##" +ec4cb40e0e418d4b 79 "00000" +708c5c852e5949f5 79 "000.000" +f13f654fb7675b9f 79 "#,###" +6b63e65690997dbe 79 "#,##0," +60fbcba49b9ed958 79 "#,##0,," +6fe193ff6ec54042 79 "0.0,,\"M\"" +502b10c567f98e3a 79 "?.??" +5cdf9aa173d09eb9 79 "##0.0" +b632beff97b1a188 79 "# " +d64d79d07498109c 79 "#" +63d32ebfca2f20cd 79 ".00" +30df5d8086c97cc3 79 ",0" +1790d042ecf903a7 79 "0,0" +2216be965ca95e38 79 "0.00," +d2a60a298ba94fbc 79 "\"$\"#,##0.00" +f82256c8ac29c8f3 79 "\"$\"#,##0_);\\(\"$\"#,##0\\)" +7fc54d574b4ee62c 79 "#,##0.00;[Red](#,##0.00)" +7a81a86d7a4d3c69 79 "#,##0 ;(#,##0)" +57a11793e92c9715 79 "0;-0;;@" +a3d6c52367c35259 79 "0.00;;" +b45d3fdec1e8ff9a 79 ";;;" +11a021e5408029bb 79 "0_);(0)" +b77cc8e5e4a2cff6 79 "0\" units\"" +c8c35b895f538f6e 79 "\\A0" +2c1c85cd54867d9a 79 "(0)" +c7825d9fb737ab18 79 "00-00" +4f2b6573a765b519 79 "000-00-0000" +28e5bc8fc3d88c8e 79 "[<=9999999]###-####;(###) ###-####" +9a39ae8b1322964a 79 "[$€-407]#,##0.00" +4e11e82e14ea8f05 79 "_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)" +fc2d989610e136f3 79 "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)" +bcb4f177a02c38ec 79 "*-0" +6962277e58b9ca7f 79 "0*x" +7f7be8ed5b287d55 79 "*0 0.0" +9a6620cdfa67c60b 79 "@*_" +e9cd06f4b22466e0 79 "0%" +055aa94e3d93fa22 79 "0.00%" +cda9c27d933bbdb5 79 "#,##0%" +3823f2bf58da2b43 79 "0.0%%" +3b5a859cd00d46b0 79 "%0" +7d08aed42e8d71e0 79 "0%;[Red]-0%" +d7d44df9f81bff2e 79 "0.00E+00" +381e808a21bcf893 79 "##0.0E+0" +497033fffb9c0967 79 "0E+0" +9679af8d3aa45c26 79 "0.0e-0" +eafc341422b759ce 79 "0.00E+000" +be98d0eb66d9340f 79 "E+0" +f9d5b40fa2875a1e 79 "0.00E+" +327f01fdb595078f 79 "#.##E+00" +8ecbeef181fb381e 79 "0.0E+00 \"x\"" +7b74fb5636bda27e 79 "# ?/?" +aa283fbec589cdfe 79 "# ??/??" +9c9625146b582644 79 "# ???/???" +104a4eed871bbbbc 79 "?/?" +3babcf6e56373ec4 79 "# ?/8" +c2e84ddf50f1e21d 79 "# ??/16" +c64b1289d9d7c8c8 79 "0/0" +6fe467468cd9a182 79 "# #/#" +19bb3d66694f66b4 79 "#/#" +36915f8285c425b0 79 "?/100" +02ee892d135e01dc 79 "# ?/?;-# ?/?" +4bd7e5c1d9b3503f 79 "0 0/0" +29ea815f2e3a3494 154 "m/d/yyyy" +531a582416d0c5d4 154 "d-mmm-yy" +f51f9fec20929fc4 154 "d-mmm" +79c4dd5daed427c7 154 "mmm-yy" +9a06f74bc39ff658 154 "h:mm AM/PM" +d37e3d929c0ac1ea 154 "h:mm:ss AM/PM" +35fd43ebcd1883c2 154 "h:mm" +5091126be1c9684c 154 "h:mm:ss" +7ff21bb4300f5821 154 "m/d/yyyy h:mm" +cfe25a8ab0fcb1d9 154 "mm:ss" +1a551c8487409852 154 "[h]:mm:ss" +d2a1b3141912902c 154 "[h]:mm" +9632b993c2c8e69a 154 "[m]:ss" +2661fe19d9c27306 154 "[s]" +ab841bd220023523 154 "mmss.0" +1de171cb0d3f5948 154 "yyyy-mm-dd" +8ec81e3ed1b082e5 154 "dddd, mmmm d, yyyy" +fdb38c903a0b4a4f 154 "ddd mmm dd yy" +4501b6cbaa2eec81 154 "mmmmm" +6508661a6696390f 154 "yy" +2ce87e066b2ab4bc 154 "h:mm:ss.000" +a5128e3da3860d4e 154 "h:mm:ss.0000" +f2378c3409049e3c 154 "hh:mm:ss A/P" +c45857863718c702 154 "[hh]:mm:ss" +284c6ba3203d1ab2 154 "d" +e6a8933306f18570 154 "dd" +376bd865212d488e 154 "dddd" +9673731c0e1f8ecb 154 "m" +a34805227ff3c285 154 "mm" +9892676ae9f19ce7 154 "s.00" +0dc1328ee314fdc9 154 "yyyy-mm-dd hh:mm:ss.00" +71d1fd9854e65d22 154 "h AM/PM" +c53458380b015df4 154 "[$-409]d-mmm-yy" +5e5f860723db7dc1 154 "yyyy\"年\"m\"月\"" +d37f36aabdb92d70 154 "[h]\" hrs\"" +840ce33e95f954b9 154 "mmmm yyyy;@" +01123dabdab536a7 79 "@" +59fa7b60ca99b9b7 79 "\"x\"@" +b1eee7ca88961e38 79 "0;\"neg\"0;\"zero\";\"txt: \"@" +4d3f8a891532765e 79 "General;General;General;@" +59dc8b6bd14d719a 79 "[Red]0.0;[Blue]-0.0" +6a4d8666a78e97b3 79 "[Green]0;[Magenta]0;[Cyan]0;[Yellow]@" +c6800ea84237b0cd 79 "[Color3]0.00" +158526f0a5b736c5 79 "[>100]\"big\"0;[<-100]\"small\"0;0.0" +7a94ed36a6bb6573 79 "[=0]\"zero\";0.0" +30d048d3f229b1ba 79 "[>=1000]#,##0,\"K\";0" +2b2b1f13faeb468d 79 "[<1]0.00%;0.0" +b78758e73c416a1e 79 "[Red][>5]0;0.00" +8b2379e7de300595 79 "General\" kg\"" +73f3485fe971d2bd 79 "\"v\"General" +2d2ce0ee57d199a2 79 "[Red]General" +ea2f102aad8c3c1b 79 "0.0;[Red]General" +19138ec795a21f84 79 "[" +d729803ef0c957af 79 "0.0.0" +881a7ad2d7598c49 79 "0\"" +68be1cb0ecbe9dd3 79 "\\" +6d238d51a33909b0 154 "[h" +ecaaf08cd448e3d6 79 "[$" +09a7d31cd1883ae0 154 "yyyy-mm-dd;" +b1bbc169427ccdf8 79 ";" +2cf377cde4d0586d 79 "0;0;0;0;0" +87c9d67f8a8c03ed 79 "\"abc" +a184947cbe851f9d 79 "[>x]0" +2fcb7cab18d2a6c2 79 "ggg" +74fb2bff8d4a4004 79 "#?#0" +daa714378f49de27 154 "0E" +f7947f333f8d9a2b 154 "E0" +e2a803b5dbd238d0 79 "/" +ee487da46393f66c 79 "0/" +37c33b0ac736269c 79 "/0" +94b161d4f81a6dc9 79 "*" +3c6524e708949388 79 "_" +9602d8d7388a2c94 79 "_0" +acee74b078767906 154 "[Red" +fe68d96a71bde370 79 "0.00.00%" +9ca39d7a52f509a9 154 "mm:ss.0.0" +ae753a95d91565ed 79 "[ABC]0" +a456ed739adcc87c 79 "[=1]0;[=2]0;0" +b0b3aa7b9f08690c 79 "0 ??/??;0" diff --git a/crates/numfmt/tests/golden/general.txt b/crates/numfmt/tests/golden/general.txt new file mode 100644 index 0000000000..ab338965e0 --- /dev/null +++ b/crates/numfmt/tests/golden/general.txt @@ -0,0 +1,555 @@ +5fda7bf42a47c479 1 0e0 +e400479573b2baf2 1 -0e0 +57f99cd6daf9cc72 1 1e0 +0ddafbadc6bf1a04 1 -1e0 +882d8b786644b3c6 1 5e-1 +846783d06acc0a60 1 -5e-1 +eb4b115b5778e14e 1 2.5e-1 +d320cfc393d5b774 1 1.005e0 +e7497067a4de5fb0 1 2.675e0 +68aed91bbaac1534 1 9.999995e2 +96a3c4703fc3b01a 1 9.995e-1 +df98061474126a71 1 9.995e0 +5916478863984e9b 1 9.95e1 +13813099e41458c9 1 5e-2 +e6381aba4f09a980 1 5e-3 +6c09e99da5f3e27f 1 5e-4 +1e9840c7c9074f0a 1 1e-5 +b8e3e7ad44ea4900 1 1e-10 +a9d70b3df9c809d1 1 1e-300 +4c1d46df8304d5b7 1 5e-324 +c2a28fae0bcd7949 1 1.23456789e8 +c38f4a506cc3267c 1 1.2345678e3 +bde85249e1dcc206 1 1.2345678901e10 +f61e4f9f86db3eea 1 1e11 +72057cacab3997fa 1 1e15 +4183c6065332dc5d 1 1.23456789012345e14 +e1cb4037184e1fe4 1 1.234567890123455e15 +503e585193837caa 1 1e20 +a59688774ce6d2a1 1 1e100 +20a1bd96371a3de0 1 1.7976931348623157e308 +3b202f375efc1640 1 -1.2345e3 +816da9ca801294d6 1 -4e-3 +f8adad661b001d1f 1 -1e-10 +a8aaed4940499212 1 3.333333333333333e-1 +b146e4ee19146f95 1 6.666666666666666e-1 +7433fb99403da062 1 3.0000000000000004e-1 +1c0a0bcb4c0422b8 1 1e2 +4d9afc58f74b7ddb 1 1e3 +0d48b7819a8a4b8d 1 5.9e1 +01452d3bffb28025 1 6e1 +4921ebb52b8c73c1 1 6.1e1 +09ea6a1671f8c563 1 3.6526e4 +b63b4cad6f332f99 1 3.652675e4 +2bc41104f2e19271 1 4.38315e4 +4de1bb6ce4b9358b 1 4.5000123456e4 +ed7fda487b9bcf16 1 2.958465e6 +9059129e9a79299b 1 2.958465999999e6 +a32ca3ab1dbe969f 1 2.958466e6 +90b8f9a1e42bd0a4 1 -5e0 +7e564b2cabd8de16 1 9.9999999e-1 +8781fec461d58bfd 1 9.99e-4 +7c708dbcf3460659 1 4.199999999999e1 +20b2def65bc7174d 1 NaN +957f300224ed1cab 1 inf +4b2f7ba9aa3ce108 1 -inf +9ae51ec9ae4d5114 1 4.1666666666666664e-2 +2689dad0ffde687c 1 9.999999999e-1 +1599f472f24a81fa 1 1.0000000001e0 +5f1f3c268b6168ac 1 1.23456789e1 +c513d218d1599535 1 3.14159265358979e0 +8140c7ee3b5a5345 1 6.5535e4 +bd982ddec1e00aaa 1 1e5 +97614145ed7f215c 1 1.23456e5 +26cde2690b2bcbcc 1 4.5e0 +1a8add9df90d93c9 1 3.75e-1 +eef82337e19fa9f3 1 1e300 +884afff127bc9c8a 1 1.5e-8 +723565414e3a8eb5 1 9.99999999999999e14 +410dc1f39f0470e8 1 9.999999999999998e13 +abe0337311e85bc2 1 1.234e-5 +e05b983d81a775a8 1 1.2345678901234568e17 +f1b04c74292211ec 1 1e22 +8bd34401b876c52c 1 1e23 +8162c16a7c44dc93 1 1.234567890123456e-1 +5df098e551929df8 1 7.083333333333334e-4 +403536010a422332 1 -2.4456788999965284e-22 +5fda7bf42a47c479 1 0e0 +6f8d525947f4496b 1 5.078754219653861e-20 +5fda7bf42a47c479 1 0e0 +db9fc2cd4be272f9 1 4.762524884899572e-19 +5fda7bf42a47c479 1 0e0 +4e42d845bdfe1e05 1 -9.481878134872896e-18 +5fda7bf42a47c479 1 0e0 +e2b246d2b6df4843 1 4.677068358276083e-17 +5fda7bf42a47c479 1 0e0 +db3ef5385bc881e0 1 4.2817210912547903e-16 +5fda7bf42a47c479 1 0e0 +bbf8726d918fa10a 1 -1.2049587102547775e-16 +5fda7bf42a47c479 1 0e0 +c0efc68af4f6f086 1 1.3789513024013423e-14 +5fda7bf42a47c479 1 0e0 +790b21d90f49a509 1 6.059016384138212e-13 +5fda7bf42a47c479 1 0e0 +ce56d3a5c4486cd1 1 -2.0839028757975573e-12 +5fda7bf42a47c479 1 0e0 +975db8510513c040 1 8.895735060164848e-11 +5fda7bf42a47c479 1 0e0 +472bbe20b67f109c 1 2.924228743531876e-11 +5fda7bf42a47c479 1 0e0 +a9c44b1d793ee953 1 -2.790173620128004e-9 +5fda7bf42a47c479 1 0e0 +b65a4a2339333f83 1 3.047667157619239e-8 +5fda7bf42a47c479 1 0e0 +0c9b50a66593ab52 1 9.768724036381636e-7 +5fda7bf42a47c479 1 0e0 +deb83d6ed094b3b2 1 -6.624321624908681e-7 +5fda7bf42a47c479 1 0e0 +cfb05c2a70b35a2d 1 1.6894338933954122e-5 +5fda7bf42a47c479 1 0e0 +dfa7bf97c3a3f630 1 5.610159605226053e-4 +aa35c0cb97f7addc 1 1e-3 +6f2e8f1578d1b6f7 1 -4.5736943003751186e-3 +e6381aba4f09a980 1 5e-3 +618e160ad7d75e3f 1 1.2177853639931391e-2 +7b3b780b251205f7 1 1.2e-2 +bc71798a9a9fd283 1 5.376575791159676e-1 +552d6fe712db0701 1 5.38e-1 +30f24eae79fdbea7 1 -1.6906316761960716e0 +9a85be1470daf3e4 1 1.691e0 +428a08f2bbd5c129 1 6.804349057483373e1 +36f22379ff98da63 1 6.8043e1 +42c9ce8792ca41ca 1 5.180578200923046e2 +b753a84983c4b8fb 1 5.18058e2 +d48ddf21b575ae2e 1 -6.281034210276412e3 +335061600a21e480 1 6.281034e3 +93205c65ad863f72 1 3.435675535581831e4 +1abaaae29d8209f0 1 3.4356755e4 +d09ef10e029d4d19 1 6.613076865085881e5 +b1c40c1667e951b0 1 6.61307687e5 +8e6cdd6bc70fbd22 1 -4.48220238062414e6 +c083e5d00d97cef4 1 4.482202381e6 +e127f684d7698427 1 1.0000341372267152e8 +d4d94bdd00026ac9 1 1.00003413723e8 +0cab466bd5e1d8fa 1 1.5849160403504446e8 +d4c67910bde50f32 1 1.58491604035e8 +0e7f466ac9853aae 1 -1.4058601193934329e9 +a643a7677b4c5ac7 1 1.405860119393e9 +2776bab16cf335d4 1 2.8871069252525196e10 +fc7419911bfbaca8 1 2.8871069252525e10 +a76f1c3dce467478 1 9.690172366761895e11 +be689212e28414e9 1 9.6901723667619e11 +c4c5bd5055043ad5 1 -2.346417551608747e12 +93b654146869cfc6 1 2.346417551608747e12 +645dfd0b8d651920 1 6.228668439208695e12 +645dfd0b8d651920 1 6.228668439208695e12 +f147744d6ebe1c8c 1 2.2428112791069544e14 +f147744d6ebe1c8c 1 2.2428112791069544e14 +ba8da370ef0b6f07 1 -7.327413159633475e15 +76ca14ec7527c55d 1 7.327413159633475e15 +813a82002c70fe12 1 6.675488044574204e16 +813a82002c70fe12 1 6.675488044574204e16 +8a50e37fec07f962 1 6.706148330480582e17 +8a50e37fec07f962 1 6.706148330480582e17 +b2bcb6262b7ad869 1 -7.27445337914015e18 +a958885d3e964a90 1 7.27445337914015e18 +f8c71a4f984138a2 1 3.874586892728994e19 +f8c71a4f984138a2 1 3.874586892728994e19 +143942bc00828b3f 1 4.207699940806956e-21 +5fda7bf42a47c479 1 0e0 +cca09550d6d0af64 1 -6.321408874535118e-20 +5fda7bf42a47c479 1 0e0 +804ebfa29a23426e 1 3.689215801512328e-19 +5fda7bf42a47c479 1 0e0 +34d878721f2d74d3 1 9.392721997790782e-18 +5fda7bf42a47c479 1 0e0 +bf13de1377b6a335 1 -5.788399687409319e-17 +5fda7bf42a47c479 1 0e0 +9164e2fa13905b35 1 2.279056237090581e-16 +5fda7bf42a47c479 1 0e0 +4fc8a97fbf0232f5 1 3.463636269309186e-15 +5fda7bf42a47c479 1 0e0 +eb0a80785bd2f283 1 -4.489928391258454e-14 +5fda7bf42a47c479 1 0e0 +9f70d66705199764 1 7.703173974678871e-13 +5fda7bf42a47c479 1 0e0 +fb73018ae299a802 1 1.3090206448341722e-12 +5fda7bf42a47c479 1 0e0 +c064d37e034a6497 1 -6.338150789669849e-11 +5fda7bf42a47c479 1 0e0 +53be38791b9b3a71 1 3.809051908390393e-11 +5fda7bf42a47c479 1 0e0 +87e6cfab84040f20 1 1.0070431565221817e-9 +5fda7bf42a47c479 1 0e0 +5db583e96d56b61f 1 -5.266594942615038e-8 +5fda7bf42a47c479 1 0e0 +87730d5ab9b90358 1 2.039725677192573e-7 +5fda7bf42a47c479 1 0e0 +2c827aff0066cbce 1 4.851068050995432e-6 +5fda7bf42a47c479 1 0e0 +31b9628affbc1814 1 -7.75941473244553e-5 +5fda7bf42a47c479 1 0e0 +8be16fa85aa991e0 1 7.381056138192671e-4 +aa35c0cb97f7addc 1 1e-3 +6c3609330a25ae41 1 1.575376680923546e-4 +5fda7bf42a47c479 1 0e0 +02745e58c6fb95fe 1 -8.309622978077537e-2 +3ae24003b2396fd4 1 8.3e-2 +35cb86c8421094f5 1 5.938677033303984e-1 +cd17bcf30868b4d7 1 5.94e-1 +5fc071051c10dcf2 1 2.1055700940714903e0 +3301c223c3ee7087 1 2.106e0 +374453ca4e69570f 1 -4.979516792760796e0 +30ac305e01179f6c 1 4.98e0 +dc9118090ba75534 1 7.702392128251413e2 +5668bd8822fd37eb 1 7.70239e2 +bc0fea3db4020ccf 1 5.295197566581555e2 +6a1f8e82ef33f9d0 1 5.2952e2 +8f67ffd819348bfb 1 -9.175765278181084e4 +7431aae5707ad8ce 1 9.1757653e4 +e1050c33ba3c5491 1 9.482550236540656e5 +a129426c85bdbae0 1 9.48255024e5 +e6d83dc1bd22212c 1 5.347153721033019e6 +e026e88cae226949 1 5.347153721e6 +03a3405b7ce1bb74 1 -2.3568277599192787e7 +61da64c36fd8c5eb 1 2.3568277599e7 +b3b4116a1d7c332a 1 1.0018445295210177e8 +ad683901e4974657 1 1.00184452952e8 +0be5c818158d8aba 1 8.356382245461647e7 +9656312094a4767c 1 8.3563822455e7 +cf7cd1e0127e076f 1 -4.557698877252628e10 +d82db809145f4cb7 1 4.5576988772526e10 +e21e145b37008e47 1 5.270348510870472e11 +3eeab7eb66861c31 1 5.27034851087047e11 +d51733208c288c7c 1 5.782033227859766e12 +d51733208c288c7c 1 5.782033227859766e12 +f0e51f3bf67fc7a3 1 -5.931696066185405e13 +e15f77a9ff4fb907 1 5.931696066185405e13 +9a7a6b9b153d807e 1 5.1455458768478e14 +3cce9402217011dc 1 5.1455458768478006e14 +c9f98f1fa9b23bc5 1 1.3538071072562253e15 +c9f98f1fa9b23bc5 1 1.3538071072562253e15 +bbff8f21267563e7 1 -9.90253824456554e15 +1a63f8bd04651493 1 9.90253824456554e15 +64140d1f4b064459 1 8.527630873184316e17 +64140d1f4b064459 1 8.527630873184316e17 +c42d45691a199adf 1 3.2925484547629455e18 +c42d45691a199adf 1 3.2925484547629455e18 +6d966e6760bed43d 1 -3.91537728865695e19 +efdecd348456b99a 1 3.91537728865695e19 +51ff7d6fc5809c27 1 4.8757246809796105e-21 +5fda7bf42a47c479 1 0e0 +84bceb480eb2dcb6 1 2.3420748266718282e-20 +5fda7bf42a47c479 1 0e0 +de6b157cd8b3068b 1 -6.293170081812841e-19 +5fda7bf42a47c479 1 0e0 +fca52fc30bf77496 1 5.53513945740202e-18 +5fda7bf42a47c479 1 0e0 +fef0b8399d29f15b 1 9.77860691981301e-17 +5fda7bf42a47c479 1 0e0 +8956eb136a63fc19 1 -5.1566694357030435e-17 +5fda7bf42a47c479 1 0e0 +c8d0796283c1a680 1 5.364510629593392e-15 +5fda7bf42a47c479 1 0e0 +c10f295101314665 1 9.661665594611814e-14 +5fda7bf42a47c479 1 0e0 +71f69f42ed1abd5f 1 -3.925165717977516e-13 +5fda7bf42a47c479 1 0e0 +4b3b18313d353d45 1 4.634788713583143e-12 +5fda7bf42a47c479 1 0e0 +1714fa5b40a6cf7b 1 2.1289153323939538e-11 +5fda7bf42a47c479 1 0e0 +fbe658ebfc667eeb 1 -3.7731542867717375e-10 +5fda7bf42a47c479 1 0e0 +ed1059691b29437f 1 3.0793918014219435e-9 +5fda7bf42a47c479 1 0e0 +d68a671d72cbb7d4 1 1.398193907677646e-8 +5fda7bf42a47c479 1 0e0 +bf3150d59315bcd7 1 -5.243893844613249e-7 +5fda7bf42a47c479 1 0e0 +93b6b3ec8b06d2ff 1 9.40673096940851e-6 +5fda7bf42a47c479 1 0e0 +71583d853e2cc61b 1 9.755344390282753e-5 +5fda7bf42a47c479 1 0e0 +a2726686baf8eaa6 1 -7.322925001904205e-4 +aa35c0cb97f7addc 1 1e-3 +7b84f8a587116b61 1 2.20079478849153e-3 +97b75dab0686cb37 1 2e-3 +69efff5278fdc06b 1 5.4369720160556294e-2 +22dfdcd990abd9ff 1 5.4e-2 +bdd2e88716b365e0 1 -1.90566789746209e-1 +a419c77256cf893c 1 1.91e-1 +cfb6f41a39039f7f 1 1.1954616597037193e0 +8818e7a995d67595 1 1.195e0 +d936acae76796026 1 3.1198426146081694e1 +f7c4c11aa02f61b9 1 3.1198e1 +4d61587438c8539f 1 -1.7276546543447557e2 +31d3905e625db28b 1 1.72765e2 +592a0f53b05918db 1 3.716258271721481e3 +c5e0c130f048add8 1 3.716258e3 +9964fe1fd34a9e66 1 6.354064796316502e4 +a3273ac06e88377e 1 6.3540648e4 +9d1cc1cc40d1e375 1 -6.247169817552513e5 +6e32184b6303fecd 1 6.24716982e5 +a59dec2d400de042 1 9.672875989854455e6 +c6184456a5f3d6dc 1 9.67287599e6 +952a4c5d6d9f3cc7 1 5.101929774891923e7 +00398960ce567662 1 5.1019297749e7 +2985fb1731569c12 1 -7.868553457247695e8 +cbe14af9c8d6397f 1 7.86855345725e8 +6ddbb1af610f79cf 1 6.192364765951433e9 +e146c8288f6cd3d2 1 6.192364765951e9 +674dfcf3b4a569b6 1 5.0322395435787346e10 +4eaeab0011c8ca30 1 5.0322395435787e10 +2a2da0e805cf5409 1 -8.387392837998628e11 +6e45a8daf47f9904 1 8.38739283799863e11 +adea7a9a9dfe1129 1 3.475227374631468e12 +adea7a9a9dfe1129 1 3.475227374631468e12 +928198bdae5603d2 1 6.046260397573301e13 +928198bdae5603d2 1 6.046260397573301e13 +3b2d1a775477a31c 1 -9.366184270581025e14 +856529b379f79ef7 1 9.366184270581025e14 +ac839bfff6b8812f 1 9.330285659281354e15 +ac839bfff6b8812f 1 9.330285659281354e15 +73160e61af42cec9 1 3.539370193508612e16 +73160e61af42cec9 1 3.539370193508612e16 +dedb455241069fa2 1 -9.996650818086104e17 +ec679a20500b031d 1 9.996650818086104e17 +26215fcc4901f42b 1 7.905433521846371e18 +26215fcc4901f42b 1 7.905433521846371e18 +ec9afc963bc85c7b 1 2.961789170831489e19 +ec9afc963bc85c7b 1 2.961789170831489e19 +f2482a70bd7552a8 1 -4.148371208994727e-21 +5fda7bf42a47c479 1 0e0 +a55409a5a800406d 1 7.170663257742498e-20 +5fda7bf42a47c479 1 0e0 +17f938f9cc679cd3 1 3.9583585927085557e-20 +5fda7bf42a47c479 1 0e0 +9a113924c396bb44 1 -5.892919583042963e-18 +5fda7bf42a47c479 1 0e0 +f0c40ba08b4e8d1c 1 6.485392183833719e-17 +5fda7bf42a47c479 1 0e0 +da9719b7ced0671f 1 7.180575172217469e-16 +5fda7bf42a47c479 1 0e0 +90ac83c7b7ea92c2 1 -2.7801459081525537e-16 +5fda7bf42a47c479 1 0e0 +069d079990d92448 1 8.42511493280958e-14 +5fda7bf42a47c479 1 0e0 +1c765f9111408737 1 1.0841559011512436e-13 +5fda7bf42a47c479 1 0e0 +7530679d8e6abd87 1 -2.1480013377898877e-12 +5fda7bf42a47c479 1 0e0 +873ab178136be7f4 1 2.8019016665180154e-11 +5fda7bf42a47c479 1 0e0 +4f3329b0b1b9010d 1 4.740529408461166e-10 +5fda7bf42a47c479 1 0e0 +84f8fb1f92637dc1 1 -7.552388125782508e-9 +5fda7bf42a47c479 1 0e0 +3d7d836e4bdb867e 1 9.755292951606497e-9 +5fda7bf42a47c479 1 0e0 +3fa8b742b75bff54 1 3.8472751651678525e-7 +5fda7bf42a47c479 1 0e0 +d349c8520a8cc6de 1 -6.977907562349183e-6 +5fda7bf42a47c479 1 0e0 +e1df5d730caf3475 1 8.218407552134532e-5 +5fda7bf42a47c479 1 0e0 +3df6765577ebd93a 1 4.996897368127074e-4 +5fda7bf42a47c479 1 0e0 +81b26f24e3ef8f0d 1 -3.151243912693312e-3 +77147cf949266682 1 3e-3 +bd1c5f937c96ac6a 1 3.0347488452323525e-2 +fc87ec37c8733143 1 3e-2 +a73ab956230ebaef 1 6.889166586655288e-1 +5fa353279229150c 1 6.89e-1 +f568fd4cfe3142c6 1 -9.743654785321732e0 +1bc6257bb3848aa9 1 9.744e0 +0f5ba1866b7d2845 1 5.668388855766816e1 +ce72a327bacd2b00 1 5.6684e1 +b4efc6800c4b914d 1 2.628257089515055e2 +21784204a4be65c0 1 2.62826e2 +e67a2d6c6630643f 1 -1.6168861365096154e3 +47815c5434eded45 1 1.616886e3 +4e3b51240c986695 1 3.280045990086601e4 +6890cb1d3a7f9b75 1 3.280046e4 +cabedf8f491b936e 1 1.4504200063410145e5 +e3836c93597707d8 1 1.45042001e5 +4271771f51534d50 1 -7.313314211991392e6 +1fb3dc6157f32057 1 7.313314212e6 +46c2ec9b7d7dd831 1 2.618837245417135e7 +3b956a81d84ff17d 1 2.6188372454e7 +5a8bbd3fdc03e83f 1 2.2253015708835256e8 +394a2739b0947d1f 1 2.22530157088e8 +40691261266728fd 1 -9.870234060874973e9 +eb3d9a317a8874aa 1 9.870234060875e9 +6a990a269a5c74f0 1 1.867926582347318e10 +f2ca9284e3a4f179 1 1.8679265823473e10 +280ece39f3f50572 1 3.567370490275957e11 +c14e0f480d1a7640 1 3.56737049027596e11 +3789dd57fc5c815c 1 -7.912946695129458e12 +b5ae37bd691873c7 1 7.912946695129458e12 +9f1553246fdb40cb 1 4.524229340690999e12 +9f1553246fdb40cb 1 4.524229340690999e12 +b24a577eb86c3e84 1 2.3102331568926594e14 +b24a577eb86c3e84 1 2.3102331568926594e14 +cdce2c77867a3106 1 -4.547025311981324e15 +e947b96f57d17bec 1 4.547025311981324e15 +cf45a38c70fcf953 1 4.6288039353802424e16 +cf45a38c70fcf953 1 4.6288039353802424e16 +014ea8cd6a1290e2 1 6.162091464101659e17 +014ea8cd6a1290e2 1 6.162091464101659e17 +ce7e9262127eff44 1 -4.849757599353334e18 +e50f3fe044d209e7 1 4.849757599353334e18 +d85b0205e7941c69 1 6.452894445273737e19 +d85b0205e7941c69 1 6.452894445273737e19 +f2c90f259094e92e 1 4.7803298279157025e-21 +5fda7bf42a47c479 1 0e0 +9a93ee439db2dc35 1 -7.233406321955394e-20 +5fda7bf42a47c479 1 0e0 +d1e6a63ae1d3a9bb 1 2.950670879528398e-19 +5fda7bf42a47c479 1 0e0 +9230461e2e26d575 1 3.063434779812392e-18 +5fda7bf42a47c479 1 0e0 +314136843a1e646c 1 -5.4890433629545986e-17 +5fda7bf42a47c479 1 0e0 +6f3c47632e4d68b3 1 3.820800755734363e-16 +5fda7bf42a47c479 1 0e0 +6f9edd60cd32e0d5 1 5.829213571595438e-16 +5fda7bf42a47c479 1 0e0 +78eb51a008a64997 1 -2.4171609780651217e-14 +5fda7bf42a47c479 1 0e0 +44e17d26d950269c 1 1.1540630209261826e-13 +5fda7bf42a47c479 1 0e0 +8c1d1f942eaccbbf 1 1.84539975340409e-12 +5fda7bf42a47c479 1 0e0 +04d9cdb28aac62c3 1 -1.1081220338777577e-11 +5fda7bf42a47c479 1 0e0 +ed11aa9ba5e5353f 1 6.04223566612428e-10 +5fda7bf42a47c479 1 0e0 +f99d682b436ba383 1 5.35352701590592e-9 +5fda7bf42a47c479 1 0e0 +98b4918f9567bfd3 1 -8.711002748201745e-8 +5fda7bf42a47c479 1 0e0 +1ad36850fde48c38 1 6.114307847139126e-7 +5fda7bf42a47c479 1 0e0 +08f08db80439f666 1 7.209491487707519e-6 +5fda7bf42a47c479 1 0e0 +6a2bea77bad32684 1 -9.107581134395797e-5 +5fda7bf42a47c479 1 0e0 +a6e64a2db8e7c580 1 1.502958882607809e-4 +5fda7bf42a47c479 1 0e0 +bb4f7310114464cd 1 9.694573849985441e-3 +e40ccf007d65d503 1 1e-2 +99d400856d68de78 1 -6.734696325484274e-2 +6f6224f1a2f8d8f1 1 6.7e-2 +e1bf6652cb8fa67a 1 5.736804397820615e-1 +3503377f10431893 1 5.74e-1 +abde9a0944f0075c 1 8.385525171527297e-1 +bba484bf8ca102d3 1 8.39e-1 +f0cadc58f065b9d4 1 -7.281427491271538e1 +3c3889813ef35db4 1 7.2814e1 +977a2a510c565470 1 8.277766877236081e1 +1342b4ca96de7c68 1 8.2778e1 +7d0118272533a618 1 6.537925550437317e3 +eb730fb885709956 1 6.537926e3 +5cbc224dc75590af 1 -9.556341676004886e4 +651cd96eb2cd481d 1 9.5563417e4 +7454f12c9c69f37a 1 8.917218866870608e5 +66dff276a09c18ca 1 8.91721887e5 +6cd5d2549504ffe4 1 1.7117781599952288e6 +b1b8b8d63f96b6e3 1 1.71177816e6 +4a42c81491eef17c 1 -7.887393405242969e7 +84299b08c59543e1 1 7.8873934052e7 +e59890374c3b92fd 1 4.975792917240606e8 +744d7f2f33d43087 1 4.97579291724e8 +62bc9e9bf68188bf 1 2.1101063788838792e9 +d651f84aed764523 1 2.110106378884e9 +03d61121d089d8a5 1 -5.020627637227572e10 +e7dec7d4092686f3 1 5.0206276372276e10 +f17fe9bd3b512a80 1 6.810322205888846e11 +734531128a13110d 1 6.81032220588885e11 +9c1dec292ef7b37f 1 1.1358071611803188e12 +dd424abd6b3af684 1 1.135807161180319e12 +694f57c35b260310 1 -3.644307586777936e13 +13611f86ee78a0ed 1 3.644307586777936e13 +d2a2a06dea91c0a6 1 4.619231907602407e14 +d2a2a06dea91c0a6 1 4.619231907602407e14 +77901e2ac387e970 1 6.618779769599664e15 +77901e2ac387e970 1 6.618779769599664e15 +89f326d5337edbf9 1 -1.6799818086520645e15 +4522b28cd51ce9fc 1 1.6799818086520645e15 +f9e0155a978000b9 1 8.738754212610816e17 +f9e0155a978000b9 1 8.738754212610816e17 +4bd5f489ae1421a8 1 3.782517862984159e18 +4bd5f489ae1421a8 1 3.782517862984159e18 +40b6664a43480fc1 1 -2.4148772852069077e19 +9441359dbc20f6b6 1 2.4148772852069077e19 +76f2826b72e1fda6 1 8.877221241287098e-21 +5fda7bf42a47c479 1 0e0 +bb4a3ae1f5949c11 1 1.8341454532358146e-20 +5fda7bf42a47c479 1 0e0 +766ae042bb11d920 1 -9.887584766107361e-19 +5fda7bf42a47c479 1 0e0 +c959d5910bc866f9 1 9.166579699039185e-18 +5fda7bf42a47c479 1 0e0 +b2699aa224c131ce 1 3.2452783206646016e-18 +5fda7bf42a47c479 1 0e0 +e784da29c1b96c28 1 -4.798816460260819e-16 +5fda7bf42a47c479 1 0e0 +c3bc806511396a74 1 5.664578770036096e-15 +5fda7bf42a47c479 1 0e0 +6c133c6895703b73 1 8.829601615987267e-14 +5fda7bf42a47c479 1 0e0 +b7721097837aef20 1 -4.3573790293046475e-13 +5fda7bf42a47c479 1 0e0 +af58a97244a9c6c3 1 5.551460106733779e-12 +5fda7bf42a47c479 1 0e0 +4646a79cd8815ccf 1 6.050294303578548e-11 +5fda7bf42a47c479 1 0e0 +5a4ffdfaaac64359 1 -3.3879885578121597e-10 +5fda7bf42a47c479 1 0e0 +d30c3dba6f8b8d72 1 9.069160028722885e-9 +5fda7bf42a47c479 1 0e0 +35719d8fb5e8e209 1 3.1971165267170906e-8 +5fda7bf42a47c479 1 0e0 +8f3a36e216599613 1 -8.76974502693964e-7 +5fda7bf42a47c479 1 0e0 +4a7c13e5c2e949a0 1 4.680359748821511e-6 +5fda7bf42a47c479 1 0e0 +c58c1c5093cfd666 1 7.563237465046436e-5 +5fda7bf42a47c479 1 0e0 +71c0d2f679b366ab 1 -1.79232054129694e-4 +5fda7bf42a47c479 1 0e0 +cc39dc0b3f31ef1a 1 8.188147563049265e-3 +1e7c9f5936317e01 1 8e-3 +7810fc63819aa54f 1 4.8276882501172715e-2 +316f14fbacc0f6dc 1 4.8e-2 +91fcbed9b6c1e1eb 1 -4.4497532569195164e-1 +1ba135db6a0ed229 1 4.45e-1 +6a646bd946016bba 1 7.6485650387578605e0 +49f07016ba5d174a 1 7.649e0 +35aacb91d397e63c 1 6.863911531586618e1 +ad8b40222f9c0b67 1 6.8639e1 +b62458bc71ec92fd 1 -4.564364881857964e2 +d8bdf521d8767965 1 4.56436e2 +f0d014cf4e9edf7f 1 1.9163551023901937e3 +15c1eb9d0605d6b0 1 1.916355e3 +b051547190f1dead 1 1.850604973023119e4 +a8b5ae0e34ac6db2 1 1.850605e4 +57da0f4c75eb541f 1 -6.297725994049538e5 +41c4a242cd154e87 1 6.29772599e5 +3053c5c8720a172e 1 7.738395738921099e4 +aaeee63373ab5c58 1 7.7383957e4 +122c1b2c08853c0c 1 8.396373407043353e7 +7b7f558a837722ff 1 8.396373407e7 +ba34066cdc427569 1 -2.4288547122230375e8 +420f589afd6b0565 1 2.42885471222e8 +041703a36b497da8 1 2.812715976368404e9 +c889fbd6f49dfffc 1 2.812715976368e9 +72f6aaac4485ab61 1 5.522396329298781e10 +60ac6434d6875dde 1 5.5223963292988e10 +36803089910ba7e2 1 -7.073708200884093e11 +5afe826153ea2a19 1 7.07370820088409e11 +8a4f45dfac6a7733 1 3.73164417144063e12 +8a4f45dfac6a7733 1 3.73164417144063e12 +69bca16e7f65b43d 1 1.69380681630822e13 +69bca16e7f65b43d 1 1.69380681630822e13 diff --git a/crates/numfmt/tests/render_equality.rs b/crates/numfmt/tests/render_equality.rs new file mode 100644 index 0000000000..12a3ab94da --- /dev/null +++ b/crates/numfmt/tests/render_equality.rs @@ -0,0 +1,225 @@ +//! Equality tests for the render/General cleanup: output over a large grid of format codes x +//! values (format_value and text_function in both date systems, then General at widths 1..=30 and a few larger ones) +//! is compared with golden digests recorded from the implementation BEFORE the cleanup +//! (`tests/golden/*.txt`, one FNV-1a digest per format code / per value, so the files stay +//! small). A failure names the code or value that differs; set `NUMFMT_DUMP_DIR` to write the +//! full text of the current output for diffing against a dump from the old code. To re-record +//! after an intended behaviour change run with `NUMFMT_BLESS=1`. + +#![allow(clippy::approx_constant, clippy::excessive_precision)] // values are test data, not constants + +use std::fmt::Write as _; + +use gridcraft_core::{DateSystem, Value}; +use gridcraft_numfmt::{NumberFormat, format_general_fit, format_value, text_function}; + +// Placeholders far beyond the 15 significant digits. +const LONG_DECIMALS: &str = "0.0000000000000000000000000"; +#[rustfmt::skip] +const CODES: &[&str] = &[ + LONG_DECIMALS, "#,###,###,###,###,###,###,###,##0.00", "0.0000000000000000000000000E+00", "# ?????/?????", + // numbers + "General", "0", "0.00", "#,##0", "#,##0.00", "#.##", "0.0##", "00000", "000.000", "#,###", "#,##0,", "#,##0,,", "0.0,,\"M\"", "?.??", "##0.0", "# ", "#", ".00", + ",0", "0,0", "0.00,", "\"$\"#,##0.00", "\"$\"#,##0_);\\(\"$\"#,##0\\)", "#,##0.00;[Red](#,##0.00)", "#,##0 ;(#,##0)", "0;-0;;@", "0.00;;", ";;;", + "0_);(0)", "0\" units\"", "\\A0", "(0)", "00-00", "000-00-0000", "[<=9999999]###-####;(###) ###-####", "[$€-407]#,##0.00", + // currency/accounting with fill + "_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)", "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)", "*-0", "0*x", "*0 0.0", "@*_", + // percent + "0%", "0.00%", "#,##0%", "0.0%%", "%0", "0%;[Red]-0%", + // scientific + "0.00E+00", "##0.0E+0", "0E+0", "0.0e-0", "0.00E+000", "E+0", "0.00E+", "#.##E+00", "0.0E+00 \"x\"", + // fractions + "# ?/?", "# ??/??", "# ???/???", "?/?", "# ?/8", "# ??/16", "0/0", "# #/#", "#/#", "?/100", "# ?/?;-# ?/?", "0 0/0", + // dates/times + "m/d/yyyy", "d-mmm-yy", "d-mmm", "mmm-yy", "h:mm AM/PM", "h:mm:ss AM/PM", "h:mm", "h:mm:ss", "m/d/yyyy h:mm", "mm:ss", "[h]:mm:ss", "[h]:mm", "[m]:ss", "[s]", "mmss.0", + "yyyy-mm-dd", "dddd, mmmm d, yyyy", "ddd mmm dd yy", "mmmmm", "yy", "h:mm:ss.000", "h:mm:ss.0000", "hh:mm:ss A/P", "[hh]:mm:ss", "d", "dd", "dddd", "m", "mm", "s.00", + "yyyy-mm-dd hh:mm:ss.00", "h AM/PM", "[$-409]d-mmm-yy", "yyyy\"年\"m\"月\"", "[h]\" hrs\"", "mmmm yyyy;@", + // text sections, conditions, colours + "@", "\"x\"@", "0;\"neg\"0;\"zero\";\"txt: \"@", "General;General;General;@", "[Red]0.0;[Blue]-0.0", "[Green]0;[Magenta]0;[Cyan]0;[Yellow]@", "[Color3]0.00", + "[>100]\"big\"0;[<-100]\"small\"0;0.0", "[=0]\"zero\";0.0", "[>=1000]#,##0,\"K\";0", "[<1]0.00%;0.0", "[Red][>5]0;0.00", "General\" kg\"", "\"v\"General", + "[Red]General", "0.0;[Red]General", + // invalid / odd + "[", "0.0.0", "0\"", "\\", "[h", "[$", "yyyy-mm-dd;", ";", "0;0;0;0;0", "\"abc", "[>x]0", "ggg", "#?#0", "0E", "E0", "/", "0/", "/0", "*", "_", "_0", "[Red", "0.00.00%", "mm:ss.0.0", + "[ABC]0", "[=1]0;[=2]0;0", "0 ??/??;0", +]; + +fn values() -> Vec { + let mut v = vec![ + 0.0, + -0.0, + 1.0, + -1.0, + 0.5, + -0.5, + 0.25, + 1.005, + 2.675, + 999.9995, + 0.9995, + 9.995, + 99.5, + 0.05, + 0.005, + 0.0005, + 1e-5, + 1e-10, + 1e-300, + 5e-324, + 123456789.0, + 1234.5678, + 12345678901.0, + 1e11, + 1e15, + 123456789012345.0, + 1234567890123455.0, + 1e20, + 1e100, + 1.7976931348623157e308, + -1234.5, + -0.004, + -1e-10, + 1.0 / 3.0, + 2.0 / 3.0, + 0.1 + 0.2, + 100.0, + 1000.0, + 59.0, + 60.0, + 61.0, + 36526.0, + 36526.75, + 43831.5, + 45000.123456, + 2958465.0, + 2958465.999999, + 2958466.0, + -5.0, + 0.99999999, + 0.000999, + 41.99999999999, + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + 0.041666666666666664, + 0.9999999999, + 1.0000000001, + 12.3456789, + 3.14159265358979, + 65535.0, + 1e5, + 123456.0, + 4.5, + 0.375, + 1e300, + 1.5e-8, + 999999999999999.0, + 99999999999999.99, + 0.00001234, + 123456789012345678.0, + 1e22, + 9.999999999999999e22, + 0.1234567890123456, + ]; + v.push(61.2 / 86400.0); + v +} + +fn fmt_formatted(f: &gridcraft_numfmt::Formatted) -> String { + format!("{:?}|{:?}|{:?}|{}", f.text, f.color, f.fill, f.numeric) +} + +fn dump_codes() -> Vec<(String, String)> { + assert!(CODES.len() >= 80 && values().len() >= 40); + let mut rows = Vec::new(); + let others = [Value::Text("abc".into()), Value::Text("12.5".into()), Value::Bool(true), Value::Empty]; + for code in CODES { + let mut out = String::new(); + let nf = NumberFormat::parse(code); + let systems: &[(DateSystem, &str)] = + if nf.is_date() { &[(DateSystem::D1900, "1900"), (DateSystem::D1904, "1904")] } else { &[(DateSystem::D1900, "1900")] }; + for (sys, name) in systems { + for n in values() { + let v = Value::Number(n); + let _ = + writeln!(out, "{code:?} {name} {n:e} => {} || {:?}", fmt_formatted(&format_value(&v, &nf, *sys)), text_function(&v, code, *sys)); + } + } + for v in &others { + let _ = writeln!( + out, + "{code:?} {v:?} => {} || {:?}", + fmt_formatted(&format_value(v, &nf, DateSystem::D1900)), + text_function(v, code, DateSystem::D1900) + ); + } + rows.push((format!("{code:?}"), out)); + } + rows +} + +fn dump_general() -> Vec<(String, String)> { + let mut vals = values(); + let mut x = 0.7f64; + // Deterministic spread over many magnitudes and digit patterns (no RNG dependency). + for i in 0..240 { + x = (x * 997.0 + 0.123_456_789).fract() + 1e-3; + let e = (i % 41) - 20; + let m = x * 10f64.powi(e); + vals.push(if i % 3 == 0 { -m } else { m }); + vals.push((m * 1000.0).round() / 1000.0); + } + assert!(vals.len() >= 300); + let mut rows = Vec::new(); + for n in vals { + let mut out = String::new(); + let _ = write!(out, "{n:e} =>"); + for w in (1..=30usize).chain([31, 40, 100, 399, 400, 401, 500, 1000]) { + let _ = write!(out, " {:?}", format_general_fit(n, w)); + } + out.push('\n'); + rows.push((format!("{n:e}"), out)); + } + rows +} + +fn fnv1a(s: &str) -> u64 { + s.bytes().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)) +} + +fn check(name: &str, rows: &[(String, String)], golden: &str, path: &str) { + if let Some(dir) = std::env::var_os("NUMFMT_DUMP_DIR") { + let all: String = rows.iter().map(|(_, b)| b.as_str()).collect(); + std::fs::write(std::path::Path::new(&dir).join(format!("{name}.txt")), all).expect("write dump"); + } + let actual: String = rows.iter().map(|(k, b)| format!("{:016x} {} {k}\n", fnv1a(b), b.lines().count())).collect(); + if std::env::var_os("NUMFMT_BLESS").is_some() { + std::fs::write(path, &actual).expect("write golden"); + return; + } + let bad: Vec = rows + .iter() + .zip(actual.lines().zip(golden.lines())) + .filter(|(_, (a, g))| a != g) + .take(3) + .map(|((_, body), (a, g))| { + format!(" new: {a}\n old: {g}\n new output of that row (first 12 lines):\n{}", body.lines().take(12).collect::>().join("\n")) + }) + .collect(); + assert!( + actual.lines().count() == golden.lines().count() && bad.is_empty(), + "{name}: output differs from the recorded golden ({} vs {} rows). If the change is intended, re-record with\n NUMFMT_BLESS=1 cargo test -p gridcraft-numfmt --test render_equality\n{}", + actual.lines().count(), + golden.lines().count(), + bad.join("\n") + ); +} + +#[test] +fn format_value_and_text_function_match_golden() { + check("codes", &dump_codes(), include_str!("golden/codes.txt"), concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden/codes.txt")); +} + +#[test] +fn general_fit_matches_golden() { + check("general", &dump_general(), include_str!("golden/general.txt"), concat!(env!("CARGO_MANIFEST_DIR"), "/tests/golden/general.txt")); +} diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index fe7cbdc429..e366fa57c7 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -326,6 +326,29 @@ mod tests { assert_eq!(px_to_pt(20.0), 15.0); } + #[test] + fn builtin_format_ids() { + // The one table of built-in formats (gridcraft-numfmt has none: file formats do not depend on it). + assert_eq!((0..=60).filter(|&id| builtin_format(id).is_some()).count(), 36); + assert_eq!(builtin_format(0), Some("General")); + assert_eq!(builtin_format(22), Some("m/d/yyyy h:mm")); + assert_eq!(builtin_format(49), Some("@")); + // 23-36 are locale formats, above 49 is custom. + assert_eq!((23..=36).find_map(builtin_format), None); + assert_eq!(builtin_format(50), None); + // Writing: an exact (case-sensitive) code gives its id, except the currency formats 5-8. + assert_eq!(builtin_id("General"), Some(0)); + assert_eq!(builtin_id("GENERAL"), None); + assert_eq!(builtin_id("[H]:MM:SS"), None); + assert_eq!(builtin_id("[h]:mm:ss"), Some(46)); + assert_eq!(builtin_id("#,##0.00_);(#,##0.00)"), None); + for id in 0..=60 { + let Some(code) = builtin_format(id) else { continue }; + let expected = if (5..=8).contains(&id) { None } else { Some(id) }; + assert_eq!(builtin_id(code), expected, "id {id}"); + } + } + #[test] fn functions() { assert_eq!(file_function_name("xlookup").as_deref(), Some("_xlfn.XLOOKUP")); From 1775a200265575e2d760857f96f22fb53fb53e07 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 00:02:02 +0100 Subject: [PATCH 2/2] xlsx test: builtin ids 42 and 44 are written as custom formats like 5-8 Co-Authored-By: Claude Sonnet 5.5 --- crates/xlsx/src/tables.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 0d8ee6c479..101d7d7ca2 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -355,7 +355,7 @@ mod tests { // 23-36 are locale formats, above 49 is custom. assert_eq!((23..=36).find_map(builtin_format), None); assert_eq!(builtin_format(50), None); - // Writing: an exact (case-sensitive) code gives its id, except the currency formats 5-8. + // Writing: an exact (case-sensitive) code gives its id, except the locale-dependent currency and accounting formats 5-8, 42 and 44. assert_eq!(builtin_id("General"), Some(0)); assert_eq!(builtin_id("GENERAL"), None); assert_eq!(builtin_id("[H]:MM:SS"), None); @@ -363,7 +363,7 @@ mod tests { assert_eq!(builtin_id("#,##0.00_);(#,##0.00)"), None); for id in 0..=60 { let Some(code) = builtin_format(id) else { continue }; - let expected = if (5..=8).contains(&id) { None } else { Some(id) }; + let expected = if matches!(id, 5..=8 | 42 | 44) { None } else { Some(id) }; assert_eq!(builtin_id(code), expected, "id {id}"); } }