mirror of
https://github.com/acamarata/pray-calc.git
synced 2026-06-30 19:04:26 +00:00
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
772 lines
36 KiB
JavaScript
772 lines
36 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|