From c79368a4eebd15065b080a19998879b2f9941768 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sat, 13 Jun 2026 10:28:16 -0400 Subject: [PATCH] fix: interpret DateTime by UTC calendar day for exact round-trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toHijri in both UAQ and FCNA engines read .year/.month/.day from the input DateTime — local calendar components — then passed them to DateTime.utc(). On hosts west of UTC a UTC-midnight local DateTime resolves to the previous local day, causing toHijri(toGregorian(y,m,d)) to return the wrong Hijri day. Fix: call date.toUtc() first in both engines before extracting calendar components. This is symmetric with toGregorian which always returns DateTime.utc(). Adds 3 regression tests covering the round-trip and the local-DateTime-on-UTC-west-host case. --- CHANGELOG.md | 10 ++++++++++ lib/src/engines/fcna.dart | 11 +++++++---- lib/src/engines/uaq.dart | 6 +++++- test/hijri_core_test.dart | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 502eba9..f08df85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Fixed + +- `toHijri` now normalizes the input `DateTime` to its UTC calendar day before + lookup (via `date.toUtc()`), matching the UTC-midnight contract of `toGregorian`. + Previously, passing a local `DateTime` on a host west of UTC could return the + previous Hijri day, breaking `toHijri(toGregorian(y, m, d))` round-trips. + Applies to both the UAQ and FCNA engines. + ## [1.0.0] - 2026-05-25 ### Added diff --git a/lib/src/engines/fcna.dart b/lib/src/engines/fcna.dart index 9956336..8a0c0f1 100644 --- a/lib/src/engines/fcna.dart +++ b/lib/src/engines/fcna.dart @@ -234,12 +234,15 @@ int _fcnaDaysInMonth(int hy, int hm) { // ---- FCNA Gregorian -> Hijri ---- HijriDate? _fcnaToHijri(DateTime date) { - // FCNA criterion is UTC-based, so UTC date components ensure correct round-trips. + // Normalize to UTC calendar day so the result is deterministic regardless of + // the host timezone and whether the caller passes a UTC or local DateTime. + // This is symmetric with toGregorian, which always returns DateTime.utc(). + final d = date.toUtc(); final inputMs = DateTime.utc( - date.year, - date.month, - date.day, + d.year, + d.month, + d.day, ).millisecondsSinceEpoch.toDouble(); final kApprox = _utcMsToKApprox(inputMs - 15 * msPerDay); diff --git a/lib/src/engines/uaq.dart b/lib/src/engines/uaq.dart index 1fe7a74..6ab97dd 100644 --- a/lib/src/engines/uaq.dart +++ b/lib/src/engines/uaq.dart @@ -36,7 +36,11 @@ int _dateUtcMs(int year, int month, int day) { } HijriDate? _uaqToHijri(DateTime date) { - final inputUtc = _dateUtcMs(date.year, date.month, date.day); + // Normalize to UTC calendar day so the result is deterministic regardless of + // the host timezone and whether the caller passes a UTC or local DateTime. + // This is symmetric with toGregorian, which always returns DateTime.utc(). + final d = date.toUtc(); + final inputUtc = _dateUtcMs(d.year, d.month, d.day); // Binary search: find the last table entry whose Gregorian start date <= input. int lo = 0; diff --git a/test/hijri_core_test.dart b/test/hijri_core_test.dart index 606a04c..5241336 100644 --- a/test/hijri_core_test.dart +++ b/test/hijri_core_test.dart @@ -93,6 +93,41 @@ void main() { }); }); + // ---- UAQ round-trips (UTC day boundary) ---- + + group('UAQ round-trips', () { + test('toHijri(toGregorian(1446,9,1)) == 1446/9/1', () { + final greg = toGregorian(1446, 9, 1); + expect(greg, isNotNull); + final h = toHijri(greg!); + expect(h, isNotNull); + expect(h!.hy, equals(1446)); + expect(h.hm, equals(9)); + expect(h.hd, equals(1)); + }); + test('local DateTime with same UTC day round-trips correctly', () { + // toGregorian returns DateTime.utc(); converting it to local must still + // produce the correct Hijri day (regression for UTC-west hosts). + final greg = toGregorian(1446, 9, 1); + expect(greg, isNotNull); + final localGreg = greg!.toLocal(); + final h = toHijri(localGreg); + expect(h, isNotNull); + expect(h!.hy, equals(1446)); + expect(h.hm, equals(9)); + expect(h.hd, equals(1)); + }); + test('toHijri(toGregorian(1318,1,1)) == 1318/1/1 (table lower bound)', () { + final greg = toGregorian(1318, 1, 1); + expect(greg, isNotNull); + final h = toHijri(greg!); + expect(h, isNotNull); + expect(h!.hy, equals(1318)); + expect(h.hm, equals(1)); + expect(h.hd, equals(1)); + }); + }); + // ---- UAQ isValid ---- group('UAQ isValid', () {