pray-calc/test.mjs
Aric Camarata c02f197ece v2.0.0 — TypeScript rewrite, dual ESM/CJS, 14 methods + PCD dynamic algorithm
Complete rewrite from plain JavaScript to TypeScript with dual CJS/ESM output
via tsup. Removes all legacy .js source files and the old CommonJS-only index.

Key changes:
- Full TypeScript source in src/ with strict mode and declaration maps
- tsup build: dist/index.cjs + dist/index.mjs + dual .d.ts / .d.mts types
- 14 traditional fixed-angle methods (UOIF through MUIS) + MSC seasonal method
- PCD dynamic algorithm: MSC seasonal base + Earth-Sun distance correction +
  ecliptic geometry + atmospheric refraction + observer elevation
- getTimesAll() batches all 14x2 zenith angles into a single SPA call
- getMscFajr() / getMscIsha() expose MSC seasonal reference directly
- getAngles() returns the PCD-computed fajrAngle and ishaAngle
- High-latitude bounds: angles clipped to [10, 20] above 55N
- 106 tests across ESM and CJS (test.mjs + test-cjs.cjs)
- CI matrix: Node 20/22/24, typecheck, pack-check
- Wiki: 12 reference pages + 6-page research section with global accuracy study,
  home-territory comparison, observational evidence, and field observation matrix
- Moon functions removed (migrated to moon-sighting package)
- pnpm-only, Node >=20, sideEffects: false
2026-02-25 18:11:20 -05:00

772 lines
36 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.

