fix: local-day adapter semantics with exact round-trips on hijri-core's UTC-day contract

Behavior changes (lock-step with hijri-core fix/utc-day-boundary):

- toHijriDate and all field/format/comparison/arithmetic functions now lift input
  Dates through localDayToUtcSlot() before calling coreToHijri(), reading the
  caller's LOCAL calendar day (date-fns convention). Previously passed the raw
  Date which caused off-by-one results in timezones west of UTC against the new
  UTC-day core contract.

- fromHijriDate now returns local-midnight Dates (new Date(y, m, d)) instead of
  UTC midnight. Local field accessors and date-fns format() render the intended
  calendar day on every host timezone. toISOString() is no longer the right API
  for this value.

- addHijriMonths, addHijriYears, startOfHijriMonth, endOfHijriMonth call
  fromHijriDate directly; the utcMidnightToLocalNoon shim is removed.

- Round-trip toHijriDate(fromHijriDate(y, m, d)) is now exact on every timezone.

Verified: 58/58 ESM tests, 10/10 CJS tests, 16/16 vitest assertions across
TZ=UTC, TZ=America/New_York, and TZ=Pacific/Auckland.
This commit is contained in:
Aric Camarata 2026-06-10 16:38:32 -04:00
parent f260912927
commit d12117f000
10 changed files with 883 additions and 61 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/
dist/
coverage/
*.tgz
*.log
.DS_Store

View file

