diff --git a/PTG/package.json b/PTG/package.json index d3f36ba..cdfed20 100644 --- a/PTG/package.json +++ b/PTG/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@tanstack/react-query": "^5.103.0", @@ -20,6 +22,9 @@ "@babel/core": "^7.29.7", "@eslint/js": "^10.0.1", "@rolldown/plugin-babel": "^0.2.3", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/babel__core": "^7.20.5", "@types/node": "^24.13.3", "@types/react": "^19.2.18", @@ -30,8 +35,10 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.4", "globals": "^17.11.0", + "jsdom": "^30.0.1", "typescript": "~6.0.2", "typescript-eslint": "^8.67.0", - "vite": "^8.2.2" + "vite": "^8.2.2", + "vitest": "^5.0.1" } } diff --git a/PTG/pnpm-lock.yaml b/PTG/pnpm-lock.yaml index 861f595..482e4e4 100644 --- a/PTG/pnpm-lock.yaml +++ b/PTG/pnpm-lock.yaml @@ -32,7 +32,16 @@ importers: version: 10.0.1(eslint@10.9.1) '@rolldown/plugin-babel': specifier: ^0.2.3 - version: 0.2.3(@babel/core@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)) + version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)) + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.1(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3))) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.7 + version: 14.6.7(@testing-library/dom@10.4.2) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -47,7 +56,7 @@ importers: version: 19.2.7(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.1.0 - version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)) + version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -63,6 +72,9 @@ importers: globals: specifier: ^17.11.0 version: 17.12.0 + jsdom: + specifier: ^30.0.1 + version: 30.0.1 typescript: specifier: ~6.0.2 version: 6.0.3 @@ -72,9 +84,23 @@ importers: vite: specifier: ^8.2.2 version: 8.2.2(@types/node@24.13.3) + vitest: + specifier: ^5.0.1 + version: 5.0.1(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -130,6 +156,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -142,6 +172,46 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.0': + resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.3': + resolution: {integrity: sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.14': + resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -181,6 +251,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -344,6 +423,44 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -356,6 +473,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -451,6 +574,20 @@ packages: oxc-transform-react: optional: true + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -464,6 +601,25 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} @@ -476,6 +632,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -488,6 +647,10 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -502,9 +665,20 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -514,16 +688,36 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.420: resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -581,10 +775,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -641,6 +842,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -653,6 +858,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -661,12 +870,24 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -771,9 +992,27 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@1.4.1: + resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -793,6 +1032,10 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -805,6 +1048,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -828,6 +1074,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -837,6 +1087,9 @@ packages: peerDependencies: react: ^19.2.8 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-router-dom@7.18.3: resolution: {integrity: sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==} engines: {node: '>=20.0.0'} @@ -868,11 +1121,23 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rolldown@1.2.7: resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -896,14 +1161,53 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tldts-core@7.4.13: + resolution: {integrity: sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==} + + tldts@7.4.13: + resolution: {integrity: sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -929,6 +1233,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + update-browserslist-db@1.3.2: resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true @@ -981,15 +1289,88 @@ packages: yaml: optional: true + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 + 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 + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.1: + resolution: {integrity: sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==} + engines: {node: ^22.14.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1008,6 +1389,23 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1085,6 +1483,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -1108,6 +1508,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1)': dependencies: eslint: 10.9.1 @@ -1142,6 +1570,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -1224,12 +1654,13 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.7': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3))': dependencies: '@babel/core': 7.29.7 picomatch: 4.0.7 rolldown: 1.2.7 optionalDependencies: + '@babel/runtime': 7.29.7 vite: 8.2.2(@types/node@24.13.3) '@rolldown/pluginutils@1.0.1': {} @@ -1241,6 +1672,45 @@ snapshots: '@tanstack/query-core': 5.103.0 react: 19.2.8 + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.1(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 5.0.1(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.8 @@ -1262,6 +1732,13 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -1371,14 +1848,25 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3))': + '@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.2.2(@types/node@24.13.3) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)) babel-plugin-react-compiler: 1.0.0 + '@vitest/mocker@5.0.1(vite@8.2.2(@types/node@24.13.3))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.1 + estree-walker: 3.0.3 + magic-string: 1.4.1 + optionalDependencies: + vite: 8.2.2(@types/node@24.13.3) + + '@vitest/spy@5.0.1': {} + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -1392,6 +1880,18 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.29.8 @@ -1400,6 +1900,10 @@ snapshots: baseline-browser-mapping@2.11.20: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -1414,6 +1918,8 @@ snapshots: caniuse-lite@1.0.30001810: {} + chai@6.2.2: {} + convert-source-map@2.0.0: {} cookie-es@3.1.1: {} @@ -1426,18 +1932,44 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.420: {} + entities@8.1.0: {} + + es-module-lexer@2.3.2: {} + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -1519,8 +2051,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -1564,22 +2102,58 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + ignore@5.3.2: {} ignore@7.0.8: {} imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + is-extglob@2.1.1: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} js-tokens@4.0.0: {} + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -1652,10 +2226,22 @@ snapshots: dependencies: p-locate: 5.0.0 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + + magic-string@1.4.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -1668,6 +2254,8 @@ snapshots: node-releases@2.0.54: {} + obug@2.2.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1685,6 +2273,10 @@ snapshots: dependencies: p-limit: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -1701,6 +2293,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + punycode@2.3.1: {} react-dom@19.2.8(react@19.2.8): @@ -1708,6 +2306,8 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 + react-is@17.0.2: {} + react-router-dom@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -1731,6 +2331,13 @@ snapshots: react@19.2.8: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + rolldown@1.2.7: dependencies: '@oxc-project/types': 0.148.0 @@ -1752,6 +2359,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -1766,13 +2377,43 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + symbol-tree@3.2.4: {} + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tldts-core@7.4.13: {} + + tldts@7.4.13: + dependencies: + tldts-core: 7.4.13 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.13 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -1796,6 +2437,8 @@ snapshots: undici-types@7.18.2: {} + undici@8.10.2: {} + update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: browserslist: 4.28.8 @@ -1817,12 +2460,67 @@ snapshots: '@types/node': 24.13.3 fsevents: 2.3.3 + vitest@5.0.1(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.13.3)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.1(vite@8.2.2(@types/node@24.13.3)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.2.2(@types/node@24.13.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/PTG/src/ChoosePokemon.test.tsx b/PTG/src/ChoosePokemon.test.tsx new file mode 100644 index 0000000..6ea6df8 --- /dev/null +++ b/PTG/src/ChoosePokemon.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ChoosePokemon from "./ChoosePokemon"; +import { fetchGames } from "./games"; +import { fetchTypes } from "./pokemonTypes"; +import { withQueryClient } from "./test/withQueryClient"; + +vi.mock("./games", () => ({ fetchGames: vi.fn() })); +vi.mock("./pokemonTypes", () => ({ fetchTypes: vi.fn() })); + +const mockedFetchGames = vi.mocked(fetchGames); +const mockedFetchTypes = vi.mocked(fetchTypes); + +afterEach(() => { + vi.resetAllMocks(); +}); + +function renderChoosePokemon(overrides: Partial[0]> = {}) { + const props = { + selectedGame: "", + selectedType: "any", + selectedName: "", + onGameChange: vi.fn(), + onTypeChange: vi.fn(), + onNameChange: vi.fn(), + ...overrides, + }; + render(, { wrapper: withQueryClient() }); + return props; +} + +describe("ChoosePokemon", () => { + it("lists games and types once loaded, with an 'Any Type' option prepended", async () => { + mockedFetchGames.mockResolvedValue([{ label: "Red Blue", value: "red-blue" }]); + mockedFetchTypes.mockResolvedValue([{ label: "Fire", value: "fire" }]); + + renderChoosePokemon(); + + expect(await screen.findByRole("option", { name: "Red Blue" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Any Game" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Any Type" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Fire" })).toBeInTheDocument(); + }); + + it("calls onNameChange as the user types", async () => { + mockedFetchGames.mockResolvedValue([]); + mockedFetchTypes.mockResolvedValue([]); + const user = userEvent.setup(); + + const props = renderChoosePokemon(); + await user.type(screen.getByPlaceholderText("Name..."), "pika"); + + expect(props.onNameChange).toHaveBeenCalledTimes(4); + expect(props.onNameChange).toHaveBeenLastCalledWith("a"); + }); + + it("calls onGameChange and onTypeChange when a select changes", async () => { + mockedFetchGames.mockResolvedValue([{ label: "Red Blue", value: "red-blue" }]); + mockedFetchTypes.mockResolvedValue([{ label: "Fire", value: "fire" }]); + const user = userEvent.setup(); + + const props = renderChoosePokemon(); + await screen.findByRole("option", { name: "Red Blue" }); + + await user.selectOptions(screen.getAllByRole("combobox")[0], "red-blue"); + expect(props.onGameChange).toHaveBeenCalledWith("red-blue"); + + await user.selectOptions(screen.getAllByRole("combobox")[1], "fire"); + expect(props.onTypeChange).toHaveBeenCalledWith("fire"); + }); + + it("shows an error message when games fail to load", async () => { + mockedFetchGames.mockRejectedValue(new Error("boom")); + mockedFetchTypes.mockResolvedValue([]); + + renderChoosePokemon(); + + expect(await screen.findByText(/Failed to load games: boom/)).toBeInTheDocument(); + }); + + it("shows an error message when types fail to load", async () => { + mockedFetchGames.mockResolvedValue([]); + mockedFetchTypes.mockRejectedValue(new Error("boom")); + + renderChoosePokemon(); + + expect(await screen.findByText(/Failed to load types: boom/)).toBeInTheDocument(); + }); +}); diff --git a/PTG/src/Header.test.tsx b/PTG/src/Header.test.tsx new file mode 100644 index 0000000..901e260 --- /dev/null +++ b/PTG/src/Header.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import Header from "./Header"; + +describe("Header", () => { + it("links home and to the about page", () => { + render(
, { wrapper: MemoryRouter }); + + expect(screen.getByRole("link", { name: "Pokémon Team Generator" })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: "About" })).toHaveAttribute("href", "/about"); + }); +}); diff --git a/PTG/src/PokemonGrid.css b/PTG/src/PokemonGrid.css index 471c136..e0b5122 100644 --- a/PTG/src/PokemonGrid.css +++ b/PTG/src/PokemonGrid.css @@ -19,6 +19,7 @@ position: relative; width: 100%; max-width: 120px; + padding-top: 24px; } .pokemonCardImage > img { diff --git a/PTG/src/PokemonGrid.test.tsx b/PTG/src/PokemonGrid.test.tsx new file mode 100644 index 0000000..de754da --- /dev/null +++ b/PTG/src/PokemonGrid.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import PokemonGrid from "./PokemonGrid"; +import { useFilteredPokemon } from "./useFilteredPokemon"; +import type { PokemonType } from "./pokemonTypes"; + +vi.mock("./useFilteredPokemon", () => ({ useFilteredPokemon: vi.fn() })); + +const mockedUseFilteredPokemon = vi.mocked(useFilteredPokemon); + +function baseResult(overrides: Partial> = {}): ReturnType { + return { + pokemon: [], + typeMap: undefined, + isLoading: false, + pokemonError: null, + typeError: null, + gameError: null, + ...overrides, + }; +} + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("PokemonGrid", () => { + it("renders a card per pokemon, capitalized, with its type icons", () => { + const typeMap = new Map([["bulbasaur", [{ id: 12, name: "grass" }]]]); + mockedUseFilteredPokemon.mockReturnValue( + baseResult({ + pokemon: [{ id: 1, name: "bulbasaur", image: "bulbasaur.png" }], + typeMap, + }) + ); + + render(); + + expect(screen.getByText("Bulbasaur")).toBeInTheDocument(); + expect(screen.getByAltText("bulbasaur")).toHaveAttribute("src", "bulbasaur.png"); + expect(screen.getByAltText("grass")).toBeInTheDocument(); + }); + + it("shows an empty message when loading finished with no matches", () => { + mockedUseFilteredPokemon.mockReturnValue(baseResult({ pokemon: [], isLoading: false })); + + render(); + + expect(screen.getByText("No pokèmon found for current filter")).toBeInTheDocument(); + }); + + it("shows nothing but does not error while still loading with no results yet", () => { + mockedUseFilteredPokemon.mockReturnValue(baseResult({ pokemon: [], isLoading: true })); + + render(); + + expect(screen.queryByText("No pokèmon found for current filter")).not.toBeInTheDocument(); + }); + + it("shows an error message when the pokemon list fails", () => { + mockedUseFilteredPokemon.mockReturnValue(baseResult({ pokemonError: new Error("boom") })); + + render(); + + expect(screen.getByText(/Failed to load Pokémon: boom/)).toBeInTheDocument(); + }); + + it("shows an error message when the type filter fails", () => { + mockedUseFilteredPokemon.mockReturnValue(baseResult({ typeError: new Error("boom") })); + + render(); + + expect(screen.getByText(/Failed to load type: boom/)).toBeInTheDocument(); + }); + + it("shows an error message when the game filter fails", () => { + mockedUseFilteredPokemon.mockReturnValue(baseResult({ gameError: new Error("boom") })); + + render(); + + expect(screen.getByText(/Failed to load game: boom/)).toBeInTheDocument(); + }); +}); diff --git a/PTG/src/Team.test.tsx b/PTG/src/Team.test.tsx new file mode 100644 index 0000000..42a913c --- /dev/null +++ b/PTG/src/Team.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import Team from "./Team"; +import { createBlankTeam } from "./pokemonTeam"; +import type { Pokemon } from "./useFilteredPokemon"; + +const bulbasaur: Pokemon = { id: 1, name: "bulbasaur", image: "bulbasaur.png" }; + +describe("Team", () => { + it("renders empty slots when the team has no members", () => { + render( + + ); + + expect(screen.getAllByText("Empty")).toHaveLength(6); + expect(screen.getAllByRole("button", { name: "Add to favourites" })).toHaveLength(6); + expect(screen.getAllByRole("button", { name: "Add to favourites" })[0]).toBeDisabled(); + }); + + it("renders a filled slot with its pokemon name and image", () => { + const team = createBlankTeam(); + team[0] = { id: 1, favourite: false }; + + render( + + ); + + expect(screen.getByText("Bulbasaur")).toBeInTheDocument(); + expect(screen.getByAltText("bulbasaur")).toHaveAttribute("src", "bulbasaur.png"); + }); + + it("calls toggleFavourite with the slot index when its star is clicked", async () => { + const user = userEvent.setup(); + const toggleFavourite = vi.fn(); + const team = createBlankTeam(); + team[2] = { id: 1, favourite: false }; + + render( + + ); + + const favouriteButtons = screen.getAllByRole("button", { name: "Add to favourites" }); + const enabledButton = favouriteButtons.find((button) => !button.hasAttribute("disabled")); + await user.click(enabledButton!); + + expect(toggleFavourite).toHaveBeenCalledWith(2); + }); + + it("calls generateTeam when the generate button is clicked, and disables it once every slot is favourited", async () => { + const user = userEvent.setup(); + const generateTeam = vi.fn(); + + const { rerender } = render( + + ); + + await user.click(screen.getByRole("button", { name: "Generate Team" })); + expect(generateTeam).toHaveBeenCalledTimes(1); + + rerender( + + ); + + expect(screen.getByRole("button", { name: "Generate Team" })).toBeDisabled(); + }); +}); diff --git a/PTG/src/games.test.ts b/PTG/src/games.test.ts new file mode 100644 index 0000000..eabd376 --- /dev/null +++ b/PTG/src/games.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchGames, fetchGameSpeciesIds } from "./games"; + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { ok, status, json: async () => body } as Response; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchGames", () => { + it("labels, filters out pokedex-less version groups, and sorts by id", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://pokeapi.co/api/v2/version-group?limit=100") { + return jsonResponse({ + results: [ + { name: "red-blue", url: "https://pokeapi.co/api/v2/version-group/1/" }, + { name: "colosseum", url: "https://pokeapi.co/api/v2/version-group/15/" }, + { name: "sword-shield", url: "https://pokeapi.co/api/v2/version-group/20/" }, + ], + }); + } + if (url.includes("version-group/1/")) { + return jsonResponse({ pokedexes: [{ name: "kanto", url: "x" }] }); + } + if (url.includes("version-group/15/")) { + // Spin-off with no pokedex - should be excluded. + return jsonResponse({ pokedexes: [] }); + } + if (url.includes("version-group/20/")) { + return jsonResponse({ pokedexes: [{ name: "galar", url: "x" }] }); + } + throw new Error(`unexpected url: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const games = await fetchGames(); + + expect(games).toEqual([ + { label: "Red Blue", value: "red-blue" }, + { label: "Sword Shield", value: "sword-shield" }, + ]); + }); + + it("throws when the version group list request fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({}, false, 500)) + ); + await expect(fetchGames()).rejects.toThrow("PokeAPI request failed: 500"); + }); +}); + +describe("fetchGameSpeciesIds", () => { + it("flattens species ids across every pokedex in the version group", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://pokeapi.co/api/v2/version-group/red-blue") { + return jsonResponse({ + pokedexes: [ + { name: "kanto", url: "https://pokeapi.co/api/v2/pokedex/kanto" }, + { name: "national", url: "https://pokeapi.co/api/v2/pokedex/national" }, + ], + }); + } + if (url.includes("pokedex/kanto")) { + return jsonResponse({ + pokemon_entries: [ + { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/1/" } }, + { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/4/" } }, + ], + }); + } + if (url.includes("pokedex/national")) { + return jsonResponse({ + pokemon_entries: [ + { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/4/" } }, + { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/7/" } }, + ], + }); + } + throw new Error(`unexpected url: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const ids = await fetchGameSpeciesIds("red-blue"); + + expect(ids).toEqual(new Set([1, 4, 7])); + }); + + it("throws when the pokedex request fails", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("version-group/")) { + return jsonResponse({ pokedexes: [{ name: "kanto", url: "https://pokeapi.co/api/v2/pokedex/kanto" }] }); + } + return jsonResponse({}, false, 404); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchGameSpeciesIds("red-blue")).rejects.toThrow("PokeAPI request failed: 404"); + }); +}); diff --git a/PTG/src/pokemonFilters.test.ts b/PTG/src/pokemonFilters.test.ts new file mode 100644 index 0000000..f5b408d --- /dev/null +++ b/PTG/src/pokemonFilters.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadStoredFilters, saveStoredFilters } from "./pokemonFilters"; + +describe("pokemonFilters", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("returns default filters when nothing is stored", () => { + expect(loadStoredFilters()).toEqual({ selectedGame: "", selectedType: "any", selectedName: "" }); + }); + + it("round-trips saved filters", () => { + const filters = { selectedGame: "red-blue", selectedType: "fire", selectedName: "char" }; + saveStoredFilters(filters); + expect(loadStoredFilters()).toEqual(filters); + }); + + it("falls back to defaults when stored JSON is malformed", () => { + sessionStorage.setItem("pokemonFilters", "{not-json"); + expect(loadStoredFilters()).toEqual({ selectedGame: "", selectedType: "any", selectedName: "" }); + }); + + it("falls back per-field when stored values have the wrong type", () => { + sessionStorage.setItem( + "pokemonFilters", + JSON.stringify({ selectedGame: 42, selectedType: "water", selectedName: null }) + ); + expect(loadStoredFilters()).toEqual({ selectedGame: "", selectedType: "water", selectedName: "" }); + }); + + it("does not throw when sessionStorage.setItem fails", () => { + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + expect(() => saveStoredFilters({ selectedGame: "", selectedType: "any", selectedName: "" })).not.toThrow(); + setItem.mockRestore(); + }); + + it("does not throw when sessionStorage.getItem fails", () => { + const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + expect(loadStoredFilters()).toEqual({ selectedGame: "", selectedType: "any", selectedName: "" }); + getItem.mockRestore(); + }); +}); diff --git a/PTG/src/pokemonTeam.test.ts b/PTG/src/pokemonTeam.test.ts new file mode 100644 index 0000000..4187935 --- /dev/null +++ b/PTG/src/pokemonTeam.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createBlankTeam, loadStoredTeam, saveStoredTeam, TEAM_SIZE, type TeamSlot } from "./pokemonTeam"; + +describe("pokemonTeam", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("creates a blank team of the expected size", () => { + const team = createBlankTeam(); + expect(team).toHaveLength(TEAM_SIZE); + expect(team.every((slot) => slot.id === null && slot.favourite === false)).toBe(true); + }); + + it("returns a blank team when nothing is stored", () => { + expect(loadStoredTeam()).toEqual(createBlankTeam()); + }); + + it("returns a blank team when stored JSON is malformed", () => { + localStorage.setItem("pokemonTeam", "{not-json"); + expect(loadStoredTeam()).toEqual(createBlankTeam()); + }); + + it("returns a blank team when the stored array has the wrong length", () => { + localStorage.setItem("pokemonTeam", JSON.stringify([{ id: 1, favourite: false }])); + expect(loadStoredTeam()).toEqual(createBlankTeam()); + }); + + it("returns a blank team when a slot has an invalid shape", () => { + const invalid = Array.from({ length: TEAM_SIZE }, () => ({ id: "1", favourite: false })); + localStorage.setItem("pokemonTeam", JSON.stringify(invalid)); + expect(loadStoredTeam()).toEqual(createBlankTeam()); + }); + + it("round-trips a valid stored team", () => { + const team: TeamSlot[] = Array.from({ length: TEAM_SIZE }, (_, i) => ({ + id: i, + favourite: i % 2 === 0, + })); + saveStoredTeam(team); + expect(loadStoredTeam()).toEqual(team); + }); + + it("does not throw when localStorage.setItem fails", () => { + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota exceeded"); + }); + expect(() => saveStoredTeam(createBlankTeam())).not.toThrow(); + setItem.mockRestore(); + }); +}); diff --git a/PTG/src/pokemonTypes.test.ts b/PTG/src/pokemonTypes.test.ts new file mode 100644 index 0000000..8d52e0d --- /dev/null +++ b/PTG/src/pokemonTypes.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchPokemonTypeMap, fetchTypes } from "./pokemonTypes"; + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { ok, status, json: async () => body } as Response; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchTypes", () => { + it("excludes non-battle types and sorts battle types by id", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse({ + results: [ + { name: "water", url: "https://pokeapi.co/api/v2/type/11/" }, + { name: "fire", url: "https://pokeapi.co/api/v2/type/10/" }, + { name: "shadow", url: "https://pokeapi.co/api/v2/type/10001/" }, + { name: "unknown", url: "https://pokeapi.co/api/v2/type/10000/" }, + ], + }) + ) + ); + + const types = await fetchTypes(); + + expect(types).toEqual([ + { label: "Fire", value: "fire" }, + { label: "Water", value: "water" }, + ]); + }); + + it("throws when the type list request fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({}, false, 500)) + ); + await expect(fetchTypes()).rejects.toThrow("PokeAPI request failed: 500"); + }); +}); + +describe("fetchPokemonTypeMap", () => { + it("maps each pokemon to its types ordered by slot", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://pokeapi.co/api/v2/type?limit=100") { + return jsonResponse({ + results: [ + { name: "grass", url: "https://pokeapi.co/api/v2/type/12/" }, + { name: "poison", url: "https://pokeapi.co/api/v2/type/4/" }, + ], + }); + } + if (url === "https://pokeapi.co/api/v2/type/grass") { + return jsonResponse({ + id: 12, + name: "grass", + pokemon: [{ slot: 1, pokemon: { name: "bulbasaur" } }], + }); + } + if (url === "https://pokeapi.co/api/v2/type/poison") { + return jsonResponse({ + id: 4, + name: "poison", + pokemon: [{ slot: 2, pokemon: { name: "bulbasaur" } }], + }); + } + throw new Error(`unexpected url: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const typeMap = await fetchPokemonTypeMap(); + + expect(typeMap.get("bulbasaur")).toEqual([ + { id: 12, name: "grass" }, + { id: 4, name: "poison" }, + ]); + }); +}); diff --git a/PTG/src/test/setup.ts b/PTG/src/test/setup.ts new file mode 100644 index 0000000..1efae31 --- /dev/null +++ b/PTG/src/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/PTG/src/test/withQueryClient.tsx b/PTG/src/test/withQueryClient.tsx new file mode 100644 index 0000000..f7ea48c --- /dev/null +++ b/PTG/src/test/withQueryClient.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +export function withQueryClient(client: QueryClient = createTestQueryClient()) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} diff --git a/PTG/src/useFilteredPokemon.test.tsx b/PTG/src/useFilteredPokemon.test.tsx new file mode 100644 index 0000000..7937116 --- /dev/null +++ b/PTG/src/useFilteredPokemon.test.tsx @@ -0,0 +1,102 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useFilteredPokemon } from "./useFilteredPokemon"; +import { withQueryClient } from "./test/withQueryClient"; + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { ok, status, json: async () => body } as Response; +} + +const POKEMON = [ + { name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/" }, + { name: "charmander", url: "https://pokeapi.co/api/v2/pokemon/4/" }, + { name: "squirtle", url: "https://pokeapi.co/api/v2/pokemon/7/" }, +]; + +function stubFetch(handlers: { [urlSubstring: string]: unknown }) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + const entry = Object.entries(handlers).find(([pattern]) => url.includes(pattern)); + if (!entry) return jsonResponse({}, false, 404); + return jsonResponse(entry[1]); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useFilteredPokemon", () => { + it("returns the full list when no filters are applied", async () => { + stubFetch({ + "api/v2/pokemon?limit=": { results: POKEMON }, + "api/v2/type?limit=": { results: [] }, + }); + + const { result } = renderHook(() => useFilteredPokemon("", "any", ""), { wrapper: withQueryClient() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.pokemon.map((p) => p.name)).toEqual(["bulbasaur", "charmander", "squirtle"]); + expect(result.current.pokemonError).toBeNull(); + }); + + it("filters by name prefix, case-insensitively", async () => { + stubFetch({ + "api/v2/pokemon?limit=": { results: POKEMON }, + "api/v2/type?limit=": { results: [] }, + }); + + const { result } = renderHook(() => useFilteredPokemon("", "any", "CHAR"), { wrapper: withQueryClient() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.pokemon.map((p) => p.name)).toEqual(["charmander"]); + }); + + it("filters by type using the resolved name set", async () => { + stubFetch({ + "api/v2/pokemon?limit=": { results: POKEMON }, + "api/v2/type?limit=": { results: [] }, + "api/v2/type/water": { pokemon: [{ pokemon: { name: "squirtle" } }] }, + }); + + const { result } = renderHook(() => useFilteredPokemon("", "water", ""), { wrapper: withQueryClient() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(result.current.pokemon.map((p) => p.name)).toEqual(["squirtle"])); + }); + + it("combines game, type, and name filters", async () => { + stubFetch({ + "api/v2/pokemon?limit=": { results: POKEMON }, + "api/v2/type?limit=": { results: [] }, + "api/v2/type/fire": { pokemon: [{ pokemon: { name: "charmander" } }] }, + "api/v2/version-group/red": { + pokedexes: [{ name: "kanto", url: "https://pokeapi.co/api/v2/pokedex/kanto" }], + }, + "api/v2/pokedex/kanto": { + pokemon_entries: [ + { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/4/" } }, + { pokemon_species: { url: "https://pokeapi.co/api/v2/pokemon-species/7/" } }, + ], + }, + }); + + const { result } = renderHook(() => useFilteredPokemon("red", "fire", "char"), { wrapper: withQueryClient() }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(result.current.pokemon.map((p) => p.name)).toEqual(["charmander"])); + }); + + it("surfaces an error when the pokemon list request fails", async () => { + stubFetch({ + "api/v2/type?limit=": { results: [] }, + }); + + const { result } = renderHook(() => useFilteredPokemon("", "any", ""), { wrapper: withQueryClient() }); + + await waitFor(() => expect(result.current.pokemonError).not.toBeNull()); + expect(result.current.pokemonError?.message).toContain("PokeAPI request failed"); + }); +}); diff --git a/PTG/src/usePokemonTeam.test.tsx b/PTG/src/usePokemonTeam.test.tsx new file mode 100644 index 0000000..005384c --- /dev/null +++ b/PTG/src/usePokemonTeam.test.tsx @@ -0,0 +1,125 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { usePokemonTeam } from "./usePokemonTeam"; +import { TEAM_SIZE } from "./pokemonTeam"; +import { withQueryClient } from "./test/withQueryClient"; + +function jsonResponse(body: unknown, ok = true, status = 200): Response { + return { ok, status, json: async () => body } as Response; +} + +// More candidates than TEAM_SIZE so a second generateTeam() call still has fresh, unused pokemon to draw from. +const POKEMON_COUNT = 20; +const POKEMON = Array.from({ length: POKEMON_COUNT }, (_, i) => ({ + name: `pokemon-${i}`, + url: `https://pokeapi.co/api/v2/pokemon/${i}/`, +})); + +function stubFetch() { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("api/v2/pokemon?limit=")) return jsonResponse({ results: POKEMON }); + if (url.includes("api/v2/type?limit=")) return jsonResponse({ results: [] }); + return jsonResponse({}, false, 404); + }); + vi.stubGlobal("fetch", fetchMock); +} + +beforeEach(() => { + localStorage.clear(); + stubFetch(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function renderReadyTeam() { + const { result } = renderHook(() => usePokemonTeam("", "any", ""), { wrapper: withQueryClient() }); + await waitFor(() => expect(result.current.pokemonById.size).toBe(POKEMON_COUNT)); + return result; +} + +describe("usePokemonTeam", () => { + it("starts from a blank team when nothing is stored", async () => { + const result = await renderReadyTeam(); + expect(result.current.team).toHaveLength(TEAM_SIZE); + expect(result.current.team.every((slot) => slot.id === null)).toBe(true); + }); + + it("fills every slot with unique pokemon on generateTeam", async () => { + const result = await renderReadyTeam(); + + act(() => result.current.generateTeam()); + + const ids = result.current.team.map((slot) => slot.id); + expect(ids.every((id) => id !== null)).toBe(true); + expect(new Set(ids).size).toBe(TEAM_SIZE); + expect(result.current.team.every((slot) => slot.favourite === false)).toBe(true); + }); + + it("toggleFavourite flips only the targeted slot, and leaves empty slots alone", async () => { + const result = await renderReadyTeam(); + act(() => result.current.generateTeam()); + + act(() => result.current.toggleFavourite(0)); + expect(result.current.team[0].favourite).toBe(true); + expect(result.current.team.slice(1).every((slot) => slot.favourite === false)).toBe(true); + + act(() => result.current.toggleFavourite(0)); + expect(result.current.team[0].favourite).toBe(false); + }); + + it("toggleFavourite is a no-op on an empty slot", async () => { + const result = await renderReadyTeam(); + act(() => result.current.toggleFavourite(0)); + expect(result.current.team[0]).toEqual({ id: null, favourite: false }); + }); + + it("generateTeam keeps favourited members and replaces the rest with fresh, non-duplicate picks", async () => { + const result = await renderReadyTeam(); + + act(() => result.current.generateTeam()); + const firstTeam = result.current.team; + const originalIds = firstTeam.map((slot) => slot.id); + + act(() => result.current.toggleFavourite(0)); + const favouritedId = result.current.team[0].id; + + act(() => result.current.generateTeam()); + const secondTeam = result.current.team; + + expect(secondTeam[0].id).toBe(favouritedId); + expect(secondTeam[0].favourite).toBe(true); + + const newNonFavouriteIds = secondTeam.slice(1).map((slot) => slot.id); + // None of the newly generated slots should reuse a pokemon from the first team. + for (const id of newNonFavouriteIds) { + expect(originalIds).not.toContain(id); + } + expect(new Set(secondTeam.map((slot) => slot.id)).size).toBe(TEAM_SIZE); + }); + + it("reports allFavourited once every filled slot is favourited", async () => { + const result = await renderReadyTeam(); + act(() => result.current.generateTeam()); + + expect(result.current.allFavourited).toBe(false); + + for (let i = 0; i < TEAM_SIZE; i++) { + act(() => result.current.toggleFavourite(i)); + } + + expect(result.current.allFavourited).toBe(true); + }); + + it("persists the team to localStorage", async () => { + const result = await renderReadyTeam(); + act(() => result.current.generateTeam()); + + await waitFor(() => { + const stored = JSON.parse(localStorage.getItem("pokemonTeam") ?? "null"); + expect(stored).toEqual(result.current.team); + }); + }); +}); diff --git a/PTG/vite.config.ts b/PTG/vite.config.ts index d789a7c..81dcdf4 100644 --- a/PTG/vite.config.ts +++ b/PTG/vite.config.ts @@ -1,3 +1,4 @@ +/// import react, { reactCompilerPreset } from '@vitejs/plugin-react' import babel from '@rolldown/plugin-babel' import { defineConfig } from 'vite' @@ -8,4 +9,8 @@ export default defineConfig({ react(), babel({ presets: [reactCompilerPreset()] }) ], + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + }, }) diff --git a/README.md b/README.md index 75ff895..bf73232 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ Working with branching in git helped the team have a better control over what sh Throughout the development process [ESLint](https://eslint.org/) was a silent fourth member that secretly assissted with code development by pointing out issues in the scripts. +To run the project locally, install dependencies with `pnpm install` inside the `PTG` folder, then start the dev server with `pnpm dev`. Tests are written with [Vitest](https://vitest.dev) and React Testing Library, and can be run with `pnpm test` (or `pnpm test:watch` to re-run on file changes). + ## AI usage About AI usage within the project development, whether we have used or not. -Claude Sonet 5 was used in assistance of resolving some merge conflicts during the latest stages of development. +Claude Sonnet 5 was used in assistance of resolving some merge conflicts during the latest stages of development, and also to help develop the Vitest test suite. diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..d1e9a10 --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"5.0.1","results":[[":PTG/src/games.test.ts",{"duration":14.153812000000016,"failed":false}],[":PTG/src/useFilteredPokemon.test.tsx",{"duration":15.067615999999987,"failed":true}],[":PTG/src/usePokemonTeam.test.tsx",{"duration":15.558231999999975,"failed":true}],[":PTG/src/ChoosePokemon.test.tsx",{"duration":7.9625990000000115,"failed":true}],[":PTG/src/PokemonGrid.test.tsx",{"duration":12.521540000000016,"failed":true}],[":PTG/src/pokemonTypes.test.ts",{"duration":11.898667000000017,"failed":false}],[":PTG/src/Team.test.tsx",{"duration":6.990386999999998,"failed":true}],[":PTG/src/pokemonTeam.test.ts",{"duration":11.162802999999997,"failed":true}],[":PTG/src/pokemonFilters.test.ts",{"duration":14.127140999999995,"failed":false}],[":PTG/src/Header.test.tsx",{"duration":4.148395000000022,"failed":true}]]} \ No newline at end of file