/**
* pray-calc v2 test suite — 100 scenarios.
*
* Tests cover:
* - Equatorial, tropical, mid-latitude, high-latitude locations
* - All four seasons (solstices + equinoxes)
* - Both Asr conventions (Shafi'i / Hanafi)
* - Atmospheric parameters (pressure, temperature, elevation)
* - All exported functions
* - Edge cases (polar regions, missing events)
* - Dynamic vs. traditional method comparison
* - Type exports and METHODS array
*/
import assert from 'assert';
import {
getTimes,
calcTimes,
getTimesAll,
calcTimesAll,
getAngles,
getAsr,
getQiyam,
getMscFajr,
getMscIsha,
solarEphemeris,
toJulianDate,
METHODS,
} from './dist/index.mjs';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ${name}... PASS`);
passed++;
} catch (err) {
console.error(` ${name}... FAIL: ${err.message}`);
failed++;
}
}
function approx(a, b, tol = 0.05) {
// Times within ±tol hours (~3 minutes default tolerance)
return Math.abs(a - b) < tol;
}
function approxAngle(a, b, tol = 0.5) {
// Angles within ±tol degrees
return Math.abs(a - b) < tol;
}
function validTime(t) {
return typeof t === 'number' && isFinite(t) && t >= 0 && t < 24;
}
function hm(h, m) {
return h + m / 60;
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 1: Exports and type structure
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[1] Exports and type structure');
test('METHODS array has 14 entries', () => {
assert.strictEqual(METHODS.length, 14);
});
test('METHODS has expected IDs', () => {
const ids = METHODS.map(m => m.id);
for (const expected of ['UOIF','ISNACA','ISNA','SAMR','IGUT','MWL','DIBT',
'Karachi','Kuwait','UAQ','Qatar','Egypt','MUIS','MSC']) {
assert(ids.includes(expected), `Missing method: ${expected}`);
}
});
test('METHODS fields present', () => {
for (const m of METHODS) {
assert(typeof m.id === 'string');
assert(typeof m.name === 'string');
assert(typeof m.region === 'string');
assert(m.fajrAngle === null || typeof m.fajrAngle === 'number');
assert(m.ishaAngle === null || typeof m.ishaAngle === 'number');
}
});
test('MSC method has useMSC=true and null angles', () => {
const msc = METHODS.find(m => m.id === 'MSC');
assert(msc.useMSC === true);
assert(msc.fajrAngle === null);
assert(msc.ishaAngle === null);
});
test('UAQ has ishaMinutes=90', () => {
const uaq = METHODS.find(m => m.id === 'UAQ');
assert.strictEqual(uaq.ishaMinutes, 90);
});
test('Qatar has ishaMinutes=90', () => {
const qatar = METHODS.find(m => m.id === 'Qatar');
assert.strictEqual(qatar.ishaMinutes, 90);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 2: toJulianDate and solarEphemeris
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[2] Solar ephemeris');
test('toJulianDate J2000 epoch', () => {
// Jan 1.5, 2000 = JD 2451545.0
const jd = toJulianDate(new Date(Date.UTC(2000, 0, 1, 12, 0, 0)));
assert(approxAngle(jd, 2451545.0, 1.0), `Got ${jd}`);
});
test('solarEphemeris returns valid structure', () => {
const jd = toJulianDate(new Date(Date.UTC(2024, 5, 21, 12, 0, 0)));
const e = solarEphemeris(jd);
assert(typeof e.decl === 'number');
assert(typeof e.r === 'number');
assert(typeof e.eclLon === 'number');
});
test('solarEphemeris summer solstice declination ~+23.44', () => {
const jd = toJulianDate(new Date(Date.UTC(2024, 5, 21, 12, 0, 0)));
const { decl } = solarEphemeris(jd);
assert(approxAngle(decl, 23.44, 0.15), `Got decl=${decl}`);
});
test('solarEphemeris winter solstice declination ~-23.44', () => {
const jd = toJulianDate(new Date(Date.UTC(2024, 11, 21, 12, 0, 0)));
const { decl } = solarEphemeris(jd);
assert(approxAngle(decl, -23.44, 0.15), `Got decl=${decl}`);
});
test('solarEphemeris r within range [0.98, 1.02] AU', () => {
const dates = [
new Date(Date.UTC(2024, 0, 3)), // perihelion
new Date(Date.UTC(2024, 6, 4)), // aphelion
new Date(Date.UTC(2024, 3, 15)), // spring
];
for (const d of dates) {
const { r } = solarEphemeris(toJulianDate(d));
assert(r > 0.98 && r < 1.02, `r=${r} out of range for ${d}`);
}
});
test('solarEphemeris equinox declination near 0', () => {
const jd = toJulianDate(new Date(Date.UTC(2024, 2, 20, 12, 0, 0)));
const { decl } = solarEphemeris(jd);
assert(Math.abs(decl) < 1.0, `Got decl=${decl} at equinox`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 3: getAngles — dynamic Fajr/Isha depression
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[3] getAngles — dynamic depression');
test('getAngles returns object with fajrAngle and ishaAngle', () => {
const a = getAngles(new Date('2024-06-21'), 40.7, -74.0);
assert(typeof a.fajrAngle === 'number');
assert(typeof a.ishaAngle === 'number');
});
test('getAngles angles within physical bounds [10,22]', () => {
const locations = [
[0, 0], [21, 39], [40.7, -74], [51.5, -0.1], [55.8, -4.2], [-33.9, 151.2],
];
const dates = ['2024-01-15', '2024-04-01', '2024-06-21', '2024-09-22', '2024-12-21'];
for (const [lat, lng] of locations) {
for (const d of dates) {
const { fajrAngle, ishaAngle } = getAngles(new Date(d), lat, lng);
assert(fajrAngle >= 10 && fajrAngle <= 22,
`fajrAngle=${fajrAngle} out of [10,22] at lat=${lat} ${d}`);
assert(ishaAngle >= 10 && ishaAngle <= 22,
`ishaAngle=${ishaAngle} out of [10,22] at lat=${lat} ${d}`);
}
}
});
test('getAngles equatorial latitude near 18', () => {
// Near equator, should converge toward ~18°
const { fajrAngle } = getAngles(new Date('2024-06-21'), 1.3, 103.8); // Singapore
assert(fajrAngle > 16 && fajrAngle < 22, `fajrAngle=${fajrAngle}`);
});
test('getAngles high-latitude summer smaller than 18', () => {
// London summer — angle should be well below 18 due to oblique sun path
const { fajrAngle } = getAngles(new Date('2024-06-21'), 51.5, -0.1);
assert(fajrAngle < 17, `Expected <17, got ${fajrAngle} at London summer solstice`);
});
test('getAngles elevation parameter accepted', () => {
const a1 = getAngles(new Date('2024-06-21'), 40.7, -74.0, 0);
const a2 = getAngles(new Date('2024-06-21'), 40.7, -74.0, 1000);
assert(typeof a1.fajrAngle === 'number');
assert(typeof a2.fajrAngle === 'number');
// At high elevation, effective depression should be slightly reduced
assert(a2.fajrAngle <= a1.fajrAngle + 0.5, 'Elevation should not increase angle by more than 0.5');
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 4: getAsr
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[4] getAsr');
test('getAsr Shafii returns finite time', () => {
const asr = getAsr(12.0, 40.7128, 20.0, false);
assert(isFinite(asr), `Expected finite, got ${asr}`);
});
test('getAsr Hanafi is later than Shafii', () => {
const asrS = getAsr(12.0, 40.7, 20.0, false);
const asrH = getAsr(12.0, 40.7, 20.0, true);
assert(asrH > asrS, `Hanafi ${asrH} should be later than Shafi'i ${asrS}`);
});
test('getAsr reasonable range (afternoon)', () => {
const asr = getAsr(12.1, 21.4, 20.0, false); // Makkah-ish
assert(asr > 14 && asr < 18, `Got ${asr}`);
});
test('getAsr Hanafi Makkah afternoon', () => {
const asr = getAsr(12.1, 21.4, 20.0, true);
assert(asr > 15 && asr < 19, `Got ${asr}`);
});
test('getAsr returns NaN when sun never reaches altitude', () => {
// Extreme case: very high latitude, extreme declination
const asr = getAsr(12.0, 89.0, -23.4, false);
// Near north pole in winter, sun may not reach Asr altitude
// Result should be NaN or finite — just verify it returns a number
assert(typeof asr === 'number', 'Should return a number');
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 5: getQiyam
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[5] getQiyam');
test('getQiyam returns last-third start', () => {
// Isha at 22:00, Fajr at 04:00 next day → night = 6h
// Last third starts at 22 + 4 = 02:00
const q = getQiyam(4.0, 22.0);
assert(approx(q, 2.0, 0.1), `Got ${q}`);
});
test('getQiyam handles wrap-around midnight', () => {
const q = getQiyam(3.5, 21.0);
// Night = 3.5 + 24 - 21 = 6.5h; last third = 21 + (2/3)*6.5 = 25.33 → 1.33 (01:20)
const expected = 21.0 + (2 / 3) * (3.5 + 24 - 21.0);
const normalized = expected >= 24 ? expected - 24 : expected;
assert(approx(q, normalized, 0.1), `Got ${q}, expected ~${normalized}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 6: getMscFajr / getMscIsha
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[6] MSC minute offsets');
test('getMscFajr returns positive minutes', () => {
const m = getMscFajr(new Date('2024-06-21'), 40.7);
assert(m > 0, `Got ${m}`);
});
test('getMscIsha returns positive minutes', () => {
const m = getMscIsha(new Date('2024-06-21'), 40.7);
assert(m > 0, `Got ${m}`);
});
test('getMscFajr increases with latitude (summer)', () => {
const m30 = getMscFajr(new Date('2024-06-21'), 30);
const m50 = getMscFajr(new Date('2024-06-21'), 50);
assert(m50 > m30, `Expected lat50 (${m50}) > lat30 (${m30})`);
});
test('getMscFajr equator ~75 minutes year-round', () => {
const summer = getMscFajr(new Date('2024-06-21'), 0);
const winter = getMscFajr(new Date('2024-12-21'), 0);
assert(approx(summer, 75, 5), `Summer: ${summer}`);
assert(approx(winter, 75, 5), `Winter: ${winter}`);
});
test('getMscIsha shafaq modes return different values at high lat', () => {
const general = getMscIsha(new Date('2024-06-21'), 51.5, 'general');
const ahmer = getMscIsha(new Date('2024-06-21'), 51.5, 'ahmer');
const abyad = getMscIsha(new Date('2024-06-21'), 51.5, 'abyad');
// All should be positive
assert(general > 0 && ahmer > 0 && abyad > 0);
// Ahmer (red glow) ends earlier, so fewer minutes after sunset
assert(ahmer <= general, `ahmer ${ahmer} should be <= general ${general}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 7: getTimes — core output structure
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[7] getTimes — structure');
test('getTimes returns all required fields', () => {
const t = getTimes(new Date('2024-06-21'), 40.7, -74.0);
for (const field of ['Qiyam','Fajr','Sunrise','Noon','Dhuhr','Asr','Maghrib','Isha']) {
assert(field in t, `Missing field: ${field}`);
}
assert('angles' in t);
assert('fajrAngle' in t.angles);
assert('ishaAngle' in t.angles);
});
test('getTimes chronological order', () => {
const t = getTimes(new Date('2024-06-21'), 40.7, -74.0);
// Fajr < Sunrise < Noon < Dhuhr ≈ Noon < Asr < Maghrib < Isha
assert(t.Fajr < t.Sunrise, `Fajr(${t.Fajr}) < Sunrise(${t.Sunrise})`);
assert(t.Sunrise < t.Noon, `Sunrise(${t.Sunrise}) < Noon(${t.Noon})`);
assert(t.Noon <= t.Dhuhr, `Noon(${t.Noon}) <= Dhuhr(${t.Dhuhr})`);
assert(t.Dhuhr < t.Asr, `Dhuhr(${t.Dhuhr}) < Asr(${t.Asr})`);
assert(t.Asr < t.Maghrib, `Asr(${t.Asr}) < Maghrib(${t.Maghrib})`);
assert(t.Maghrib < t.Isha, `Maghrib(${t.Maghrib}) < Isha(${t.Isha})`);
});
test('getTimes Dhuhr is slightly after Noon', () => {
const t = getTimes(new Date('2024-06-21'), 40.7, -74.0);
const diff = (t.Dhuhr - t.Noon) * 60; // minutes
assert(diff > 2 && diff < 4, `Dhuhr - Noon = ${diff} min`);
});
test('getTimes angles present and in bounds', () => {
const t = getTimes(new Date('2024-06-21'), 40.7, -74.0);
assert(t.angles.fajrAngle > 10 && t.angles.fajrAngle < 22);
assert(t.angles.ishaAngle > 10 && t.angles.ishaAngle < 22);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 8: getTimes — geographic validation
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[8] getTimes — geographic scenarios');
// Reference times from independent sources (tolerances ±4 min = 0.067h)
const TOL = 0.07; // ~4 minutes
test('Makkah summer solstice — Sunrise ~05:39', () => {
// Makkah 39.83°E, UTC+3: solar noon ~12:23 local. Sunrise ~5:39.
const t = getTimes(new Date('2024-06-21'), 21.4225, 39.8262, 3);
assert(approx(t.Sunrise, hm(5,39), 0.12), `Got ${t.Sunrise}`);
});
test('Makkah summer solstice — Maghrib ~19:06', () => {
// Makkah summer solstice sunset: ~19:06-19:10 local.
const t = getTimes(new Date('2024-06-21'), 21.4225, 39.8262, 3);
assert(approx(t.Maghrib, hm(19,7), 0.12), `Got ${t.Maghrib}`);
});
test('New York summer solstice — Sunrise ~05:25', () => {
const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4);
assert(approx(t.Sunrise, hm(5,25), TOL), `Got ${t.Sunrise}`);
});
test('New York summer solstice — Sunset ~20:31', () => {
const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4);
assert(approx(t.Maghrib, hm(20,31), TOL), `Got ${t.Maghrib}`);
});
test('New York winter solstice — Sunrise ~07:20', () => {
const t = getTimes(new Date('2024-12-21'), 40.7128, -74.0060, -5);
assert(approx(t.Sunrise, hm(7,20), TOL), `Got ${t.Sunrise}`);
});
test('New York winter solstice — Sunset ~16:32', () => {
const t = getTimes(new Date('2024-12-21'), 40.7128, -74.0060, -5);
assert(approx(t.Maghrib, hm(16,32), TOL), `Got ${t.Maghrib}`);
});
test('London summer — Sunrise ~04:43', () => {
const t = getTimes(new Date('2024-06-21'), 51.5074, -0.1278, 1);
assert(approx(t.Sunrise, hm(4,43), TOL), `Got ${t.Sunrise}`);
});
test('London summer — Sunset ~21:21', () => {
const t = getTimes(new Date('2024-06-21'), 51.5074, -0.1278, 1);
assert(approx(t.Maghrib, hm(21,21), TOL), `Got ${t.Maghrib}`);
});
test('Sydney summer (Gregorian Jan) — Sunrise ~06:00', () => {
// Sydney 151.21°E, UTC+11: solar noon ~12:04. Sunrise ~5:59-6:01 Jan 15.
const t = getTimes(new Date('2024-01-15'), -33.8688, 151.2093, 11);
assert(approx(t.Sunrise, hm(6,0), 0.12), `Got ${t.Sunrise}`);
});
test('Jakarta — Sunrise within 20min of 5:50 year-round', () => {
// Jakarta 106.85°E, UTC+7: sunrise varies 5:30-6:10 across the year.
for (const month of [1, 4, 7, 10]) {
const t = getTimes(new Date(`2024-${String(month).padStart(2,'0')}-15`),
-6.2088, 106.8456, 7);
assert(approx(t.Sunrise, hm(5,50), 0.33), `Month ${month}: Sunrise=${t.Sunrise}`);
}
});
test('Singapore — all times finite', () => {
const t = getTimes(new Date('2024-06-21'), 1.3521, 103.8198, 8);
for (const field of ['Fajr','Sunrise','Noon','Dhuhr','Asr','Maghrib','Isha']) {
assert(isFinite(t[field]), `${field} should be finite`);
}
});
test('Cairo summer — Sunrise ~06:00 ±12min', () => {
const t = getTimes(new Date('2024-06-21'), 30.0444, 31.2357, 3);
assert(approx(t.Sunrise, hm(6, 0), 0.20), `Got ${t.Sunrise}`);
});
test('Istanbul spring equinox — Noon ~13:11 ±10min', () => {
// Istanbul 28.98°E, UTC+3: solar noon = 12:00 + (45-28.98)/15 = 13:04 + eq-of-time ~7min = ~13:11
const t = getTimes(new Date('2024-03-20'), 41.0082, 28.9784, 3);
assert(approx(t.Noon, hm(13,11), 0.17), `Got ${t.Noon}`);
});
test('Karachi summer — Maghrib ~19:20 ±10min', () => {
const t = getTimes(new Date('2024-06-21'), 24.8607, 67.0011, 5);
assert(approx(t.Maghrib, hm(19,20), 0.17), `Got ${t.Maghrib}`);
});
test('Toronto summer — Sunset ~21:02 ±12min', () => {
// Toronto 79.38°W, UTC-4: solar noon ~13:17. Sunset June 21 ~21:00-21:04.
const t = getTimes(new Date('2024-06-21'), 43.6532, -79.3832, -4);
assert(approx(t.Maghrib, hm(21,2), 0.22), `Got ${t.Maghrib}`);
});
test('Reykjavik summer — Sunrise and Maghrib finite', () => {
// ~64°N — high latitude, Midnight Sun territory
const t = getTimes(new Date('2024-06-21'), 64.1265, -21.8174, 0);
// May produce NaN for some times; just check Noon is finite
assert(isFinite(t.Noon), `Noon should be finite`);
});
test('South pole winter — Noon finite', () => {
const t = getTimes(new Date('2024-06-21'), -90, 0, 0);
// Extreme case — just should not throw
assert(typeof t.Noon === 'number');
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 9: getTimes — seasonal variation at fixed location
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[9] getTimes — seasonal variation');
test('NY Sunrise earlier in summer than winter', () => {
const summer = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4).Sunrise;
const winter = getTimes(new Date('2024-12-21'), 40.7, -74.0, -5).Sunrise;
assert(summer < winter, `Summer ${summer} < Winter ${winter}`);
});
test('NY Sunset later in summer than winter', () => {
const summer = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4).Maghrib;
const winter = getTimes(new Date('2024-12-21'), 40.7, -74.0, -5).Maghrib;
assert(summer > winter, `Summer ${summer} > Winter ${winter}`);
});
test('Noon time consistent across seasons (same tz, within 30 min)', () => {
// Use EST (-5) for all dates to avoid EDT/EST offset masking the comparison.
// Equation of time spans ±16 min; NY longitude offset is fixed. Max variation ~30 min.
const base = getTimes(new Date('2024-06-21'), 40.7, -74.0, -5).Noon;
for (const d of ['2024-01-15','2024-04-01','2024-09-22','2024-12-21']) {
const t = getTimes(new Date(d), 40.7, -74.0, -5).Noon;
assert(Math.abs(t - base) < 0.5, `Noon ${t} vs ${base} on ${d}`);
}
});
test('Fajr angle smaller in London summer than London winter', () => {
const summer = getAngles(new Date('2024-06-21'), 51.5, -0.1).fajrAngle;
const winter = getAngles(new Date('2024-12-21'), 51.5, -0.1).fajrAngle;
assert(summer < winter, `Summer ${summer} should be < Winter ${winter}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 10: Hanafi vs Shafi'i
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[10] Asr convention');
test('Hanafi Asr later than Shafii at multiple locations', () => {
const locations = [
[40.7, -74.0, -4], // New York
[21.4, 39.8, 3], // Makkah
[51.5, -0.1, 1], // London
[-33.9, 151.2, 10], // Sydney
];
for (const [lat, lng, tz] of locations) {
const tS = getTimes(new Date('2024-06-21'), lat, lng, tz, 0, 15, 1013.25, false);
const tH = getTimes(new Date('2024-06-21'), lat, lng, tz, 0, 15, 1013.25, true);
assert(tH.Asr > tS.Asr,
`Hanafi Asr (${tH.Asr}) should be > Shafi'i Asr (${tS.Asr}) at lat=${lat}`);
}
});
test('Hanafi-Shafii difference 20-85 min at typical latitudes', () => {
// At high summer latitudes (long day), the shadow-ratio difference can reach ~75 min.
const t = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4);
const tH = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4, 0, 15, 1013.25, true);
const diffMin = (tH.Asr - t.Asr) * 60;
assert(diffMin > 20 && diffMin < 85, `Difference ${diffMin} min`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 11: Atmospheric parameters
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[11] Atmospheric parameters');
test('Higher elevation brings Sunrise earlier', () => {
const t0 = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4, 0);
const t1 = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4, 2000);
assert(t1.Sunrise <= t0.Sunrise, `High-elevation sunrise (${t1.Sunrise}) should be <= sea level (${t0.Sunrise})`);
});
test('Temperature and pressure accepted without error', () => {
const t = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4, 100, 5, 950);
assert(isFinite(t.Sunrise));
});
test('Extreme cold reduces refraction slightly', () => {
const tHot = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4, 0, 40, 1013.25);
const tCold = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4, 0, -20, 1013.25);
// Both should return finite values
assert(isFinite(tHot.Sunrise) && isFinite(tCold.Sunrise));
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 12: calcTimes — formatted output
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[12] calcTimes — formatting');
test('calcTimes returns HH:MM:SS strings', () => {
const t = calcTimes(new Date('2024-06-21'), 40.7, -74.0, -4);
const timeRe = /^\d{2}:\d{2}:\d{2}$/;
for (const field of ['Fajr','Sunrise','Noon','Dhuhr','Asr','Maghrib','Isha']) {
assert(timeRe.test(t[field]), `${field}="${t[field]}" not HH:MM:SS`);
}
});
test('calcTimes Qiyam returns HH:MM:SS or N/A', () => {
const t = calcTimes(new Date('2024-06-21'), 40.7, -74.0, -4);
assert(t.Qiyam === 'N/A' || /^\d{2}:\d{2}:\d{2}$/.test(t.Qiyam),
`Qiyam="${t.Qiyam}"`);
});
test('calcTimes angles preserved correctly', () => {
const t = calcTimes(new Date('2024-06-21'), 40.7, -74.0, -4);
assert(typeof t.angles.fajrAngle === 'number');
assert(typeof t.angles.ishaAngle === 'number');
});
test('calcTimes default timezone matches getTimes', () => {
const date = new Date('2024-06-21T12:00:00.000Z');
const raw = getTimes(date, 40.7, -74.0);
const fmt = calcTimes(date, 40.7, -74.0);
// Sunrise should parse to same fractional hour
const [h, m, s] = fmt.Sunrise.split(':').map(Number);
const parsed = h + m / 60 + s / 3600;
assert(approx(parsed, raw.Sunrise, 0.005), `Parsed ${parsed}, raw ${raw.Sunrise}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 13: getTimesAll — method comparison
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[13] getTimesAll — method comparison');
test('getTimesAll returns Methods map with 14 entries', () => {
const t = getTimesAll(new Date('2024-06-21'), 40.7, -74.0, -4);
assert.strictEqual(Object.keys(t.Methods).length, 14);
});
test('getTimesAll Methods entries are [number, number]', () => {
const t = getTimesAll(new Date('2024-06-21'), 40.7, -74.0, -4);
for (const [id, [fajr, isha]] of Object.entries(t.Methods)) {
assert(typeof fajr === 'number', `${id} fajr is not a number`);
assert(typeof isha === 'number', `${id} isha is not a number`);
}
});
test('getTimesAll ISNA Fajr is finite at NY summer', () => {
const t = getTimesAll(new Date('2024-06-21'), 40.7, -74.0, -4);
assert(isFinite(t.Methods.ISNA[0]), `ISNA Fajr=${t.Methods.ISNA[0]}`);
});
test('getTimesAll MWL Isha at London summer may be NaN (18° fails)', () => {
const t = getTimesAll(new Date('2024-06-21'), 51.5, -0.1, 1);
// MWL uses 17° Isha. London summer — may or may not reach it.
// Just verify it's a number (finite or NaN)
assert(typeof t.Methods.MWL[1] === 'number');
});
test('getTimesAll UAQ Isha = Maghrib + 90min', () => {
const t = getTimesAll(new Date('2024-06-21'), 21.4, 39.8, 3);
const diff = (t.Methods.UAQ[1] - t.Maghrib) * 60;
assert(approx(diff, 90, 2), `UAQ isha diff=${diff} min, expected 90`);
});
test('getTimesAll Qatar Isha = Maghrib + 90min', () => {
const t = getTimesAll(new Date('2024-06-21'), 25.3, 51.5, 3);
const diff = (t.Methods.Qatar[1] - t.Maghrib) * 60;
assert(approx(diff, 90, 2), `Qatar isha diff=${diff} min, expected 90`);
});
test('getTimesAll higher-angle methods have earlier Fajr', () => {
// MUIS (20°) should give earlier Fajr than ISNA (15°)
const t = getTimesAll(new Date('2024-06-21'), 1.3, 103.8, 8);
const muis = t.Methods.MUIS[0];
const isna = t.Methods.ISNA[0];
if (isFinite(muis) && isFinite(isna)) {
assert(muis < isna, `MUIS Fajr (${muis}) should be < ISNA Fajr (${isna})`);
}
});
test('getTimesAll dynamic Fajr within method range', () => {
// Higher depression angle = earlier Fajr. Dynamic (14.8°) falls between 12° (UOIF, latest)
// and 18° (Karachi, earliest). So: Karachi[0] <= dynamic <= UOIF[0].
const t = getTimesAll(new Date('2024-06-21'), 40.7, -74.0, -4);
const earliest = t.Methods.Karachi[0]; // 18° → earliest Fajr
const latest = t.Methods.UOIF[0]; // 12° → latest Fajr
if (isFinite(earliest) && isFinite(latest)) {
assert(t.Fajr >= earliest - 0.10 && t.Fajr <= latest + 0.10,
`Dynamic Fajr ${t.Fajr} not between Karachi=${earliest} and UOIF=${latest}`);
}
});
test('getTimesAll MSC and dynamic are close', () => {
// MSC is the base for the dynamic method — they should be within ~20 minutes
const t = getTimesAll(new Date('2024-06-21'), 40.7, -74.0, -4);
const mscFajr = t.Methods.MSC[0];
const dynFajr = t.Fajr;
if (isFinite(mscFajr) && isFinite(dynFajr)) {
const diffMin = Math.abs(mscFajr - dynFajr) * 60;
assert(diffMin < 25, `MSC Fajr (${mscFajr}) vs Dynamic Fajr (${dynFajr}) = ${diffMin} min`);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 14: calcTimesAll — formatted all methods
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[14] calcTimesAll');
test('calcTimesAll returns formatted strings', () => {
const t = calcTimesAll(new Date('2024-06-21'), 40.7, -74.0, -4);
const timeRe = /^\d{2}:\d{2}:\d{2}$/;
assert(timeRe.test(t.Fajr), `Fajr="${t.Fajr}"`);
assert(timeRe.test(t.Maghrib), `Maghrib="${t.Maghrib}"`);
});
test('calcTimesAll Methods entries are [string, string]', () => {
const t = calcTimesAll(new Date('2024-06-21'), 40.7, -74.0, -4);
for (const [id, [fajr, isha]] of Object.entries(t.Methods)) {
assert(typeof fajr === 'string', `${id} fajr is not a string`);
assert(typeof isha === 'string', `${id} isha is not a string`);
}
});
test('calcTimesAll N/A for unreachable events', () => {
// At very high lat summer, some 18° methods may be N/A
const t = calcTimesAll(new Date('2024-06-21'), 58.0, 25.0, 3);
// Just verify Methods map exists and all values are strings
for (const [fajr, isha] of Object.values(t.Methods)) {
assert(typeof fajr === 'string');
assert(typeof isha === 'string');
}
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 15: Multi-year and edge date coverage
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[15] Date coverage');
test('Works across multiple years', () => {
for (const year of [2020, 2022, 2024, 2025, 2026]) {
const t = getTimes(new Date(`${year}-06-21`), 40.7, -74.0, -4);
assert(isFinite(t.Sunrise), `Year ${year} Sunrise not finite`);
}
});
test('Works on Feb 29 in leap year', () => {
const t = getTimes(new Date('2024-02-29'), 40.7, -74.0, -5);
assert(isFinite(t.Fajr), 'Feb 29 Fajr not finite');
});
test('Works on Dec 31', () => {
const t = getTimes(new Date('2024-12-31'), 40.7, -74.0, -5);
assert(isFinite(t.Sunrise));
});
test('Works on Jan 1', () => {
const t = getTimes(new Date('2024-01-01'), 40.7, -74.0, -5);
assert(isFinite(t.Sunrise));
});
test('Both equinoxes consistent', () => {
// NY 74°W, UTC-4 (EDT in both March 20 and Sep 22): solar noon ~12:56 EDT.
// At equinox, day ≈ 12h, sunrise ≈ noon 6h ≈ 6:56 EDT.
const t1 = getTimes(new Date('2024-03-20'), 40.7, -74.0, -4);
const t2 = getTimes(new Date('2024-09-22'), 40.7, -74.0, -4);
assert(approx(t1.Sunrise, hm(6,57), 0.30), `Spring equinox Sunrise ${t1.Sunrise}`);
assert(approx(t2.Sunrise, hm(6,54), 0.30), `Autumn equinox Sunrise ${t2.Sunrise}`);
// The two equinox sunrises should be within 15 min of each other
assert(Math.abs(t1.Sunrise - t2.Sunrise) < 0.25,
`Equinox sunrises differ by ${Math.abs(t1.Sunrise - t2.Sunrise) * 60} min`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 16: Global coverage — additional locations
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[16] Global coverage');
const globalLocations = [
{ name: 'Dubai', lat: 25.2048, lng: 55.2708, tz: 4, date: '2024-06-21' },
{ name: 'Kuala Lumpur', lat: 3.1390, lng: 101.6869, tz: 8, date: '2024-06-21' },
{ name: 'Paris', lat: 48.8566, lng: 2.3522, tz: 2, date: '2024-06-21' },
{ name: 'Lagos', lat: 6.5244, lng: 3.3792, tz: 1, date: '2024-06-21' },
{ name: 'Moscow', lat: 55.7558, lng: 37.6173, tz: 3, date: '2024-06-21' },
{ name: 'Cape Town', lat: -33.9249, lng: 18.4241, tz: 2, date: '2024-06-21' },
{ name: 'Buenos Aires', lat: -34.6037, lng: -58.3816, tz: -3, date: '2024-06-21' },
{ name: 'Oslo', lat: 59.9139, lng: 10.7522, tz: 2, date: '2024-06-21' },
{ name: 'Dhaka', lat: 23.8103, lng: 90.4125, tz: 6, date: '2024-06-21' },
{ name: 'Riyadh', lat: 24.7136, lng: 46.6753, tz: 3, date: '2024-06-21' },
];
for (const loc of globalLocations) {
test(`${loc.name} — all times numeric`, () => {
const t = getTimes(new Date(loc.date), loc.lat, loc.lng, loc.tz);
assert(typeof t.Fajr === 'number', `Fajr: ${t.Fajr}`);
assert(typeof t.Noon === 'number', `Noon: ${t.Noon}`);
assert(typeof t.Maghrib === 'number', `Maghrib: ${t.Maghrib}`);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 17: Winter scenarios
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[17] Winter scenarios');
test('London winter — all core times finite', () => {
const t = getTimes(new Date('2024-12-21'), 51.5, -0.1, 0);
for (const f of ['Fajr','Sunrise','Noon','Dhuhr','Asr','Maghrib','Isha']) {
assert(isFinite(t[f]), `${f}=${t[f]}`);
}
});
test('Moscow winter — all core times finite', () => {
const t = getTimes(new Date('2024-12-21'), 55.8, 37.6, 3);
for (const f of ['Fajr','Sunrise','Noon','Dhuhr','Asr','Maghrib','Isha']) {
assert(isFinite(t[f]), `${f}=${t[f]}`);
}
});
test('Oslo winter — Noon finite', () => {
const t = getTimes(new Date('2024-12-21'), 59.9, 10.8, 1);
assert(isFinite(t.Noon));
});
test('Oslo winter — Sunrise, Sunset near solstice values', () => {
const t = getTimes(new Date('2024-12-21'), 59.9, 10.8, 1);
// Oslo Dec 21: Sunrise ~09:18, Sunset ~15:12
if (isFinite(t.Sunrise)) {
assert(approx(t.Sunrise, hm(9,18), 0.25), `Oslo Sunrise ${t.Sunrise}`);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────────────────────────
const total = passed + failed;
console.log(`\n${'─'.repeat(50)}`);
console.log(`${passed}/${total} tests passed`);
if (failed > 0) {
process.exit(1);
}