From 18a7157d1a9449dbcee49b4fb0d4d4b31612a270 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 1 Oct 2026 18:11:50 +0200 Subject: [PATCH 1/8] build(config): sett opp Vitest og React Testing Library Co-authored-by: tordtst --- package-lock.json | 1051 ++++++++++++++++++++++++++++++- package.json | 10 +- src/test/setup.ts | 9 + src/utils/listingFormat.test.ts | 23 + vite.config.ts | 9 +- 5 files changed, 1097 insertions(+), 5 deletions(-) create mode 100644 src/test/setup.ts create mode 100644 src/utils/listingFormat.test.ts diff --git a/package-lock.json b/package-lock.json index cbee598..ba87ddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,10 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", @@ -22,16 +26,78 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.7", "globals": "^17.12.0", + "jsdom": "^30.1.1", "prettier": "^3.9.9", "typescript": "~6.0.0", "typescript-eslint": "^8.71.0", - "vite": "^8.3.1" + "vite": "^8.3.1", + "vitest": "^5.0.3" }, "engines": { - "node": ">=22.12.0", + "node": ">=22.22.0", "npm": ">=10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", + "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.1", + "@csstools/css-color-parser": "^4.2.4", + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.2.tgz", + "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -224,6 +290,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -272,6 +348,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -296,6 +385,146 @@ "keyv": "^5.6.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.2.tgz", + "integrity": "sha512-tL6LRF844sHjAKO7jt2w+uHo8Tocqpb9+KPUvIQETpR4RU87IaUzDuOV0QyzuQ37jmzFgwPHLLtLu/PDuH5qgg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.5.tgz", + "integrity": "sha512-Qqbp5pjbMwoaErP2t5qne5QDVDr3G5243oNW8ev5zfyyoqbzrQKbYDpKP/cuo3xBkzwqZqpP/rChrJhBdyTX7g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.2" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -424,6 +653,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "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 + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -842,6 +1089,129 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "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 + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "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/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1156,6 +1526,44 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "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.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -1196,6 +1604,49 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "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/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1219,6 +1670,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -1301,6 +1762,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1329,6 +1800,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1336,6 +1828,35 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1354,6 +1875,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1361,6 +1889,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1371,12 +1909,39 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.440", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.440.tgz", "integrity": "sha512-SghDzqdJokdz8zP8YNlvS74+CwLRoUPDGvP/gFA+HrKVw+pOshUAgx8pXR37xl/u16zGae746rFNWvZ/22kU2Q==", "dev": true, - "license": "ISC" + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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/escalade": { "version": "3.2.0", @@ -1592,6 +2157,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", @@ -1602,6 +2177,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", @@ -1775,6 +2360,19 @@ "dev": true, "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1795,6 +2393,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1818,6 +2426,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1832,6 +2447,56 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2183,6 +2848,43 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2242,6 +2944,20 @@ "node": ">=18" } }, + "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/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2292,6 +3008,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2387,6 +3116,21 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2438,6 +3182,13 @@ "react": "^19.3.0" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-router": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz", @@ -2460,6 +3211,30 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -2494,6 +3269,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -2543,6 +3331,46 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2560,6 +3388,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2624,6 +3498,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", @@ -2743,6 +3627,137 @@ } } }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "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": "3.2.1" + }, + "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.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "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/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2759,6 +3774,19 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2769,6 +3797,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index ce2ee1c..c3be60a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "build": "npm run typecheck && vite build", "preview": "vite preview", "typecheck": "tsc -b", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint .", "format": "prettier . --write", "format:check": "prettier . --check" @@ -23,6 +25,10 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", @@ -31,9 +37,11 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.7", "globals": "^17.12.0", + "jsdom": "^30.1.1", "prettier": "^3.9.9", "typescript": "~6.0.0", "typescript-eslint": "^8.71.0", - "vite": "^8.3.1" + "vite": "^8.3.1", + "vitest": "^5.0.3" } } diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..36d26bd --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,9 @@ +// Legger til matchere som toBeInTheDocument og toHaveAccessibleName. +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +// Fjerner det som ble rendret, så hver test starter med et tomt dokument. +afterEach(() => { + cleanup(); +}); diff --git a/src/utils/listingFormat.test.ts b/src/utils/listingFormat.test.ts new file mode 100644 index 0000000..303ceec --- /dev/null +++ b/src/utils/listingFormat.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { formatDailyPrice, formatKroner } from './listingFormat.ts'; + +describe('formatDailyPrice', () => { + it('viser gratis utlån når prisen er 0', () => { + expect(formatDailyPrice(0)).toBe('Gratis utlån'); + }); + + it('viser døgnpris i kroner', () => { + expect(formatDailyPrice(150)).toBe('150 kr/døgn'); + }); + + it('bruker norsk tusenskille', () => { + // Intl bruker hardt mellomrom som tusenskille på norsk. + expect(formatDailyPrice(1200)).toBe('1 200 kr/døgn'); + }); +}); + +describe('formatKroner', () => { + it('viser beløp med kr', () => { + expect(formatKroner(300)).toBe('300 kr'); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 0466183..18f31aa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,13 @@ -import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +// defineConfig fra vitest/config godtar også test-innstillingene under. +import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [react()], + test: { + // jsdom gir et DOM i Node, slik at komponentene kan rendres uten nettleser. + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, }); From ac843663dbaad0e2659e38f6bcb4cbfbbd43776e Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 1 Oct 2026 18:12:03 +0200 Subject: [PATCH 2/8] test(src): test pris og gratismerking i prismerket Co-authored-by: birkjr --- src/components/PriceTag/PriceTag.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/components/PriceTag/PriceTag.test.tsx diff --git a/src/components/PriceTag/PriceTag.test.tsx b/src/components/PriceTag/PriceTag.test.tsx new file mode 100644 index 0000000..83e823d --- /dev/null +++ b/src/components/PriceTag/PriceTag.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import PriceTag from './PriceTag.tsx'; + +describe('PriceTag', () => { + it('merker pris 0 som gratis utlån med tekst', () => { + render(); + expect(screen.getByText('Gratis utlån')).toBeInTheDocument(); + }); + + it('viser døgnpris for betalt leie', () => { + render(); + expect(screen.getByText('150 kr/døgn')).toBeInTheDocument(); + expect(screen.queryByText('Gratis utlån')).not.toBeInTheDocument(); + }); +}); From 3a1a67701e9e70db01e1c3405ad7381b4e54047e Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 1 Oct 2026 18:12:40 +0200 Subject: [PATCH 3/8] =?UTF-8?q?test(src):=20test=20s=C3=B8k,=20filtre=20og?= =?UTF-8?q?=20sortering=20av=20annonser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: andretg --- src/utils/listingFilters.test.ts | 119 +++++++++++++++++++++++++++++++ src/utils/listingSort.test.ts | 60 ++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/utils/listingFilters.test.ts create mode 100644 src/utils/listingSort.test.ts diff --git a/src/utils/listingFilters.test.ts b/src/utils/listingFilters.test.ts new file mode 100644 index 0000000..4680d4a --- /dev/null +++ b/src/utils/listingFilters.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { mockListings } from '../data/mockListings.ts'; +import type { Listing } from '../types/listing.ts'; +import { + filterListings, + hasActiveFilters, + readListingFilters, + writeListingFilters, + type ListingFilters, +} from './listingFilters.ts'; + +const noFilters: ListingFilters = { + query: '', + category: null, + freeOnly: false, +}; + +function titles(listings: Listing[]) { + return listings.map((listing) => listing.title); +} + +describe('filterListings', () => { + it('viser alle annonser når søket er tomt', () => { + expect(filterListings(mockListings, noFilters)).toEqual(mockListings); + expect( + filterListings(mockListings, { ...noFilters, query: ' ' }), + ).toEqual(mockListings); + }); + + it('søker i tittel uten å skille mellom store og små bokstaver', () => { + const result = filterListings(mockListings, { + ...noFilters, + query: 'DRILL', + }); + expect(titles(result)).toContain('Drill'); + expect( + result.every((listing) => + /drill/i.test(listing.title + listing.description), + ), + ).toBe(true); + }); + + it('søker også i beskrivelsen', () => { + const result = filterListings(mockListings, { + ...noFilters, + query: 'hørselvern', + }); + expect(titles(result)).toEqual([ + 'Slagdrill med borsett for betong og murvegger', + ]); + }); + + it('viser bare gratis utlån når filteret er på', () => { + const result = filterListings(mockListings, { + ...noFilters, + freeOnly: true, + }); + expect(result.length).toBeGreaterThan(0); + expect(result.every((listing) => listing.dailyPrice === 0)).toBe(true); + }); + + it('kombinerer kategori og gratisfilter', () => { + const result = filterListings(mockListings, { + ...noFilters, + category: 'garden', + freeOnly: true, + }); + expect(titles(result)).toEqual(['Spade']); + }); + + it('gir tom liste når ingenting passer', () => { + expect( + filterListings(mockListings, { ...noFilters, query: 'motorsag' }), + ).toEqual([]); + }); +}); + +describe('filtre i URL-en', () => { + it('leser gyldige filtre', () => { + const params = new URLSearchParams('q=drill&kategori=garden&gratis=1'); + expect(readListingFilters(params)).toEqual({ + query: 'drill', + category: 'garden', + freeOnly: true, + }); + }); + + it('ignorerer ukjent kategori og ugyldig gratisverdi', () => { + const params = new URLSearchParams('kategori=biler&gratis=ja'); + expect(readListingFilters(params)).toEqual(noFilters); + }); + + it('fjerner tomme filtre og beholder andre parametere', () => { + const params = new URLSearchParams('q=drill&sortering=priceAsc'); + const next = writeListingFilters(params, noFilters); + expect(next.toString()).toBe('sortering=priceAsc'); + }); + + it('gir samme filtre tilbake etter skriving og lesing', () => { + const filters: ListingFilters = { + query: 'maling', + category: 'painting', + freeOnly: true, + }; + const params = writeListingFilters(new URLSearchParams(), filters); + expect(readListingFilters(params)).toEqual(filters); + }); +}); + +describe('hasActiveFilters', () => { + it('er usann når ingenting er valgt, også med bare mellomrom', () => { + expect(hasActiveFilters(noFilters)).toBe(false); + expect(hasActiveFilters({ ...noFilters, query: ' ' })).toBe(false); + }); + + it('er sann når et filter er valgt', () => { + expect(hasActiveFilters({ ...noFilters, freeOnly: true })).toBe(true); + }); +}); diff --git a/src/utils/listingSort.test.ts b/src/utils/listingSort.test.ts new file mode 100644 index 0000000..5493f98 --- /dev/null +++ b/src/utils/listingSort.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { mockListings } from '../data/mockListings.ts'; +import type { Listing } from '../types/listing.ts'; +import { + readListingSort, + sortListings, + writeListingSort, +} from './listingSort.ts'; + +function prices(listings: Listing[]) { + return listings.map((listing) => listing.dailyPrice); +} + +describe('sortListings', () => { + it('beholder rekkefølgen fra datakilden ved standardsortering', () => { + expect(sortListings(mockListings, 'standard')).toEqual(mockListings); + }); + + it('sorterer på lavest pris først', () => { + const result = prices(sortListings(mockListings, 'priceAsc')); + expect(result).toEqual([...result].sort((a, b) => a - b)); + }); + + it('sorterer på høyest pris først', () => { + const result = prices(sortListings(mockListings, 'priceDesc')); + expect(result).toEqual([...result].sort((a, b) => b - a)); + }); + + it('beholder rekkefølgen mellom annonser med samme pris', () => { + const freeInOrder = mockListings.filter((l) => l.dailyPrice === 0); + const sortedFree = sortListings(mockListings, 'priceAsc').filter( + (l) => l.dailyPrice === 0, + ); + expect(sortedFree).toEqual(freeInOrder); + }); + + it('endrer ikke listen som sendes inn', () => { + const original = [...mockListings]; + sortListings(mockListings, 'priceDesc'); + expect(mockListings).toEqual(original); + }); +}); + +describe('sortering i URL-en', () => { + it('gir standardsortering for ukjente verdier', () => { + expect(readListingSort(new URLSearchParams('sortering=nyeste'))).toBe( + 'standard', + ); + }); + + it('fjerner standardsortering fra URL-en og beholder søket', () => { + const params = new URLSearchParams('q=drill&sortering=priceAsc'); + expect(writeListingSort(params, 'standard').toString()).toBe('q=drill'); + }); + + it('gir samme sortering tilbake etter skriving og lesing', () => { + const params = writeListingSort(new URLSearchParams(), 'priceDesc'); + expect(readListingSort(params)).toBe('priceDesc'); + }); +}); From cd3895c0732bbfd23709ab9b60b63b85131266e2 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 1 Oct 2026 18:13:01 +0200 Subject: [PATCH 4/8] test(src): test leieperiode og validering av annonseskjema Co-authored-by: tordtst Co-authored-by: birkjr --- src/utils/listingForm.test.ts | 69 ++++++++++++++++++++++++++++++++++ src/utils/rentalPeriod.test.ts | 61 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/utils/listingForm.test.ts create mode 100644 src/utils/rentalPeriod.test.ts diff --git a/src/utils/listingForm.test.ts b/src/utils/listingForm.test.ts new file mode 100644 index 0000000..6590a15 --- /dev/null +++ b/src/utils/listingForm.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + emptyListingForm, + parseDailyPrice, + validateListingForm, + type ListingFormValues, +} from './listingForm.ts'; + +const validForm: ListingFormValues = { + title: 'Stikksag', + category: 'powerTools', + description: 'Stikksag for tre og laminat.', + area: 'Lade', + dailyPrice: '80', +}; + +describe('parseDailyPrice', () => { + it('godtar hele kroner, også 0 for gratis utlån', () => { + expect(parseDailyPrice('0')).toBe(0); + expect(parseDailyPrice(' 150 ')).toBe(150); + }); + + it.each(['-5', '12,5', '12.5', '1e3', 'ti', ''])('avviser «%s»', (value) => { + expect(parseDailyPrice(value)).toBeNull(); + }); +}); + +describe('validateListingForm', () => { + it('godtar et utfylt skjema', () => { + expect(validateListingForm(validForm)).toEqual({}); + }); + + it('godtar pris 0 som gratis utlån', () => { + expect(validateListingForm({ ...validForm, dailyPrice: '0' })).toEqual({}); + }); + + it('krever alle feltene i et tomt skjema', () => { + expect(Object.keys(validateListingForm(emptyListingForm)).sort()).toEqual([ + 'area', + 'category', + 'dailyPrice', + 'description', + 'title', + ]); + }); + + it('regner tekst med bare mellomrom som tom', () => { + const errors = validateListingForm({ ...validForm, title: ' ' }); + expect(errors.title).toBeDefined(); + }); + + it('avviser for lang tittel', () => { + const errors = validateListingForm({ + ...validForm, + title: 'a'.repeat(81), + }); + expect(errors.title).toMatch(/80 tegn/); + }); + + it('avviser pris som ikke er et helt tall', () => { + const errors = validateListingForm({ ...validForm, dailyPrice: '12,5' }); + expect(errors.dailyPrice).toMatch(/helt tall/); + }); + + it('avviser pris over grensen', () => { + const errors = validateListingForm({ ...validForm, dailyPrice: '10001' }); + expect(errors.dailyPrice).toBeDefined(); + }); +}); diff --git a/src/utils/rentalPeriod.test.ts b/src/utils/rentalPeriod.test.ts new file mode 100644 index 0000000..ced2e93 --- /dev/null +++ b/src/utils/rentalPeriod.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + countRentalDays, + todayAsDateString, + validateRentalPeriod, +} from './rentalPeriod.ts'; + +const today = '2026-10-05'; + +describe('countRentalDays', () => { + it('regner antall døgn mellom henting og retur', () => { + expect(countRentalDays('2026-10-05', '2026-10-08')).toBe(3); + }); + + it('regner henting og retur samme dag som ett døgn', () => { + expect(countRentalDays('2026-10-05', '2026-10-05')).toBe(1); + }); + + it('teller riktig over overgangen fra sommertid', () => { + // Sommertiden slutter natt til 25. oktober 2026. + expect(countRentalDays('2026-10-24', '2026-10-26')).toBe(2); + }); + + it('teller riktig over månedsskifte', () => { + expect(countRentalDays('2026-10-30', '2026-11-02')).toBe(3); + }); +}); + +describe('validateRentalPeriod', () => { + it('godtar gyldige datoer', () => { + expect(validateRentalPeriod(today, '2026-10-07', today)).toEqual({}); + }); + + it('krever begge datoene', () => { + const errors = validateRentalPeriod('', '', today); + expect(errors.startDate).toBeDefined(); + expect(errors.endDate).toBeDefined(); + }); + + it('avviser fradato tilbake i tid', () => { + const errors = validateRentalPeriod('2026-10-04', '2026-10-07', today); + expect(errors.startDate).toMatch(/tilbake i tid/); + }); + + it('avviser tildato før fradato', () => { + const errors = validateRentalPeriod('2026-10-08', '2026-10-06', today); + expect(errors.endDate).toMatch(/før fradatoen/); + }); +}); + +describe('todayAsDateString', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('gir dagens dato på samme form som datofeltene', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 9, 12)); + expect(todayAsDateString()).toBe('2026-01-09'); + }); +}); From ae69d6463a02ee05405a96a87691ce792601d649 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 1 Oct 2026 18:13:24 +0200 Subject: [PATCH 5/8] =?UTF-8?q?test(src):=20test=20tilgjengelige=20navn=20?= =?UTF-8?q?p=C3=A5=20skjemafelt=20og=20knapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: andretg --- src/components/Button/Button.test.tsx | 37 ++++++++++++ src/components/TextField/TextField.test.tsx | 67 +++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/components/Button/Button.test.tsx create mode 100644 src/components/TextField/TextField.test.tsx diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx new file mode 100644 index 0000000..27fd200 --- /dev/null +++ b/src/components/Button/Button.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import Button from './Button.tsx'; + +describe('Button', () => { + it('sender ikke inn skjemaet når type ikke er satt', async () => { + const onSubmit = vi.fn((event: Event) => event.preventDefault()); + render( +
onSubmit(event.nativeEvent)}> + +
, + ); + await userEvent.click(screen.getByRole('button', { name: 'Avbryt' })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('kan trykkes med tastatur', async () => { + const onClick = vi.fn(); + render(); + await userEvent.tab(); + expect(screen.getByRole('button', { name: 'Lagre' })).toHaveFocus(); + await userEvent.keyboard('{Enter}'); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('kan ikke trykkes når den er deaktivert', async () => { + const onClick = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Send' })); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/TextField/TextField.test.tsx b/src/components/TextField/TextField.test.tsx new file mode 100644 index 0000000..d316492 --- /dev/null +++ b/src/components/TextField/TextField.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import SelectField from '../SelectField/SelectField.tsx'; +import TextAreaField from '../TextAreaField/TextAreaField.tsx'; +import TextField from './TextField.tsx'; + +// SelectField og TextAreaField testes her også, siden de skal oppføre seg +// likt som TextField for skjermlesere. +describe('tilgjengelige navn på skjemafelt', () => { + it('TextField får etiketten som navn og hjelpeteksten som beskrivelse', () => { + render(); + const field = screen.getByRole('textbox', { name: 'Område' }); + expect(field).toHaveAccessibleDescription('Bydel i Trondheim.'); + expect(field).not.toBeInvalid(); + }); + + it('TextField med feil er ugyldig og leser opp feilmeldingen', () => { + render( + , + ); + const field = screen.getByRole('textbox', { name: 'Tittel' }); + expect(field).toBeInvalid(); + expect(field).toHaveAccessibleDescription('Maks 80 tegn. Skriv en tittel.'); + }); + + it('SelectField får etiketten som navn og viser valgene', () => { + render( + , + ); + const select = screen.getByRole('combobox', { name: 'Kategori' }); + expect(select).toBeInvalid(); + expect(select).toHaveAccessibleDescription('Velg en kategori.'); + expect(screen.getByRole('option', { name: 'Hage' })).toBeInTheDocument(); + }); + + it('TextAreaField får etiketten som navn', () => { + render( + , + ); + expect( + screen.getByRole('textbox', { name: 'Beskrivelse' }), + ).toHaveAccessibleDescription('Hva verktøyet kan brukes til.'); + }); + + it('to felt med samme etikett får ulike id-er', () => { + render( + <> + + + , + ); + const [first, second] = screen.getAllByRole('textbox', { name: 'Navn' }); + expect(first?.id).not.toBe(second?.id); + }); +}); From 4ea70a767cfa83188301d3f1f9e6bee7893b1157 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 1 Oct 2026 18:13:51 +0200 Subject: [PATCH 6/8] =?UTF-8?q?test(app):=20test=20navigasjon=20mellom=20s?= =?UTF-8?q?idene=20og=20s=C3=B8k=20i=20katalogen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: birkjr --- src/App.test.tsx | 114 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/App.test.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..5cc2e05 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,114 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; +import { describe, expect, it } from 'vitest'; +import App from './App.tsx'; + +function renderApp(route = '/') { + return render( + + + , + ); +} + +function heading() { + return screen.getByRole('heading', { level: 1 }); +} + +describe('navigasjon', () => { + it('viser katalogen på startsiden og markerer aktiv side', () => { + renderApp(); + expect(heading()).toHaveTextContent('Finn verktøy'); + expect(document.title).toBe('Katalog – Verktøyutlån Trondheim'); + const menu = screen.getByRole('navigation', { name: 'Hovedmeny' }); + expect(within(menu).getByRole('link', { name: 'Katalog' })).toHaveAttribute( + 'aria-current', + 'page', + ); + }); + + it('går til utleieskjemaet fra menyen', async () => { + renderApp(); + await userEvent.click(screen.getByRole('link', { name: 'Lei ut verktøy' })); + expect(heading()).toHaveTextContent('Lei ut verktøy'); + expect(document.title).toBe('Lei ut verktøy – Verktøyutlån Trondheim'); + }); + + it('åpner detaljvisningen fra et annonsekort og går tilbake', async () => { + renderApp(); + await userEvent.click(screen.getByRole('link', { name: 'Drill' })); + expect(heading()).toHaveTextContent('Drill'); + expect(screen.getByText('Moholt')).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('link', { name: 'Tilbake til katalogen' }), + ); + expect(heading()).toHaveTextContent('Finn verktøy'); + }); + + it('flytter fokus til hovedinnholdet ved sidebytte', async () => { + renderApp(); + await userEvent.click(screen.getByRole('link', { name: 'Lei ut verktøy' })); + expect(screen.getByRole('main')).toHaveFocus(); + }); + + it('har en hopp-lenke som første tabulatorstopp', async () => { + renderApp(); + await userEvent.tab(); + expect( + screen.getByRole('link', { name: 'Hopp til hovedinnhold' }), + ).toHaveFocus(); + }); + + it('viser «Fant ikke siden» for ukjente adresser', () => { + renderApp('/finnes-ikke'); + expect(heading()).toHaveTextContent('Fant ikke siden'); + expect( + screen.getByRole('link', { name: 'Gå til katalogen' }), + ).toBeInTheDocument(); + }); + + it('viser «Fant ikke annonsen» for ukjent annonse-ID', () => { + renderApp('/annonser/finnes-ikke'); + expect(heading()).toHaveTextContent('Fant ikke annonsen'); + }); +}); + +describe('katalogen', () => { + it('leser filtre fra URL-en', () => { + renderApp('/?gratis=1'); + expect( + screen.getByRole('checkbox', { name: 'Kun gratis utlån' }), + ).toBeChecked(); + const cards = screen + .getAllByRole('listitem') + .filter((item) => within(item).queryByRole('heading')); + expect(cards.length).toBeGreaterThan(0); + for (const card of cards) { + expect(card).toHaveTextContent('Gratis utlån'); + } + }); + + it('oppdaterer antall treff mens brukeren søker', async () => { + renderApp(); + await userEvent.type( + screen.getByRole('searchbox', { name: 'Søk' }), + 'spade', + ); + expect(screen.getByRole('status')).toHaveTextContent(/^Viser 2 av \d+/); + }); + + it('viser tomtilstand og kan fjerne filtrene', async () => { + renderApp('/?q=motorsag'); + expect( + screen.getByRole('heading', { name: 'Ingen annonser passer søket' }), + ).toBeInTheDocument(); + + await userEvent.click( + screen.getByRole('button', { name: 'Fjern alle filtre' }), + ); + expect(screen.getByRole('searchbox', { name: 'Søk' })).toHaveValue(''); + expect(screen.getByRole('status')).not.toHaveTextContent(/Viser/); + }); +}); From 2d26300a7b1563a954028a0f5d2c95c023cf9213 Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 1 Oct 2026 18:14:37 +0200 Subject: [PATCH 7/8] =?UTF-8?q?test(src):=20test=20leieforesp=C3=B8rsel=20?= =?UTF-8?q?og=20utleieskjema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: tordtst Co-authored-by: andretg --- .../ListingForm/ListingForm.test.tsx | 94 +++++++++++++++++++ .../RentalRequestForm.test.tsx | 83 ++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/components/ListingForm/ListingForm.test.tsx create mode 100644 src/components/RentalRequestForm/RentalRequestForm.test.tsx diff --git a/src/components/ListingForm/ListingForm.test.tsx b/src/components/ListingForm/ListingForm.test.tsx new file mode 100644 index 0000000..1c42a23 --- /dev/null +++ b/src/components/ListingForm/ListingForm.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import ListingForm from './ListingForm.tsx'; + +function submit() { + return userEvent.click( + screen.getByRole('button', { name: 'Legg ut verktøy (demo)' }), + ); +} + +async function fillValidForm(dailyPrice: string) { + await userEvent.type( + screen.getByRole('textbox', { name: 'Tittel' }), + 'Stikksag', + ); + await userEvent.selectOptions( + screen.getByRole('combobox', { name: 'Kategori' }), + 'Elektroverktøy', + ); + await userEvent.type( + screen.getByRole('textbox', { name: 'Beskrivelse' }), + 'Stikksag for tre.', + ); + await userEvent.type(screen.getByRole('textbox', { name: 'Område' }), 'Lade'); + await userEvent.type( + screen.getByRole('textbox', { name: 'Døgnpris i kroner' }), + dailyPrice, + ); +} + +describe('ListingForm', () => { + it('har synlige etiketter på alle feltene', () => { + render(); + for (const name of [ + 'Tittel', + 'Beskrivelse', + 'Område', + 'Døgnpris i kroner', + ]) { + expect(screen.getByRole('textbox', { name })).toBeInTheDocument(); + } + expect( + screen.getByRole('combobox', { name: 'Kategori' }), + ).toBeInTheDocument(); + }); + + it('viser feil og flytter fokus til første ugyldige felt', async () => { + render(); + await submit(); + const title = screen.getByRole('textbox', { name: 'Tittel' }); + expect(title).toHaveAttribute('aria-invalid', 'true'); + expect(title).toHaveFocus(); + expect(screen.getByRole('combobox', { name: 'Kategori' })).toHaveAttribute( + 'aria-invalid', + 'true', + ); + }); + + it('forhåndsviser gratis utlån når prisen er 0', async () => { + render(); + await userEvent.type( + screen.getByRole('textbox', { name: 'Døgnpris i kroner' }), + '0', + ); + expect(screen.getByText('Gratis utlån')).toBeInTheDocument(); + }); + + it('forhåndsviser døgnpris for betalt leie', async () => { + render(); + await userEvent.type( + screen.getByRole('textbox', { name: 'Døgnpris i kroner' }), + '150', + ); + expect(screen.getByText('150 kr/døgn')).toBeInTheDocument(); + }); + + it('sier tydelig at annonsen ikke er lagret', async () => { + render(); + await fillValidForm('80'); + await submit(); + expect(screen.getByRole('status')).toHaveTextContent( + /Annonsen er ikke lagret/, + ); + }); + + it('fjerner demomeldingen når brukeren endrer et felt', async () => { + render(); + await fillValidForm('80'); + await submit(); + await userEvent.type(screen.getByRole('textbox', { name: 'Område' }), 'n'); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/RentalRequestForm/RentalRequestForm.test.tsx b/src/components/RentalRequestForm/RentalRequestForm.test.tsx new file mode 100644 index 0000000..b7944a8 --- /dev/null +++ b/src/components/RentalRequestForm/RentalRequestForm.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import RentalRequestForm from './RentalRequestForm.tsx'; + +function setDate(label: string, value: string) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); +} + +function submit() { + return userEvent.click( + screen.getByRole('button', { name: 'Send forespørsel (demo)' }), + ); +} + +describe('RentalRequestForm', () => { + // Fast dato, slik at testene ikke avhenger av når de kjøres. Bare Date + // forfalskes, så userEvent sine tidtakere virker som vanlig. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 9, 5, 12)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('viser ingen feil før brukeren prøver å sende', () => { + render(); + expect(screen.getByLabelText('Fra dato')).not.toHaveAttribute( + 'aria-invalid', + ); + }); + + it('viser feil ved begge datofeltene ved tom innsending', async () => { + render(); + await submit(); + expect(screen.getByLabelText('Fra dato')).toHaveAttribute( + 'aria-invalid', + 'true', + ); + expect(screen.getByLabelText('Til dato')).toHaveAttribute( + 'aria-invalid', + 'true', + ); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); + + it('viser samlet pris for betalt leie', () => { + render(); + setDate('Fra dato', '2026-10-05'); + setDate('Til dato', '2026-10-08'); + expect(screen.getByText(/3 døgn × 100 kr =/)).toBeInTheDocument(); + expect(screen.getByText('300 kr')).toBeInTheDocument(); + }); + + it('viser at gratis utlån ikke koster noe', () => { + render(); + setDate('Fra dato', '2026-10-05'); + setDate('Til dato', '2026-10-05'); + expect(screen.getByText(/det koster ingenting/)).toBeInTheDocument(); + }); + + it('sier tydelig at ingenting er sendt eller reservert', async () => { + render(); + setDate('Fra dato', '2026-10-05'); + setDate('Til dato', '2026-10-06'); + await submit(); + expect(screen.getByRole('status')).toHaveTextContent( + /Ingen forespørsel er sendt/, + ); + }); + + it('avviser tildato før fradato', async () => { + render(); + setDate('Fra dato', '2026-10-08'); + setDate('Til dato', '2026-10-06'); + await submit(); + expect(screen.getByLabelText('Til dato')).toHaveAccessibleDescription( + /før fradatoen/, + ); + }); +}); From 8db3b7dfb25d24f494c352c0c60517ffa345e37b Mon Sep 17 00:00:00 2001 From: Sebastian Ingebrigtsen Date: Thu, 1 Oct 2026 18:16:20 +0200 Subject: [PATCH 8/8] docs(docs): dokumenter tester og kontroller av brukeropplevelsen Co-authored-by: andretg --- README.md | 6 +++ docs/testing.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 docs/testing.md diff --git a/README.md b/README.md index 4cf757c..8b98a5c 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ npm run dev # start utviklingsserver på http://localhost:5173 | `npm run build` | Kjører typekontroll og bygger til `dist/` | | `npm run preview` | Viser det bygde resultatet lokalt | | `npm run typecheck` | Kjører TypeScript med streng typekontroll | +| `npm test` | Kjører alle testene én gang | +| `npm run test:watch` | Kjører testene ved hver endring | | `npm run lint` | Kjører ESLint på hele prosjektet | | `npm run format` | Formaterer alle filer med Prettier | | `npm run format:check` | Sjekker formatering uten å endre filer | @@ -101,6 +103,9 @@ ESLint finner feil i koden, mens Prettier bestemmer formateringen. ESLint-regler som ellers ville kollidert med Prettier. Byggfiler, avhengigheter og `package-lock.json` formateres ikke. +Testene bruker Vitest og React Testing Library. Hva som testes, og hvilke +kontroller som er gjort i nettleser, står i [docs/testing.md](docs/testing.md). + ## Mappestruktur ``` @@ -114,6 +119,7 @@ src/ pages/ Én mappe per side hooks/ Små felles hooks, for eksempel usePageTitle design/ Designreferanse, bare under utvikling + test/ Felles oppsett for testene ``` Komponenter i `components/` får data gjennom props og importerer ikke diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..37bbda9 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,117 @@ +# Testing + +Oversikt over automatiske tester og kontroller av brukeropplevelsen for første +innlevering. Dokumentet oppdateres når nye tester eller kontroller kommer til. + +## Kjøre testene + +```bash +npm test # kjører alle testene én gang og avslutter +npm run test:watch # kjører testene på nytt ved hver endring +``` + +Testene bruker [Vitest](https://vitest.dev/) med +[React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) +og jsdom, så de trenger ingen nettleser. Vitest bruker samme oppsett som Vite, +så CSS-moduler, SVG-importer og TypeScript virker likt i test og app. +Oppsettet ligger i `vite.config.ts` og `src/test/setup.ts`. + +Testfilene ligger ved siden av koden de tester og heter `*.test.ts` eller +`*.test.tsx`. + +## Prinsipper + +- **Test oppførsel, ikke implementasjon.** Testene finner elementer slik en + bruker eller skjermleser gjør, med rolle og tilgjengelig navn, for eksempel + `getByRole('button', { name: 'Send forespørsel (demo)' })`. CSS-klasser og + intern tilstand testes ikke. +- **Bruk `userEvent` for interaksjon**, slik at klikk og tastetrykk oppfører + seg som i en nettleser. Datofelt endres med `fireEvent.change`, fordi + jsdom ikke har nettleserens datovelger. +- **Sjekk `aria-invalid`, ikke `toBeInvalid()`, på påkrevde felt.** + `toBeInvalid()` bruker også nettleserens egen validering, og et tomt + `required`-felt er da alltid ugyldig. Testen ville bestått selv om skjemaet + ikke viste noen feil. +- **Lås datoen når testen avhenger av «i dag».** Testene for leieforespørsel + bruker `vi.useFakeTimers({ toFake: ['Date'] })`, så bare `Date` forfalskes + og `userEvent` virker som vanlig. + +## Hva som testes + +| Fil | Hva som testes | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `src/App.test.tsx` | Navigasjon via meny, annonsekort og tilbakelenke, aktiv side, fanetittel, fokus ved sidebytte, hopp-lenke, 404-sider, katalogsøk | +| `utils/listingFormat.test.ts` | Døgnpris, gratis utlån og norsk tusenskille | +| `utils/listingFilters.test.ts` | Søk uten å skille store og små bokstaver, tomt søk, kombinerte filtre, filtre i URL-en | +| `utils/listingSort.test.ts` | Sortering på pris, stabil rekkefølge ved lik pris, sortering i URL-en | +| `utils/rentalPeriod.test.ts` | Antall døgn, samme dag, overgang fra sommertid, datovalidering | +| `utils/listingForm.test.ts` | Validering av alle feltene, pris 0, ugyldige priser som «12,5» og «1e3» | +| `components/PriceTag/` | Gratismerking og døgnpris vises som tekst | +| `components/TextField/` | Tilgjengelig navn, beskrivelse og feilmelding for `TextField`, `SelectField` og `TextAreaField` | +| `components/Button/` | Sender ikke inn skjema ved et uhell, tastatur og deaktivert knapp | +| `components/RentalRequestForm/` | Feil ved tomme og ugyldige datoer, samlet pris, gratis utlån, demomelding | +| `components/ListingForm/` | Etiketter, feil og fokus på første ugyldige felt, prisforhåndsvisning, demomelding | + +Status 1. oktober 2026: 11 testfiler og 79 tester, alle grønne. + +## Kontroller før innlevering + +Disse kommandoene skal gå uten feil før en pull request merges: + +```bash +npm run typecheck +npm run lint +npm run format:check +npm test +npm run build +``` + +### Ren installasjon + +Kontrollert 1. oktober 2026 med en ny klone av repoet, Node 24.21 og npm +11.19, ved å følge README: `npm ci`, deretter alle kommandoene over og +`npm run dev`. Alt gikk uten feil, og utviklingsserveren svarte på +`http://localhost:5173`. + +npm skriver en advarsel om at `fsevents` har installasjonsskript. Pakken +brukes bare på macOS av filovervåkingen, og advarselen kan ignoreres. + +### Mobil og desktop + +Kontrollert i Chromium med Playwright i breddene 320, 375, 768 og 1280 px. +Sidene som ble sjekket: + +- katalogen uten filtre, med filtre og sortering, og uten treff +- detaljvisning med lang tittel, uten bilde og med gratis utlån +- utleieskjemaet med feilmeldinger synlige +- «Fant ikke siden» og «Fant ikke annonsen» + +Resultat: ingen sider gir horisontal scrolling i noen av breddene. Minste +tekststørrelse er 14 px. Lange titler brytes over flere linjer. + +### Tastatur og synlig fokus + +Kontrollert ved å tabulere gjennom katalogen, detaljvisningen og +utleieskjemaet. + +- Rekkefølgen følger visningen: hopp-lenke, logo, meny, filtre, sortering og + annonsekort. +- Alle elementer som får fokus, har synlig fokusring. På annonsekortene + tegnes ringen rundt hele kortet. +- Hopp-lenken flytter fokus til hovedinnholdet. +- Annonsekort åpnes med Enter. +- Etter en innsending med feil havner fokus på første ugyldige felt. + +## Begrensninger + +- **Nettleserkontrollene er automatiserte.** Mobil, desktop og tastatur er + kontrollert med Playwright i Chromium, ikke på en ekte telefon, i Firefox + eller Safari, eller med skjermleser. Dette bør kontrolleres for hånd før + senere innleveringer. +- **Datofelt i Chromium** har ett tabulatorstopp for dag, måned og år. Det er + nettleserens egen oppførsel. +- **Ingen ende-til-ende-tester eller API-tester ennå.** De kommer når + backenden finnes. Testene for appen rendrer hele `App` i en `MemoryRouter`, + men kjører i jsdom og sjekker ikke layout. +- **`favicon.ico` mangler**, så nettleseren logger en 404 i konsollen. Det + påvirker ikke appen.