diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ac01123 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-build: + name: Test and build + runs-on: ubuntu-latest + steps: + - name: Checkout Source + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Run Unit Tests + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dff73cd..52f53fe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,15 +20,22 @@ jobs: with: node-version: '22' - # 3. Устанавливаем зависимости и собираем - - name: Install and Build + # 3. Устанавливаем зависимости + - name: Install Dependencies + run: npm ci + + # 4. Прогоняем юнит-тесты до сборки: упавший тест останавливает деплой + - name: Run Unit Tests + run: npm test + + # 5. Собираем + - name: Build run: | - npm ci npm run build mv dist/index.html dist/app.html # Теперь у нас есть папка dist с готовой игрой - # 4. Отправляем папку dist в репозиторий сайта + # 6. Отправляем папку dist в репозиторий сайта - name: Push to Website Repository uses: cpina/github-action-push-to-another-repository@main env: diff --git a/docs/architecture.md b/docs/architecture.md index 49fa766..bc6cf6c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -147,5 +147,5 @@ Vite configuration highlights (`vite.config.ts`): - Duplicate geometry paths: lightweight Three.js mesh vs OpenCascade solid (kept consistent via shared offset helpers). - Legacy `app.html` at repo root is not the Vite entry; production deploy renames `dist/index.html` → `dist/app.html`. -- No automated test suite; rely on lint and manual verification for releases. +- The calculation engine, pattern dimensions and share-link protocol are covered by Vitest (`npm test`); 2D/3D rendering and the DXF/PDF/STEP exports still rely on the manual checklist in `docs/development.md`. - `process.env.GEMINI_API_KEY` is defined in Vite config for historical template reasons; the shell calculator does not call Gemini APIs. diff --git a/docs/development.md b/docs/development.md index 73f5253..f2f5707 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,12 +26,28 @@ Dev server: `http://localhost:3000` (bound to `0.0.0.0`). | `npm run build` | Production build to `dist/` | | `npm run preview` | Preview production build | | `npm run lint` | ESLint (TypeScript + React Hooks + Refresh) | -| `npm run verify` | Numerical and protocol checks (both suites below) | -| `npm run verify:eccentric` | Eccentric cone development vs. independently known values | -| `npm run verify:bend-dimensions` | Bend-line spacing dimensions in all three shape modes | -| `npm run verify:share-link` | Share-link round trip, real link payload, hostile input | +| `npm test` | Vitest unit tests, single run (used by CI) | +| `npm run test:watch` | Vitest in watch mode | -There is currently **no** automated unit/e2e test script in `package.json`. +## Tests + +Tests live next to the code they cover (`*.test.ts`) and run in Node — no +browser, WebGL or WASM is needed. + +| Suite | Covers | +|-------|--------| +| `math/cylinder.test.ts` | Neutral diameter from OD/ID, developed length, K-factor, weld gap, bend lines | +| `math/cone.test.ts` | Sector radii and angle, gap on the neutral fibre, blank rectangle | +| `math/eccentric-cone.test.ts` | Development vs. independently known values (`docs/eccentric-cone.md` § 7) | +| `math/validation.test.ts` | Invalid and boundary input for every mode | +| `utils/pattern-dimensions.test.ts` | Bend-line spacing dimensions in all three shape modes | +| `shareLink.test.ts` | Share-link round trip, real link payload, hostile input | + +Reference values are derived by hand from the closed-form relations in +`docs/calculations.md`, never by pasting the engine's own output. The cylinder +and cone reference cases are also published as validation cases on the +[documentation page](https://cadautoscript.com/docs/utilities/cylindrical-shell-rolling), +so change both together. ## Stack versions (declared) @@ -93,10 +109,12 @@ Before merging calculation or export changes: - [ ] DXF opens in a CAD tool with expected mm scale - [ ] STEP opens in a CAD tool as an open-seam thick shell - [ ] Embed share restore (if host available) recomputes matching results -- [ ] `npm run lint`, `npm run verify` and `npm run build` succeed +- [ ] `npm run lint`, `npm test` and `npm run build` succeed ## Deployment +Pull requests run `.github/workflows/ci.yml`: `npm ci`, `npm test`, `npm run build`. + Workflow: `.github/workflows/deploy.yml` **Trigger:** push to `main` @@ -105,9 +123,10 @@ Workflow: `.github/workflows/deploy.yml` 1. Checkout source 2. Setup Node.js 22 -3. `npm ci` && `npm run build` -4. `mv dist/index.html dist/app.html` -5. Push `dist/` to `YurMil/cadautoscript.com` at +3. `npm ci` +4. `npm test` — a failing test stops the workflow before anything is built or published +5. `npm run build` && `mv dist/index.html dist/app.html` +6. Push `dist/` to `YurMil/cadautoscript.com` at `static/utility-apps/cylindrical-shell-rolling` using `DEPLOY_TOKEN` (`API_TOKEN_GITHUB`) diff --git a/docs/eccentric-cone.md b/docs/eccentric-cone.md index 49cbecb..fd573eb 100644 --- a/docs/eccentric-cone.md +++ b/docs/eccentric-cone.md @@ -357,7 +357,7 @@ Store (`useShellStore`) получает сеттеры `setEccentricity`, `setS — порядок ключей таблицы, `mode` первым: store пересчитывает результат на каждом сеттере, и остальные значения осмысленны только после выбора формы. -Проверка: `npm run verify:share-link` (полный round-trip, реальная ссылка +Проверка: `src/shareLink.test.ts`, `npm test` (полный round-trip, реальная ссылка с сайта, все значения перечислений, устойчивость к битой нагрузке). --- @@ -472,10 +472,10 @@ Store (`useShellStore`) получает сеттеры `setEccentricity`, `setS ## 7. Проверка -Автотест: `tools/verify-eccentric-cone.ts`, запуск +Автотест: `src/features/calculator/math/eccentric-cone.test.ts`, запуск ```bash -npm run verify:eccentric +npm test ``` Числовые сверки (сравнение с независимо известными величинами) и фактически @@ -522,7 +522,7 @@ npm run verify:eccentric | 5. DXF | `utils/dxf-writer.ts` (`addPolyline`, `addText`, `addAlignedDimension`, `generateEccentricConeDxf`), `utils/aligned-dimension.ts` | готово | | 6. PDF-отчёт с таблицей станций | `utils/eccentric-cone-report.ts` | готово | | 7. STEP | `cad/types/cad-types.ts`, `cad/geometry/build-eccentric-cone-solid.ts`, `cad-worker.ts` | готово | -| 8. Проверки § 7, share-link, сборка | `tools/verify-eccentric-cone.ts`, `tools/verify-share-link.ts`, `shareLink.ts` | готово | +| 8. Проверки § 7, share-link, сборка | `math/eccentric-cone.test.ts`, `shareLink.test.ts`, `shareLink.ts` | готово | Реализованные отличия от первоначального плана: diff --git a/docs/export-formats.md b/docs/export-formats.md index fe312c1..8e5bfda 100644 --- a/docs/export-formats.md +++ b/docs/export-formats.md @@ -144,7 +144,7 @@ enumerated values are validated against the `SHAPE_TYPES` / `SPEC_TYPES` / `SEAM_POSITIONS` tuples the unions are derived from rather than a repeated allow-list. -Implementation: `src/shareLink.ts`. Round-trip checks: `npm run verify:share-link`. +Implementation: `src/shareLink.ts`. Round-trip checks: `src/shareLink.test.ts` (`npm test`). --- diff --git a/package-lock.json b/package-lock.json index 62e8392..cd43fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "temp_project", - "version": "0.21.0", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "temp_project", - "version": "0.21.0", + "version": "0.22.0", "dependencies": { "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", @@ -37,7 +37,8 @@ "tailwindcss": "^3.4.17", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^5.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -84,6 +85,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1034,9 +1036,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1152,6 +1154,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -1603,6 +1606,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/draco3d": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", @@ -1629,6 +1650,7 @@ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1663,6 +1685,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1697,6 +1720,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -1765,6 +1789,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2028,6 +2053,44 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.69", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", @@ -2040,6 +2103,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2138,6 +2202,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.4.17", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", @@ -2289,6 +2363,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2401,6 +2476,16 @@ "node": ">=10.0.0" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2653,6 +2738,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2724,6 +2816,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2892,6 +2985,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2902,6 +3005,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3572,6 +3685,16 @@ "three": ">=0.134.0" } }, + "node_modules/magic-string": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3729,6 +3852,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/opentype.js": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz", @@ -3855,11 +3992,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3907,6 +4045,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -4115,6 +4254,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4124,6 +4264,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4383,6 +4524,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4393,6 +4541,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackblur-canvas": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", @@ -4429,6 +4584,13 @@ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string.prototype.codepointat": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", @@ -4630,7 +4792,8 @@ "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -4670,15 +4833,35 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4806,6 +4989,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4927,6 +5111,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5025,6 +5210,89 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/webgl-constants": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", @@ -5051,6 +5319,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5103,6 +5388,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index d03f586..f7c78a2 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "verify": "npm run verify:eccentric && npm run verify:bend-dimensions && npm run verify:share-link", - "verify:eccentric": "esbuild tools/verify-eccentric-cone.ts --bundle --platform=node --format=esm --outfile=node_modules/.cache/verify-eccentric-cone.mjs && node node_modules/.cache/verify-eccentric-cone.mjs", - "verify:bend-dimensions": "esbuild tools/verify-bend-dimensions.ts --bundle --platform=node --format=esm --outfile=node_modules/.cache/verify-bend-dimensions.mjs && node node_modules/.cache/verify-bend-dimensions.mjs", - "verify:share-link": "esbuild tools/verify-share-link.ts --bundle --platform=node --format=esm --outfile=node_modules/.cache/verify-share-link.mjs && node node_modules/.cache/verify-share-link.mjs", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@react-three/drei": "^10.7.7", @@ -43,6 +41,7 @@ "tailwindcss": "^3.4.17", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^5.0.0" } } diff --git a/src/features/calculator/math/cone.test.ts b/src/features/calculator/math/cone.test.ts new file mode 100644 index 0000000..6bb5f34 --- /dev/null +++ b/src/features/calculator/math/cone.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; +import { calculateShell } from './index'; +import { shellParams } from '../../../test/params'; +import type { ShellParameters } from '../types'; + +// A concentric frustum develops into an annular sector. With neutral radii +// R_L > R_S, height h and slant s = √((R_L − R_S)² + h²): +// +// outer development radius ρo = s·R_L / (R_L − R_S) +// inner development radius ρi = ρo − s +// sector angle θ = 360·R_L / ρo = 360·(R_L − R_S) / s +// +// The weld gap is removed as an arc length on the neutral fibre, i.e. at the +// mean radius (ρo + ρi) / 2. The blank reported for cutting is the minimum +// bounding rectangle plus a 10 mm cutting margin in each direction. + +const CUT_MARGIN = 10; + +const cone = (overrides: Partial = {}) => + calculateShell(shellParams({ mode: 'cone', ...overrides })); + +// Reference case: OD 2000 / 1500, t 15, K 0.44, h 2500, closed seam. +// Neutral diameters 1983.2 / 1483.2 → R_L = 991.6, R_S = 741.6, ΔR = 250. +const R_L = 991.6; +const R_S = 741.6; +const H = 2500; +const SLANT = Math.sqrt(250 ** 2 + H ** 2); // 2512.4689 mm +const RHO_OUT = (SLANT * R_L) / (R_L - R_S); // 9965.4567 mm +const RHO_IN = RHO_OUT - SLANT; // 7452.9878 mm +const THETA = (360 * (R_L - R_S)) / SLANT; // 35.8213° + +describe('concentric cone development', () => { + it('reference case: OD 2000 → 1500, h 2500, t 15, K 0.44', () => { + const result = cone({ gap: 0 }); + + expect(result.isValid).toBe(true); + expect(result.shape).toBe('sector'); + expect(result.d1_neutral).toBeCloseTo(1983.2, 9); + expect(result.d2_neutral).toBeCloseTo(1483.2, 9); + expect(result.rOut).toBeCloseTo(RHO_OUT, 6); + expect(result.rIn).toBeCloseTo(RHO_IN, 6); + expect(result.angle).toBeCloseTo(THETA, 9); + + // Published rounded values. + expect(result.rOut).toBeCloseTo(9965.46, 2); + expect(result.rIn).toBeCloseTo(7452.99, 2); + expect(result.angle).toBeCloseTo(35.821, 3); + }); + + it('both developed arcs equal the true neutral circumferences with a closed seam', () => { + const result = cone({ gap: 0 }); + const angleRad = (result.angle! * Math.PI) / 180; + + expect(result.rOut! * angleRad).toBeCloseTo(2 * Math.PI * R_L, 6); + expect(result.rIn! * angleRad).toBeCloseTo(2 * Math.PI * R_S, 6); + }); + + it('the seam edge is the slant height', () => { + const result = cone(); + expect(result.rOut! - result.rIn!).toBeCloseTo(SLANT, 9); + }); + + it('is independent of which end is on top', () => { + const wideTop = cone({ d1: 2000, d2: 1500 }); + const wideBottom = cone({ d1: 1500, d2: 2000 }); + + expect(wideBottom.rOut).toBeCloseTo(wideTop.rOut!, 9); + expect(wideBottom.rIn).toBeCloseTo(wideTop.rIn!, 9); + expect(wideBottom.angle).toBeCloseTo(wideTop.angle!, 9); + }); + + it('from ID adds 2·K·t to both diameters', () => { + const result = cone({ specType: 'ID' }); + expect(result.d1_neutral).toBeCloseTo(2013.2, 9); + expect(result.d2_neutral).toBeCloseTo(1513.2, 9); + }); + + it('a full cone (small end → 0) develops to a sector starting at the apex', () => { + // D2 such that R_S is small but the wall still fits; ρi stays ≥ 0. + const result = cone({ d1: 2000, d2: 100, thickness: 5, h: 1000 }); + expect(result.isValid).toBe(true); + expect(result.rIn!).toBeGreaterThanOrEqual(0); + expect(result.angle!).toBeLessThan(360); + }); +}); + +describe('cone weld gap', () => { + it('removes the gap as arc length on the neutral fibre', () => { + const gap = 6; + const result = cone({ gap }); + const rhoMid = (RHO_OUT + RHO_IN) / 2; + const expectedAngle = THETA - (gap / rhoMid) * (180 / Math.PI); + + expect(result.angle).toBeCloseTo(expectedAngle, 9); + + // Equivalently: the neutral arc is the mean circumference minus the gap, + // exactly as for a cylinder. + const neutralArc = ((result.angle! * Math.PI) / 180) * rhoMid; + expect(neutralArc).toBeCloseTo(Math.PI * (R_L + R_S) - gap, 6); + }); + + it('agrees with the cylinder in the near-cylindrical limit', () => { + const nearCylinder = cone({ d1: 2000, d2: 1998, gap: 2 }); + const cylinder = calculateShell(shellParams({ mode: 'cylinder', d1: 1999, gap: 2 })); + const rhoMid = (nearCylinder.rOut! + nearCylinder.rIn!) / 2; + const neutralArc = ((nearCylinder.angle! * Math.PI) / 180) * rhoMid; + + expect(neutralArc).toBeCloseTo(cylinder.flatLength, 6); + }); +}); + +describe('cone blank rectangle', () => { + it('is landscape and includes the cutting margin', () => { + const result = cone(); + expect(result.flatLength).toBeGreaterThanOrEqual(result.flatWidth); + expect(result.bboxWidth).toBe(result.flatLength); + expect(result.bboxHeight).toBe(result.flatWidth); + }); + + it('encloses the sector and is no larger than the upright bounding box', () => { + const result = cone({ gap: 0 }); + const half = (THETA * Math.PI) / 360; + + const blankArea = (result.flatLength - CUT_MARGIN) * (result.flatWidth - CUT_MARGIN); + const sectorArea = (THETA / 360) * Math.PI * (RHO_OUT ** 2 - RHO_IN ** 2); + // Sector symmetric about the x axis (θ < 180°): exact axis-aligned box. + const uprightArea = (RHO_OUT - RHO_IN * Math.cos(half)) * (2 * RHO_OUT * Math.sin(half)); + + expect(blankArea).toBeGreaterThan(sectorArea); + expect(blankArea).toBeLessThanOrEqual(uprightArea + 1e-6); + }); + + it('spaces bend lines evenly along the neutral arc', () => { + const result = cone({ bendLinesEnabled: true, bendLinesCount: 5, gap: 0 }); + const rhoMid = (RHO_OUT + RHO_IN) / 2; + const neutralArc = ((THETA * Math.PI) / 180) * rhoMid; + + expect(result.bendLines).toHaveLength(5); + expect(result.bendStep).toBeCloseTo(neutralArc / 6, 6); + }); +}); diff --git a/src/features/calculator/math/cylinder.test.ts b/src/features/calculator/math/cylinder.test.ts new file mode 100644 index 0000000..985ed68 --- /dev/null +++ b/src/features/calculator/math/cylinder.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { calculateShell } from './index'; +import { shellParams } from '../../../test/params'; + +// Reference values are derived by hand from the two relations the engine +// implements (docs/calculations.md), not by running the code: +// +// from OD: Dn = OD − 2·t·(1 − K) from ID: Dn = ID + 2·K·t +// developed length L = π·Dn − gap + +const cylinder = (overrides: Parameters[0] = {}) => + calculateShell(shellParams({ mode: 'cylinder', ...overrides })); + +describe('cylinder developed length', () => { + it('worked example: OD 2000, t 15, K 0.44, gap 2', () => { + // Dn = 2000 − 2·15·0.56 = 1983.2 mm + const result = cylinder(); + + expect(result.isValid).toBe(true); + expect(result.d1_neutral).toBeCloseTo(1983.2, 9); + expect(result.d2_neutral).toBeCloseTo(1983.2, 9); + expect(result.flatLength).toBeCloseTo(Math.PI * 1983.2 - 2, 9); + expect(result.flatLength).toBeCloseTo(6228.41, 2); + expect(result.flatWidth).toBe(2500); + expect(result.shape).toBe('rect'); + }); + + it('from ID: ID 2000, t 15, K 0.44, gap 2', () => { + // Dn = 2000 + 2·0.44·15 = 2013.2 mm + const result = cylinder({ specType: 'ID' }); + + expect(result.isValid).toBe(true); + expect(result.d1_neutral).toBeCloseTo(2013.2, 9); + expect(result.flatLength).toBeCloseTo(Math.PI * 2013.2 - 2, 9); + expect(result.flatLength).toBeCloseTo(6322.65, 2); + }); + + it('reading the same number as ID instead of OD adds exactly π·2t, independent of K', () => { + // Dn(ID) − Dn(OD) = 2Kt + 2t(1 − K) = 2t, so ΔL = 2πt = 94.25 mm at t = 15. + for (const kFactor of [0.33, 0.44, 0.5, 1]) { + const od = cylinder({ specType: 'OD', kFactor }); + const id = cylinder({ specType: 'ID', kFactor }); + expect(id.flatLength - od.flatLength).toBeCloseTo(2 * Math.PI * 15, 9); + } + expect(cylinder({ specType: 'ID' }).flatLength - cylinder().flatLength).toBeCloseTo(94.25, 2); + }); + + it('wall thickness 15 → 20 mm shortens an OD-specified blank', () => { + // Dn = 2000 − 2·20·0.56 = 1977.6 mm + const result = cylinder({ thickness: 20 }); + expect(result.flatLength).toBeCloseTo(Math.PI * 1977.6 - 2, 9); + expect(result.flatLength).toBeCloseTo(6210.81, 2); + }); + + it('the weld gap is subtracted one-for-one', () => { + const base = cylinder({ gap: 2 }); + const wider = cylinder({ gap: 4 }); + const closed = cylinder({ gap: 0 }); + + expect(base.flatLength - wider.flatLength).toBeCloseTo(2, 9); + expect(closed.flatLength).toBeCloseTo(Math.PI * 1983.2, 9); + }); + + it('is unit-agnostic: scaling every length scales the blank', () => { + const mm = cylinder({ d1: 2000, thickness: 15, h: 2500, gap: 2 }); + const scaled = cylinder({ d1: 200, thickness: 1.5, h: 250, gap: 0.2 }); + expect(scaled.flatLength).toBeCloseTo(mm.flatLength / 10, 9); + }); +}); + +describe('K-factor', () => { + it('K 0.44 → 0.33 moves the neutral axis inwards', () => { + // Dn = 2000 − 2·15·0.67 = 1979.9 mm; ΔL = π·3.3 = 10.37 mm + const result = cylinder({ kFactor: 0.33 }); + expect(result.flatLength).toBeCloseTo(Math.PI * 1979.9 - 2, 9); + expect(cylinder().flatLength - result.flatLength).toBeCloseTo(Math.PI * 3.3, 9); + }); + + it('K = 1 puts the neutral axis on the outer surface', () => { + expect(cylinder({ specType: 'OD', kFactor: 1 }).d1_neutral).toBeCloseTo(2000, 9); + expect(cylinder({ specType: 'ID', kFactor: 1 }).d1_neutral).toBeCloseTo(2030, 9); + }); + + it('K → 0 approaches the inner surface', () => { + const result = cylinder({ specType: 'OD', kFactor: 1e-6 }); + expect(result.isValid).toBe(true); + expect(result.d1_neutral).toBeCloseTo(1970, 3); + }); + + it('the developed length grows linearly with K at 2πt per unit of K', () => { + const low = cylinder({ kFactor: 0.3 }); + const high = cylinder({ kFactor: 0.5 }); + expect(high.flatLength - low.flatLength).toBeCloseTo(2 * Math.PI * 15 * 0.2, 9); + }); +}); + +describe('bend lines', () => { + it('divides the blank into equal spaces', () => { + const result = cylinder({ bendLinesEnabled: true, bendLinesCount: 5 }); + + expect(result.bendLines).toHaveLength(5); + expect(result.bendStep).toBeCloseTo(result.flatLength / 6, 9); + }); + + it('reports no bend lines when the option is off', () => { + const result = cylinder({ bendLinesEnabled: false, bendLinesCount: 5 }); + expect(result.bendLines).toEqual([]); + expect(result.bendStep).toBeUndefined(); + }); + + it('centres the blank bounding box on the origin', () => { + const result = cylinder(); + expect(result.bboxWidth).toBeCloseTo(result.flatLength, 9); + expect(result.bboxHeight).toBe(2500); + expect(result.bboxMinX).toBeCloseTo(-result.flatLength / 2, 9); + expect(result.bboxMinY).toBe(-1250); + }); +}); diff --git a/src/features/calculator/math/eccentric-cone.test.ts b/src/features/calculator/math/eccentric-cone.test.ts new file mode 100644 index 0000000..8627810 --- /dev/null +++ b/src/features/calculator/math/eccentric-cone.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { calculateShell } from './index'; +import { shellParams } from '../../../test/params'; +import type { ShellParameters } from '../types'; + +// Numerical verification of the eccentric cone development +// (docs/eccentric-cone.md § 7). The reference values are independent of the +// integrator: closed-form cone relations, circle arc lengths, 3D ruling lengths +// and an elliptic integral evaluated separately below. + +const develop = (overrides: Partial) => { + const result = calculateShell( + shellParams({ + mode: 'eccentric-cone', + d1: 1500, + d2: 2000, + h: 2500, + // A vanishing wall makes the neutral diameters equal the inputs. + thickness: 0.0001, + kFactor: 0.5, + gap: 0, + eccentricity: 0, + ...overrides + }) + ); + if (!result.isValid || !result.eccentric) { + throw new Error(`Invalid result: ${result.error ?? 'no development'}`); + } + return { result, dev: result.eccentric }; +}; + +const maxOf = (values: number[]) => values.reduce((max, value) => Math.max(max, value), 0); + +describe('eccentricity 0 reproduces the straight cone', () => { + const { dev } = develop({}); + const cone = calculateShell( + shellParams({ mode: 'cone', d1: 1500, d2: 2000, h: 2500, thickness: 0.0001, kFactor: 0.5, gap: 0 }) + ); + + const slant = Math.hypot(dev.rBottom - dev.rTop, 2500); + const rhoOuter = (slant * dev.rBottom) / (dev.rBottom - dev.rTop); + const rhoInner = rhoOuter - slant; + + it('matches the concentric development radii', () => { + expect(Math.abs(rhoOuter - cone.rOut!)).toBeLessThan(1e-9); + expect(Math.abs(rhoInner - cone.rIn!)).toBeLessThan(1e-9); + }); + + it('develops both edges as perfect arcs around the apex', () => { + const a0 = dev.bottomEdge[0]; + const b0 = dev.topEdge[0]; + const cx = a0.x + ((b0.x - a0.x) / slant) * rhoOuter; + const cy = a0.y + ((b0.y - a0.y) / slant) * rhoOuter; + + const outer = dev.bottomEdge.map(p => Math.abs(Math.hypot(p.x - cx, p.y - cy) - rhoOuter)); + const inner = dev.topEdge.map(p => Math.abs(Math.hypot(p.x - cx, p.y - cy) - rhoInner)); + + expect(maxOf(outer)).toBeLessThan(1e-6); + expect(maxOf(inner)).toBeLessThan(1e-6); + }); + + it('gives the same sector angle and the frustum lateral area π·(R1 + R2)·s', () => { + const sectorAngleDeg = (dev.totalBottomArc / rhoOuter) * (180 / Math.PI); + expect(Math.abs(sectorAngleDeg - cone.angle!)).toBeLessThan(1e-9); + expect(Math.abs(dev.surfaceArea - Math.PI * (dev.rBottom + dev.rTop) * slant)).toBeLessThan(1e-6); + }); +}); + +describe('equal diameters develop an oblique cylinder', () => { + const { dev } = develop({ d1: 1000, d2: 1000, h: 1000, eccentricity: 300 }); + const ruling = Math.hypot(300, 1000); + + it('has constant ruling length and full circumference', () => { + expect(dev.totalBottomArc).toBeCloseTo(2 * Math.PI * dev.rBottom, 9); + expect(dev.minRuling.length).toBeCloseTo(ruling, 9); + expect(dev.maxRuling.length).toBeCloseTo(ruling, 9); + }); + + it('keeps all rulings parallel in the flat pattern', () => { + const dir = { + x: dev.topEdge[0].x - dev.bottomEdge[0].x, + y: dev.topEdge[0].y - dev.bottomEdge[0].y + }; + const cross = dev.bottomEdge.map((bottom, i) => { + const dx = dev.topEdge[i].x - bottom.x; + const dy = dev.topEdge[i].y - bottom.y; + return Math.abs(dir.x * dy - dir.y * dx) / ruling; + }); + expect(maxOf(cross)).toBeLessThan(1e-6); + }); + + it('has lateral area = right-section ellipse perimeter × ruling', () => { + // Independent reference: composite Simpson on the elliptic integral. + const tilt = 300 / ruling; + const steps = 200000; + const step = (2 * Math.PI) / steps; + const f = (phi: number) => Math.sqrt(1 - tilt * tilt * Math.sin(phi) ** 2); + let integral = f(0) + f(2 * Math.PI); + for (let i = 1; i < steps; i += 1) { + integral += (i % 2 === 0 ? 2 : 4) * f(i * step); + } + integral = (integral * step) / 3; + + expect(Math.abs(dev.surfaceArea - dev.rBottom * integral * ruling)).toBeLessThan(1e-6); + }); +}); + +describe('genuinely eccentric cone is an isometry', () => { + const e = 550; + const h = 1470; + const { dev } = develop({ d1: 1360, d2: 2460, h, eccentricity: e, thickness: 20, kFactor: 0.44 }); + + it('preserves both edge lengths', () => { + expect(dev.totalBottomArc).toBeCloseTo(2 * Math.PI * dev.rBottom, 9); + expect(dev.totalTopArc).toBeCloseTo(2 * Math.PI * dev.rTop, 9); + }); + + it('inscribes the contour polyline within 0.02 mm of the true arcs', () => { + const length = (points: { x: number; y: number }[]) => + points.slice(1).reduce((sum, p, i) => sum + Math.hypot(p.x - points[i].x, p.y - points[i].y), 0); + + const bottomError = dev.totalBottomArc - length(dev.bottomEdge); + const topError = dev.totalTopArc - length(dev.topEdge); + + expect(bottomError).toBeGreaterThanOrEqual(0); + expect(bottomError).toBeLessThan(0.02); + expect(topError).toBeGreaterThanOrEqual(0); + expect(topError).toBeLessThan(0.02); + }); + + it('lays every ruling out at its true 3D length', () => { + const errors = dev.stations.map(station => { + const phi = (station.phiDeg * Math.PI) / 180; + const vx = e + (dev.rTop - dev.rBottom) * Math.cos(phi); + const vy = (dev.rTop - dev.rBottom) * Math.sin(phi); + const expected = Math.hypot(vx, vy, h); + const planar = Math.hypot(station.top.x - station.bottom.x, station.top.y - station.bottom.y); + return Math.abs(planar - expected); + }); + + expect(maxOf(errors)).toBeLessThan(1e-6); + expect(dev.integrationError).toBeLessThan(1e-6); + }); +}); + +describe('eccentric cone weld gap', () => { + it('removes the gap as the mean of the two edge reductions', () => { + const gap = 6; + const { dev } = develop({ d1: 1360, d2: 2460, h: 1470, eccentricity: 550, gap }); + + expect((dev.gapBottom + dev.gapTop) / 2).toBeCloseTo(gap, 9); + expect(dev.totalBottomArc).toBeCloseTo(2 * Math.PI * dev.rBottom - dev.gapBottom, 9); + expect(dev.totalTopArc).toBeCloseTo(2 * Math.PI * dev.rTop - dev.gapTop, 9); + }); +}); + +describe('seam placement', () => { + const shape = { d1: 1360, d2: 2460, h: 1470, eccentricity: 550 }; + + it('starts the short seam on the shortest ruling and the long seam on the longest', () => { + const short = develop({ ...shape, seamPosition: 'short' }).dev; + const long = develop({ ...shape, seamPosition: 'long' }).dev; + + expect(short.stations[0].rulingLength).toBeCloseTo(short.minRuling.length, 6); + expect(long.stations[0].rulingLength).toBeCloseTo(long.maxRuling.length, 6); + expect(Math.abs(short.surfaceArea - long.surfaceArea)).toBeLessThan(1e-3); + }); + + it('moves the short ruling to 180° when the wide end is on top', () => { + const flipped = { ...shape, d1: 2460, d2: 1360 }; + const short = develop({ ...flipped, seamPosition: 'short' }).dev; + const long = develop({ ...flipped, seamPosition: 'long' }).dev; + + expect(short.stations[0].rulingLength).toBeCloseTo(short.minRuling.length, 6); + expect(long.stations[0].rulingLength).toBeCloseTo(long.maxRuling.length, 6); + expect(short.seamPhiDeg).toBeCloseTo(180, 9); + }); +}); diff --git a/src/features/calculator/math/validation.test.ts b/src/features/calculator/math/validation.test.ts new file mode 100644 index 0000000..30643b4 --- /dev/null +++ b/src/features/calculator/math/validation.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { calculateShell } from './index'; +import { shellParams } from '../../../test/params'; +import type { ShellParameters } from '../types'; + +const expectInvalid = (overrides: Partial, message: RegExp) => { + const result = calculateShell(shellParams(overrides)); + expect(result.isValid).toBe(false); + expect(result.error).toMatch(message); + return result; +}; + +describe('input validation', () => { + describe('non-positive and non-finite dimensions', () => { + const cases: Array<[string, Partial]> = [ + ['zero diameter', { d1: 0 }], + ['negative diameter', { d1: -2000 }], + ['zero height', { h: 0 }], + ['negative height', { h: -1 }], + ['zero thickness', { thickness: 0 }], + ['NaN thickness', { thickness: Number.NaN }], + ['infinite diameter', { d1: Infinity }] + ]; + + it.each(cases)('rejects %s', (_label, overrides) => { + expectInvalid(overrides, /positive numbers/); + }); + + it('rejects a non-positive D2 in cone modes only', () => { + expectInvalid({ mode: 'cone', d2: 0 }, /D2 must be positive/); + expectInvalid({ mode: 'eccentric-cone', d2: -1 }, /D2 must be positive/); + expect(calculateShell(shellParams({ mode: 'cylinder', d2: 0 })).isValid).toBe(true); + }); + + it('returns a zeroed result rather than partial numbers', () => { + const result = expectInvalid({ d1: 0 }, /positive numbers/); + expect(result.flatLength).toBe(0); + expect(result.d1_neutral).toBe(0); + }); + }); + + describe('K-factor range', () => { + it('accepts the closed upper bound K = 1', () => { + expect(calculateShell(shellParams({ kFactor: 1 })).isValid).toBe(true); + }); + + it('rejects K above 1', () => { + expectInvalid({ kFactor: 1.0001 }, /K-Factor/); + expectInvalid({ kFactor: 44 }, /K-Factor/); + }); + + it('rejects K = 0 and negative K', () => { + expectInvalid({ kFactor: 0 }, /positive numbers/); + expectInvalid({ kFactor: -0.44 }, /positive numbers/); + }); + }); + + describe('weld gap', () => { + it('accepts a zero gap', () => { + expect(calculateShell(shellParams({ gap: 0 })).isValid).toBe(true); + }); + + it('rejects negative and non-finite gaps', () => { + expectInvalid({ gap: -1 }, /welding gap/); + expectInvalid({ gap: Number.NaN }, /welding gap/); + expectInvalid({ gap: Infinity }, /welding gap/); + }); + + it('rejects a cylinder gap equal to or larger than the neutral circumference', () => { + const circumference = Math.PI * 1983.2; + expectInvalid({ gap: circumference }, /Gap is larger than circumference/); + expectInvalid({ gap: circumference + 1 }, /Gap is larger than circumference/); + expect(calculateShell(shellParams({ gap: circumference - 1 })).isValid).toBe(true); + }); + + it('rejects a cone gap that consumes the whole development angle', () => { + expectInvalid({ mode: 'cone', gap: 1e6 }, /Gap is too large/); + }); + }); + + describe('physical wall thickness', () => { + it('rejects a wall at least as thick as the neutral radius', () => { + // OD 100, t 40, K 0.5: Dn = 100 − 40 = 60, r = 30 ≤ t + expectInvalid({ d1: 100, thickness: 40, kFactor: 0.5 }, /Thickness is too large/); + }); + + it('checks the small end of a cone as well', () => { + // D2: Dn = 60 − 2·25·0.56 = 32, r = 16 ≤ t = 25 while D1 is fine + expectInvalid({ mode: 'cone', d1: 2000, d2: 60, thickness: 25 }, /Thickness is too large/); + }); + + it('rejects an OD so small that the neutral diameter is negative', () => { + expect(calculateShell(shellParams({ d1: 10, thickness: 15 })).isValid).toBe(false); + }); + }); + + describe('mode-specific guards', () => { + it('refuses a cone whose diameters are practically equal', () => { + expectInvalid({ mode: 'cone', d1: 2000, d2: 2000.5 }, /Diameters too similar/); + }); + + it('refuses an eccentric cone that is really a cylinder', () => { + expectInvalid({ mode: 'eccentric-cone', d1: 2000, d2: 2000, eccentricity: 0 }, /use Cylinder mode/); + }); + + it('rejects a non-finite eccentricity', () => { + expectInvalid({ mode: 'eccentric-cone', eccentricity: Number.NaN }, /eccentricity/i); + }); + }); +}); diff --git a/src/shareLink.test.ts b/src/shareLink.test.ts new file mode 100644 index 0000000..ec57ec8 --- /dev/null +++ b/src/shareLink.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { applySharedState, collectParams } from './shareLink'; +import { useShellStore } from './store/useShellStore'; +import { SEAM_POSITIONS, SHAPE_TYPES, SPEC_TYPES } from './features/calculator/types'; + +// Round-trip checks for the share-link protocol. + +const PARAMETER_KEYS = [ + 'mode', 'specType', 'd1', 'd2', 'h', 'thickness', 'kFactor', 'gap', + 'bendLinesEnabled', 'bendLinesCount', 'eccentricity', 'seamPosition', + 'seamAngleDeg', 'stationCount', 'density', 'bendDimensionsEnabled', + 'bendDimensionOffset' +]; + +const resetToDefaults = () => { + const store = useShellStore.getState(); + store.setMode('cylinder'); + store.setSpecType('OD'); + store.setD1(2000); + store.setD2(1500); + store.setHeight(2500); + store.setThickness(15); + store.setKFactor(0.44); + store.setGap(2); + store.setBendLinesEnabled(false); + store.setBendLinesCount(0); + store.setEccentricity(250); + store.setSeamPosition('short'); + store.setSeamAngle(0); + store.setStationCount(24); + store.setDensity(7850); + store.setBendDimensionsEnabled(false); + store.setBendDimensionOffset(120); +}; + +beforeEach(resetToDefaults); + +describe('payload', () => { + it('contains every input parameter, and nothing else', () => { + // Mirrors the compile-time `keyof ShellParameters` guarantee at runtime + // and catches a parameter the store stopped exposing. + const payload = collectParams() as Record; + + expect(Object.keys(payload).sort()).toEqual([...PARAMETER_KEYS].sort()); + expect(PARAMETER_KEYS.filter(key => payload[key] === undefined)).toEqual([]); + }); +}); + +describe('round trip', () => { + it('restores an eccentric cone configuration field for field', () => { + const store = useShellStore.getState(); + store.setMode('eccentric-cone'); + store.setSpecType('ID'); + store.setD1(1360); + store.setD2(2460); + store.setHeight(1470); + store.setThickness(20); + store.setKFactor(0.42); + store.setGap(3); + store.setBendLinesEnabled(true); + store.setEccentricity(550); + store.setSeamPosition('custom'); + store.setSeamAngle(37); + store.setStationCount(36); + store.setDensity(7900); + store.setBendDimensionsEnabled(true); + store.setBendDimensionOffset(80); + + const shared = collectParams(); + resetToDefaults(); + expect(useShellStore.getState().mode).toBe('cylinder'); + + applySharedState(JSON.parse(JSON.stringify(shared))); + + expect(collectParams()).toEqual(shared); + expect(useShellStore.getState().results.isValid).toBe(true); + expect(useShellStore.getState().results.eccentric).toBeDefined(); + }); + + it('decodes a real link produced by the utility shell', () => { + // Payload of https://cadautoscript.com/utilities/cylindrical-shell-rolling/?calc=... + const encoded = 'eyJ2IjoxLCJzIjp7Im1vZGUiOiJlY2NlbnRyaWMtY29uZSIsInNwZWNUeXBlIjoiT0QiLCJkMSI6MzAwMCwiZDIiOjUwMCwiaCI6MjUwMCwidGhpY2tuZXNzIjoxNSwia0ZhY3RvciI6MC40NCwiZ2FwIjoyLCJiZW5kTGluZXNFbmFibGVkIjpmYWxzZSwiYmVuZExpbmVzQ291bnQiOjAsImVjY2VudHJpY2l0eSI6MjUwLCJzZWFtUG9zaXRpb24iOiJzaG9ydCIsInNlYW1BbmdsZURlZyI6MCwic3RhdGlvbkNvdW50IjoyNCwiZGVuc2l0eSI6Nzg1MCwiYmVuZERpbWVuc2lvbnNFbmFibGVkIjpmYWxzZSwiYmVuZERpbWVuc2lvbk9mZnNldCI6MTIwfX0'; + const decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { + v: number; + s: Record; + }; + + applySharedState(decoded.s); + const state = useShellStore.getState(); + + expect(decoded.v).toBe(1); + expect(state.mode).toBe('eccentric-cone'); + expect(state.d1).toBe(3000); + expect(state.d2).toBe(500); + expect(state.h).toBe(2500); + expect(state.eccentricity).toBe(250); + expect(state.stationCount).toBe(24); + expect(state.results.isValid).toBe(true); + }); + + it.each(SHAPE_TYPES)('keeps mode "%s"', mode => { + applySharedState({ mode }); + expect(useShellStore.getState().mode).toBe(mode); + }); + + it.each(SPEC_TYPES)('keeps specType "%s"', specType => { + applySharedState({ specType }); + expect(useShellStore.getState().specType).toBe(specType); + }); + + it.each(SEAM_POSITIONS)('keeps seamPosition "%s"', seamPosition => { + applySharedState({ seamPosition }); + expect(useShellStore.getState().seamPosition).toBe(seamPosition); + }); +}); + +describe('hostile payloads', () => { + it('degrade per field instead of rejecting the whole link', () => { + applySharedState({ + mode: 'trapezoid', + specType: 42, + d1: 'huge', + h: Number.NaN, + thickness: Infinity, + stationCount: 99999, + seamPosition: null, + somethingElse: { nested: true }, + d2: 1800 + }); + + const state = useShellStore.getState(); + expect(state.mode).toBe('cylinder'); + expect(state.specType).toBe('OD'); + expect(state.d1).toBe(2000); + expect(state.h).toBe(2500); + expect(state.thickness).toBe(15); + expect(state.stationCount).toBe(360); + expect(state.seamPosition).toBe('short'); + expect(state.d2).toBe(1800); + }); + + it('ignores payloads that are not objects', () => { + applySharedState(null); + applySharedState('nope'); + expect(useShellStore.getState().mode).toBe('cylinder'); + }); +}); diff --git a/src/test/params.ts b/src/test/params.ts new file mode 100644 index 0000000..725b245 --- /dev/null +++ b/src/test/params.ts @@ -0,0 +1,30 @@ +import type { ShellParameters } from '../features/calculator/types'; + +/** + * Baseline for the engine tests: the DN 2000 / 15 mm shell used as the worked + * example on the documentation page. Each test overrides only what it varies. + */ +export const BASE_PARAMS: ShellParameters = { + mode: 'cylinder', + specType: 'OD', + d1: 2000, + d2: 1500, + h: 2500, + thickness: 15, + kFactor: 0.44, + gap: 2, + bendLinesEnabled: false, + bendLinesCount: 0, + eccentricity: 250, + seamPosition: 'short', + seamAngleDeg: 0, + stationCount: 24, + density: 7850, + bendDimensionsEnabled: false, + bendDimensionOffset: 120 +}; + +export const shellParams = (overrides: Partial = {}): ShellParameters => ({ + ...BASE_PARAMS, + ...overrides +}); diff --git a/src/utils/pattern-dimensions.test.ts b/src/utils/pattern-dimensions.test.ts new file mode 100644 index 0000000..e4e1df0 --- /dev/null +++ b/src/utils/pattern-dimensions.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { calculateShell } from '../features/calculator/math'; +import type { ShellParameters } from '../features/calculator/types'; +import { shellParams } from '../test/params'; +import { buildPatternDimensions } from './pattern-dimensions'; +import { getPatternStations } from './pattern-stations'; + +// Bend-line spacing dimensions across all three shape modes. + +const build = (overrides: Partial) => { + const params = shellParams({ + bendLinesEnabled: true, + bendLinesCount: 5, + bendDimensionsEnabled: true, + ...overrides + }); + const result = calculateShell(params); + if (!result.isValid) throw new Error(`Invalid result: ${result.error}`); + + return { + result, + stations: getPatternStations(params, result), + dimensions: buildPatternDimensions(params, result, 20) + }; +}; + +describe('cylinder', () => { + const { result, stations, dimensions } = build({ mode: 'cylinder' }); + + it('emits one spacing run plus both seam edges', () => { + // 5 bend lines → 7 stations (seam edges included) → 6 spaces. + expect(stations).toHaveLength(7); + expect(dimensions).toHaveLength(8); + }); + + it('spaces equal bendStep and add up to the blank length', () => { + const spacings = dimensions.slice(0, 6).map(d => d.length); + for (const spacing of spacings) { + expect(spacing).toBeCloseTo(result.bendStep!, 9); + } + expect(spacings.reduce((a, b) => a + b, 0)).toBeCloseTo(result.flatLength, 9); + }); + + it('dimensions both seam edges at the blank height', () => { + expect(dimensions[6].length).toBeCloseTo(result.flatWidth, 9); + expect(dimensions[7].length).toBeCloseTo(result.flatWidth, 9); + }); +}); + +describe('straight cone', () => { + const { result, stations, dimensions } = build({ mode: 'cone' }); + const outer = dimensions.filter((_, i) => i < 12 && i % 2 === 0).map(d => d.length); + const inner = dimensions.filter((_, i) => i < 12 && i % 2 === 1).map(d => d.length); + + it('runs both arcs, since their chords differ', () => { + expect(stations).toHaveLength(7); + expect(dimensions).toHaveLength(14); + expect(outer.every(value => value > inner[0])).toBe(true); + }); + + it('keeps chords equal along each arc', () => { + expect(Math.max(...outer) - Math.min(...outer)).toBeLessThan(1e-9); + expect(Math.max(...inner) - Math.min(...inner)).toBeLessThan(1e-9); + }); + + it('brackets the neutral-fibre bendStep between the two chords', () => { + const mid = (outer[0] + inner[0]) / 2; + expect(Math.abs(mid - result.bendStep!)).toBeLessThanOrEqual(result.bendStep! * 0.002); + }); + + it('dimensions both seam edges at the slant height', () => { + const slant = result.rOut! - result.rIn!; + expect(dimensions[12].length).toBeCloseTo(slant, 9); + expect(dimensions[13].length).toBeCloseTo(slant, 9); + }); +}); + +describe('eccentric cone', () => { + const { result, stations, dimensions } = build({ mode: 'eccentric-cone', stationCount: 12 }); + const development = result.eccentric!; + + it('follows the station count', () => { + expect(stations).toHaveLength(13); + expect(dimensions).toHaveLength(26); + }); + + it('dimensions the seam edge at the seam ruling length', () => { + expect(dimensions[24].length).toBeCloseTo(development.stations[0].rulingLength, 9); + }); + + it('inscribes the chord chains within 0.3 % of the developed arcs', () => { + const bottom = dimensions.filter((_, i) => i < 24 && i % 2 === 0).reduce((a, d) => a + d.length, 0); + const top = dimensions.filter((_, i) => i < 24 && i % 2 === 1).reduce((a, d) => a + d.length, 0); + + expect(bottom).toBeLessThan(development.totalBottomArc); + expect(top).toBeLessThan(development.totalTopArc); + expect(Math.abs(bottom - development.totalBottomArc)).toBeLessThanOrEqual(development.totalBottomArc * 0.003); + }); +}); + +describe('toggles', () => { + it.each(['cylinder', 'cone', 'eccentric-cone'] as const)('%s: no dimensions when disabled or without bend lines', mode => { + expect(build({ mode, bendDimensionsEnabled: false }).dimensions).toHaveLength(0); + expect(build({ mode, bendLinesEnabled: false }).dimensions).toHaveLength(0); + }); + + it('cylinder without bend lines: one span plus two seam edges', () => { + expect(build({ mode: 'cylinder', bendLinesCount: 0 }).dimensions).toHaveLength(3); + }); +}); + +describe('dimension placement', () => { + const { result, dimensions } = build({ mode: 'cylinder' }); + + it('draws each dimension line at the measured length, readable and complete', () => { + for (const dimension of dimensions) { + const { x1, y1, x2, y2 } = dimension.line; + expect(Math.hypot(x2 - x1, y2 - y1)).toBeCloseTo(dimension.length, 9); + expect(dimension.extensions).toHaveLength(2); + expect(dimension.ticks).toHaveLength(2); + expect(dimension.angleDeg).toBeGreaterThanOrEqual(-90); + expect(dimension.angleDeg).toBeLessThanOrEqual(90); + } + }); + + it('offsets the bottom run outside the blank', () => { + expect(dimensions[0].line.y1).toBeLessThan(-result.flatWidth / 2); + }); +}); diff --git a/tools/verify-bend-dimensions.ts b/tools/verify-bend-dimensions.ts deleted file mode 100644 index ec1703b..0000000 --- a/tools/verify-bend-dimensions.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Bend-line spacing dimensions across all three shape modes. - * - * Run with: npm run verify:bend-dimensions - */ - -import { calculateShell } from '../src/features/calculator/math'; -import type { ShellParameters } from '../src/features/calculator/types'; -import { buildPatternDimensions } from '../src/utils/pattern-dimensions'; -import { getPatternStations } from '../src/utils/pattern-stations'; - -const baseParams: ShellParameters = { - mode: 'cylinder', - specType: 'OD', - d1: 2000, - d2: 1500, - h: 2500, - thickness: 15, - kFactor: 0.44, - gap: 2, - bendLinesEnabled: true, - bendLinesCount: 5, - eccentricity: 250, - seamPosition: 'short', - seamAngleDeg: 0, - stationCount: 24, - density: 7850, - bendDimensionsEnabled: true, - bendDimensionOffset: 120 -}; - -let failures = 0; - -const report = (ok: boolean, label: string, detail = '') => { - if (!ok) failures += 1; - console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? `\n ${detail}` : ''}`); -}; - -const close = (label: string, actual: number, expected: number, tolerance: number) => { - const delta = Math.abs(actual - expected); - report(delta <= tolerance, label, delta <= tolerance ? '' : `actual=${actual} expected=${expected} |d|=${delta}`); -}; - -const build = (overrides: Partial) => { - const params = { ...baseParams, ...overrides }; - const result = calculateShell(params); - if (!result.isValid) throw new Error(`Invalid result: ${result.error}`); - - const stations = getPatternStations(params, result); - const dimensions = buildPatternDimensions(params, result, 20); - return { params, result, stations, dimensions }; -}; - -console.log('\n=== 1. Cylinder ==='); -{ - const { result, stations, dimensions } = build({ mode: 'cylinder' }); - - // 5 bend lines -> 7 stations (both seam edges included) -> 6 gaps. - report(stations.length === 7, 'stations = bend lines + 2 seam edges', `got ${stations.length}`); - // One edge only (the two edges of a rectangle are parallel) + 2 seam edges. - report(dimensions.length === 8, 'one dimension run plus both seam edges', `got ${dimensions.length}`); - - const spacings = dimensions.slice(0, 6).map(dimension => dimension.length); - for (const [index, spacing] of spacings.entries()) { - close(`gap ${index + 1} equals bendStep`, spacing, result.bendStep!, 1e-9); - } - close('spacing chain covers the blank', spacings.reduce((a, b) => a + b, 0), result.flatLength, 1e-9); - close('first seam edge = blank height', dimensions[6].length, result.flatWidth, 1e-9); - close('last seam edge = blank height', dimensions[7].length, result.flatWidth, 1e-9); -} - -console.log('\n=== 2. Straight cone ==='); -{ - const { result, stations, dimensions } = build({ mode: 'cone' }); - - report(stations.length === 7, 'stations = bend lines + 2 seam edges', `got ${stations.length}`); - // Outer and inner arcs have different chord lengths, so both edges are run. - report(dimensions.length === 14, 'two dimension runs plus both seam edges', `got ${dimensions.length}`); - - const outer = dimensions.filter((_, index) => index < 12 && index % 2 === 0).map(d => d.length); - const inner = dimensions.filter((_, index) => index < 12 && index % 2 === 1).map(d => d.length); - - report(outer.every(value => value > inner[0]), 'outer chords are longer than inner ones'); - close('outer chords are equal', Math.max(...outer) - Math.min(...outer), 0, 1e-9); - close('inner chords are equal', Math.max(...inner) - Math.min(...inner), 0, 1e-9); - - // The neutral-fibre step reported in the UI sits between the two chords. - const mid = (outer[0] + inner[0]) / 2; - close('bendStep is bracketed by the two chords', mid, result.bendStep!, result.bendStep! * 0.002); - - const slant = result.rOut! - result.rIn!; - close('first seam edge = slant height', dimensions[12].length, slant, 1e-9); - close('last seam edge = slant height', dimensions[13].length, slant, 1e-9); -} - -console.log('\n=== 3. Eccentric cone ==='); -{ - const { result, stations, dimensions } = build({ mode: 'eccentric-cone', stationCount: 12 }); - - report(stations.length === 13, 'stations follow stationCount', `got ${stations.length}`); - report(dimensions.length === 26, 'two runs (12 gaps x 2) plus both seam edges', `got ${dimensions.length}`); - - const development = result.eccentric!; - close( - 'first seam edge = ruling length at the seam', - dimensions[24].length, - development.stations[0].rulingLength, - 1e-9 - ); - - // Chord runs must add up to slightly less than the exact developed arcs. - const bottom = dimensions.filter((_, index) => index < 24 && index % 2 === 0).reduce((a, d) => a + d.length, 0); - const top = dimensions.filter((_, index) => index < 24 && index % 2 === 1).reduce((a, d) => a + d.length, 0); - report(bottom < development.totalBottomArc, 'bottom chord chain is inscribed in the arc'); - report(top < development.totalTopArc, 'top chord chain is inscribed in the arc'); - close('bottom chain is within 0.3% of the arc', bottom, development.totalBottomArc, development.totalBottomArc * 0.003); -} - -console.log('\n=== 4. Toggles ==='); -{ - for (const mode of ['cylinder', 'cone', 'eccentric-cone'] as const) { - report( - build({ mode, bendDimensionsEnabled: false }).dimensions.length === 0, - `${mode}: no dimensions when the option is off` - ); - report( - build({ mode, bendLinesEnabled: false }).dimensions.length === 0, - `${mode}: no dimensions without bend lines` - ); - } - - // Without bend lines there is nothing between the seams, but the chain is - // still well formed for the modes that always sample stations. - const { dimensions } = build({ mode: 'cylinder', bendLinesCount: 0 }); - report(dimensions.length === 3, 'cylinder without bend lines: one span plus two seam edges', `got ${dimensions.length}`); -} - -console.log('\n=== 5. Dimension placement ==='); -{ - const { dimensions } = build({ mode: 'cylinder' }); - - for (const dimension of dimensions) { - const dx = dimension.line.x2 - dimension.line.x1; - const dy = dimension.line.y2 - dimension.line.y1; - report( - Math.abs(Math.hypot(dx, dy) - dimension.length) < 1e-9, - 'dimension line length matches the measured value' - ); - report(dimension.extensions.length === 2 && dimension.ticks.length === 2, 'extension lines and ticks are emitted'); - report(dimension.angleDeg >= -90 && dimension.angleDeg <= 90, 'text is never upside down', `${dimension.angleDeg}`); - break; - } - - // The run along the bottom edge must sit below the blank, not on top of it. - const { result } = build({ mode: 'cylinder' }); - const bottomRun = dimensions[0]; - report( - bottomRun.line.y1 < -result.flatWidth / 2, - 'the run is offset outside the blank', - `y=${bottomRun.line.y1} edge=${-result.flatWidth / 2}` - ); -} - -console.log(`\n${failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`}\n`); -process.exit(failures === 0 ? 0 : 1); diff --git a/tools/verify-eccentric-cone.ts b/tools/verify-eccentric-cone.ts deleted file mode 100644 index 8f6edef..0000000 --- a/tools/verify-eccentric-cone.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Numerical verification of the eccentric cone development (docs/eccentric-cone.md § 7). - * - * Run with: npm run verify:eccentric - */ - -import { calculateShell } from '../src/features/calculator/math'; -import type { ShellParameters } from '../src/features/calculator/types'; - -const baseParams: ShellParameters = { - mode: 'eccentric-cone', - specType: 'OD', - d1: 1500, - d2: 2000, - h: 2500, - thickness: 0.0001, - kFactor: 0.5, - gap: 0, - bendLinesEnabled: false, - bendLinesCount: 0, - eccentricity: 0, - seamPosition: 'short', - seamAngleDeg: 0, - stationCount: 24, - density: 7850, - bendDimensionsEnabled: false, - bendDimensionOffset: 120 -}; - -let failures = 0; - -const check = (label: string, actual: number, expected: number, tolerance: number) => { - const delta = Math.abs(actual - expected); - const ok = delta <= tolerance; - if (!ok) failures += 1; - console.log( - `${ok ? 'PASS' : 'FAIL'} ${label}\n actual=${actual.toPrecision(12)} expected=${expected.toPrecision(12)} |d|=${delta.toExponential(3)} tol=${tolerance.toExponential(3)}` - ); -}; - -const develop = (overrides: Partial) => { - const result = calculateShell({ ...baseParams, ...overrides }); - if (!result.isValid || !result.eccentric) { - throw new Error(`Invalid result: ${result.error ?? 'no development'}`); - } - return result; -}; - -console.log('\n=== 1. e = 0 must reproduce the straight cone sector ==='); -{ - const ecc = develop({}); - const dev = ecc.eccentric!; - const cone = calculateShell({ ...baseParams, mode: 'cone' }); - - const rBottom = dev.rBottom; - const rTop = dev.rTop; - const slant = Math.hypot(rBottom - rTop, baseParams.h); - const rhoOuter = (slant * rBottom) / (rBottom - rTop); - const rhoInner = rhoOuter - slant; - - check('development outer radius', rhoOuter, cone.rOut!, 1e-9); - check('development inner radius', rhoInner, cone.rIn!, 1e-9); - - // All bottom-edge points must sit on a circle of radius rhoOuter around the - // developed apex; the apex lies along the first ruling. - const a0 = dev.bottomEdge[0]; - const b0 = dev.topEdge[0]; - const ux = (b0.x - a0.x) / slant; - const uy = (b0.y - a0.y) / slant; - const cx = a0.x + ux * rhoOuter; - const cy = a0.y + uy * rhoOuter; - - let maxOuterDev = 0; - let maxInnerDev = 0; - for (let i = 0; i < dev.bottomEdge.length; i += 1) { - maxOuterDev = Math.max(maxOuterDev, Math.abs(Math.hypot(dev.bottomEdge[i].x - cx, dev.bottomEdge[i].y - cy) - rhoOuter)); - maxInnerDev = Math.max(maxInnerDev, Math.abs(Math.hypot(dev.topEdge[i].x - cx, dev.topEdge[i].y - cy) - rhoInner)); - } - check('bottom edge is a perfect arc', maxOuterDev, 0, 1e-6); - check('top edge is a perfect arc', maxInnerDev, 0, 1e-6); - - // Sector angle from the developed arc length. - const sectorAngleDeg = (dev.totalBottomArc / rhoOuter) * (180 / Math.PI); - check('sector angle vs cone module', sectorAngleDeg, cone.angle!, 1e-9); - - // Lateral area of a straight frustum: pi*(R1+R2)*slant. - check('lateral area', dev.surfaceArea, Math.PI * (rBottom + rTop) * slant, 1e-6); -} - -console.log('\n=== 2. Equal diameters => oblique cylinder ==='); -{ - const dev = develop({ d1: 1000, d2: 1000, h: 1000, eccentricity: 300 }).eccentric!; - const expectedRuling = Math.hypot(300, 1000); - - check('bottom developed arc', dev.totalBottomArc, 2 * Math.PI * dev.rBottom, 1e-9); - check('min ruling', dev.minRuling.length, expectedRuling, 1e-9); - check('max ruling', dev.maxRuling.length, expectedRuling, 1e-9); - - // All rulings are parallel in the development. - const dir0 = { - x: dev.topEdge[0].x - dev.bottomEdge[0].x, - y: dev.topEdge[0].y - dev.bottomEdge[0].y - }; - let maxCross = 0; - for (let i = 0; i < dev.bottomEdge.length; i += 1) { - const dx = dev.topEdge[i].x - dev.bottomEdge[i].x; - const dy = dev.topEdge[i].y - dev.bottomEdge[i].y; - maxCross = Math.max(maxCross, Math.abs(dir0.x * dy - dir0.y * dx) / expectedRuling); - } - check('rulings stay parallel', maxCross, 0, 1e-6); - - // The lateral area of an oblique cylinder is (perimeter of the RIGHT cross - // section) x (ruling length); the right section is an ellipse, so the - // reference value is an independent elliptic integral, evaluated here by - // composite Simpson with a very fine step. - const tilt = 300 / expectedRuling; - const simpsonSteps = 200000; - const stepSize = (2 * Math.PI) / simpsonSteps; - const f = (phi: number) => Math.sqrt(1 - tilt * tilt * Math.sin(phi) * Math.sin(phi)); - let integral = f(0) + f(2 * Math.PI); - for (let i = 1; i < simpsonSteps; i += 1) { - integral += (i % 2 === 0 ? 2 : 4) * f(i * stepSize); - } - integral = (integral * stepSize) / 3; - check('lateral area = right-section perimeter * ruling', dev.surfaceArea, dev.rBottom * integral * expectedRuling, 1e-6); -} - -console.log('\n=== 3. Isometry of a genuinely eccentric cone ==='); -{ - const result = develop({ d1: 1360, d2: 2460, h: 1470, eccentricity: 550, thickness: 20, kFactor: 0.44, specType: 'OD' }); - const dev = result.eccentric!; - - // Edge lengths of the development must equal the true circle arcs. - check('bottom arc = 2*pi*R_bottom', dev.totalBottomArc, 2 * Math.PI * dev.rBottom, 1e-9); - check('top arc = 2*pi*R_top', dev.totalTopArc, 2 * Math.PI * dev.rTop, 1e-9); - - // The polyline through the developed nodes is inscribed, so it must be - // slightly shorter than the true arc but converge to it. - let polyBottom = 0; - let polyTop = 0; - for (let i = 1; i < dev.bottomEdge.length; i += 1) { - polyBottom += Math.hypot(dev.bottomEdge[i].x - dev.bottomEdge[i - 1].x, dev.bottomEdge[i].y - dev.bottomEdge[i - 1].y); - polyTop += Math.hypot(dev.topEdge[i].x - dev.topEdge[i - 1].x, dev.topEdge[i].y - dev.topEdge[i - 1].y); - } - console.log(` contour polyline bottom=${polyBottom.toFixed(4)} (arc ${dev.totalBottomArc.toFixed(4)}), top=${polyTop.toFixed(4)} (arc ${dev.totalTopArc.toFixed(4)})`); - check('bottom contour chord error', dev.totalBottomArc - polyBottom, 0, 0.02); - check('top contour chord error', dev.totalTopArc - polyTop, 0, 0.02); - - // Ruling lengths in the development must match the 3D ruling lengths. - const rBottom = dev.rBottom; - const rTop = dev.rTop; - let maxRulingError = 0; - for (const station of dev.stations) { - const phi = (station.phiDeg * Math.PI) / 180; - const vx = 550 + (rTop - rBottom) * Math.cos(phi); - const vy = (rTop - rBottom) * Math.sin(phi); - const expected = Math.hypot(vx, vy, 1470); - const planar = Math.hypot(station.top.x - station.bottom.x, station.top.y - station.bottom.y); - maxRulingError = Math.max(maxRulingError, Math.abs(planar - expected)); - } - check('planar ruling == 3D ruling', maxRulingError, 0, 1e-6); - check('RK4 self-check residual', dev.integrationError, 0, 1e-6); - - console.log(` ruling min=${dev.minRuling.length.toFixed(3)} @ ${dev.minRuling.phiDeg.toFixed(1)}°, max=${dev.maxRuling.length.toFixed(3)} @ ${dev.maxRuling.phiDeg.toFixed(1)}°`); - console.log(` blank ${result.flatLength.toFixed(1)} x ${result.flatWidth.toFixed(1)} mm, rotation ${result.patternRotationDeg?.toFixed(2)}°`); -} - -console.log('\n=== 4. Weld gap ==='); -{ - const gap = 6; - const dev = develop({ d1: 1360, d2: 2460, h: 1470, eccentricity: 550, gap }).eccentric!; - check('average removed edge length', (dev.gapBottom + dev.gapTop) / 2, gap, 1e-9); - check('bottom arc reduced by gapBottom', dev.totalBottomArc, 2 * Math.PI * dev.rBottom - dev.gapBottom, 1e-9); - check('top arc reduced by gapTop', dev.totalTopArc, 2 * Math.PI * dev.rTop - dev.gapTop, 1e-9); -} - -console.log('\n=== 5. Seam placement ==='); -{ - const shortSeam = develop({ d1: 1360, d2: 2460, h: 1470, eccentricity: 550, seamPosition: 'short' }).eccentric!; - const longSeam = develop({ d1: 1360, d2: 2460, h: 1470, eccentricity: 550, seamPosition: 'long' }).eccentric!; - - check('short seam starts on the short ruling', shortSeam.stations[0].rulingLength, shortSeam.minRuling.length, 1e-6); - check('long seam starts on the long ruling', longSeam.stations[0].rulingLength, longSeam.maxRuling.length, 1e-6); - check('seam choice does not change the blank area', shortSeam.surfaceArea, longSeam.surfaceArea, 1e-3); - - // Same check with the wide end on top: the short ruling moves to phi = 180. - const flippedShort = develop({ d1: 2460, d2: 1360, h: 1470, eccentricity: 550, seamPosition: 'short' }).eccentric!; - const flippedLong = develop({ d1: 2460, d2: 1360, h: 1470, eccentricity: 550, seamPosition: 'long' }).eccentric!; - check('flipped: short seam picks the short ruling', flippedShort.stations[0].rulingLength, flippedShort.minRuling.length, 1e-6); - check('flipped: long seam picks the long ruling', flippedLong.stations[0].rulingLength, flippedLong.maxRuling.length, 1e-6); - check('flipped: short seam sits at 180 deg', flippedShort.seamPhiDeg, 180, 1e-9); -} - -console.log(`\n${failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`}\n`); -process.exit(failures === 0 ? 0 : 1); diff --git a/tools/verify-share-link.ts b/tools/verify-share-link.ts deleted file mode 100644 index fa11d09..0000000 --- a/tools/verify-share-link.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Round-trip checks for the share-link protocol (src/shareLink.ts). - * - * Run with: npm run verify:share-link - */ - -import { applySharedState, collectParams } from '../src/shareLink'; -import { useShellStore } from '../src/store/useShellStore'; -import { SEAM_POSITIONS, SHAPE_TYPES, SPEC_TYPES } from '../src/features/calculator/types'; - -let failures = 0; - -const report = (ok: boolean, label: string, detail = '') => { - if (!ok) failures += 1; - console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? `\n ${detail}` : ''}`); -}; - -const check = (label: string, actual: unknown, expected: unknown) => { - const ok = Object.is(actual, expected); - report(ok, label, ok ? '' : `actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)}`); -}; - -const resetToDefaults = () => { - const store = useShellStore.getState(); - store.setMode('cylinder'); - store.setSpecType('OD'); - store.setD1(2000); - store.setD2(1500); - store.setHeight(2500); - store.setThickness(15); - store.setKFactor(0.44); - store.setGap(2); - store.setBendLinesEnabled(false); - store.setBendLinesCount(0); - store.setEccentricity(250); - store.setSeamPosition('short'); - store.setSeamAngle(0); - store.setStationCount(24); - store.setDensity(7850); - store.setBendDimensionsEnabled(false); - store.setBendDimensionOffset(120); -}; - -console.log('\n=== 1. Every input parameter is part of the payload ==='); -{ - // The applier map is keyed by `keyof ShellParameters`, so this mirrors the - // compile-time guarantee at runtime and also catches a parameter that the - // store stopped exposing. - const payload = collectParams() as Record; - const expectedKeys = [ - 'mode', 'specType', 'd1', 'd2', 'h', 'thickness', 'kFactor', 'gap', - 'bendLinesEnabled', 'bendLinesCount', 'eccentricity', 'seamPosition', - 'seamAngleDeg', 'stationCount', 'density', 'bendDimensionsEnabled', - 'bendDimensionOffset' - ]; - - const missing = expectedKeys.filter(key => !(key in payload)); - const undefinedValues = expectedKeys.filter(key => payload[key] === undefined); - - report(missing.length === 0, 'no parameter is missing from the payload', missing.join(', ')); - report(undefinedValues.length === 0, 'no parameter serialises as undefined', undefinedValues.join(', ')); - check('payload has no extra keys', Object.keys(payload).length, expectedKeys.length); -} - -console.log('\n=== 2. Eccentric cone round trip ==='); -{ - const store = useShellStore.getState(); - store.setMode('eccentric-cone'); - store.setSpecType('ID'); - store.setD1(1360); - store.setD2(2460); - store.setHeight(1470); - store.setThickness(20); - store.setKFactor(0.42); - store.setGap(3); - store.setBendLinesEnabled(true); - store.setEccentricity(550); - store.setSeamPosition('custom'); - store.setSeamAngle(37); - store.setStationCount(36); - store.setDensity(7900); - store.setBendDimensionsEnabled(true); - store.setBendDimensionOffset(80); - - const shared = collectParams(); - resetToDefaults(); - check('state really was reset', useShellStore.getState().mode, 'cylinder'); - - applySharedState(JSON.parse(JSON.stringify(shared))); - const restored = collectParams() as Record; - - for (const [key, value] of Object.entries(shared as Record)) { - check(`restored ${key}`, restored[key], value); - } - - report(useShellStore.getState().results.isValid, 'restored configuration recalculates to a valid result'); - report( - useShellStore.getState().results.eccentric !== undefined, - 'restored configuration produces an eccentric development' - ); -} - -console.log('\n=== 3. The real link from the utility shell ==='); -{ - // Payload of https://cadautoscript.com/utilities/cylindrical-shell-rolling/?calc=... - const encoded = 'eyJ2IjoxLCJzIjp7Im1vZGUiOiJlY2NlbnRyaWMtY29uZSIsInNwZWNUeXBlIjoiT0QiLCJkMSI6MzAwMCwiZDIiOjUwMCwiaCI6MjUwMCwidGhpY2tuZXNzIjoxNSwia0ZhY3RvciI6MC40NCwiZ2FwIjoyLCJiZW5kTGluZXNFbmFibGVkIjpmYWxzZSwiYmVuZExpbmVzQ291bnQiOjAsImVjY2VudHJpY2l0eSI6MjUwLCJzZWFtUG9zaXRpb24iOiJzaG9ydCIsInNlYW1BbmdsZURlZyI6MCwic3RhdGlvbkNvdW50IjoyNCwiZGVuc2l0eSI6Nzg1MCwiYmVuZERpbWVuc2lvbnNFbmFibGVkIjpmYWxzZSwiYmVuZERpbWVuc2lvbk9mZnNldCI6MTIwfX0'; - const decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as { - v: number; - s: Record; - }; - - resetToDefaults(); - applySharedState(decoded.s); - - const state = useShellStore.getState(); - check('link schema version', decoded.v, 1); - check('mode', state.mode, 'eccentric-cone'); - check('d1', state.d1, 3000); - check('d2', state.d2, 500); - check('height', state.h, 2500); - check('eccentricity', state.eccentricity, 250); - check('station count', state.stationCount, 24); - report(state.results.isValid, 'link restores to a valid calculation', state.results.error ?? ''); -} - -console.log('\n=== 4. Every enumerated value survives a round trip ==='); -{ - for (const mode of SHAPE_TYPES) { - resetToDefaults(); - applySharedState({ mode }); - check(`mode "${mode}"`, useShellStore.getState().mode, mode); - } - - for (const specType of SPEC_TYPES) { - resetToDefaults(); - applySharedState({ specType }); - check(`specType "${specType}"`, useShellStore.getState().specType, specType); - } - - for (const seamPosition of SEAM_POSITIONS) { - resetToDefaults(); - applySharedState({ seamPosition }); - check(`seamPosition "${seamPosition}"`, useShellStore.getState().seamPosition, seamPosition); - } -} - -console.log('\n=== 5. Hostile payloads degrade per field ==='); -{ - resetToDefaults(); - applySharedState({ - mode: 'trapezoid', - specType: 42, - d1: 'huge', - h: Number.NaN, - thickness: Infinity, - stationCount: 99999, - seamPosition: null, - somethingElse: { nested: true }, - d2: 1800 - }); - - const state = useShellStore.getState(); - check('unknown mode ignored', state.mode, 'cylinder'); - check('non-string specType ignored', state.specType, 'OD'); - check('non-numeric d1 ignored', state.d1, 2000); - check('NaN height ignored', state.h, 2500); - check('infinite thickness ignored', state.thickness, 15); - check('out-of-range stationCount clamped', state.stationCount, 360); - check('null seamPosition ignored', state.seamPosition, 'short'); - check('valid field still applied', state.d2, 1800); - - applySharedState(null); - applySharedState('nope'); - report(useShellStore.getState().mode === 'cylinder', 'non-object payloads are no-ops'); -} - -console.log(`\n${failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`}\n`); -process.exit(failures === 0 ? 0 : 1); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4e9b4c5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// Kept separate from vite.config.ts: the engine tests are pure TypeScript and +// need neither the React plugin nor the app's build-time defines. +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'] + } +});