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', () {