@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- `toHijriDate` and all field getters now produce exact round-trips on every host timezone (input Date interpreted by its local calendar day, matching date-fns conventions; previously used raw Date which failed in timezones west of UTC against hijri-core's UTC-day contract).
### Changed
- `fromHijriDate` and all arithmetic/boundary helpers (`addHijriMonths`, `addHijriYears`, `startOfHijriMonth`, `endOfHijriMonth`) now return **local-midnight** Dates instead of UTC midnight / local noon. Use `getFullYear()`/`getMonth()`/`getDate()` (or date-fns `format()`) on the result — not `toISOString()`.
- Lock-step with unreleased hijri-core `fix/utc-day-boundary` (UTC-day contract).
## [1.0.2] - 2026-05-30
### Changed

View file

@ -51,6 +51,31 @@ Full API reference, guides, and examples: **[Wiki](https://github.com/acamarata/
- [Architecture](https://github.com/acamarata/date-fns-hijri/wiki/Architecture): design decisions and hijri-core integration
- [Quick Start](https://github.com/acamarata/date-fns-hijri/wiki/guides/quickstart)
## Day boundaries and time zones
This package follows date-fns local-time conventions:
- **Inputs** (`toHijriDate`, `getHijri*`, `formatHijriDate`, arithmetic, comparisons) — the input `Date` is read by its **local calendar day** (using `getFullYear`/`getMonth`/`getDate`). This matches how date-fns' own `format()` and field accessors work.
- **Outputs** (`fromHijriDate` and all arithmetic/boundary functions) — returned `Date` values are **local midnight** of the equivalent Gregorian day. Local field accessors and date-fns' `format()` will render the intended date on every timezone.
Round-trips are exact on every host timezone:
```typescript
toHijriDate(fromHijriDate(1446, 9, 1)); // always { hy: 1446, hm: 9, hd: 1 }
```
**Pitfall:** `new Date("2025-03-01")` parses as UTC midnight. In timezones west of UTC this resolves to the previous local day (Feb 28), giving an off-by-one result. Use the local-date constructor instead:
```typescript
// Wrong in timezones west of UTC:
toHijriDate(new Date("2025-03-01")); // may return 29 Shaban in some zones
// Correct everywhere:
toHijriDate(new Date(2025, 2, 1)); // always 1 Ramadan 1446
```
Religious day-start (sunset boundary) is out of scope — this package only handles civil calendar day alignment.
## Related
- [hijri-core](https://github.com/acamarata/hijri-core): the calendar engine powering this library

126
date-fns-hijri.test.ts Normal file
View file

@ -0,0 +1,126 @@
/**
* Purpose: Vitest suite for date-fns-hijri functional Hijri date utilities.
* Inputs: Pure functions from src/index.ts wrapping hijri-core. No network, no I/O.
* Outputs: Vitest pass/fail assertions.
* Constraints: UAQ range 13181500 AH; fromHijriDate throws on invalid input (null path).
* Use local-date constructor new Date(y, m, d) not string "YYYY-MM-DD" which
* parses as UTC midnight and can be the previous LOCAL day west of UTC.
* Usage: pnpm vitest run
* SOT: packages.md date-fns-hijri row
*/
import { describe, it, expect } from "vitest";
import {
toHijriDate,
fromHijriDate,
isValidHijriDate,
getHijriYear,
getHijriMonth,
getHijriDay,
getDaysInHijriMonth,
getHijriMonthName,
getHijriWeekdayName,
} from "./src/index";
// Anchor: 1 Ramadan 1446 = 2025-03-01 in the Gregorian calendar.
// Use local-date constructor to avoid the UTC-parsing pitfall with string form.
// At local noon the local calendar day is unambiguous on every timezone.
const RAMADAN_1446_NOON = new Date(2025, 2, 1, 12); // local noon 2025-03-01
describe("toHijriDate", () => {
it("converts noon 2025-03-01 UTC to 1 Ramadan 1446", () => {
const result = toHijriDate(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 (2100)", () => {
expect(toHijriDate(new Date("2100-01-01"))).toBeNull();
});
});
describe("fromHijriDate", () => {
it("converts 1 Ramadan 1446 to local 2025-03-01 (via local accessors)", () => {
const result = fromHijriDate(1446, 9, 1);
// Returns local midnight: local accessors show the intended calendar day
// on every host timezone. Do NOT use toISOString() — it shows UTC which
// will be the previous day in timezones west of UTC.
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(2); // March
expect(result.getDate()).toBe(1);
});
it("round-trip: toHijriDate(fromHijriDate(1446, 9, 1)) === {1446, 9, 1}", () => {
const d = fromHijriDate(1446, 9, 1);
const h = toHijriDate(d);
expect(h).not.toBeNull();
expect(h!.hy).toBe(1446);
expect(h!.hm).toBe(9);
expect(h!.hd).toBe(1);
});
it("throws on an out-of-range Hijri year (1501)", () => {
expect(() => fromHijriDate(1501, 1, 1)).toThrow();
});
});
describe("isValidHijriDate", () => {
it("returns true for 1 Ramadan 1446", () => {
expect(isValidHijriDate(1446, 9, 1)).toBe(true);
});
it("returns false for month 13", () => {
expect(isValidHijriDate(1446, 13, 1)).toBe(false);
});
});
describe("field getters", () => {
it("getHijriYear returns 1446 for noon 2025-03-01", () => {
expect(getHijriYear(RAMADAN_1446_NOON)).toBe(1446);
});
it("getHijriMonth returns 9 for Ramadan", () => {
expect(getHijriMonth(RAMADAN_1446_NOON)).toBe(9);
});
it("getHijriDay returns 1", () => {
expect(getHijriDay(RAMADAN_1446_NOON)).toBe(1);
});
});
describe("getDaysInHijriMonth", () => {
it("returns 29 or 30 for Ramadan 1446", () => {
const days = getDaysInHijriMonth(1446, 9);
expect([29, 30]).toContain(days);
});
});
describe("getHijriMonthName", () => {
it("returns Ramadan for month 9 (long)", () => {
expect(getHijriMonthName(9, "long")).toBe("Ramadan");
});
it("throws RangeError for month 0", () => {
expect(() => getHijriMonthName(0)).toThrow(RangeError);
});
it("returns a non-empty medium name for month 1", () => {
const name = getHijriMonthName(1, "medium");
expect(name.length).toBeGreaterThan(0);
});
});
describe("getHijriWeekdayName", () => {
it("returns a non-empty long weekday name for 2025-03-01 (Saturday)", () => {
const name = getHijriWeekdayName(RAMADAN_1446_NOON, "long");
expect(typeof name).toBe("string");
expect(name.length).toBeGreaterThan(0);
});
it("short name is no longer than long name for the same date", () => {
const long = getHijriWeekdayName(RAMADAN_1446_NOON, "long");
const short = getHijriWeekdayName(RAMADAN_1446_NOON, "short");
expect(short.length).toBeLessThanOrEqual(long.length);
});
});

View file

@ -40,7 +40,8 @@
"prepublishOnly": "pnpm run build",
"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": [
"date-fns",
@ -74,7 +75,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",

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,27 @@ export type { HijriDate, CalendarEngine, ConversionOptions } from "./types";
import type { HijriDate, ConversionOptions } from "./types";
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Purpose: Lift a Date's LOCAL calendar components (year, month, day) into the
* UTC slot so that hijri-core's UTC-day contract reads the caller's
* intended calendar day regardless of host timezone.
* Inputs: Any Gregorian Date.
* Outputs: A new Date whose UTC year/month/date equal the input's LOCAL year/month/date.
* Constraints: Used only as input to coreToHijri; the returned value is an ephemeral
* intermediate never hand it to Date#getFullYear or date-fns functions.
* WHY: date-fns is a LOCAL-time library: its functions read local components.
* hijri-core (after fix/utc-day-boundary) reads the UTC calendar day.
* Without this shim, hosts west of UTC see the previous UTC day for
* a local-midnight Date, causing off-by-one conversions.
*/
function localDayToUtcSlot(date: Date): Date {
return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
}
// ---------------------------------------------------------------------------
// Conversion
// ---------------------------------------------------------------------------
@ -22,19 +43,39 @@ import type { HijriDate, ConversionOptions } from "./types";
/**
* Convert a Gregorian `Date` to a Hijri date object.
*
* Follows date-fns conventions: the input `Date` is interpreted by its
* **local calendar day** (year/month/date in the host timezone). This matches
* how date-fns' own `format()` and field accessors work, so there are no
* timezone surprises when chaining with other date-fns functions.
*
* Returns `null` when the date falls outside the calendar's supported range
* (UAQ: 13181500 AH / 19002076 CE; FCNA extends slightly further).
*
* @example
* // Use local-date constructor, not the string form "2025-03-01" (parses as UTC)
* toHijriDate(new Date(2025, 2, 1)); // { hy: 1446, hm: 9, hd: 1 }
*/
export function toHijriDate(date: Date, options?: ConversionOptions): HijriDate | null {
return coreToHijri(date, options);
return coreToHijri(localDayToUtcSlot(date), options);
}
/**
* Convert a Hijri date to a Gregorian `Date`.
*
* The returned `Date` is set to midnight UTC of the equivalent Gregorian day.
* Returns a **local-midnight** Date so that local field accessors
* (`getFullYear`, `getMonth`, `getDate`) and date-fns' `format()` render the
* intended calendar day on every host timezone.
*
* Round-trips exactly: `toHijriDate(fromHijriDate(y, m, d))` returns
* `{ hy: y, hm: m, hd: d }` on every timezone.
*
* @throws {Error} If the Hijri date is invalid or outside the calendar's range.
*
* @example
* const d = fromHijriDate(1446, 9, 1);
* d.getFullYear(); // 2025
* d.getMonth(); // 2 (March)
* d.getDate(); // 1
*/
export function fromHijriDate(
hy: number,
@ -42,11 +83,13 @@ export function fromHijriDate(
hd: number,
options?: ConversionOptions,
): Date {
const result = coreToGregorian(hy, hm, hd, options);
if (result === null) {
const greg = coreToGregorian(hy, hm, hd, options);
if (greg === null) {
throw new Error(`Hijri date ${hy}/${hm}/${hd} is invalid or outside the supported range.`);
}
return result;
// coreToGregorian returns UTC midnight; lift to local midnight so that
// local field accessors and date-fns format() show the right calendar day.
return new Date(greg.getUTCFullYear(), greg.getUTCMonth(), greg.getUTCDate());
}
// ---------------------------------------------------------------------------
@ -75,28 +118,34 @@ export function isValidHijriDate(
/**
* Get the Hijri year for a Gregorian date.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention).
*
* Returns `null` when the date is outside the supported range.
*/
export function getHijriYear(date: Date, options?: ConversionOptions): number | null {
return coreToHijri(date, options)?.hy ?? null;
return coreToHijri(localDayToUtcSlot(date), options)?.hy ?? null;
}
/**
* Get the Hijri month (112) for a Gregorian date.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention).
*
* Returns `null` when the date is outside the supported range.
*/
export function getHijriMonth(date: Date, options?: ConversionOptions): number | null {
return coreToHijri(date, options)?.hm ?? null;
return coreToHijri(localDayToUtcSlot(date), options)?.hm ?? null;
}
/**
* Get the Hijri day of month (130) for a Gregorian date.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention).
*
* Returns `null` when the date is outside the supported range.
*/
export function getHijriDay(date: Date, options?: ConversionOptions): number | null {
return coreToHijri(date, options)?.hd ?? null;
return coreToHijri(localDayToUtcSlot(date), options)?.hd ?? null;
}
/**
@ -141,6 +190,8 @@ export function getHijriMonthName(
* Get the Arabic weekday name for a Gregorian date.
*
* Uses `Date.getDay()` (0 = Sunday, 6 = Saturday) as the index.
* `getDay()` reads the local weekday, which is correct weekday display
* follows the host's local calendar day just like date-fns.
*
* @param date - Any Gregorian `Date`.
* @param length - `'long'` (default) or `'short'`.
@ -162,6 +213,9 @@ const TOKEN_RE = /iYYYY|iYY|iMMMM|iMMM|iMM|iM|iDD|iD|iEEEE|iEEE|iE|ioooo|iooo/g;
/**
* Format a Gregorian date using Hijri calendar tokens.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention),
* matching the behavior of date-fns' own `format()`.
*
* Supported tokens:
*
* | Token | Output | Example |
@ -187,10 +241,10 @@ export function formatHijriDate(
formatStr: string,
options?: ConversionOptions,
): string {
const h = coreToHijri(date, options);
const h = coreToHijri(localDayToUtcSlot(date), options);
if (!h) return "";
const day = date.getDay(); // 06
const day = date.getDay(); // 06 local weekday — correct for display
return formatStr.replace(TOKEN_RE, (token): string => {
switch (token) {
@ -233,24 +287,6 @@ export function formatHijriDate(
});
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* `coreToGregorian` returns a UTC-midnight Date. When `coreToHijri` is then
* called on that Date, it normalises using local year/month/day components
* (`getFullYear`, `getMonth`, `getDate`). In timezones west of UTC the local
* date of a UTC-midnight instant is the *previous* calendar day, which causes
* the round-trip to drift by one day.
*
* This helper converts a UTC-midnight Date to a local-noon Date so that local
* calendar components always match the intended Gregorian date.
*/
function utcMidnightToLocalNoon(d: Date): Date {
return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12);
}
// ---------------------------------------------------------------------------
// Arithmetic
// ---------------------------------------------------------------------------
@ -258,6 +294,9 @@ function utcMidnightToLocalNoon(d: Date): Date {
/**
* Add a number of Hijri months to a Gregorian date.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention).
* Returns a **local-midnight** Date.
*
* Handles year rollover automatically. Month addition wraps at month 12 and
* increments the year. If the result's month has fewer days than the original
* day, the day is clamped to the last day of the new month.
@ -265,7 +304,7 @@ function utcMidnightToLocalNoon(d: Date): Date {
* @throws {Error} If the resulting Hijri date is outside the supported range.
*/
export function addHijriMonths(date: Date, months: number, options?: ConversionOptions): Date {
const h = coreToHijri(date, options);
const h = coreToHijri(localDayToUtcSlot(date), options);
if (!h) {
throw new Error("Date is outside the supported Hijri calendar range.");
}
@ -279,19 +318,22 @@ export function addHijriMonths(date: Date, months: number, options?: ConversionO
const maxDay = coreDaysInHijriMonth(newYear, newMonth, options);
const newDay = Math.min(h.hd, maxDay);
return utcMidnightToLocalNoon(fromHijriDate(newYear, newMonth, newDay, options));
return fromHijriDate(newYear, newMonth, newDay, options);
}
/**
* Add a number of Hijri years to a Gregorian date.
*
* If the resulting year has a shorter Ramadan (or any month) than the original
* day, the day is clamped to the last day of that month.
* The input Date is interpreted by its **local calendar day** (date-fns convention).
* Returns a **local-midnight** Date.
*
* If the resulting year has a shorter month than the original day, the day is
* clamped to the last day of that month.
*
* @throws {Error} If the resulting Hijri date is outside the supported range.
*/
export function addHijriYears(date: Date, years: number, options?: ConversionOptions): Date {
const h = coreToHijri(date, options);
const h = coreToHijri(localDayToUtcSlot(date), options);
if (!h) {
throw new Error("Date is outside the supported Hijri calendar range.");
}
@ -300,7 +342,7 @@ export function addHijriYears(date: Date, years: number, options?: ConversionOpt
const maxDay = coreDaysInHijriMonth(newYear, h.hm, options);
const newDay = Math.min(h.hd, maxDay);
return utcMidnightToLocalNoon(fromHijriDate(newYear, h.hm, newDay, options));
return fromHijriDate(newYear, h.hm, newDay, options);
}
// ---------------------------------------------------------------------------
@ -310,28 +352,34 @@ export function addHijriYears(date: Date, years: number, options?: ConversionOpt
/**
* Get the first day of the Hijri month that contains the given date.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention).
* Returns a **local-midnight** Date.
*
* @throws {Error} If the date is outside the supported range.
*/
export function startOfHijriMonth(date: Date, options?: ConversionOptions): Date {
const h = coreToHijri(date, options);
const h = coreToHijri(localDayToUtcSlot(date), options);
if (!h) {
throw new Error("Date is outside the supported Hijri calendar range.");
}
return utcMidnightToLocalNoon(fromHijriDate(h.hy, h.hm, 1, options));
return fromHijriDate(h.hy, h.hm, 1, options);
}
/**
* Get the last day of the Hijri month that contains the given date.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention).
* Returns a **local-midnight** Date.
*
* @throws {Error} If the date is outside the supported range.
*/
export function endOfHijriMonth(date: Date, options?: ConversionOptions): Date {
const h = coreToHijri(date, options);
const h = coreToHijri(localDayToUtcSlot(date), options);
if (!h) {
throw new Error("Date is outside the supported Hijri calendar range.");
}
const lastDay = coreDaysInHijriMonth(h.hy, h.hm, options);
return utcMidnightToLocalNoon(fromHijriDate(h.hy, h.hm, lastDay, options));
return fromHijriDate(h.hy, h.hm, lastDay, options);
}
// ---------------------------------------------------------------------------
@ -341,11 +389,13 @@ export function endOfHijriMonth(date: Date, options?: ConversionOptions): Date {
/**
* Check whether two Gregorian dates fall in the same Hijri month.
*
* Both input Dates are interpreted by their **local calendar days** (date-fns convention).
*
* Returns `false` if either date is outside the supported range.
*/
export function isSameHijriMonth(dateA: Date, dateB: Date, options?: ConversionOptions): boolean {
const a = coreToHijri(dateA, options);
const b = coreToHijri(dateB, options);
const a = coreToHijri(localDayToUtcSlot(dateA), options);
const b = coreToHijri(localDayToUtcSlot(dateB), options);
if (!a || !b) return false;
return a.hy === b.hy && a.hm === b.hm;
}
@ -353,11 +403,13 @@ export function isSameHijriMonth(dateA: Date, dateB: Date, options?: ConversionO
/**
* Check whether two Gregorian dates fall in the same Hijri year.
*
* Both input Dates are interpreted by their **local calendar days** (date-fns convention).
*
* Returns `false` if either date is outside the supported range.
*/
export function isSameHijriYear(dateA: Date, dateB: Date, options?: ConversionOptions): boolean {
const a = coreToHijri(dateA, options);
const b = coreToHijri(dateB, options);
const a = coreToHijri(localDayToUtcSlot(dateA), options);
const b = coreToHijri(localDayToUtcSlot(dateB), options);
if (!a || !b) return false;
return a.hy === b.hy;
}
@ -369,12 +421,14 @@ export function isSameHijriYear(dateA: Date, dateB: Date, options?: ConversionOp
/**
* Get the Hijri quarter (14) for a Gregorian date.
*
* The input Date is interpreted by its **local calendar day** (date-fns convention).
*
* Months 13 = Q1, 46 = Q2, 79 = Q3, 1012 = Q4.
*
* Returns `null` when the date is outside the supported range.
*/
export function getHijriQuarter(date: Date, options?: ConversionOptions): number | null {
const h = coreToHijri(date, options);
const h = coreToHijri(localDayToUtcSlot(date), options);
if (!h) return null;
return Math.ceil(h.hm / 3);
}

View file

@ -26,11 +26,12 @@ describe('CJS: toHijriDate', () => {
});
describe('CJS: fromHijriDate', () => {
it('converts to correct Gregorian date', () => {
it('converts to correct Gregorian date (local midnight)', () => {
const d = fromHijriDate(1444, 9, 1);
assert.equal(d.getUTCFullYear(), 2023);
assert.equal(d.getUTCMonth(), 2);
assert.equal(d.getUTCDate(), 23);
// Returns local midnight — use local accessors, not UTC
assert.equal(d.getFullYear(), 2023);
assert.equal(d.getMonth(), 2);
assert.equal(d.getDate(), 23);
});
});

View file

@ -43,21 +43,48 @@ describe('toHijriDate', () => {
const h = toHijriDate(new Date(1800, 0, 1));
assert.equal(h, null);
});
it('toHijriDate(new Date(2025, 2, 1, 12)) -> {1446, 9, 1}', () => {
// Local-noon: verifies local-day interpretation ignores the time component
const h = toHijriDate(new Date(2025, 2, 1, 12));
assert.ok(h !== null, 'expected non-null');
assert.equal(h.hy, 1446);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
});
});
describe('fromHijriDate', () => {
it('1 Ramadan 1444 -> 2023-03-23', () => {
it('1 Ramadan 1444 -> local 2023-03-23', () => {
const d = fromHijriDate(1444, 9, 1);
assert.equal(d.getUTCFullYear(), 2023);
assert.equal(d.getUTCMonth(), 2);
assert.equal(d.getUTCDate(), 23);
// Returns local midnight: local accessors show the intended calendar day
assert.equal(d.getFullYear(), 2023);
assert.equal(d.getMonth(), 2);
assert.equal(d.getDate(), 23);
});
it('1 Muharram 1446 -> 2024-07-07', () => {
it('1 Muharram 1446 -> local 2024-07-07', () => {
const d = fromHijriDate(1446, 1, 1);
assert.equal(d.getUTCFullYear(), 2024);
assert.equal(d.getUTCMonth(), 6);
assert.equal(d.getUTCDate(), 7);
assert.equal(d.getFullYear(), 2024);
assert.equal(d.getMonth(), 6);
assert.equal(d.getDate(), 7);
});
it('round-trip: toHijriDate(fromHijriDate(1446, 9, 1)) === {1446, 9, 1}', () => {
const d = fromHijriDate(1446, 9, 1);
const h = toHijriDate(d);
assert.ok(h !== null, 'expected non-null round-trip result');
assert.equal(h.hy, 1446);
assert.equal(h.hm, 9);
assert.equal(h.hd, 1);
});
it('fromHijriDate(1446,9,1) local accessors show 2025-03-01', () => {
const d = fromHijriDate(1446, 9, 1);
// Local accessors — not toISOString() — are the correct API for this adapter
assert.equal(d.getFullYear(), 2025);
assert.equal(d.getMonth(), 2); // March
assert.equal(d.getDate(), 1);
});
it('throws on invalid month', () => {

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["date-fns-hijri.test.ts"],
},
});