mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 02:36:48 -06:00
Use chrono instead of hand-rolled UTC conversion (#2121)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days / days_from_civil copies and the is_leap / days_in_month calendar helpers with chrono as a workspace dependency (default-features = false, std only, so wasm is unaffected). The spreadsheet serial-date semantics are untouched: Excel's 1900 fictitious leap day (serial 60 = 1900-02-29), the 1899-12-30 / 1904-01-01 serial offsets, Excel's weekday numbering and the 1900/1904 systems keep their explicit special cases in serial_from_ymd / datetime_from_serial / days_in_month_in. chrono only backs the plain proleptic-Gregorian day-number <-> date conversion and the leap-year / month-length helpers those build on; a small fallback keeps the old answer for dates outside chrono's NaiveDate range. The duplicate local is_leap in the financial functions now uses the core one. Output is byte-identical and pinned by the existing date, parse, financial and logging tests. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic on out-of-range clocks or years; keep calendar results outside chrono's range - logging::timestamp: chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp and read such a clock as the epoch, like the old saturating code. Years past 9999 are written with {:04} (not %Y, which prefixes a '+'). - date::is_leap: restore the Gregorian leap-year rule for years outside chrono's NaiveDate range (e.g. is_leap(400000) is true again) via direct arithmetic. - date::days_in_month: compute the December boundary with checked_add so days_in_month(i32::MAX, 12) no longer overflows, and compute month lengths through days_from_civil's fallback so out-of-range years keep their real lengths (e.g. days_in_month(400000, 2) is 29). Invalid months still return 0. - civil_from_days: add missing docstring. - AGENTS.md: note the narrow out-of-range fallback exception to the chrono rule. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies The PR kept hand-rolled Hinnant `days_from_civil_fallback`/`civil_from_days_fallback` copies for dates outside chrono's ±262143-year range, plus a hand-rolled Gregorian leap rule in `is_leap` and an i32::MAX special-case in `days_in_month`. AGENTS.md carved a "narrow fallback" exception for them, which left calendar arithmetic outside chrono after all. Replace all of it with the Gregorian 400-year cycle (146_097 days): chrono converts the day/year within one cycle and whole cycles are added back via i128 (so the i32/i64 extremes can't overflow). `is_leap` and `days_in_month` reduce the year with `rem_euclid(400)` (leap years and month lengths repeat every 400 years), which also removes the December `i32::MAX` overflow special-case. chrono is now the only calendar logic, and every output is unchanged: verified the cycle result equals the old formula over wide ranges and at the i32/i64 extremes with a scratch chrono program. The Excel 1900 leap bug (serial 60 = fictitious 1900-02-29) and 1904 semantics are untouched -- `serial_from_ymd`/ `datetime_from_serial` only ever hand the converters in-range years, so their serials are byte-for-byte the pre-chrono ones (pinned by a new test). New tests pin `days_from_civil`/`civil_from_days` past chrono's range on both sides of the epoch, round-tripping, with values from the old formula; the existing leap/month-length-beyond-range test still passes unchanged. Removed the AGENTS.md "narrow fallback" exception. Tested: cargo test -p gridcraft-core -p gridcraft-functions; fmt --check; clippy --all-targets -D warnings. * time: preserve out-of-range years in the log timestamp instead of the epoch `DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a clock set far ahead (an input the old hand-rolled civil-from-days converted) collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the in-range remainder with chrono, and carry the year back as i128. Only the year leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the epoch as before. chrono stays the only calendar logic (no new features). apps/gridcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a platform-safe `UNIX_EPOCH.checked_add`: u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."), 10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...". * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does.
This commit is contained in:
@@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
||||
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
|
||||
|
||||
|
||||
Generated
+11
@@ -619,6 +619,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
@@ -1499,6 +1508,7 @@ name = "gridcraft"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"craft-print",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1555,6 +1565,7 @@ dependencies = [
|
||||
name = "gridcraft-core"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -30,6 +30,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.41"
|
||||
|
||||
@@ -18,6 +18,7 @@ egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
|
||||
@@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
|
||||
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
|
||||
/// instead of panicking.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -393,6 +399,17 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
|
||||
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
|
||||
// with a checked add and only asserted where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
|
||||
+111
-24
@@ -28,15 +28,22 @@ pub struct DateTime {
|
||||
pub weekday: u32,
|
||||
}
|
||||
|
||||
/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01.
|
||||
/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
|
||||
///
|
||||
/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
|
||||
/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
|
||||
/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
|
||||
/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
|
||||
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let m = m as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146097 + doe - 719468
|
||||
let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
|
||||
i32::try_from(year)
|
||||
.ok()
|
||||
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
|
||||
.and_then(|date| {
|
||||
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
|
||||
i64::try_from(days).ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
|
||||
@@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
|
||||
/// [`days_from_civil`]).
|
||||
///
|
||||
/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
|
||||
/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
|
||||
/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
|
||||
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
((if m <= 2 { y + 1 } else { y }) as i32, m, d)
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
|
||||
}
|
||||
// Unreachable: `rest + 719_163` is always a valid CE day.
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
|
||||
const UNIX_1904_01_01: i64 = -24107;
|
||||
|
||||
/// Whether `y` is a Gregorian leap year, for every `i32` year.
|
||||
pub fn is_leap(y: i32) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
// A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
|
||||
// the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
|
||||
let y = i64::from(y).rem_euclid(400) as i32;
|
||||
chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
|
||||
}
|
||||
|
||||
/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
|
||||
/// `i32` year.
|
||||
pub fn days_in_month(y: i32, m: u32) -> u32 {
|
||||
match m {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if is_leap(y) => 29,
|
||||
2 => 28,
|
||||
// The length of a month is the day before the first of the next month. Month lengths repeat
|
||||
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
|
||||
// always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
|
||||
if !(1..=12).contains(&m) {
|
||||
return 0;
|
||||
}
|
||||
let y = i64::from(y).rem_euclid(400) as i32;
|
||||
let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
|
||||
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
|
||||
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leap_and_month_length_outside_chrono_range() {
|
||||
// In range, the ordinary Gregorian rule holds.
|
||||
assert!(is_leap(2000));
|
||||
assert!(!is_leap(1900));
|
||||
assert!(is_leap(2024));
|
||||
assert!(!is_leap(2023));
|
||||
// Outside chrono's NaiveDate range the Gregorian rule is still applied.
|
||||
assert!(is_leap(400_000)); // divisible by 400
|
||||
assert!(!is_leap(300_100)); // century not divisible by 400
|
||||
assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
|
||||
assert!(!is_leap(i32::MAX)); // 2147483647 is odd
|
||||
// days_in_month keeps calendar lengths out of range and 0 for invalid months.
|
||||
assert_eq!(days_in_month(400_000, 2), 29);
|
||||
assert_eq!(days_in_month(300_100, 2), 28);
|
||||
assert_eq!(days_in_month(400_000, 1), 31);
|
||||
assert_eq!(days_in_month(400_000, 4), 30);
|
||||
assert_eq!(days_in_month(2024, 2), 29);
|
||||
assert_eq!(days_in_month(2023, 2), 28);
|
||||
assert_eq!(days_in_month(2023, 0), 0);
|
||||
assert_eq!(days_in_month(2023, 13), 0);
|
||||
// December at i32::MAX does not overflow and still reports 31 days.
|
||||
assert_eq!(days_in_month(i32::MAX, 12), 31);
|
||||
assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_conversions_exact_beyond_chrono_range() {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
|
||||
// the epoch; values round-trip through the proleptic Gregorian converters.
|
||||
for (y, m, d, z) in [
|
||||
(262_144i32, 1u32, 1u32, 95_026_602i64),
|
||||
(300_000, 1, 1, 108_853_222),
|
||||
(400_000, 2, 29, 145_377_531),
|
||||
(-262_145, 3, 1, -96_465_964),
|
||||
(-300_000, 6, 15, -110_292_112),
|
||||
] {
|
||||
assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
|
||||
assert_eq!(civil_from_days(z), (y, m, d), "{z}");
|
||||
}
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serials_unchanged_through_cycle_conversion() {
|
||||
// The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
|
||||
// in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
|
||||
let s = DateSystem::D1900;
|
||||
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
|
||||
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
|
||||
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
|
||||
assert_eq!(days_in_month_in(s, 1900, 2), 29);
|
||||
let s4 = DateSystem::D1904;
|
||||
assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
|
||||
assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
|
||||
assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
|
||||
assert_eq!(days_in_month_in(s4, 1900, 2), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_from_unix_time() {
|
||||
assert_eq!(serial_from_unix_ms(0.0), 25569.0);
|
||||
|
||||
Reference in New Issue
Block a user