luxon-hijri/test.mjs
Aric Camarata eea0bc808d 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).
2026-06-10 16:38:29 -04:00

369 lines
15 KiB
JavaScript

// test.mjs — ESM test suite for luxon-hijri
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
toHijri,
toGregorian,
isValidHijriDate,
formatHijriDate,
formatPatterns,
hDatesTable,
hmLong,
hmMedium,
hmShort,
hwLong,
hwShort,
hwNumeric,
} from './dist/index.mjs';
const FCNA = { calendar: 'fcna' };
// ─── Exports ────────────────────────────────────────────────────────────────
describe('exports', () => {
it('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
it('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
it('isValidHijriDate is a function', () =>
assert.strictEqual(typeof isValidHijriDate, 'function'));
it('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
it('formatPatterns is an object', () => assert.strictEqual(typeof formatPatterns, 'object'));
it('hDatesTable is an array', () => assert(Array.isArray(hDatesTable)));
it('hDatesTable has 184 entries (1318-1500 + sentinel 1501)', () =>
assert.strictEqual(hDatesTable.length, 184));
it('hmLong has 12 entries', () => assert.strictEqual(hmLong.length, 12));
it('hmMedium has 12 entries', () => assert.strictEqual(hmMedium.length, 12));
it('hmShort has 12 entries', () => assert.strictEqual(hmShort.length, 12));
it('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
it('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
it('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
});
// ─── toGregorian ────────────────────────────────────────────────────────────
describe('toGregorian - known dates', () => {
it('1 Muharram 1444 = 2022-07-30', () => {
const d = toGregorian(1444, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2022-07-30');
});
it('1 Ramadan 1444 = 2023-03-23', () => {
const d = toGregorian(1444, 9, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-03-23');
});
it('1 Shawwal 1444 = 2023-04-21', () => {
const d = toGregorian(1444, 10, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2023-04-21');
});
it('1 Muharram 1446 = 2024-07-07', () => {
const d = toGregorian(1446, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2024-07-07');
});
it('first table entry: 1 Muharram 1318 = 1900-04-30', () => {
const d = toGregorian(1318, 1, 1);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '1900-04-30');
});
});
describe('toGregorian - error cases', () => {
it('throws on invalid Hijri year (out of table range)', () => {
assert.throws(() => toGregorian(1317, 1, 1), /Invalid Hijri date/);
});
it('throws on month 0', () => {
assert.throws(() => toGregorian(1444, 0, 1), /Invalid Hijri date/);
});
it('throws on month 13', () => {
assert.throws(() => toGregorian(1444, 13, 1), /Invalid Hijri date/);
});
it('throws on day 0', () => {
assert.throws(() => toGregorian(1444, 9, 0), /Invalid Hijri date/);
});
it('throws on day 30 in 29-day month (Ramadan 1444)', () => {
assert.throws(() => toGregorian(1444, 9, 30), /Invalid Hijri date/);
});
});
// ─── toHijri ────────────────────────────────────────────────────────────────
describe('toHijri - known dates', () => {
it('2022-07-30 = 1 Muharram 1444', () => {
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(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(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(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(Date.UTC(1900, 3, 30)));
assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 });
});
});
describe('toHijri - error cases', () => {
it('throws on invalid Date', () => {
assert.throws(() => toHijri(new Date('not a date')), /Invalid Gregorian date/);
});
it('returns null for date before first table entry', () => {
const h = toHijri(new Date(1800, 0, 1, 12));
assert.strictEqual(h, null);
});
});
// ─── isValidHijriDate ───────────────────────────────────────────────────────
describe('isValidHijriDate', () => {
it('1444-09-01 is valid', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
it('1444-09-29 is valid (last day of Ramadan 1444)', () =>
assert.strictEqual(isValidHijriDate(1444, 9, 29), true));
it('1318-01-01 is valid (first table entry)', () =>
assert.strictEqual(isValidHijriDate(1318, 1, 1), true));
it('1500-12-29 is valid (last table entry)', () =>
assert.strictEqual(isValidHijriDate(1500, 12, 29), true));
it('year 1317 is out of range', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
it('year 1501 is out of range', () => assert.strictEqual(isValidHijriDate(1501, 1, 1), false));
it('month 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
it('month 13 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 13, 1), false));
it('day 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 0), false));
it('day 30 in Ramadan 1444 (29-day month) is invalid', () =>
assert.strictEqual(isValidHijriDate(1444, 9, 30), false));
});
// ─── formatHijriDate ────────────────────────────────────────────────────────
const ramadan1 = { hy: 1444, hm: 9, hd: 1 };
describe('formatHijriDate - date tokens', () => {
it('iYYYY-iMM-iDD', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
});
it('iYY (last 2 digits of year)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iYY'), '44');
});
it('iM (month without padding)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iM'), '9');
});
it('iMM (month zero-padded)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMM'), '09');
});
it('iMMM (medium month name: Ramadan)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMM'), 'Ramadan');
});
it('iMMMM (full month name: Ramadan)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
});
it('iD (day without padding)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iD'), '1');
});
it('iDD (day zero-padded)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iDD'), '01');
});
});
describe('formatHijriDate - weekday tokens (1 Ramadan 1444 = Thursday)', () => {
it('iE = 5 (Thursday = 5th Islamic day, Sunday=1)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iE'), '5');
});
it('iEEE = Kham (Thursday abbreviated)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEE'), 'Kham');
});
it('iEEEE = Yawm al-Khamis (Thursday full)', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
});
});
describe('formatHijriDate - era tokens', () => {
it('iooo = AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
});
it('ioooo = AH', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'ioooo'), 'AH');
});
});
describe('formatHijriDate - composite format', () => {
it('iMMMM iD, iYYYY', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM iD, iYYYY'), 'Ramadan 1, 1444');
});
it('iDD/iMM/iYYYY', () => {
assert.strictEqual(formatHijriDate(ramadan1, 'iDD/iMM/iYYYY'), '01/09/1444');
});
it('iEEEE, iD iMMMM iYYYY ioooo', () => {
assert.strictEqual(
formatHijriDate(ramadan1, 'iEEEE, iD iMMMM iYYYY ioooo'),
'Yawm al-Khamis, 1 Ramadan 1444 AH',
);
});
});
describe('formatHijriDate - invalid month', () => {
it('throws for month 0', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 0, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
it('throws for month 13', () => {
assert.throws(
() => formatHijriDate({ hy: 1444, hm: 13, hd: 1 }, 'iMMMM'),
/Hijri month must be 1-12/,
);
});
});
// ─── hDatesTable structure ──────────────────────────────────────────────────
describe('hDatesTable structure', () => {
it('first entry is 1318', () => assert.strictEqual(hDatesTable[0].hy, 1318));
it('last valid year is 1500 (index 182)', () => assert.strictEqual(hDatesTable[182].hy, 1500));
it('index 183 is sentinel year 1501 with dpm=0', () => {
assert.strictEqual(hDatesTable[183].hy, 1501);
assert.strictEqual(hDatesTable[183].dpm, 0);
});
it('table is sorted ascending by hy', () => {
for (let i = 1; i < hDatesTable.length; i++) {
assert(hDatesTable[i].hy > hDatesTable[i - 1].hy);
}
});
});
// ─── FCNA calendar ──────────────────────────────────────────────────────────
describe('FCNA toGregorian', () => {
it('1 Ramadan 1446 = 2025-03-01 (ISNA 2025 calendar)', () => {
const d = toGregorian(1446, 9, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
});
it('1 Shawwal 1446 = 2025-03-30 (Eid al-Fitr per ISNA)', () => {
const d = toGregorian(1446, 10, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-30');
});
it('1 Ramadan 1445 = 2024-03-11 (ISNA 2024 calendar)', () => {
const d = toGregorian(1445, 9, 1, FCNA);
assert(d instanceof Date);
assert.strictEqual(d.toISOString().slice(0, 10), '2024-03-11');
});
it('1 Muharram 1444 = ~2022-07-30', () => {
const d = toGregorian(1444, 1, 1, FCNA);
assert(d instanceof Date);
const iso = d.toISOString().slice(0, 10);
assert(
iso === '2022-07-29' || iso === '2022-07-30' || iso === '2022-07-31',
`Expected ~2022-07-30, got ${iso}`,
);
});
});
describe('FCNA toHijri', () => {
it('2025-03-01 = 1 Ramadan 1446', () => {
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(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(Date.UTC(2024, 2, 11)), FCNA);
assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 });
});
});
describe('FCNA round-trips', () => {
it('1446/9/1 toGregorian then toHijri', () => {
const greg = toGregorian(1446, 9, 1, FCNA);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
});
it('1446/10/15 toGregorian then toHijri', () => {
const greg = toGregorian(1446, 10, 15, FCNA);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1446, hm: 10, hd: 15 });
});
it('1318/1/1 toGregorian then toHijri', () => {
const greg = toGregorian(1318, 1, 1, FCNA);
assert(greg instanceof Date);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1318, hm: 1, hd: 1 });
});
it('out-of-range year 1200/6/1 round-trip', () => {
const greg = toGregorian(1200, 6, 1, FCNA);
assert(greg instanceof Date);
const hijri = toHijri(greg, FCNA);
assert.deepEqual(hijri, { hy: 1200, hm: 6, hd: 1 });
});
});
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));
it('month 13 = false', () => assert.strictEqual(isValidHijriDate(1446, 13, 1, FCNA), false));
it('day 0 = false', () => assert.strictEqual(isValidHijriDate(1446, 9, 0, FCNA), false));
it('day 31 = false', () => assert.strictEqual(isValidHijriDate(1446, 9, 31, FCNA), false));
it('year 1 AH = true', () => assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true));
it('year 1600 = true (beyond UAQ table)', () =>
assert.strictEqual(isValidHijriDate(1600, 1, 1, FCNA), true));
});
describe('UAQ default regression', () => {
it('1 Ramadan 1446 = 2025-03-01 (UAQ matches FCNA here)', () => {
const d = toGregorian(1446, 9, 1);
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
});
it('toHijri still works without options', () => {
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', () => {
assert.strictEqual(isValidHijriDate(1444, 9, 1), true);
assert.strictEqual(isValidHijriDate(1501, 1, 1), false);
});
});