mirror of
https://github.com/acamarata/luxon-hijri.git
synced 2026-06-30 18:54:28 +00:00
test: align day-boundary tests and docs with hijri-core's UTC-day contract
Convert all LOCAL-noon Date anchors (new Date(y, m, d, 12)) to UTC-explicit
anchors (new Date(Date.UTC(y, m-1, d))) in test.mjs and test-cjs.cjs.
Add UAQ default-engine round-trip regression suite (5 cases).
Extend FCNA round-trips; update vitest header comment.
README: add "Day boundaries and time zones" section explaining the UTC-day
contract, the correct pattern for zone-aware Luxon DateTimes, and ISO-string
parsing behaviour. Quick Start examples updated to use Date.UTC.
CHANGELOG: document inherited UTC-day fix under [Unreleased].
Lock-step dependency: requires hijri-core fix (commit 3419378,
branch fix/utc-day-boundary). Both packages release together per ADR-013.
Verified: TZ={UTC,America/New_York,Pacific/Auckland} × {test.mjs,
test-cjs.cjs, test-crossval.mjs, vitest} — all pass (88+26+120+15 tests).
This commit is contained in:
parent
1e6fdfa407
commit
eea0bc808d
8 changed files with 767 additions and 21 deletions
|
|
@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Inherits hijri-core's UTC-day fix: `toHijri` with UTC-midnight Dates is now exact on all hosts
|
||||
(previously, LOCAL date components were read, causing off-by-one errors west of UTC and on UTC+13+).
|
||||
- Round-trips (`toGregorian` then `toHijri`) are now exact for both the UAQ (default) and FCNA engines.
|
||||
- Tests updated to use `new Date(Date.UTC(...))` anchors throughout; UAQ engine round-trip regression
|
||||
suite added. Lock-step release with hijri-core fix (commit 3419378).
|
||||
|
||||
## [3.0.1] - 2026-05-30
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
30
README.md
30
README.md
|
|
@ -20,8 +20,8 @@ npm install luxon-hijri luxon hijri-core
|
|||
```javascript
|
||||
import { toHijri, toGregorian, formatHijriDate } from 'luxon-hijri';
|
||||
|
||||
// Gregorian to Hijri (Umm al-Qura, default)
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
// Gregorian to Hijri (Umm al-Qura, default) — use Date.UTC for cross-host consistency
|
||||
const h = toHijri(new Date(Date.UTC(2023, 2, 23)));
|
||||
// { hy: 1444, hm: 9, hd: 1 }
|
||||
|
||||
// Hijri to Gregorian
|
||||
|
|
@ -33,9 +33,33 @@ formatHijriDate({ hy: 1444, hm: 9, hd: 1 }, 'iEEEE, iD iMMMM iYYYY ioooo');
|
|||
// "Yawm al-Khamis, 1 Ramadan 1444 AH"
|
||||
|
||||
// FCNA/ISNA calendar
|
||||
toHijri(new Date(2025, 2, 1, 12), { calendar: 'fcna' });
|
||||
toHijri(new Date(Date.UTC(2025, 2, 1)), { calendar: 'fcna' });
|
||||
```
|
||||
|
||||
## Day boundaries and time zones
|
||||
|
||||
`toHijri(date)` reads the **UTC calendar day** of the Date you pass. `toGregorian(hy, hm, hd)` returns a Date at **UTC midnight** on the corresponding Gregorian day. Round-trips are therefore exact and produce identical results on any machine regardless of local timezone.
|
||||
|
||||
**Converting a zone-aware Luxon DateTime.** Pass the DateTime's calendar fields, not `.toJSDate()`, unless the DateTime is already pinned to UTC:
|
||||
|
||||
```javascript
|
||||
import { DateTime } from 'luxon';
|
||||
import { toHijri } from 'luxon-hijri';
|
||||
|
||||
const dt = DateTime.now().setZone('America/New_York');
|
||||
|
||||
// Correct — reads the calendar date in the DateTime's own zone
|
||||
const h = toHijri(new Date(Date.UTC(dt.year, dt.month - 1, dt.day)));
|
||||
|
||||
// Wrong if dt is not UTC-anchored — toJSDate() produces local-zone midnight,
|
||||
// which may land on the previous UTC day for western timezones
|
||||
// const h = toHijri(dt.toJSDate());
|
||||
```
|
||||
|
||||
**ISO string parsing.** `new Date("2025-03-01")` parses as UTC midnight — that is exactly the right input for a calendar-day conversion and will produce the correct Hijri date.
|
||||
|
||||
Note: determining when the Hijri day begins at local sunset is out of scope for this library.
|
||||
|
||||
## TypeScript
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
103
luxon-hijri.test.ts
Normal file
103
luxon-hijri.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Purpose: Vitest suite for luxon-hijri — conversion, formatting, and validation.
|
||||
* Inputs: Pure functions from src/index.ts. Requires luxon + hijri-core as peer deps.
|
||||
* Outputs: Vitest pass/fail assertions.
|
||||
* Constraints: UAQ range 1318–1500 AH. toGregorian throws (not null) on invalid input.
|
||||
* toHijri reads the Date's UTC calendar day; pass UTC midnight or use
|
||||
* Date.UTC(year, month-1, day) for exact results on all hosts.
|
||||
* Usage: pnpm vitest run
|
||||
* SOT: packages.md — luxon-hijri row
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toHijri,
|
||||
toGregorian,
|
||||
isValidHijriDate,
|
||||
formatHijriDate,
|
||||
hmLong,
|
||||
hmMedium,
|
||||
hmShort,
|
||||
} from "./src/index";
|
||||
|
||||
// Anchor: toGregorian(1446, 9, 1) = 2025-03-01 midnight UTC
|
||||
// toHijri on noon 2025-03-01 reliably returns { hm: 9, hd: 1 }
|
||||
const RAMADAN_1446_NOON = new Date("2025-03-01T12:00:00Z");
|
||||
|
||||
describe("toHijri", () => {
|
||||
it("converts noon 2025-03-01 UTC to 1 Ramadan 1446", () => {
|
||||
const result = toHijri(RAMADAN_1446_NOON);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.hy).toBe(1446);
|
||||
expect(result!.hm).toBe(9);
|
||||
expect(result!.hd).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null for dates outside UAQ range", () => {
|
||||
expect(toHijri(new Date("2100-01-01"))).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on an invalid Date", () => {
|
||||
expect(() => toHijri(new Date("not-a-date"))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toGregorian", () => {
|
||||
it("converts 1 Ramadan 1446 to 2025-03-01 UTC midnight", () => {
|
||||
const result = toGregorian(1446, 9, 1);
|
||||
expect(result.toISOString()).toBe("2025-03-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("throws on invalid Hijri date (out of range)", () => {
|
||||
expect(() => toGregorian(1501, 1, 1)).toThrow("Invalid Hijri date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidHijriDate", () => {
|
||||
it("returns true for 1 Ramadan 1446", () => {
|
||||
expect(isValidHijriDate(1446, 9, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for month 0", () => {
|
||||
expect(isValidHijriDate(1446, 0, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for day 31", () => {
|
||||
expect(isValidHijriDate(1446, 1, 31)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHijriDate", () => {
|
||||
const hijriDate = { hy: 1446, hm: 9, hd: 1 };
|
||||
|
||||
it("formats iYYYY-iMM-iDD correctly", () => {
|
||||
expect(formatHijriDate(hijriDate, "iYYYY-iMM-iDD")).toBe("1446-09-01");
|
||||
});
|
||||
|
||||
it("formats iMMMM as full month name Ramadan", () => {
|
||||
expect(formatHijriDate(hijriDate, "iMMMM")).toBe("Ramadan");
|
||||
});
|
||||
|
||||
it("formats iMMM as a non-empty medium month name", () => {
|
||||
const result = formatHijriDate(hijriDate, "iMMM");
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("throws RangeError on invalid month 0", () => {
|
||||
expect(() => formatHijriDate({ hy: 1446, hm: 0, hd: 1 }, "iMMMM")).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("month name tables", () => {
|
||||
it("hmLong index 8 is Ramadan", () => {
|
||||
expect(hmLong[8]).toBe("Ramadan");
|
||||
});
|
||||
|
||||
it("hmMedium has 12 entries", () => {
|
||||
expect(hmMedium).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("hmShort has 12 entries", () => {
|
||||
expect(hmShort).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
|
@ -40,7 +40,8 @@
|
|||
"format:check": "prettier --check src/",
|
||||
"coverage": "c8 --reporter=lcov --reporter=text node test.mjs",
|
||||
"docs": "typedoc --out .github/wiki/api src/index.ts",
|
||||
"postbuild": "cp dist/index.d.ts dist/index.d.mts"
|
||||
"postbuild": "cp dist/index.d.ts dist/index.d.mts",
|
||||
"test:vitest": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"hijri",
|
||||
|
|
@ -85,7 +86,8 @@
|
|||
"typedoc": "^0.28.19",
|
||||
"typedoc-plugin-markdown": "^4.11.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.56.1"
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
|
|
|||
578
pnpm-lock.yaml
578
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -43,11 +43,11 @@ describe('CJS core conversions', () => {
|
|||
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
|
||||
});
|
||||
it('toHijri(2022-07-30) = 1 Muharram 1444', () => {
|
||||
const h = toHijri(new Date(2022, 6, 30, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2022, 6, 30)));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
|
||||
});
|
||||
it('toHijri(2023-03-23) = 1 Ramadan 1444', () => {
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2023, 2, 23)));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
|
||||
});
|
||||
});
|
||||
|
|
@ -106,7 +106,7 @@ describe('CJS FCNA calendar', () => {
|
|||
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
|
||||
});
|
||||
it('2025-03-01 = 1 Ramadan 1446', () => {
|
||||
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
|
||||
const h = toHijri(new Date(Date.UTC(2025, 2, 1)), FCNA);
|
||||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
it('isValidHijriDate(1446, 9, 1) = true', () => {
|
||||
|
|
|
|||
48
test.mjs
48
test.mjs
|
|
@ -95,23 +95,23 @@ describe('toGregorian - error cases', () => {
|
|||
|
||||
describe('toHijri - known dates', () => {
|
||||
it('2022-07-30 = 1 Muharram 1444', () => {
|
||||
const h = toHijri(new Date(2022, 6, 30, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2022, 6, 30)));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
|
||||
});
|
||||
it('2023-03-23 = 1 Ramadan 1444', () => {
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2023, 2, 23)));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
|
||||
});
|
||||
it('2023-04-21 = 1 Shawwal 1444', () => {
|
||||
const h = toHijri(new Date(2023, 3, 21, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2023, 3, 21)));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 });
|
||||
});
|
||||
it('2024-07-07 = 1 Muharram 1446', () => {
|
||||
const h = toHijri(new Date(2024, 6, 7, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2024, 6, 7)));
|
||||
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
|
||||
});
|
||||
it('1900-04-30 = 1 Muharram 1318 (first table entry)', () => {
|
||||
const h = toHijri(new Date(1900, 3, 30, 12));
|
||||
const h = toHijri(new Date(Date.UTC(1900, 3, 30)));
|
||||
assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 });
|
||||
});
|
||||
});
|
||||
|
|
@ -274,15 +274,15 @@ describe('FCNA toGregorian', () => {
|
|||
|
||||
describe('FCNA toHijri', () => {
|
||||
it('2025-03-01 = 1 Ramadan 1446', () => {
|
||||
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
|
||||
const h = toHijri(new Date(Date.UTC(2025, 2, 1)), FCNA);
|
||||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
it('2025-03-30 = 1 Shawwal 1446', () => {
|
||||
const h = toHijri(new Date(2025, 2, 30, 12), FCNA);
|
||||
const h = toHijri(new Date(Date.UTC(2025, 2, 30)), FCNA);
|
||||
assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 });
|
||||
});
|
||||
it('2024-03-11 = 1 Ramadan 1445', () => {
|
||||
const h = toHijri(new Date(2024, 2, 11, 12), FCNA);
|
||||
const h = toHijri(new Date(Date.UTC(2024, 2, 11)), FCNA);
|
||||
assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 });
|
||||
});
|
||||
});
|
||||
|
|
@ -312,6 +312,36 @@ describe('FCNA round-trips', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('UAQ round-trips (default engine)', () => {
|
||||
it('1444/1/1 toGregorian then toHijri', () => {
|
||||
const greg = toGregorian(1444, 1, 1);
|
||||
const hijri = toHijri(greg);
|
||||
assert.deepEqual(hijri, { hy: 1444, hm: 1, hd: 1 });
|
||||
});
|
||||
it('1444/9/1 toGregorian then toHijri', () => {
|
||||
const greg = toGregorian(1444, 9, 1);
|
||||
const hijri = toHijri(greg);
|
||||
assert.deepEqual(hijri, { hy: 1444, hm: 9, hd: 1 });
|
||||
});
|
||||
it('1446/9/1 toGregorian then toHijri', () => {
|
||||
const greg = toGregorian(1446, 9, 1);
|
||||
const hijri = toHijri(greg);
|
||||
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
|
||||
});
|
||||
it('1318/1/1 toGregorian then toHijri (first table entry)', () => {
|
||||
const greg = toGregorian(1318, 1, 1);
|
||||
assert(greg instanceof Date);
|
||||
const hijri = toHijri(greg);
|
||||
assert.deepEqual(hijri, { hy: 1318, hm: 1, hd: 1 });
|
||||
});
|
||||
it('1500/12/29 toGregorian then toHijri (last table entry)', () => {
|
||||
const greg = toGregorian(1500, 12, 29);
|
||||
assert(greg instanceof Date);
|
||||
const hijri = toHijri(greg);
|
||||
assert.deepEqual(hijri, { hy: 1500, hm: 12, hd: 29 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FCNA isValidHijriDate', () => {
|
||||
it('1446/9/1 = true', () => assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true));
|
||||
it('month 0 = false', () => assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false));
|
||||
|
|
@ -329,7 +359,7 @@ describe('UAQ default regression', () => {
|
|||
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
|
||||
});
|
||||
it('toHijri still works without options', () => {
|
||||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||||
const h = toHijri(new Date(Date.UTC(2023, 2, 23)));
|
||||
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
|
||||
});
|
||||
it('isValidHijriDate still works without options', () => {
|
||||
|
|
|
|||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["luxon-hijri.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue