luxon-hijri/test.mjs
Aric Camarata 1ab6463184 feat: v2.0.0 — FCNA calendar, dual ESM/CJS build, weekday bug fix, full test suite
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.
2026-02-25 13:25:11 -05:00

399 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (13181500) + 1 sentinel entry (1501) marking the table boundary.
test('hDatesTable has 184 entries (13181500 + 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 13181500.
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);