fix: interpret DateTime by UTC calendar day for exact round-trips

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.
This commit is contained in:
Aric Camarata 2026-06-13 10:28:16 -04:00
parent 5dd4cecc12
commit c79368a4ee
4 changed files with 57 additions and 5 deletions

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

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