mirror of
https://github.com/acamarata/luxon-hijri.git
synced 2026-06-30 18:54:28 +00:00
Core fixes:
- Fix critical weekday bug: iE/iEEE/iEEEE tokens used Hijri year as Gregorian,
returning weekdays ~580 years wrong. Now converts via toGregorian() first.
- Fix era tokens iooo/ioooo: were returning Gregorian era, now always return "AH".
- Fix toGregorian timezone sensitivity: was using DateTime.local(), now DateTime.utc().
- Fix format token regex: word-boundary approach caused partial matches.
New: FCNA/ISNA calendar support:
- toHijri, toGregorian, isValidHijriDate now accept { calendar: 'fcna' } option.
- FCNA criterion: conjunction before 12:00 UTC → month starts D+1, else D+2.
- New moon times from Meeus Ch.49 full formula (accurate to within minutes, 1000–3000 CE).
- Works for all Hijri years, not just the 1318–1500 UAQ table range.
- Anchor: UAQ table for in-range years, Islamic epoch estimate for out-of-range.
- Exports: CalendarSystem, ConversionOptions types.
Build and infrastructure:
- pnpm replaces npm; tsup replaces tsc for dual CJS/ESM output.
- Exports map with types-first conditional exports for import/require.
- Binary search O(log 183) replaces linear O(n) scan in all three functions.
- Luxon upgraded from ^2.5.2 to ^3.5.0; TypeScript from ^4 to ^5.5.
- CI: Node 20/22/24 matrix, typecheck, and pack-check jobs.
- GitHub Wiki: four pages synced via Actions on push.
- Test suite: 81 ESM tests + 24 CJS tests, verified against ISNA 2023–2025 calendars.
- Exports hwLong, hwShort, hwNumeric weekday arrays.
Breaking changes:
- Dual ESM/CJS exports map (CJS consumers: no change via main field).
- HijriYearRecord replaces hDates interface name.
- Luxon peer dep bumped to ^3.5.0.
- Node >=20 required.
399 lines
15 KiB
JavaScript
399 lines
15 KiB
JavaScript
// test.mjs — ESM test suite for luxon-hijri
|
||
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' };
|
||
|
||
let passed = 0;
|
||
let failed = 0;
|
||
|
||
function test(name, fn) {
|
||
try {
|
||
fn();
|
||
console.log(` ${name}... PASS`);
|
||
passed++;
|
||
} catch (err) {
|
||
console.error(` ${name}... FAIL`);
|
||
console.error(` ${err.message}`);
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
// ─── Exports ────────────────────────────────────────────────────────────────
|
||
|
||
console.log('\nExports');
|
||
|
||
test('toHijri is a function', () => assert.strictEqual(typeof toHijri, 'function'));
|
||
test('toGregorian is a function', () => assert.strictEqual(typeof toGregorian, 'function'));
|
||
test('isValidHijriDate is a function', () => assert.strictEqual(typeof isValidHijriDate, 'function'));
|
||
test('formatHijriDate is a function', () => assert.strictEqual(typeof formatHijriDate, 'function'));
|
||
test('formatPatterns is an object', () => assert.strictEqual(typeof formatPatterns, 'object'));
|
||
test('hDatesTable is an array', () => assert(Array.isArray(hDatesTable)));
|
||
// 183 real year entries (1318–1500) + 1 sentinel entry (1501) marking the table boundary.
|
||
test('hDatesTable has 184 entries (1318–1500 + sentinel 1501)', () => assert.strictEqual(hDatesTable.length, 184));
|
||
test('hmLong has 12 entries', () => assert.strictEqual(hmLong.length, 12));
|
||
test('hmMedium has 12 entries', () => assert.strictEqual(hmMedium.length, 12));
|
||
test('hmShort has 12 entries', () => assert.strictEqual(hmShort.length, 12));
|
||
test('hwLong has 7 entries', () => assert.strictEqual(hwLong.length, 7));
|
||
test('hwShort has 7 entries', () => assert.strictEqual(hwShort.length, 7));
|
||
test('hwNumeric has 7 entries', () => assert.strictEqual(hwNumeric.length, 7));
|
||
|
||
// ─── toGregorian ────────────────────────────────────────────────────────────
|
||
|
||
console.log('\ntoGregorian — known dates');
|
||
|
||
test('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');
|
||
});
|
||
|
||
test('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');
|
||
});
|
||
|
||
test('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');
|
||
});
|
||
|
||
test('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');
|
||
});
|
||
|
||
test('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');
|
||
});
|
||
|
||
console.log('\ntoGregorian — error cases');
|
||
|
||
test('throws on invalid Hijri year (out of table range)', () => {
|
||
assert.throws(() => toGregorian(1317, 1, 1), /Invalid Hijri date/);
|
||
});
|
||
|
||
test('throws on month 0', () => {
|
||
assert.throws(() => toGregorian(1444, 0, 1), /Invalid Hijri date/);
|
||
});
|
||
|
||
test('throws on month 13', () => {
|
||
assert.throws(() => toGregorian(1444, 13, 1), /Invalid Hijri date/);
|
||
});
|
||
|
||
test('throws on day 0', () => {
|
||
assert.throws(() => toGregorian(1444, 9, 0), /Invalid Hijri date/);
|
||
});
|
||
|
||
test('throws on day 30 in 29-day month (Ramadan 1444)', () => {
|
||
// Ramadan 1444 has 29 days (1 Ramadan = Mar 23, 1 Shawwal = Apr 21 → 29 days)
|
||
assert.throws(() => toGregorian(1444, 9, 30), /Invalid Hijri date/);
|
||
});
|
||
|
||
// ─── toHijri ────────────────────────────────────────────────────────────────
|
||
|
||
console.log('\ntoHijri — known dates');
|
||
|
||
// Use noon (hour=12) to avoid date-boundary issues across timezones.
|
||
test('2022-07-30 = 1 Muharram 1444', () => {
|
||
const h = toHijri(new Date(2022, 6, 30, 12));
|
||
assert.deepEqual(h, { hy: 1444, hm: 1, hd: 1 });
|
||
});
|
||
|
||
test('2023-03-23 = 1 Ramadan 1444', () => {
|
||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
|
||
});
|
||
|
||
test('2023-04-21 = 1 Shawwal 1444', () => {
|
||
const h = toHijri(new Date(2023, 3, 21, 12));
|
||
assert.deepEqual(h, { hy: 1444, hm: 10, hd: 1 });
|
||
});
|
||
|
||
test('2024-07-07 = 1 Muharram 1446', () => {
|
||
const h = toHijri(new Date(2024, 6, 7, 12));
|
||
assert.deepEqual(h, { hy: 1446, hm: 1, hd: 1 });
|
||
});
|
||
|
||
test('1900-04-30 = 1 Muharram 1318 (first table entry)', () => {
|
||
const h = toHijri(new Date(1900, 3, 30, 12));
|
||
assert.deepEqual(h, { hy: 1318, hm: 1, hd: 1 });
|
||
});
|
||
|
||
console.log('\ntoHijri — error cases');
|
||
|
||
test('throws on invalid Date', () => {
|
||
assert.throws(() => toHijri(new Date('not a date')), /Invalid Gregorian date/);
|
||
});
|
||
|
||
test('returns null for date before first table entry', () => {
|
||
const h = toHijri(new Date(1800, 0, 1, 12));
|
||
assert.strictEqual(h, null);
|
||
});
|
||
|
||
// ─── isValidHijriDate ───────────────────────────────────────────────────────
|
||
|
||
console.log('\nisValidHijriDate');
|
||
|
||
test('1444-09-01 is valid', () => assert.strictEqual(isValidHijriDate(1444, 9, 1), true));
|
||
test('1444-09-29 is valid (last day of Ramadan 1444)', () => assert.strictEqual(isValidHijriDate(1444, 9, 29), true));
|
||
test('1318-01-01 is valid (first table entry)', () => assert.strictEqual(isValidHijriDate(1318, 1, 1), true));
|
||
test('1500-12-29 is valid (last table entry, last day)', () => assert.strictEqual(isValidHijriDate(1500, 12, 29), true));
|
||
test('year 1317 is out of range', () => assert.strictEqual(isValidHijriDate(1317, 1, 1), false));
|
||
test('year 1501 is out of range', () => assert.strictEqual(isValidHijriDate(1501, 1, 1), false));
|
||
test('month 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 0, 1), false));
|
||
test('month 13 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 13, 1), false));
|
||
test('day 0 is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 0), false));
|
||
test('day 30 in Ramadan 1444 (29-day month) is invalid', () => assert.strictEqual(isValidHijriDate(1444, 9, 30), false));
|
||
|
||
// ─── formatHijriDate ────────────────────────────────────────────────────────
|
||
|
||
console.log('\nformatHijriDate — date tokens');
|
||
|
||
const ramadan1 = { hy: 1444, hm: 9, hd: 1 };
|
||
|
||
test('iYYYY-iMM-iDD', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iYYYY-iMM-iDD'), '1444-09-01');
|
||
});
|
||
|
||
test('iYY (last 2 digits of year)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iYY'), '44');
|
||
});
|
||
|
||
test('iM (month without padding)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iM'), '9');
|
||
});
|
||
|
||
test('iMM (month zero-padded)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iMM'), '09');
|
||
});
|
||
|
||
test('iMMM (medium month name: Ramadan)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iMMM'), 'Ramadan');
|
||
});
|
||
|
||
test('iMMMM (full month name: Ramadan)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM'), 'Ramadan');
|
||
});
|
||
|
||
test('iD (day without padding)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iD'), '1');
|
||
});
|
||
|
||
test('iDD (day zero-padded)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iDD'), '01');
|
||
});
|
||
|
||
console.log('\nformatHijriDate — weekday tokens (1 Ramadan 1444 = Thursday)');
|
||
|
||
test('iE → 5 (Thursday = 5th Islamic day, Sunday=1)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iE'), '5');
|
||
});
|
||
|
||
test('iEEE → Kham (Thursday abbreviated)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iEEE'), 'Kham');
|
||
});
|
||
|
||
test('iEEEE → Yawm al-Khamis (Thursday full)', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iEEEE'), 'Yawm al-Khamis');
|
||
});
|
||
|
||
console.log('\nformatHijriDate — era tokens');
|
||
|
||
test('iooo → AH', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iooo'), 'AH');
|
||
});
|
||
|
||
test('ioooo → AH', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'ioooo'), 'AH');
|
||
});
|
||
|
||
console.log('\nformatHijriDate — composite format');
|
||
|
||
test('iMMMM iD, iYYYY', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iMMMM iD, iYYYY'), 'Ramadan 1, 1444');
|
||
});
|
||
|
||
test('iDD/iMM/iYYYY', () => {
|
||
assert.strictEqual(formatHijriDate(ramadan1, 'iDD/iMM/iYYYY'), '01/09/1444');
|
||
});
|
||
|
||
test('iEEEE, iD iMMMM iYYYY ioooo', () => {
|
||
assert.strictEqual(
|
||
formatHijriDate(ramadan1, 'iEEEE, iD iMMMM iYYYY ioooo'),
|
||
'Yawm al-Khamis, 1 Ramadan 1444 AH',
|
||
);
|
||
});
|
||
|
||
// ─── hDatesTable structure ──────────────────────────────────────────────────
|
||
|
||
console.log('\nhDatesTable structure');
|
||
|
||
test('first entry is 1318', () => assert.strictEqual(hDatesTable[0].hy, 1318));
|
||
test('last valid year is 1500 (index 182)', () => assert.strictEqual(hDatesTable[182].hy, 1500));
|
||
test('index 183 is sentinel year 1501 with dpm=0', () => {
|
||
assert.strictEqual(hDatesTable[183].hy, 1501);
|
||
assert.strictEqual(hDatesTable[183].dpm, 0);
|
||
});
|
||
test('table is sorted ascending by hy', () => {
|
||
for (let i = 1; i < hDatesTable.length; i++) {
|
||
assert(hDatesTable[i].hy > hDatesTable[i - 1].hy);
|
||
}
|
||
});
|
||
|
||
// ─── FCNA calendar — toGregorian ────────────────────────────────────────────
|
||
//
|
||
// FCNA/ISNA criterion: conjunction before 12:00 UTC → month starts D+1; else D+2.
|
||
// New moon for 1 Ramadan 1446: Feb 28, 2025 ~00:45 UTC → before noon → March 1.
|
||
// New moon for 1 Shawwal 1446: March 29, 2025 ~10:57 UTC → before noon → March 30.
|
||
// Both match ISNA's publicly published 2025 Ramadan/Eid calendar.
|
||
|
||
console.log('\nFCNA — toGregorian known dates');
|
||
|
||
test('FCNA: 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');
|
||
});
|
||
|
||
test('FCNA: 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');
|
||
});
|
||
|
||
test('FCNA: 1 Ramadan 1445 = 2024-03-11 (ISNA 2024 calendar)', () => {
|
||
// New moon: March 10, 2024 ~09:00 UTC → before noon → D+1 = March 11.
|
||
const d = toGregorian(1445, 9, 1, FCNA);
|
||
assert(d instanceof Date);
|
||
assert.strictEqual(d.toISOString().slice(0, 10), '2024-03-11');
|
||
});
|
||
|
||
test('FCNA: 1 Muharram 1444 = 2022-07-30', () => {
|
||
// New moon near July 28-29, 2022 → FCNA starts July 30 (same as UAQ for this month).
|
||
const d = toGregorian(1444, 1, 1, FCNA);
|
||
assert(d instanceof Date);
|
||
// Allow ±1 day: FCNA and UAQ can differ by 1 day on month boundaries.
|
||
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}`);
|
||
});
|
||
|
||
console.log('\nFCNA — toHijri known dates');
|
||
|
||
test('FCNA: 2025-03-01 = 1 Ramadan 1446', () => {
|
||
const h = toHijri(new Date(2025, 2, 1, 12), FCNA);
|
||
assert.deepEqual(h, { hy: 1446, hm: 9, hd: 1 });
|
||
});
|
||
|
||
test('FCNA: 2025-03-30 = 1 Shawwal 1446', () => {
|
||
const h = toHijri(new Date(2025, 2, 30, 12), FCNA);
|
||
assert.deepEqual(h, { hy: 1446, hm: 10, hd: 1 });
|
||
});
|
||
|
||
test('FCNA: 2024-03-11 = 1 Ramadan 1445', () => {
|
||
const h = toHijri(new Date(2024, 2, 11, 12), FCNA);
|
||
assert.deepEqual(h, { hy: 1445, hm: 9, hd: 1 });
|
||
});
|
||
|
||
console.log('\nFCNA — round-trip consistency');
|
||
|
||
test('FCNA round-trip: toGregorian → toHijri for 1446/9/1', () => {
|
||
const greg = toGregorian(1446, 9, 1, FCNA);
|
||
const hijri = toHijri(greg, FCNA);
|
||
assert.deepEqual(hijri, { hy: 1446, hm: 9, hd: 1 });
|
||
});
|
||
|
||
test('FCNA round-trip: toGregorian → toHijri for 1446/10/15', () => {
|
||
const greg = toGregorian(1446, 10, 15, FCNA);
|
||
const hijri = toHijri(greg, FCNA);
|
||
assert.deepEqual(hijri, { hy: 1446, hm: 10, hd: 15 });
|
||
});
|
||
|
||
test('FCNA round-trip: toGregorian → toHijri for 1318/1/1', () => {
|
||
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 });
|
||
});
|
||
|
||
test('FCNA round-trip: toGregorian → toHijri for out-of-range year 1200/6/1', () => {
|
||
// Out of UAQ table range — uses mean k estimate + Meeus correction.
|
||
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 });
|
||
});
|
||
|
||
console.log('\nFCNA — isValidHijriDate');
|
||
|
||
test('FCNA: isValidHijriDate(1446, 9, 1) = true', () => {
|
||
assert.strictEqual(isValidHijriDate(1446, 9, 1, FCNA), true);
|
||
});
|
||
|
||
test('FCNA: isValidHijriDate(1446, 0, 1) = false (month 0)', () => {
|
||
assert.strictEqual(isValidHijriDate(1446, 0, 1, FCNA), false);
|
||
});
|
||
|
||
test('FCNA: isValidHijriDate(1446, 13, 1) = false (month 13)', () => {
|
||
assert.strictEqual(isValidHijriDate(1446, 13, 1, FCNA), false);
|
||
});
|
||
|
||
test('FCNA: isValidHijriDate(1446, 9, 0) = false (day 0)', () => {
|
||
assert.strictEqual(isValidHijriDate(1446, 9, 0, FCNA), false);
|
||
});
|
||
|
||
test('FCNA: isValidHijriDate(1446, 9, 31) = false (day 31 always invalid)', () => {
|
||
assert.strictEqual(isValidHijriDate(1446, 9, 31, FCNA), false);
|
||
});
|
||
|
||
test('FCNA: isValidHijriDate(1, 1, 1) = true (year 1 AH supported)', () => {
|
||
// FCNA works for any year ≥ 1 AH, not limited to 1318–1500.
|
||
assert.strictEqual(isValidHijriDate(1, 1, 1, FCNA), true);
|
||
});
|
||
|
||
test('FCNA: isValidHijriDate(1600, 1, 1) = true (beyond UAQ table)', () => {
|
||
assert.strictEqual(isValidHijriDate(1600, 1, 1, FCNA), true);
|
||
});
|
||
|
||
console.log('\nFCNA — UAQ default unchanged (regression)');
|
||
|
||
test('UAQ default: 1 Ramadan 1446 = 2025-03-01 (UAQ matches FCNA here)', () => {
|
||
const d = toGregorian(1446, 9, 1); // no options → UAQ
|
||
assert.strictEqual(d.toISOString().slice(0, 10), '2025-03-01');
|
||
});
|
||
|
||
test('UAQ default: toHijri still works without options', () => {
|
||
const h = toHijri(new Date(2023, 2, 23, 12));
|
||
assert.deepEqual(h, { hy: 1444, hm: 9, hd: 1 });
|
||
});
|
||
|
||
test('UAQ default: isValidHijriDate still works without options', () => {
|
||
assert.strictEqual(isValidHijriDate(1444, 9, 1), true);
|
||
assert.strictEqual(isValidHijriDate(1501, 1, 1), false);
|
||
});
|
||
|
||
// ─── Summary ────────────────────────────────────────────────────────────────
|
||
|
||
const total = passed + failed;
|
||
console.log(`\n${total} tests total: ${passed} passed, ${failed} failed`);
|
||
if (failed > 0) process.exit(1);
|