From 8f39fcd82e57e2af3312ab676c64c050f1da6100 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 8 Mar 2026 13:45:29 +0000 Subject: [PATCH] refactor: code quality improvements across the board - Extract magic numbers into named constants (DHUHR_OFFSET_MINUTES, ANGLE_MIN/MAX, LAT_SCALE) with source citations for MCW coefficients - Add input validation (RangeError) for lat, lng, tz, elevation on all public API functions (getTimes, getTimesAll) - Optimize solar ephemeris: computeAngles() returns declination so getTimes/getTimesAll reuse it for Asr instead of computing twice - DRY: shared constants.ts for DEG, Dhuhr offset, angle bounds - Improve MethodEntry type with labeled tuple elements and NaN docs - Add stricter tsconfig (noImplicitReturns, noFallthroughCasesInSwitch) - Switch tests to node:test framework (TAP output, describe/it blocks) - Add 8 new input validation tests (104 ESM + 13 CJS total) - Add ESLint + Prettier with CI lint job - Remove src/ from npm package files (smaller published tarball) - Document NaN return behavior in JSDoc for getTimes/getTimesAll --- .github/workflows/ci.yml | 20 +- .gitignore | 6 + .prettierrc | 6 + eslint.config.mjs | 12 + package.json | 13 +- pnpm-lock.yaml | 733 +++++++++++++++++++++++++ src/calcTimes.ts | 14 +- src/calcTimesAll.ts | 14 +- src/constants.ts | 35 ++ src/getAngles.ts | 65 ++- src/getAsr.ts | 6 +- src/getMSC.ts | 72 ++- src/getSolarEphemeris.ts | 20 +- src/getTimes.ts | 66 ++- src/getTimesAll.ts | 167 ++++-- src/index.ts | 1 + src/types.ts | 12 +- src/validate.ts | 23 + test-cjs.cjs | 173 +++--- test.mjs | 1129 +++++++++++++++++++------------------- tsconfig.json | 2 + 21 files changed, 1759 insertions(+), 830 deletions(-) create mode 100644 .prettierrc create mode 100644 eslint.config.mjs create mode 100644 src/constants.ts create mode 100644 src/validate.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 382b729..56177a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,24 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build - - run: node test.mjs - - run: node test-cjs.cjs + - run: node --test test.mjs + - run: node --test test-cjs.cjs + + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run lint + - run: pnpm run format:check typecheck: name: Typecheck diff --git a/.gitignore b/.gitignore index c8c6507..176185c 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,9 @@ coverage/ .windsurf/ .cody/ .sourcegraph/ +.vscode/* +.codex/ +.aider/ +.aider.chat.history.md +.continue/ +.gemini/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..383f607 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b9816c6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,12 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + eslintConfigPrettier, + { + ignores: ['dist/', 'node_modules/', 'test.mjs', 'test-cjs.cjs'], + }, +); diff --git a/package.json b/package.json index f5cfe95..b850cf9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "sideEffects": false, "files": [ "dist/", - "src/", "README.md", "CHANGELOG.md", "LICENSE" @@ -31,7 +30,10 @@ "build": "tsup", "typecheck": "tsc --noEmit", "pretest": "tsup", - "test": "node test.mjs && node test-cjs.cjs", + "test": "node --test test.mjs && node --test test-cjs.cjs", + "lint": "eslint src/", + "format": "prettier --write src/", + "format:check": "prettier --check src/", "prepublishOnly": "tsup" }, "keywords": [ @@ -72,8 +74,13 @@ "nrel-spa": "^2.0.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^25.3.0", + "eslint": "^10.0.3", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", "tsup": "^8.5.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d42116..9f5b95b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,15 +12,30 @@ importers: specifier: ^2.0.1 version: 2.0.1 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.0.3) '@types/node': specifier: ^25.3.0 version: 25.3.0 + eslint: + specifier: ^10.0.3 + version: 10.0.3 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.0.3) + prettier: + specifier: ^3.8.1 + version: 3.8.1 tsup: specifier: ^8.5.1 version: 8.5.1(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.56.1 + version: 8.56.1(eslint@10.0.3)(typescript@5.9.3) packages: @@ -180,6 +195,61 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -331,20 +401,101 @@ packages: cpu: [x64] os: [win32] + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -370,6 +521,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -379,11 +534,75 @@ packages: supports-color: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.3: + resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -393,18 +612,76 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.4: + resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -416,9 +693,17 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -428,6 +713,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nrel-spa@2.0.1: resolution: {integrity: sha512-KwsudVfAHMUwz9RwhriI7oNqFYz77+VGi2vUpJeR+xNx57MU28EYcdt1TQ1frEDbpBXkF4EJxM62Hi2iX6QNCA==} engines: {node: '>=20'} @@ -436,6 +724,26 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -471,6 +779,19 @@ packages: yaml: optional: true + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -484,6 +805,19 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} @@ -511,6 +845,12 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -533,6 +873,17 @@ packages: typescript: optional: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -544,6 +895,22 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: '@esbuild/aix-ppc64@0.27.3': @@ -624,6 +991,51 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.3)': + dependencies: + eslint: 10.0.3 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.3': + dependencies: + '@eslint/object-schema': 3.0.3 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.3': + dependencies: + '@eslint/core': 1.1.1 + + '@eslint/core@1.1.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.0.3)': + optionalDependencies: + eslint: 10.0.3 + + '@eslint/object-schema@3.0.3': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -713,16 +1125,128 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} + '@types/json-schema@7.0.15': {} + '@types/node@25.3.0': dependencies: undici-types: 7.18.2 + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 10.0.3 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + eslint: 10.0.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.3)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.0.3 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@10.0.3)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 10.0.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + any-promise@1.3.0: {} + balanced-match@4.0.4: {} + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + bundle-require@5.1.0(esbuild@0.27.3): dependencies: esbuild: 0.27.3 @@ -740,10 +1264,18 @@ snapshots: consola@3.4.2: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 + deep-is@0.1.4: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -773,31 +1305,164 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@10.0.3): + dependencies: + eslint: 10.0.3 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.3: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.0 rollup: 4.59.0 + flat-cache@4.0.1: + dependencies: + flatted: 3.3.4 + keyv: 4.5.4 + + flatted@3.3.4: {} + fsevents@2.3.3: optional: true + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + joycon@3.1.1: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} load-tsconfig@0.2.5: {} + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + mlly@1.8.0: dependencies: acorn: 8.16.0 @@ -813,10 +1478,33 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + natural-compare@1.4.0: {} + nrel-spa@2.0.1: {} object-assign@4.1.1: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -835,6 +1523,12 @@ snapshots: dependencies: lilconfig: 3.1.3 + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + + punycode@2.3.1: {} + readdirp@4.1.2: {} resolve-from@5.0.0: {} @@ -870,6 +1564,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + source-map@0.7.6: {} sucrase@3.35.1: @@ -899,6 +1601,10 @@ snapshots: tree-kill@1.2.2: {} + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-interface-checker@0.1.13: {} tsup@8.5.1(typescript@5.9.3): @@ -928,8 +1634,35 @@ snapshots: - tsx - yaml + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.56.1(eslint@10.0.3)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3)(typescript@5.9.3))(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3)(typescript@5.9.3) + eslint: 10.0.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} ufo@1.6.3: {} undici-types@7.18.2: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/src/calcTimes.ts b/src/calcTimes.ts index 7ab0a9e..c220370 100644 --- a/src/calcTimes.ts +++ b/src/calcTimes.ts @@ -30,14 +30,14 @@ export function calcTimes( // Sort by fractional hour value so output reflects chronological order. // Angles are preserved as-is (not time values). return { - Qiyam: formatTime(raw.Qiyam), - Fajr: formatTime(raw.Fajr), + Qiyam: formatTime(raw.Qiyam), + Fajr: formatTime(raw.Fajr), Sunrise: formatTime(raw.Sunrise), - Noon: formatTime(raw.Noon), - Dhuhr: formatTime(raw.Dhuhr), - Asr: formatTime(raw.Asr), + Noon: formatTime(raw.Noon), + Dhuhr: formatTime(raw.Dhuhr), + Asr: formatTime(raw.Asr), Maghrib: formatTime(raw.Maghrib), - Isha: formatTime(raw.Isha), - angles: raw.angles, + Isha: formatTime(raw.Isha), + angles: raw.angles, }; } diff --git a/src/calcTimesAll.ts b/src/calcTimesAll.ts index a6595cf..9b3e91d 100644 --- a/src/calcTimesAll.ts +++ b/src/calcTimesAll.ts @@ -34,15 +34,15 @@ export function calcTimesAll( } return { - Qiyam: formatTime(raw.Qiyam), - Fajr: formatTime(raw.Fajr), + Qiyam: formatTime(raw.Qiyam), + Fajr: formatTime(raw.Fajr), Sunrise: formatTime(raw.Sunrise), - Noon: formatTime(raw.Noon), - Dhuhr: formatTime(raw.Dhuhr), - Asr: formatTime(raw.Asr), + Noon: formatTime(raw.Noon), + Dhuhr: formatTime(raw.Dhuhr), + Asr: formatTime(raw.Asr), Maghrib: formatTime(raw.Maghrib), - Isha: formatTime(raw.Isha), - angles: raw.angles, + Isha: formatTime(raw.Isha), + angles: raw.angles, Methods, }; } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..2da2b09 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,35 @@ +/** + * Shared constants for pray-calc. + */ + +/** Degrees-to-radians conversion factor. */ +export const DEG = Math.PI / 180; + +/** + * Minutes added to solar noon to obtain Dhuhr time. + * + * Standard practice adds a small buffer after geometric solar transit to + * ensure the sun has clearly passed the meridian before Dhuhr begins. + * The 2.5-minute convention is widely used across Islamic timekeeping + * authorities and accounts for the sun's angular diameter (~0.5°) plus + * a small safety margin. + */ +export const DHUHR_OFFSET_MINUTES = 2.5; + +/** + * Minimum allowed dynamic twilight depression angle (degrees). + * + * At very high latitudes in summer the MCW base angle can drop below + * physically meaningful values. 10° is the lower clamp — below this + * the sky is too bright for any twilight definition. + */ +export const ANGLE_MIN = 10; + +/** + * Maximum allowed dynamic twilight depression angle (degrees). + * + * 22° is the upper clamp. Values above ~20° correspond to deep + * astronomical twilight where the sky is indistinguishable from full + * night. No standard method exceeds 20° for Fajr. + */ +export const ANGLE_MAX = 22; diff --git a/src/getAngles.ts b/src/getAngles.ts index 7cbfbf0..7f132c1 100644 --- a/src/getAngles.ts +++ b/src/getAngles.ts @@ -52,13 +52,14 @@ import { toJulianDate, solarEphemeris, atmosphericRefraction } from './getSolarEphemeris.js'; import { getMscFajr, getMscIsha, minutesToDepression } from './getMSC.js'; +import { DEG, ANGLE_MIN, ANGLE_MAX } from './constants.js'; import type { TwilightAngles } from './types.js'; -const DEG = Math.PI / 180; -const FAJR_MIN = 10; -const FAJR_MAX = 22; -const ISHA_MIN = 10; -const ISHA_MAX = 22; +/** Internal result type including ephemeris data for caller reuse. */ +export interface AnglesWithEphemeris extends TwilightAngles { + /** Solar declination in degrees (reusable for Asr computation). */ + decl: number; +} /** Clamp a value to [min, max]. */ function clip(value: number, min: number, max: number): number { @@ -108,18 +109,15 @@ function earthSunDistanceCorrection(r: number): number { * * Net effect is small (< 0.3°) and primarily improves day-to-day smoothness. */ -function fourierSmoothingCorrection( - eclLon: number, - latAbsDeg: number, -): number { +function fourierSmoothingCorrection(eclLon: number, latAbsDeg: number): number { const theta = eclLon; // solar ecliptic longitude, radians [0, 2π) const phi = latAbsDeg * DEG; // First harmonic: small annual asymmetry correction // The perihelion/aphelion asymmetry causes slightly different twilight // behavior in January vs July even at the same declination. - const a1 = 0.03 * Math.sin(theta); // peaks at ~Jun solstice - const b1 = -0.05 * Math.cos(theta); // peaks at equinoxes + const a1 = 0.03 * Math.sin(theta); // peaks at ~Jun solstice + const b1 = -0.05 * Math.cos(theta); // peaks at equinoxes // Second harmonic: semi-annual variation const a2 = 0.02 * Math.sin(2 * theta); @@ -133,27 +131,23 @@ function fourierSmoothingCorrection( } /** - * Compute dynamic twilight depression angles for Fajr and Isha. + * Internal: compute angles and return solar declination for Asr reuse. * - * @param date - Observer's local date (time-of-day is ignored) - * @param lat - Latitude in decimal degrees - * @param lng - Longitude in decimal degrees (currently unused; reserved) - * @param elevation - Observer elevation in meters (default: 0) - * @param temperature - Ambient temperature in °C (default: 15) - * @param pressure - Atmospheric pressure in mbar (default: 1013.25) - * @returns Fajr and Isha depression angles in degrees + * This avoids recomputing solarEphemeris in getTimes/getTimesAll. */ -export function getAngles( +export function computeAngles( date: Date, lat: number, lng: number, elevation = 0, temperature = 15, pressure = 1013.25, -): TwilightAngles { +): AnglesWithEphemeris { // 1. Solar ephemeris features at solar noon of the given date. // Using UTC noon as a stable reference that avoids timezone artifacts. - const noonDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)); + const noonDate = new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0), + ); const jd = toJulianDate(noonDate); const { decl, r, eclLon } = solarEphemeris(jd); @@ -202,8 +196,31 @@ export function getAngles( const rawFajr = fajrBase + rCorr + fourierCorr + refrFajr + elevCorr; const rawIsha = ishaBase + rCorr + fourierCorr + refrIsha + elevCorr; - const fajrAngle = round3(clip(rawFajr, FAJR_MIN, FAJR_MAX)); - const ishaAngle = round3(clip(rawIsha, ISHA_MIN, ISHA_MAX)); + const fajrAngle = round3(clip(rawFajr, ANGLE_MIN, ANGLE_MAX)); + const ishaAngle = round3(clip(rawIsha, ANGLE_MIN, ANGLE_MAX)); + return { fajrAngle, ishaAngle, decl }; +} + +/** + * Compute dynamic twilight depression angles for Fajr and Isha. + * + * @param date - Observer's local date (time-of-day is ignored) + * @param lat - Latitude in decimal degrees (-90 to 90) + * @param lng - Longitude in decimal degrees (-180 to 180, currently unused; reserved) + * @param elevation - Observer elevation in meters (default: 0) + * @param temperature - Ambient temperature in °C (default: 15) + * @param pressure - Atmospheric pressure in mbar (default: 1013.25) + * @returns Fajr and Isha depression angles in degrees + */ +export function getAngles( + date: Date, + lat: number, + lng: number, + elevation = 0, + temperature = 15, + pressure = 1013.25, +): TwilightAngles { + const { fajrAngle, ishaAngle } = computeAngles(date, lat, lng, elevation, temperature, pressure); return { fajrAngle, ishaAngle }; } diff --git a/src/getAsr.ts b/src/getAsr.ts index 30e655e..57a88ef 100644 --- a/src/getAsr.ts +++ b/src/getAsr.ts @@ -7,7 +7,7 @@ * and solar noon are known. */ -const DEG = Math.PI / 180; +import { DEG } from './constants.js'; /** * Compute Asr time as fractional hours. @@ -36,9 +36,7 @@ export function getAsr( // Solve the hour-angle equation: // cos(H0) = (sin(A) − sin(φ)sin(δ)) / (cos(φ)cos(δ)) - const cosH0 = - (sinA - Math.sin(phi) * Math.sin(delta)) / - (Math.cos(phi) * Math.cos(delta)); + const cosH0 = (sinA - Math.sin(phi) * Math.sin(delta)) / (Math.cos(phi) * Math.cos(delta)); if (cosH0 < -1 || cosH0 > 1) return NaN; // sun never reaches A diff --git a/src/getMSC.ts b/src/getMSC.ts index d838c5e..aec1dc8 100644 --- a/src/getMSC.ts +++ b/src/getMSC.ts @@ -9,11 +9,29 @@ * * Reference: moonsighting.com/isha_fajr.html * + * ## MCW Coefficient Key + * + * The piecewise-linear anchor values (a, b, c, d) follow the pattern: + * value = BASE + (SLOPE / LAT_SCALE) × |latitude| + * + * where BASE is the equatorial offset in minutes, SLOPE is the per-degree + * latitude coefficient, and LAT_SCALE = 55° is the normalisation latitude. + * Coefficients were curve-fit to multi-latitude observations of Subh Sadiq + * and Shafaq by the Moonsighting Committee (moonsighting.com/isha_fajr.html). + * * High-latitude handling (|lat| > 55°): falls back to 1/7-night rule. */ export type ShafaqMode = 'general' | 'ahmer' | 'abyad'; +/** + * Normalisation latitude (degrees) used as the divisor in MCW latitude + * scaling coefficients. All MCW slope values are expressed per 55° of + * latitude so that the piecewise function smoothly scales from equator + * to mid-high latitudes. + */ +const LAT_SCALE = 55; + function isLeapYear(year: number): boolean { return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; } @@ -79,10 +97,12 @@ export function getMscFajr(date: Date, latitude: number): number { const latAbs = Math.abs(latitude); const { dyy, daysInYear } = computeDyy(date, latitude); - const a = 75 + (28.65 / 55) * latAbs; - const b = 75 + (19.44 / 55) * latAbs; - const c = 75 + (32.74 / 55) * latAbs; - const d = 75 + (48.1 / 55) * latAbs; + // Anchor values: BASE + (SLOPE / LAT_SCALE) × |lat| + // BASE = 75 min (equatorial Fajr offset). Slopes from MCW curve-fit. + const a = 75 + (28.65 / LAT_SCALE) * latAbs; + const b = 75 + (19.44 / LAT_SCALE) * latAbs; + const c = 75 + (32.74 / LAT_SCALE) * latAbs; + const d = 75 + (48.1 / LAT_SCALE) * latAbs; return Math.round(interpolateSegment(dyy, daysInYear, a, b, c, d)); } @@ -95,11 +115,7 @@ export function getMscFajr(date: Date, latitude: number): number { * - 'ahmer': based on disappearance of redness (shafaq ahmer) * - 'abyad': based on disappearance of whiteness (shafaq abyad), later */ -export function getMscIsha( - date: Date, - latitude: number, - shafaq: ShafaqMode = 'general', -): number { +export function getMscIsha(date: Date, latitude: number, shafaq: ShafaqMode = 'general'): number { const latAbs = Math.abs(latitude); const { dyy, daysInYear } = computeDyy(date, latitude); @@ -107,22 +123,25 @@ export function getMscIsha( switch (shafaq) { case 'ahmer': - a = 62 + (17.4 / 55) * latAbs; - b = 62 - (7.16 / 55) * latAbs; - c = 62 + (5.12 / 55) * latAbs; - d = 62 + (19.44 / 55) * latAbs; + // Shafaq ahmer (red glow): BASE = 62 min (shorter twilight) + a = 62 + (17.4 / LAT_SCALE) * latAbs; + b = 62 - (7.16 / LAT_SCALE) * latAbs; + c = 62 + (5.12 / LAT_SCALE) * latAbs; + d = 62 + (19.44 / LAT_SCALE) * latAbs; break; case 'abyad': - a = 75 + (25.6 / 55) * latAbs; - b = 75 + (7.16 / 55) * latAbs; - c = 75 + (36.84 / 55) * latAbs; - d = 75 + (81.84 / 55) * latAbs; + // Shafaq abyad (white glow): BASE = 75 min (longer twilight) + a = 75 + (25.6 / LAT_SCALE) * latAbs; + b = 75 + (7.16 / LAT_SCALE) * latAbs; + c = 75 + (36.84 / LAT_SCALE) * latAbs; + d = 75 + (81.84 / LAT_SCALE) * latAbs; break; default: // 'general' - a = 75 + (25.6 / 55) * latAbs; - b = 75 + (2.05 / 55) * latAbs; - c = 75 - (9.21 / 55) * latAbs; - d = 75 + (6.14 / 55) * latAbs; + // General (blended) mode: BASE = 75 min + a = 75 + (25.6 / LAT_SCALE) * latAbs; + b = 75 + (2.05 / LAT_SCALE) * latAbs; + c = 75 - (9.21 / LAT_SCALE) * latAbs; + d = 75 + (6.14 / LAT_SCALE) * latAbs; } return Math.round(interpolateSegment(dyy, daysInYear, a, b, c, d)); @@ -138,11 +157,7 @@ export function getMscIsha( * * Returns NaN if the geometry is unreachable (polar day/night). */ -export function minutesToDepression( - minutes: number, - latDeg: number, - declDeg: number, -): number { +export function minutesToDepression(minutes: number, latDeg: number, declDeg: number): number { const phi = latDeg * (Math.PI / 180); const delta = declDeg * (Math.PI / 180); @@ -162,7 +177,7 @@ export function minutesToDepression( const cosH_rise = (sinH0 - sinPhi * sinDelta) / denominator; if (cosH_rise < -1) return NaN; // polar night - if (cosH_rise > 1) return NaN; // polar day + if (cosH_rise > 1) return NaN; // polar day const H_rise = Math.acos(cosH_rise); // radians @@ -179,8 +194,7 @@ export function minutesToDepression( } // Solar altitude at H_prayer - const sinH_prayer = - sinPhi * sinDelta + cosPhi * cosDelta * Math.cos(H_prayer); + const sinH_prayer = sinPhi * sinDelta + cosPhi * cosDelta * Math.cos(H_prayer); const h_prayer = Math.asin(Math.max(-1, Math.min(1, sinH_prayer))); // Depression angle: positive when sun is below horizon diff --git a/src/getSolarEphemeris.ts b/src/getSolarEphemeris.ts index 2226a4e..fbf0f0b 100644 --- a/src/getSolarEphemeris.ts +++ b/src/getSolarEphemeris.ts @@ -8,7 +8,7 @@ * prayer time solving still uses the full SPA via nrel-spa. */ -const DEG = Math.PI / 180; +import { DEG } from './constants.js'; /** Julian Date from a JavaScript Date (UTC). */ export function toJulianDate(date: Date): number { @@ -32,10 +32,10 @@ export function solarEphemeris(jd: number): SolarEphemeris { const T = (jd - 2451545.0) / 36525.0; // Geometric mean longitude L0 (degrees) - const L0 = ((280.46646 + 36000.76983 * T + 0.0003032 * T * T) % 360 + 360) % 360; + const L0 = (((280.46646 + 36000.76983 * T + 0.0003032 * T * T) % 360) + 360) % 360; // Mean anomaly M (degrees) - const M = ((357.52911 + 35999.05029 * T - 0.0001537 * T * T) % 360 + 360) % 360; + const M = (((357.52911 + 35999.05029 * T - 0.0001537 * T * T) % 360) + 360) % 360; const Mrad = M * DEG; // Orbital eccentricity @@ -58,7 +58,7 @@ export function solarEphemeris(jd: number): SolarEphemeris { const r = (1.000001018 * (1 - e * e)) / (1 + e * Math.cos(nuRad)); // Longitude of ascending node of Moon's orbit (for nutation) - const Omega = ((125.04 - 1934.136 * T) % 360 + 360) % 360; + const Omega = (((125.04 - 1934.136 * T) % 360) + 360) % 360; const OmegaRad = Omega * DEG; // Apparent solar longitude corrected for nutation and aberration @@ -66,11 +66,7 @@ export function solarEphemeris(jd: number): SolarEphemeris { const lambdaRad = lambda * DEG; // Mean obliquity of the ecliptic (degrees) - const epsilon0 = - 23.439291 - - 0.013004 * T - - 1.638e-7 * T * T + - 5.036e-7 * T * T * T; + const epsilon0 = 23.439291 - 0.013004 * T - 1.638e-7 * T * T + 5.036e-7 * T * T * T; // True obliquity with nutation correction const epsilon = (epsilon0 + 0.00256 * Math.cos(OmegaRad)) * DEG; @@ -92,11 +88,7 @@ export function solarEphemeris(jd: number): SolarEphemeris { * * Formula: dh/dt ≈ 15 × cos(φ) × cos(δ) × sin(H) [°/hr] */ -export function solarVerticalSpeed( - latRad: number, - declRad: number, - hAngleRad: number, -): number { +export function solarVerticalSpeed(latRad: number, declRad: number, hAngleRad: number): number { return 15 * Math.abs(Math.cos(latRad) * Math.cos(declRad) * Math.sin(hAngleRad)); } diff --git a/src/getTimes.ts b/src/getTimes.ts index 9ec9bf0..cb6ebc6 100644 --- a/src/getTimes.ts +++ b/src/getTimes.ts @@ -7,26 +7,33 @@ */ import { getSpa } from 'nrel-spa'; -import { toJulianDate, solarEphemeris } from './getSolarEphemeris.js'; -import { getAngles } from './getAngles.js'; +import { computeAngles } from './getAngles.js'; import { getAsr } from './getAsr.js'; import { getQiyam } from './getQiyam.js'; +import { validateInputs } from './validate.js'; +import { DHUHR_OFFSET_MINUTES } from './constants.js'; import type { PrayerTimes } from './types.js'; /** * Compute prayer times for a given date and location. * + * Uses the dynamic twilight angle algorithm to determine Fajr and Isha + * depression angles, then solves for all prayer events via SPA. + * * @param date - Observer's local date (time-of-day is ignored) - * @param lat - Latitude in decimal degrees (−90 to 90, south = negative) - * @param lng - Longitude in decimal degrees (−180 to 180, west = negative) - * @param tz - UTC offset in hours (e.g. −5 for EST). Defaults to the + * @param lat - Latitude in decimal degrees (-90 to 90, south = negative) + * @param lng - Longitude in decimal degrees (-180 to 180, west = negative) + * @param tz - UTC offset in hours (e.g. -5 for EST). Defaults to the * system timezone derived from the Date object. * @param elevation - Observer elevation in meters (default: 0) * @param temperature - Ambient temperature in °C (default: 15) * @param pressure - Atmospheric pressure in mbar/hPa (default: 1013.25) * @param hanafi - Asr convention: false = Shafi'i/Maliki/Hanbali (default), * true = Hanafi - * @returns Prayer times as fractional hours and the dynamic angles used + * @returns Prayer times as fractional hours and the dynamic angles used. + * Any time that cannot be computed (e.g. polar night/day, or the + * sun never reaching the required depression) is returned as `NaN`. + * @throws {RangeError} if lat, lng, tz, or elevation are out of valid range */ export function getTimes( date: Date, @@ -38,8 +45,17 @@ export function getTimes( pressure = 1013.25, hanafi = false, ): PrayerTimes { - // 1. Compute dynamic twilight angles. - const { fajrAngle, ishaAngle } = getAngles(date, lat, lng, elevation, temperature, pressure); + validateInputs(lat, lng, tz, elevation); + + // 1. Compute dynamic twilight angles and reuse solar declination. + const { fajrAngle, ishaAngle, decl } = computeAngles( + date, + lat, + lng, + elevation, + temperature, + pressure, + ); // 2. Convert depression angles to SPA zenith angles. // SPA uses zenith angle (90° + depression) for custom altitude events. @@ -50,36 +66,30 @@ export function getTimes( const spaOpts = { elevation, temperature, pressure }; const spaData = getSpa(date, lat, lng, tz, spaOpts, [fajrZenith, ishaZenith]); - const fajrTime = spaData.angles[0].sunrise; + const fajrTime = spaData.angles[0].sunrise; const sunriseTime = spaData.sunrise; - const noonTime = spaData.solarNoon; + const noonTime = spaData.solarNoon; const maghribTime = spaData.sunset; - const ishaTime = spaData.angles[1].sunset; + const ishaTime = spaData.angles[1].sunset; - // Dhuhr: 2.5 minutes after solar noon (standard practice to confirm transit). - const dhuhrTime = noonTime + 2.5 / 60; + // Dhuhr: offset after solar noon (standard practice to confirm transit). + const dhuhrTime = noonTime + DHUHR_OFFSET_MINUTES / 60; - // 4. Solar declination for Asr (Meeus formula, accurate to ~0.01°). - const jd = toJulianDate( - new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)), - ); - const { decl } = solarEphemeris(jd); - - // 5. Asr time. + // 4. Asr time (reuses declination from computeAngles — no extra ephemeris call). const asrTime = getAsr(noonTime, lat, decl, hanafi); - // 6. Qiyam al-Layl (last third of the night). + // 5. Qiyam al-Layl (last third of the night). const qiyamTime = getQiyam(fajrTime, ishaTime); return { - Qiyam: isFinite(qiyamTime) ? qiyamTime : NaN, - Fajr: isFinite(fajrTime) ? fajrTime : NaN, + Qiyam: isFinite(qiyamTime) ? qiyamTime : NaN, + Fajr: isFinite(fajrTime) ? fajrTime : NaN, Sunrise: isFinite(sunriseTime) ? sunriseTime : NaN, - Noon: isFinite(noonTime) ? noonTime : NaN, - Dhuhr: isFinite(dhuhrTime) ? dhuhrTime : NaN, - Asr: isFinite(asrTime) ? asrTime : NaN, + Noon: isFinite(noonTime) ? noonTime : NaN, + Dhuhr: isFinite(dhuhrTime) ? dhuhrTime : NaN, + Asr: isFinite(asrTime) ? asrTime : NaN, Maghrib: isFinite(maghribTime) ? maghribTime : NaN, - Isha: isFinite(ishaTime) ? ishaTime : NaN, - angles: { fajrAngle, ishaAngle }, + Isha: isFinite(ishaTime) ? ishaTime : NaN, + angles: { fajrAngle, ishaAngle }, }; } diff --git a/src/getTimesAll.ts b/src/getTimesAll.ts index 09d97d9..04bc378 100644 --- a/src/getTimesAll.ts +++ b/src/getTimesAll.ts @@ -25,43 +25,128 @@ */ import { getSpa } from 'nrel-spa'; -import { toJulianDate, solarEphemeris } from './getSolarEphemeris.js'; -import { getAngles } from './getAngles.js'; +import { computeAngles } from './getAngles.js'; import { getAsr } from './getAsr.js'; import { getQiyam } from './getQiyam.js'; import { getMscFajr, getMscIsha } from './getMSC.js'; +import { validateInputs } from './validate.js'; +import { DHUHR_OFFSET_MINUTES } from './constants.js'; import type { MethodDefinition, PrayerTimesAll } from './types.js'; /** All supported traditional methods. */ const METHODS: MethodDefinition[] = [ - { id: 'UOIF', name: 'Union des Organisations Islamiques de France', region: 'France', fajrAngle: 12, ishaAngle: 12 }, - { id: 'ISNACA', name: 'IQNA / Islamic Council of North America', region: 'Canada', fajrAngle: 13, ishaAngle: 13 }, - { id: 'ISNA', name: 'FCNA / Islamic Society of North America', region: 'US, UK, AU, NZ', fajrAngle: 15, ishaAngle: 15 }, - { id: 'SAMR', name: 'Spiritual Administration of Muslims of Russia', region: 'Russia', fajrAngle: 16, ishaAngle: 15 }, - { id: 'IGUT', name: 'Institute of Geophysics, University of Tehran', region: 'Iran', fajrAngle: 17.7, ishaAngle: 14 }, - { id: 'MWL', name: 'Muslim World League', region: 'Global', fajrAngle: 18, ishaAngle: 17 }, - { id: 'DIBT', name: 'Diyanet İşleri Başkanlığı, Turkey', region: 'Turkey', fajrAngle: 18, ishaAngle: 17 }, - { id: 'Karachi', name: 'University of Islamic Sciences, Karachi', region: 'PK, BD, IN, AF', fajrAngle: 18, ishaAngle: 18 }, - { id: 'Kuwait', name: 'Kuwait Ministry of Islamic Affairs', region: 'Kuwait', fajrAngle: 18, ishaAngle: 17.5 }, - { id: 'UAQ', name: 'Umm Al-Qura University, Makkah', region: 'Saudi Arabia', fajrAngle: 18.5, ishaAngle: null, ishaMinutes: 90 }, - { id: 'Qatar', name: 'Qatar / Gulf Standard', region: 'Qatar, Gulf', fajrAngle: 18, ishaAngle: null, ishaMinutes: 90 }, - { id: 'Egypt', name: 'Egyptian General Authority of Survey', region: 'EG, SY, IQ, LB', fajrAngle: 19.5, ishaAngle: 17.5 }, - { id: 'MUIS', name: 'Majlis Ugama Islam Singapura', region: 'Singapore', fajrAngle: 20, ishaAngle: 18 }, - { id: 'MSC', name: 'Moonsighting Committee Worldwide', region: 'Global', fajrAngle: null, ishaAngle: null, useMSC: true }, + { + id: 'UOIF', + name: 'Union des Organisations Islamiques de France', + region: 'France', + fajrAngle: 12, + ishaAngle: 12, + }, + { + id: 'ISNACA', + name: 'IQNA / Islamic Council of North America', + region: 'Canada', + fajrAngle: 13, + ishaAngle: 13, + }, + { + id: 'ISNA', + name: 'FCNA / Islamic Society of North America', + region: 'US, UK, AU, NZ', + fajrAngle: 15, + ishaAngle: 15, + }, + { + id: 'SAMR', + name: 'Spiritual Administration of Muslims of Russia', + region: 'Russia', + fajrAngle: 16, + ishaAngle: 15, + }, + { + id: 'IGUT', + name: 'Institute of Geophysics, University of Tehran', + region: 'Iran', + fajrAngle: 17.7, + ishaAngle: 14, + }, + { id: 'MWL', name: 'Muslim World League', region: 'Global', fajrAngle: 18, ishaAngle: 17 }, + { + id: 'DIBT', + name: 'Diyanet İşleri Başkanlığı, Turkey', + region: 'Turkey', + fajrAngle: 18, + ishaAngle: 17, + }, + { + id: 'Karachi', + name: 'University of Islamic Sciences, Karachi', + region: 'PK, BD, IN, AF', + fajrAngle: 18, + ishaAngle: 18, + }, + { + id: 'Kuwait', + name: 'Kuwait Ministry of Islamic Affairs', + region: 'Kuwait', + fajrAngle: 18, + ishaAngle: 17.5, + }, + { + id: 'UAQ', + name: 'Umm Al-Qura University, Makkah', + region: 'Saudi Arabia', + fajrAngle: 18.5, + ishaAngle: null, + ishaMinutes: 90, + }, + { + id: 'Qatar', + name: 'Qatar / Gulf Standard', + region: 'Qatar, Gulf', + fajrAngle: 18, + ishaAngle: null, + ishaMinutes: 90, + }, + { + id: 'Egypt', + name: 'Egyptian General Authority of Survey', + region: 'EG, SY, IQ, LB', + fajrAngle: 19.5, + ishaAngle: 17.5, + }, + { + id: 'MUIS', + name: 'Majlis Ugama Islam Singapura', + region: 'Singapore', + fajrAngle: 20, + ishaAngle: 18, + }, + { + id: 'MSC', + name: 'Moonsighting Committee Worldwide', + region: 'Global', + fajrAngle: null, + ishaAngle: null, + useMSC: true, + }, ]; /** * Compute prayer times plus all traditional method comparisons. * - * @param date - Observer's local date - * @param lat - Latitude in decimal degrees - * @param lng - Longitude in decimal degrees + * @param date - Observer's local date (time-of-day is ignored) + * @param lat - Latitude in decimal degrees (-90 to 90) + * @param lng - Longitude in decimal degrees (-180 to 180) * @param tz - UTC offset in hours (defaults to system tz) * @param elevation - Observer elevation in meters (default: 0) * @param temperature - Ambient temperature in °C (default: 15) * @param pressure - Atmospheric pressure in mbar (default: 1013.25) * @param hanafi - Asr convention: false = Shafi'i (default), true = Hanafi - * @returns Prayer times for the dynamic method plus all traditional methods + * @returns Prayer times for the dynamic method plus all traditional methods. + * Any time that cannot be computed is returned as `NaN`. + * Methods map contains `[fajrTime, ishaTime]` per method. + * @throws {RangeError} if lat, lng, tz, or elevation are out of valid range */ export function getTimesAll( date: Date, @@ -73,8 +158,17 @@ export function getTimesAll( pressure = 1013.25, hanafi = false, ): PrayerTimesAll { - // 1. Dynamic angles. - const { fajrAngle, ishaAngle } = getAngles(date, lat, lng, elevation, temperature, pressure); + validateInputs(lat, lng, tz, elevation); + + // 1. Dynamic angles and reusable solar declination. + const { fajrAngle, ishaAngle, decl } = computeAngles( + date, + lat, + lng, + elevation, + temperature, + pressure, + ); // 2. Build batch zenith angles for the SPA call: // Slot 0: dynamic Fajr, Slot 1: dynamic Isha, then pairs for each method. @@ -97,20 +191,15 @@ export function getTimesAll( const spaData = getSpa(date, lat, lng, tz, spaOpts, allZeniths); // 3. Extract core times (index 0 = dynamic Fajr, index 1 = dynamic Isha). - const fajrTime = spaData.angles[0].sunrise; + const fajrTime = spaData.angles[0].sunrise; const sunriseTime = spaData.sunrise; - const noonTime = spaData.solarNoon; + const noonTime = spaData.solarNoon; const maghribTime = spaData.sunset; - const ishaTime = spaData.angles[1].sunset; - const dhuhrTime = noonTime + 2.5 / 60; + const ishaTime = spaData.angles[1].sunset; + const dhuhrTime = noonTime + DHUHR_OFFSET_MINUTES / 60; - // 4. Solar declination for Asr. - const jd = toJulianDate( - new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)), - ); - const { decl } = solarEphemeris(jd); - - const asrTime = getAsr(noonTime, lat, decl, hanafi); + // 4. Asr time (reuses declination from computeAngles — no extra ephemeris call). + const asrTime = getAsr(noonTime, lat, decl, hanafi); const qiyamTime = getQiyam(fajrTime, ishaTime); // 5. Build Methods map. @@ -140,14 +229,14 @@ export function getTimesAll( } return { - Qiyam: isFinite(qiyamTime) ? qiyamTime : NaN, - Fajr: isFinite(fajrTime) ? fajrTime : NaN, + Qiyam: isFinite(qiyamTime) ? qiyamTime : NaN, + Fajr: isFinite(fajrTime) ? fajrTime : NaN, Sunrise: isFinite(sunriseTime) ? sunriseTime : NaN, - Noon: isFinite(noonTime) ? noonTime : NaN, - Dhuhr: isFinite(dhuhrTime) ? dhuhrTime : NaN, - Asr: isFinite(asrTime) ? asrTime : NaN, + Noon: isFinite(noonTime) ? noonTime : NaN, + Dhuhr: isFinite(dhuhrTime) ? dhuhrTime : NaN, + Asr: isFinite(asrTime) ? asrTime : NaN, Maghrib: isFinite(maghribTime) ? maghribTime : NaN, - Isha: isFinite(ishaTime) ? ishaTime : NaN, + Isha: isFinite(ishaTime) ? ishaTime : NaN, Methods, angles: { fajrAngle, ishaAngle }, }; diff --git a/src/index.ts b/src/index.ts index 2030e2a..9a7140e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export { getAsr } from './getAsr.js'; export { getQiyam } from './getQiyam.js'; export { getMscFajr, getMscIsha } from './getMSC.js'; export { solarEphemeris, toJulianDate } from './getSolarEphemeris.js'; +export { DHUHR_OFFSET_MINUTES, ANGLE_MIN, ANGLE_MAX } from './constants.js'; export type { FractionalHours, diff --git a/src/types.ts b/src/types.ts index a5b688e..e54006e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,8 +57,16 @@ export interface FormattedPrayerTimes { angles: TwilightAngles; } -/** Method entry in the Methods map: [fajrTime, ishaTime] as fractional hours. */ -export type MethodEntry = [FractionalHours, FractionalHours]; +/** + * Method entry in the Methods map: `[fajrTime, ishaTime]` as fractional hours. + * + * - Index 0 (`fajr`): Fajr time for this method (fractional hours, or `NaN`) + * - Index 1 (`isha`): Isha time for this method (fractional hours, or `NaN`) + * + * A value of `NaN` indicates the event is unreachable at this location/date + * (e.g. the sun never dips to 18° below the horizon at high latitudes in summer). + */ +export type MethodEntry = [fajr: FractionalHours, isha: FractionalHours]; /** Prayer times plus all method comparison times as fractional hours. */ export interface PrayerTimesAll extends PrayerTimes { diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..760f212 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,23 @@ +/** + * Input validation for public API boundaries. + */ + +/** + * Validate geographic and atmospheric inputs for prayer time computation. + * + * @throws {RangeError} if any parameter is out of its valid range + */ +export function validateInputs(lat: number, lng: number, tz?: number, elevation?: number): void { + if (!Number.isFinite(lat) || lat < -90 || lat > 90) { + throw new RangeError(`latitude must be between -90 and 90, got ${lat}`); + } + if (!Number.isFinite(lng) || lng < -180 || lng > 180) { + throw new RangeError(`longitude must be between -180 and 180, got ${lng}`); + } + if (tz !== undefined && (!Number.isFinite(tz) || tz < -14 || tz > 14)) { + throw new RangeError(`timezone offset must be between -14 and 14, got ${tz}`); + } + if (elevation !== undefined && (!Number.isFinite(elevation) || elevation < -500)) { + throw new RangeError(`elevation must be >= -500m, got ${elevation}`); + } +} diff --git a/test-cjs.cjs b/test-cjs.cjs index 1a3db1a..a4fd04b 100644 --- a/test-cjs.cjs +++ b/test-cjs.cjs @@ -6,7 +6,8 @@ 'use strict'; -const assert = require('assert'); +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); const { getTimes, calcTimes, @@ -22,101 +23,83 @@ const { METHODS, } = require('./dist/index.cjs'); -let passed = 0; -let failed = 0; +describe('[CJS] Core exports', () => { + it('METHODS exported and has 14 entries', () => { + assert(Array.isArray(METHODS)); + assert.strictEqual(METHODS.length, 14); + }); -function test(name, fn) { - try { - fn(); - console.log(` ${name}... PASS`); - passed++; - } catch (err) { - console.error(` ${name}... FAIL: ${err.message}`); - failed++; - } -} + it('getTimes returns valid structure', () => { + const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); + assert(isFinite(t.Fajr), `Fajr=${t.Fajr}`); + assert(isFinite(t.Sunrise), `Sunrise=${t.Sunrise}`); + assert(isFinite(t.Maghrib), `Maghrib=${t.Maghrib}`); + assert(isFinite(t.Isha), `Isha=${t.Isha}`); + assert(typeof t.angles.fajrAngle === 'number'); + }); -console.log('\n[CJS] Core exports'); + it('calcTimes returns HH:MM:SS strings', () => { + const t = calcTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); + assert(/^\d{2}:\d{2}:\d{2}$/.test(t.Fajr), `Fajr="${t.Fajr}"`); + assert(/^\d{2}:\d{2}:\d{2}$/.test(t.Sunrise), `Sunrise="${t.Sunrise}"`); + assert(/^\d{2}:\d{2}:\d{2}$/.test(t.Maghrib), `Maghrib="${t.Maghrib}"`); + }); -test('METHODS exported and has 14 entries', () => { - assert(Array.isArray(METHODS)); - assert.strictEqual(METHODS.length, 14); + it('getTimesAll returns 14 methods', () => { + const t = getTimesAll(new Date('2024-06-21'), 40.7128, -74.0060, -4); + assert.strictEqual(Object.keys(t.Methods).length, 14); + }); + + it('calcTimesAll Methods are string pairs', () => { + const t = calcTimesAll(new Date('2024-06-21'), 40.7128, -74.0060, -4); + for (const [fajr, isha] of Object.values(t.Methods)) { + assert(typeof fajr === 'string'); + assert(typeof isha === 'string'); + } + }); + + it('getAngles returns bounded angles', () => { + const a = getAngles(new Date('2024-06-21'), 40.7128, -74.0060); + assert(a.fajrAngle >= 10 && a.fajrAngle <= 22); + assert(a.ishaAngle >= 10 && a.ishaAngle <= 22); + }); + + it('getAsr Hanafi later than Shafii', () => { + const s = getAsr(12.0, 40.7, 20.0, false); + const h = getAsr(12.0, 40.7, 20.0, true); + assert(h > s); + }); + + it('getQiyam returns a number', () => { + const q = getQiyam(4.0, 22.0); + assert(typeof q === 'number'); + }); + + it('getMscFajr returns positive minutes', () => { + const m = getMscFajr(new Date('2024-06-21'), 40.7); + assert(m > 0); + }); + + it('getMscIsha returns positive minutes', () => { + const m = getMscIsha(new Date('2024-06-21'), 40.7); + assert(m > 0); + }); + + it('toJulianDate and solarEphemeris work', () => { + 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'); + }); + + it('Makkah all-methods comparison — UAQ Isha = Maghrib + 90min', () => { + const t = getTimesAll(new Date('2024-06-21'), 21.4225, 39.8262, 3); + const diff = (t.Methods.UAQ[1] - t.Maghrib) * 60; + assert(Math.abs(diff - 90) < 2, `UAQ isha diff=${diff}`); + }); + + it('rejects invalid inputs', () => { + assert.throws(() => getTimes(new Date('2024-06-21'), 91, 0, 0), { name: 'RangeError' }); + }); }); - -test('getTimes returns valid structure', () => { - const t = getTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); - assert(isFinite(t.Fajr), `Fajr=${t.Fajr}`); - assert(isFinite(t.Sunrise), `Sunrise=${t.Sunrise}`); - assert(isFinite(t.Maghrib), `Maghrib=${t.Maghrib}`); - assert(isFinite(t.Isha), `Isha=${t.Isha}`); - assert(typeof t.angles.fajrAngle === 'number'); -}); - -test('calcTimes returns HH:MM:SS strings', () => { - const t = calcTimes(new Date('2024-06-21'), 40.7128, -74.0060, -4); - assert(/^\d{2}:\d{2}:\d{2}$/.test(t.Fajr), `Fajr="${t.Fajr}"`); - assert(/^\d{2}:\d{2}:\d{2}$/.test(t.Sunrise), `Sunrise="${t.Sunrise}"`); - assert(/^\d{2}:\d{2}:\d{2}$/.test(t.Maghrib), `Maghrib="${t.Maghrib}"`); -}); - -test('getTimesAll returns 14 methods', () => { - const t = getTimesAll(new Date('2024-06-21'), 40.7128, -74.0060, -4); - assert.strictEqual(Object.keys(t.Methods).length, 14); -}); - -test('calcTimesAll Methods are string pairs', () => { - const t = calcTimesAll(new Date('2024-06-21'), 40.7128, -74.0060, -4); - for (const [fajr, isha] of Object.values(t.Methods)) { - assert(typeof fajr === 'string'); - assert(typeof isha === 'string'); - } -}); - -test('getAngles returns bounded angles', () => { - const a = getAngles(new Date('2024-06-21'), 40.7128, -74.0060); - assert(a.fajrAngle >= 10 && a.fajrAngle <= 22); - assert(a.ishaAngle >= 10 && a.ishaAngle <= 22); -}); - -test('getAsr Hanafi later than Shafii', () => { - const s = getAsr(12.0, 40.7, 20.0, false); - const h = getAsr(12.0, 40.7, 20.0, true); - assert(h > s); -}); - -test('getQiyam returns a number', () => { - const q = getQiyam(4.0, 22.0); - assert(typeof q === 'number'); -}); - -test('getMscFajr returns positive minutes', () => { - const m = getMscFajr(new Date('2024-06-21'), 40.7); - assert(m > 0); -}); - -test('getMscIsha returns positive minutes', () => { - const m = getMscIsha(new Date('2024-06-21'), 40.7); - assert(m > 0); -}); - -test('toJulianDate and solarEphemeris work', () => { - 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('Makkah all-methods comparison — UAQ Isha = Maghrib + 90min', () => { - const t = getTimesAll(new Date('2024-06-21'), 21.4225, 39.8262, 3); - const diff = (t.Methods.UAQ[1] - t.Maghrib) * 60; - assert(Math.abs(diff - 90) < 2, `UAQ isha diff=${diff}`); -}); - -const total = passed + failed; -console.log(`\n${'─'.repeat(50)}`); -console.log(`${passed}/${total} CJS tests passed`); - -if (failed > 0) { - process.exit(1); -} diff --git a/test.mjs b/test.mjs index 2b58e35..2481ef3 100644 --- a/test.mjs +++ b/test.mjs @@ -1,5 +1,5 @@ /** - * pray-calc v2 test suite — 100 scenarios. + * pray-calc v2 test suite. * * Tests cover: * - Equatorial, tropical, mid-latitude, high-latitude locations @@ -7,12 +7,13 @@ * - Both Asr conventions (Shafi'i / Hanafi) * - Atmospheric parameters (pressure, temperature, elevation) * - All exported functions - * - Edge cases (polar regions, missing events) + * - Edge cases (polar regions, missing events, invalid inputs) * - Dynamic vs. traditional method comparison * - Type exports and METHODS array */ -import assert from 'assert'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; import { getTimes, calcTimes, @@ -26,36 +27,19 @@ import { solarEphemeris, toJulianDate, METHODS, + DHUHR_OFFSET_MINUTES, + ANGLE_MIN, + ANGLE_MAX, } 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; } @@ -63,712 +47,705 @@ function hm(h, m) { // ───────────────────────────────────────────────────────────────────────────── // Section 1: Exports and type structure // ───────────────────────────────────────────────────────────────────────────── -console.log('\n[1] Exports and type structure'); +describe('Exports and type structure', () => { + it('METHODS array has 14 entries', () => { + assert.strictEqual(METHODS.length, 14); + }); -test('METHODS array has 14 entries', () => { - assert.strictEqual(METHODS.length, 14); -}); + it('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 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}`); - } -}); + it('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('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'); - } -}); + it('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('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); -}); + it('UAQ has ishaMinutes=90', () => { + const uaq = METHODS.find(m => m.id === 'UAQ'); + assert.strictEqual(uaq.ishaMinutes, 90); + }); -test('UAQ has ishaMinutes=90', () => { - const uaq = METHODS.find(m => m.id === 'UAQ'); - assert.strictEqual(uaq.ishaMinutes, 90); -}); + it('Qatar has ishaMinutes=90', () => { + const qatar = METHODS.find(m => m.id === 'Qatar'); + assert.strictEqual(qatar.ishaMinutes, 90); + }); -test('Qatar has ishaMinutes=90', () => { - const qatar = METHODS.find(m => m.id === 'Qatar'); - assert.strictEqual(qatar.ishaMinutes, 90); + it('DHUHR_OFFSET_MINUTES is exported and equals 2.5', () => { + assert.strictEqual(DHUHR_OFFSET_MINUTES, 2.5); + }); + + it('ANGLE_MIN and ANGLE_MAX are exported', () => { + assert.strictEqual(ANGLE_MIN, 10); + assert.strictEqual(ANGLE_MAX, 22); + }); }); // ───────────────────────────────────────────────────────────────────────────── // Section 2: toJulianDate and solarEphemeris // ───────────────────────────────────────────────────────────────────────────── -console.log('\n[2] Solar ephemeris'); +describe('Solar ephemeris', () => { + it('toJulianDate J2000 epoch', () => { + const jd = toJulianDate(new Date(Date.UTC(2000, 0, 1, 12, 0, 0))); + assert(approxAngle(jd, 2451545.0, 1.0), `Got ${jd}`); + }); -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}`); -}); + it('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 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'); -}); + it('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 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}`); -}); + it('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 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}`); -}); + it('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 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`); + it('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'); +describe('getAngles — dynamic depression', () => { + it('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 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}`); + it('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}`); -}); + it('equatorial latitude near 18', () => { + const { fajrAngle } = getAngles(new Date('2024-06-21'), 1.3, 103.8); + 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`); -}); + it('high-latitude summer smaller than 18', () => { + 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'); + it('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'); + 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'); +describe('getAsr', () => { + it('Shafii returns finite time', () => { + const asr = getAsr(12.0, 40.7128, 20.0, false); + assert(isFinite(asr), `Expected finite, got ${asr}`); + }); -test('getAsr Shafii returns finite time', () => { - const asr = getAsr(12.0, 40.7128, 20.0, false); - assert(isFinite(asr), `Expected finite, got ${asr}`); -}); + it('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 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}`); -}); + it('reasonable range (afternoon)', () => { + const asr = getAsr(12.1, 21.4, 20.0, false); + assert(asr > 14 && asr < 18, `Got ${asr}`); + }); -test('getAsr reasonable range (afternoon)', () => { - const asr = getAsr(12.1, 21.4, 20.0, false); // Makkah-ish - assert(asr > 14 && asr < 18, `Got ${asr}`); -}); + it('Hanafi Makkah afternoon', () => { + const asr = getAsr(12.1, 21.4, 20.0, true); + assert(asr > 15 && asr < 19, `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'); + it('returns NaN when sun never reaches altitude', () => { + const asr = getAsr(12.0, 89.0, -23.4, false); + assert(typeof asr === 'number', 'Should return a number'); + }); }); // ───────────────────────────────────────────────────────────────────────────── // Section 5: getQiyam // ───────────────────────────────────────────────────────────────────────────── -console.log('\n[5] getQiyam'); +describe('getQiyam', () => { + it('returns last-third start', () => { + const q = getQiyam(4.0, 22.0); + assert(approx(q, 2.0, 0.1), `Got ${q}`); + }); -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}`); + it('handles wrap-around midnight', () => { + const q = getQiyam(3.5, 21.0); + 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'); +describe('MSC minute offsets', () => { + it('getMscFajr returns positive minutes', () => { + const m = getMscFajr(new Date('2024-06-21'), 40.7); + assert(m > 0, `Got ${m}`); + }); -test('getMscFajr returns positive minutes', () => { - const m = getMscFajr(new Date('2024-06-21'), 40.7); - assert(m > 0, `Got ${m}`); -}); + it('getMscIsha returns positive minutes', () => { + const m = getMscIsha(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}`); -}); + it('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 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})`); -}); + it('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('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}`); + it('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'); + assert(general > 0 && ahmer > 0 && abyad > 0); + assert(ahmer <= general, `ahmer ${ahmer} should be <= general ${general}`); + }); }); // ───────────────────────────────────────────────────────────────────────────── // Section 7: getTimes — core output structure // ───────────────────────────────────────────────────────────────────────────── -console.log('\n[7] getTimes — structure'); +describe('getTimes — structure', () => { + it('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 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); -}); + it('chronological order', () => { + const t = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4); + 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 chronological order', () => { - // Use explicit tz=-4 (EDT) so CI (UTC) and local machines give identical results. - // Without it, NY's Maghrib falls past UTC midnight, wrapping to ~0.5h < Asr(~21h). - const t = getTimes(new Date('2024-06-21'), 40.7, -74.0, -4); - // 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})`); -}); + it('Dhuhr is slightly after Noon', () => { + const t = getTimes(new Date('2024-06-21'), 40.7, -74.0); + const diff = (t.Dhuhr - t.Noon) * 60; + assert(diff > 2 && diff < 4, `Dhuhr - Noon = ${diff} min`); + }); -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); + it('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'); +describe('getTimes — geographic scenarios', () => { + const TOL = 0.07; // ~4 minutes -// Reference times from independent sources (tolerances ±4 min = 0.067h) -const TOL = 0.07; // ~4 minutes + it('Makkah summer solstice — Sunrise ~05: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 — 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}`); -}); + it('Makkah summer solstice — Maghrib ~19:06', () => { + 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('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}`); -}); + it('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 — 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}`); -}); + it('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 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}`); -}); + it('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 — 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}`); -}); + it('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('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}`); -}); + it('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 — 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}`); -}); + it('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('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}`); -}); + it('Sydney summer (Gregorian Jan) — Sunrise ~06:00', () => { + 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('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}`); -}); + it('Jakarta — Sunrise within 20min of 5:50 year-round', () => { + 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('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}`); - } -}); + it('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('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`); - } -}); + it('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('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}`); -}); + it('Istanbul spring equinox — Noon ~13:11 ±10min', () => { + 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('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}`); -}); + it('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('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}`); -}); + it('Toronto summer — Sunset ~21:02 ±12min', () => { + 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('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}`); -}); + it('Reykjavik summer — Sunrise and Maghrib finite', () => { + const t = getTimes(new Date('2024-06-21'), 64.1265, -21.8174, 0); + assert(isFinite(t.Noon), `Noon should be finite`); + }); -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'); + it('South pole winter — Noon finite', () => { + const t = getTimes(new Date('2024-06-21'), -90, 0, 0); + assert(typeof t.Noon === 'number'); + }); }); // ───────────────────────────────────────────────────────────────────────────── // Section 9: getTimes — seasonal variation at fixed location // ───────────────────────────────────────────────────────────────────────────── -console.log('\n[9] getTimes — seasonal variation'); +describe('getTimes — seasonal variation', () => { + it('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 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}`); -}); + it('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('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}`); -}); + it('Noon time consistent across seasons (same tz, within 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('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}`); + it('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'); +describe('Asr convention', () => { + it('Hanafi Asr later than Shafii at multiple locations', () => { + const locations = [ + [40.7, -74.0, -4], + [21.4, 39.8, 3], + [51.5, -0.1, 1], + [-33.9, 151.2, 10], + ]; + 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 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`); + it('Hanafi-Shafii difference 20-85 min at typical latitudes', () => { + 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'); +describe('Atmospheric parameters', () => { + it('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('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})`); -}); + it('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('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)); + it('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); + assert(isFinite(tHot.Sunrise) && isFinite(tCold.Sunrise)); + }); }); // ───────────────────────────────────────────────────────────────────────────── // Section 12: calcTimes — formatted output // ───────────────────────────────────────────────────────────────────────────── -console.log('\n[12] calcTimes — formatting'); +describe('calcTimes — formatting', () => { + it('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 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`); - } -}); + it('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 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}"`); -}); + it('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 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}`); + it('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); + 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'); +describe('getTimesAll — method comparison', () => { + it('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 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); -}); + it('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 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`); - } -}); + it('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 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]}`); -}); + it('MWL Isha at London summer may be NaN (18° fails)', () => { + const t = getTimesAll(new Date('2024-06-21'), 51.5, -0.1, 1); + assert(typeof t.Methods.MWL[1] === 'number'); + }); -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'); -}); + it('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 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`); -}); + it('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 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`); -}); + it('higher-angle methods have earlier Fajr', () => { + 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 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})`); - } -}); + it('dynamic Fajr within method range', () => { + const t = getTimesAll(new Date('2024-06-21'), 40.7, -74.0, -4); + const earliest = t.Methods.Karachi[0]; + const latest = t.Methods.UOIF[0]; + 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 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`); - } + it('MSC and dynamic are close', () => { + 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'); +describe('calcTimesAll', () => { + it('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 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}"`); -}); + it('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 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'); - } + it('N/A for unreachable events', () => { + const t = calcTimesAll(new Date('2024-06-21'), 58.0, 25.0, 3); + 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'); +describe('Date coverage', () => { + it('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 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`); - } -}); + it('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 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'); -}); + it('works on Dec 31', () => { + const t = getTimes(new Date('2024-12-31'), 40.7, -74.0, -5); + assert(isFinite(t.Sunrise)); + }); -test('Works on Dec 31', () => { - const t = getTimes(new Date('2024-12-31'), 40.7, -74.0, -5); - assert(isFinite(t.Sunrise)); -}); + it('works on Jan 1', () => { + const t = getTimes(new Date('2024-01-01'), 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`); + it('both equinoxes consistent', () => { + 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}`); + 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'); +describe('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' }, + ]; -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}`); - }); -} + for (const loc of globalLocations) { + it(`${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'); +describe('Winter scenarios', () => { + it('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('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]}`); - } -}); + it('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('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]}`); - } -}); + it('Oslo winter — Noon finite', () => { + const t = getTimes(new Date('2024-12-21'), 59.9, 10.8, 1); + assert(isFinite(t.Noon)); + }); -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}`); - } + it('Oslo winter — Sunrise, Sunset near solstice values', () => { + const t = getTimes(new Date('2024-12-21'), 59.9, 10.8, 1); + if (isFinite(t.Sunrise)) { + assert(approx(t.Sunrise, hm(9,18), 0.25), `Oslo Sunrise ${t.Sunrise}`); + } + }); }); // ───────────────────────────────────────────────────────────────────────────── -// Summary +// Section 18: Input validation // ───────────────────────────────────────────────────────────────────────────── -const total = passed + failed; -console.log(`\n${'─'.repeat(50)}`); -console.log(`${passed}/${total} tests passed`); +describe('Input validation', () => { + it('rejects latitude > 90', () => { + assert.throws(() => getTimes(new Date('2024-06-21'), 91, 0, 0), { name: 'RangeError' }); + }); -if (failed > 0) { - process.exit(1); -} + it('rejects latitude < -90', () => { + assert.throws(() => getTimes(new Date('2024-06-21'), -91, 0, 0), { name: 'RangeError' }); + }); + + it('rejects longitude > 180', () => { + assert.throws(() => getTimes(new Date('2024-06-21'), 0, 181, 0), { name: 'RangeError' }); + }); + + it('rejects longitude < -180', () => { + assert.throws(() => getTimes(new Date('2024-06-21'), 0, -181, 0), { name: 'RangeError' }); + }); + + it('rejects timezone > 14', () => { + assert.throws(() => getTimes(new Date('2024-06-21'), 0, 0, 15), { name: 'RangeError' }); + }); + + it('rejects NaN latitude', () => { + assert.throws(() => getTimes(new Date('2024-06-21'), NaN, 0, 0), { name: 'RangeError' }); + }); + + it('rejects Infinity longitude', () => { + assert.throws(() => getTimes(new Date('2024-06-21'), 0, Infinity, 0), { name: 'RangeError' }); + }); + + it('getTimesAll also validates', () => { + assert.throws(() => getTimesAll(new Date('2024-06-21'), 91, 0, 0), { name: 'RangeError' }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index ac403cc..a44ab09 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,8 @@ "module": "ESNext", "moduleResolution": "bundler", "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "declaration": true, "declarationMap": true,