From f47fcf31b83c3f40d10218578fd5e37779bc5542 Mon Sep 17 00:00:00 2001 From: ewalid Date: Sat, 3 Jan 2026 00:18:53 +0100 Subject: [PATCH 1/4] update tests --- frontend/package-lock.json | 1268 ++++++++++++++++- frontend/package.json | 20 +- frontend/src/api/client.test.ts | 200 +++ .../features/feedback/FeedbackModal.test.tsx | 197 +++ .../translate/ProgressIndicator.test.tsx | 77 + frontend/src/test/setup.ts | 27 + frontend/vite.config.ts | 7 + 7 files changed, 1787 insertions(+), 9 deletions(-) create mode 100644 frontend/src/api/client.test.ts create mode 100644 frontend/src/components/features/feedback/FeedbackModal.test.tsx create mode 100644 frontend/src/components/features/translate/ProgressIndicator.test.tsx create mode 100644 frontend/src/test/setup.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 38eb6f8..c7442bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,10 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.0.3", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", @@ -25,13 +29,29 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "happy-dom": "^20.0.11", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^4.0.16" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "optional": true, + "peer": true + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -44,6 +64,66 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -262,6 +342,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -307,6 +396,147 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "optional": true, + "peer": true, + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -869,6 +1099,25 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", + "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1297,6 +1546,104 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "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": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "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": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "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 + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "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.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "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, + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1338,6 +1685,22 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "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 + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1350,6 +1713,15 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1375,6 +1747,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.51.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", @@ -1650,6 +2028,110 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.0.16", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", + "dev": true, + "dependencies": { + "@vitest/utils": "4.0.16", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.0.16", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1671,6 +2153,17 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1687,6 +2180,16 @@ "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, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1733,8 +2236,26 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/autoprefixer": { - "version": "10.4.23", + "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, + "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, + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", "dev": true, @@ -1784,6 +2305,17 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1889,6 +2421,15 @@ } ] }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2002,6 +2543,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.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 + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2014,12 +2576,55 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.6.tgz", + "integrity": "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2037,12 +2642,29 @@ } } }, + "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, + "optional": true, + "peer": true + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2055,12 +2677,39 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, + "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, + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2281,6 +2930,15 @@ "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, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2290,6 +2948,15 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "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", @@ -2499,6 +3166,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/happy-dom": { + "version": "20.0.11", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.11.tgz", + "integrity": "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g==", + "dev": true, + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2520,6 +3225,50 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2554,6 +3303,15 @@ "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, + "engines": { + "node": ">=8" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2611,6 +3369,14 @@ "node": ">=0.12.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, + "optional": true, + "peer": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2643,6 +3409,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2774,6 +3581,33 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "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, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2796,6 +3630,15 @@ "node": ">=8.6" } }, + "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, + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2895,6 +3738,16 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ] + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2954,6 +3807,20 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "entities": "^6.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", @@ -2978,6 +3845,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3179,6 +4052,34 @@ "node": ">= 0.8.0" } }, + "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, + "peer": true, + "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/pretty-format/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, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3231,6 +4132,13 @@ "react": "^18.3.1" } }, + "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, + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3291,6 +4199,30 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "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, + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3394,6 +4326,20 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3432,6 +4378,12 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3441,6 +4393,30 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "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, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3499,6 +4475,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3557,6 +4541,21 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3602,6 +4601,37 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "optional": true, + "peer": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3614,6 +4644,34 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "optional": true, + "peer": true, + "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, + "optional": true, + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -3685,6 +4743,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3833,6 +4897,146 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", + "happy-dom": "*", + "jsdom": "*" + }, + "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/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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, + "optional": true, + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3848,6 +5052,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3857,6 +5077,48 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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, + "optional": true, + "peer": true, + "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, + "optional": true, + "peer": true + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8f7c54a..f146353 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,18 +7,24 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run" }, "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0", + "clsx": "^2.1.1", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", - "clsx": "^2.1.1" + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" }, "devDependencies": { "@eslint/js": "^9.17.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.0.3", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", @@ -27,10 +33,12 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "happy-dom": "^20.0.11", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", - "vite": "^6.0.5" + "vite": "^6.0.5", + "vitest": "^4.0.16" } } diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts new file mode 100644 index 0000000..8d6b96e --- /dev/null +++ b/frontend/src/api/client.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getSheets, translateFile, submitFeedback } from './client'; + +describe('API Client', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('getSheets', () => { + it('returns sheets on success', async () => { + const mockSheets = ['Sheet1', 'Sheet2', 'Sheet3']; + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ sheets: mockSheets }), + } as Response); + + const file = new File(['test'], 'test.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + const result = await getSheets(file); + + expect(result.success).toBe(true); + expect(result.sheets).toEqual(mockSheets); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/sheets'), + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('returns error on failure', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ detail: 'Invalid file type' }), + } as Response); + + const file = new File(['test'], 'test.txt', { type: 'text/plain' }); + const result = await getSheets(file); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid file type'); + }); + + it('handles network errors', async () => { + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error')); + + const file = new File(['test'], 'test.xlsx'); + const result = await getSheets(file); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + }); + + describe('translateFile', () => { + it('returns blob on success', async () => { + const mockBlob = new Blob(['translated content'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(mockBlob), + } as Response); + + const file = new File(['test'], 'test.xlsx'); + const result = await translateFile({ + file, + targetLanguage: 'french', + }); + + expect(result.success).toBe(true); + expect(result.blob).toBe(mockBlob); + expect(result.filename).toBe('test_french.xlsx'); + }); + + it('sends optional parameters when provided', async () => { + const mockBlob = new Blob(['translated']); + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(mockBlob), + } as Response); + + const file = new File(['test'], 'data.xlsx'); + await translateFile({ + file, + targetLanguage: 'spanish', + sourceLanguage: 'english', + context: 'Medical terminology', + sheets: ['Sheet1', 'Sheet2'], + }); + + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + const formData = fetchCall[1]?.body as FormData; + + expect(formData.get('target_lang')).toBe('spanish'); + expect(formData.get('source_lang')).toBe('english'); + expect(formData.get('context')).toBe('Medical terminology'); + expect(formData.get('sheets')).toBe('Sheet1,Sheet2'); + }); + + it('returns error on failure', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({ detail: 'Translation failed' }), + } as Response); + + const file = new File(['test'], 'test.xlsx'); + const result = await translateFile({ + file, + targetLanguage: 'french', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Translation failed'); + }); + + it('handles network errors', async () => { + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Connection refused')); + + const file = new File(['test'], 'test.xlsx'); + const result = await translateFile({ + file, + targetLanguage: 'german', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Connection refused'); + }); + }); + + describe('submitFeedback', () => { + it('submits feedback successfully', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, message: 'Feedback submitted' }), + } as Response); + + const result = await submitFeedback({ + rating: 5, + improvements: ['Translation quality', 'Speed/Performance'], + additionalFeedback: 'Great tool!', + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Feedback submitted'); + + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + expect(fetchCall[0]).toContain('/feedback'); + expect(fetchCall[1]?.headers).toEqual({ 'Content-Type': 'application/json' }); + + const body = JSON.parse(fetchCall[1]?.body as string); + expect(body.rating).toBe(5); + expect(body.improvements).toEqual(['Translation quality', 'Speed/Performance']); + expect(body.additional_feedback).toBe('Great tool!'); + }); + + it('submits minimal feedback', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + } as Response); + + const result = await submitFeedback({ + rating: 3, + improvements: [], + }); + + expect(result.success).toBe(true); + + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(fetchCall[1]?.body as string); + expect(body.additional_feedback).toBeUndefined(); + }); + + it('handles server errors', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 422, + json: () => Promise.resolve({ detail: 'Invalid rating' }), + } as Response); + + const result = await submitFeedback({ + rating: 5, + improvements: [], + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid rating'); + }); + + it('handles network errors', async () => { + vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network failure')); + + const result = await submitFeedback({ + rating: 4, + improvements: ['User interface'], + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network failure'); + }); + }); +}); diff --git a/frontend/src/components/features/feedback/FeedbackModal.test.tsx b/frontend/src/components/features/feedback/FeedbackModal.test.tsx new file mode 100644 index 0000000..ea16e79 --- /dev/null +++ b/frontend/src/components/features/feedback/FeedbackModal.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FeedbackModal } from './FeedbackModal'; + +// Mock the API client +vi.mock('../../../api/client', () => ({ + submitFeedback: vi.fn(), +})); + +import { submitFeedback } from '../../../api/client'; + +describe('FeedbackModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders when open', () => { + render( {}} />); + expect(screen.getByText('How satisfied are you with Rosetta?')).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render( {}} />); + expect(screen.queryByText('How satisfied are you with Rosetta?')).not.toBeInTheDocument(); + }); + + it('shows rating emojis on step 1', () => { + render( {}} />); + + // Check for emoji buttons (5 ratings) + const emojiButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-emoji') + ); + expect(emojiButtons).toHaveLength(5); + }); + + it('advances to step 2 when rating is selected', async () => { + const user = userEvent.setup(); + render( {}} />); + + // Click the "Very Satisfied" emoji (last one) + const emojiButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-emoji') + ); + await user.click(emojiButtons[4]); + + // Wait for step 2 to appear + await waitFor(() => { + expect(screen.getByText('What could we improve?')).toBeInTheDocument(); + }); + }); + + it('shows improvement chips on step 2', async () => { + const user = userEvent.setup(); + render( {}} />); + + // Select a rating to advance + const emojiButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-emoji') + ); + await user.click(emojiButtons[2]); + + await waitFor(() => { + expect(screen.getByText('Translation quality')).toBeInTheDocument(); + expect(screen.getByText('Speed/Performance')).toBeInTheDocument(); + expect(screen.getByText('User interface')).toBeInTheDocument(); + }); + }); + + it('allows selecting multiple improvements', async () => { + const user = userEvent.setup(); + render( {}} />); + + // Select rating + const emojiButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-emoji') + ); + await user.click(emojiButtons[3]); + + await waitFor(() => { + expect(screen.getByText('What could we improve?')).toBeInTheDocument(); + }); + + // Select improvements + await user.click(screen.getByText('Translation quality')); + await user.click(screen.getByText('User interface')); + + // Both should be selected + expect(screen.getByText('Translation quality').closest('button')).toHaveClass('selected'); + expect(screen.getByText('User interface').closest('button')).toHaveClass('selected'); + }); + + it('calls onClose when close button is clicked', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + + const closeButton = screen.getByRole('button', { name: '' }); // X button has no text + await user.click(closeButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it('submits feedback successfully', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + vi.mocked(submitFeedback).mockResolvedValueOnce({ success: true }); + + render(); + + // Step 1: Select rating + const emojiButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-emoji') + ); + await user.click(emojiButtons[4]); + + // Step 2: Select improvements + await waitFor(() => { + expect(screen.getByText('What could we improve?')).toBeInTheDocument(); + }); + await user.click(screen.getByText('Translation quality')); + await user.click(screen.getByText('Next')); + + // Step 3: Add feedback and submit + await waitFor(() => { + expect(screen.getByText('Any additional feedback?')).toBeInTheDocument(); + }); + + const textarea = screen.getByPlaceholderText(/Tell us more/); + await user.type(textarea, 'Great tool!'); + + await user.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(submitFeedback).toHaveBeenCalledWith({ + rating: 5, + improvements: ['Translation quality'], + additionalFeedback: 'Great tool!', + }); + }); + }); + + it('shows success message after submission', async () => { + const user = userEvent.setup(); + vi.mocked(submitFeedback).mockResolvedValueOnce({ success: true }); + + render( {}} />); + + // Complete the flow + const emojiButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-emoji') + ); + await user.click(emojiButtons[4]); + + await waitFor(() => screen.getByText('What could we improve?')); + await user.click(screen.getByText('Speed/Performance')); + await user.click(screen.getByText('Next')); + + await waitFor(() => screen.getByText('Any additional feedback?')); + await user.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(screen.getByText('Thank you!')).toBeInTheDocument(); + }); + }); + + it('allows navigating back between steps', async () => { + const user = userEvent.setup(); + render( {}} />); + + // Go to step 2 + const emojiButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-emoji') + ); + await user.click(emojiButtons[2]); + + await waitFor(() => screen.getByText('What could we improve?')); + await user.click(screen.getByText('User interface')); + await user.click(screen.getByText('Next')); + + // Go to step 3 + await waitFor(() => screen.getByText('Any additional feedback?')); + + // Go back to step 2 + await user.click(screen.getByText('Back')); + await waitFor(() => { + expect(screen.getByText('What could we improve?')).toBeInTheDocument(); + }); + + // Go back to step 1 + await user.click(screen.getByText('Back')); + await waitFor(() => { + expect(screen.getByText('How satisfied are you with Rosetta?')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/features/translate/ProgressIndicator.test.tsx b/frontend/src/components/features/translate/ProgressIndicator.test.tsx new file mode 100644 index 0000000..5f88ffd --- /dev/null +++ b/frontend/src/components/features/translate/ProgressIndicator.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProgressIndicator } from './ProgressIndicator'; + +describe('ProgressIndicator', () => { + it('does not render when status is idle', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('does not render when status is error', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders when status is uploading', () => { + render(); + expect(screen.getByText('Uploading file')).toBeInTheDocument(); + }); + + it('renders when status is translating', () => { + render(); + expect(screen.getByText('Translating content')).toBeInTheDocument(); + }); + + it('renders when status is success', () => { + render(); + expect(screen.getByText('Complete')).toBeInTheDocument(); + }); + + it('shows all three stages', () => { + render(); + expect(screen.getByText('Uploading file')).toBeInTheDocument(); + expect(screen.getByText('Translating content')).toBeInTheDocument(); + expect(screen.getByText('Complete')).toBeInTheDocument(); + }); + + it('highlights current stage as active', () => { + render(); + + const uploadingStage = screen.getByText('Uploading file').closest('.progress-stage'); + const translatingStage = screen.getByText('Translating content').closest('.progress-stage'); + const completeStage = screen.getByText('Complete').closest('.progress-stage'); + + expect(uploadingStage).toHaveClass('complete'); + expect(translatingStage).toHaveClass('active'); + expect(completeStage).not.toHaveClass('active'); + expect(completeStage).not.toHaveClass('complete'); + }); + + it('marks all stages complete on success', () => { + render(); + + const uploadingStage = screen.getByText('Uploading file').closest('.progress-stage'); + const translatingStage = screen.getByText('Translating content').closest('.progress-stage'); + const completeStage = screen.getByText('Complete').closest('.progress-stage'); + + expect(uploadingStage).toHaveClass('complete'); + expect(translatingStage).toHaveClass('complete'); + expect(completeStage).toHaveClass('complete'); + }); + + it('shows hint text during translation', () => { + render(); + expect(screen.getByText(/Translation time depends on file size/)).toBeInTheDocument(); + }); + + it('does not show hint text during upload', () => { + render(); + expect(screen.queryByText(/Translation time depends/)).not.toBeInTheDocument(); + }); + + it('does not show hint text on success', () => { + render(); + expect(screen.queryByText(/Translation time depends/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..bafbc89 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,27 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +// Mock framer-motion to avoid animation issues in tests +vi.mock('framer-motion', async () => { + const actual = await vi.importActual('framer-motion'); + return { + ...actual, + motion: { + div: 'div', + button: 'button', + footer: 'footer', + label: 'label', + span: 'span', + p: 'p', + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => children, + }; +}); + +// Mock fetch globally +global.fetch = vi.fn(); + +// Reset mocks after each test +afterEach(() => { + vi.clearAllMocks(); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9f4c934..94816f2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' @@ -13,4 +14,10 @@ export default defineConfig({ }, }, }, + test: { + globals: true, + environment: 'happy-dom', + setupFiles: './src/test/setup.ts', + include: ['src/**/*.{test,spec}.{ts,tsx}'], + }, }) From dd4139cd8e2028460f2c3e5fc5250c8f563405f8 Mon Sep 17 00:00:00 2001 From: ewalid Date: Sat, 3 Jan 2026 00:22:37 +0100 Subject: [PATCH 2/4] trigger js test for each PR --- .github/workflows/tests.yml | 29 +++++- tests/test_api.py | 185 ++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6dab004..4f8477d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,8 @@ on: branches: [master, main] jobs: - test: + backend-tests: + name: Backend Tests runs-on: ubuntu-latest strategy: matrix: @@ -35,3 +36,29 @@ jobs: with: file: ./coverage.xml fail_ci_if_error: false + + frontend-tests: + name: Frontend Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Run tests + working-directory: frontend + run: npm run test:run + + - name: Build + working-directory: frontend + run: npm run build diff --git a/tests/test_api.py b/tests/test_api.py index dc21023..ba4bd6a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -223,3 +223,188 @@ def test_large_file_returns_400(self, client): assert response.status_code == 400 assert "File too large" in response.json()["detail"] + + +class TestSheetsEndpoint: + """Tests for the /sheets endpoint.""" + + def test_get_sheets_returns_sheet_names(self, client, sample_excel_bytes): + """POST /sheets should return list of sheet names.""" + response = client.post( + "/sheets", + files={"file": ("test.xlsx", sample_excel_bytes)}, + ) + + assert response.status_code == 200 + data = response.json() + assert "sheets" in data + assert isinstance(data["sheets"], list) + assert len(data["sheets"]) >= 1 + + def test_get_sheets_multiple_sheets(self, client): + """POST /sheets with multi-sheet file returns all sheet names.""" + # Create Excel with multiple sheets + wb = Workbook() + wb.active.title = "First" + wb.create_sheet("Second") + wb.create_sheet("Third") + + buffer = io.BytesIO() + wb.save(buffer) + buffer.seek(0) + + response = client.post( + "/sheets", + files={"file": ("multi.xlsx", buffer.getvalue())}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["sheets"] == ["First", "Second", "Third"] + + def test_get_sheets_invalid_file_type(self, client): + """POST /sheets with non-Excel file should return 400.""" + response = client.post( + "/sheets", + files={"file": ("test.txt", b"Hello world")}, + ) + + assert response.status_code == 400 + assert "Invalid file type" in response.json()["detail"] + + def test_get_sheets_missing_file(self, client): + """POST /sheets without file should return 422.""" + response = client.post("/sheets") + assert response.status_code == 422 + + def test_get_sheets_large_file(self, client): + """POST /sheets with file over 50MB should return 400.""" + large_content = b"x" * (51 * 1024 * 1024) + + response = client.post( + "/sheets", + files={"file": ("large.xlsx", large_content)}, + ) + + assert response.status_code == 400 + assert "File too large" in response.json()["detail"] + + +class TestFeedbackEndpoint: + """Tests for the /feedback endpoint.""" + + def test_submit_feedback_success(self, client): + """POST /feedback with valid data should succeed.""" + response = client.post( + "/feedback", + json={ + "rating": 5, + "improvements": ["Translation quality", "Speed/Performance"], + "additional_feedback": "Great tool!", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + def test_submit_feedback_minimal(self, client): + """POST /feedback with only required fields should succeed.""" + response = client.post( + "/feedback", + json={ + "rating": 3, + "improvements": [], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + def test_submit_feedback_all_ratings(self, client): + """POST /feedback should accept all valid rating values.""" + for rating in [1, 2, 3, 4, 5]: + response = client.post( + "/feedback", + json={ + "rating": rating, + "improvements": ["User interface"], + }, + ) + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_submit_feedback_missing_rating(self, client): + """POST /feedback without rating should return 422.""" + response = client.post( + "/feedback", + json={ + "improvements": ["Translation quality"], + }, + ) + assert response.status_code == 422 + + def test_submit_feedback_missing_improvements(self, client): + """POST /feedback without improvements should return 422.""" + response = client.post( + "/feedback", + json={ + "rating": 4, + }, + ) + assert response.status_code == 422 + + def test_submit_feedback_invalid_json(self, client): + """POST /feedback with invalid JSON should return 422.""" + response = client.post( + "/feedback", + content="not valid json", + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 422 + + def test_submit_feedback_empty_body(self, client): + """POST /feedback with empty body should return 422.""" + response = client.post( + "/feedback", + json={}, + ) + assert response.status_code == 422 + + def test_submit_feedback_with_multiple_improvements(self, client): + """POST /feedback with multiple improvement areas should succeed.""" + response = client.post( + "/feedback", + json={ + "rating": 4, + "improvements": [ + "Translation quality", + "Speed/Performance", + "User interface", + "Language options", + "File format support", + "Documentation", + ], + "additional_feedback": "Comprehensive feedback with all options selected.", + }, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_submit_feedback_long_additional_feedback(self, client): + """POST /feedback with long additional feedback should succeed.""" + long_feedback = "This is a detailed feedback. " * 100 # ~3000 chars + + response = client.post( + "/feedback", + json={ + "rating": 5, + "improvements": ["Translation quality"], + "additional_feedback": long_feedback, + }, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True From 9c4c230b93596337748cb038b631cff10a94097e Mon Sep 17 00:00:00 2001 From: ewalid Date: Sat, 3 Jan 2026 00:32:13 +0100 Subject: [PATCH 3/4] fix testing --- .github/workflows/tests.yml | 7 ++----- frontend/tsconfig.json | 3 ++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4f8477d..d97224b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,17 +10,14 @@ jobs: backend-tests: name: Backend Tests runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - name: Install dependencies run: | diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 10f71ea..f37697f 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,5 +22,6 @@ "@/*": ["./src/*"] } }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"] } From c1672d34392052e9c96b364ddabb3c3fae71bae0 Mon Sep 17 00:00:00 2001 From: ewalid Date: Sat, 3 Jan 2026 00:35:34 +0100 Subject: [PATCH 4/4] fix: add missing frontend/src/lib directory to git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lib/ pattern in .gitignore was ignoring all lib directories including frontend/src/lib/ which contains utils.ts needed by the application. Changed lib/ to /lib/ to only ignore lib at the root level. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 4 +- frontend/src/lib/languages.ts | 37 ++++++++++ frontend/src/lib/utils.test.ts | 124 +++++++++++++++++++++++++++++++++ frontend/src/lib/utils.ts | 37 ++++++++++ 4 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/languages.ts create mode 100644 frontend/src/lib/utils.test.ts create mode 100644 frontend/src/lib/utils.ts diff --git a/.gitignore b/.gitignore index 570019c..a9021fc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/frontend/src/lib/languages.ts b/frontend/src/lib/languages.ts new file mode 100644 index 0000000..886742b --- /dev/null +++ b/frontend/src/lib/languages.ts @@ -0,0 +1,37 @@ +import type { Language } from '../types'; + +export const languages: Language[] = [ + { code: 'en', name: 'English', flag: '🇬🇧' }, + { code: 'es', name: 'Spanish', flag: '🇪🇸' }, + { code: 'fr', name: 'French', flag: '🇫🇷' }, + { code: 'de', name: 'German', flag: '🇩🇪' }, + { code: 'it', name: 'Italian', flag: '🇮🇹' }, + { code: 'pt', name: 'Portuguese', flag: '🇵🇹' }, + { code: 'nl', name: 'Dutch', flag: '🇳🇱' }, + { code: 'pl', name: 'Polish', flag: '🇵🇱' }, + { code: 'ru', name: 'Russian', flag: '🇷🇺' }, + { code: 'ja', name: 'Japanese', flag: '🇯🇵' }, + { code: 'ko', name: 'Korean', flag: '🇰🇷' }, + { code: 'zh', name: 'Chinese', flag: '🇨🇳' }, + { code: 'ar', name: 'Arabic', flag: '🇸🇦' }, + { code: 'hi', name: 'Hindi', flag: '🇮🇳' }, + { code: 'tr', name: 'Turkish', flag: '🇹🇷' }, + { code: 'sv', name: 'Swedish', flag: '🇸🇪' }, + { code: 'da', name: 'Danish', flag: '🇩🇰' }, + { code: 'no', name: 'Norwegian', flag: '🇳🇴' }, + { code: 'fi', name: 'Finnish', flag: '🇫🇮' }, + { code: 'el', name: 'Greek', flag: '🇬🇷' }, + { code: 'cs', name: 'Czech', flag: '🇨🇿' }, + { code: 'ro', name: 'Romanian', flag: '🇷🇴' }, + { code: 'hu', name: 'Hungarian', flag: '🇭🇺' }, + { code: 'th', name: 'Thai', flag: '🇹🇭' }, + { code: 'vi', name: 'Vietnamese', flag: '🇻🇳' }, + { code: 'id', name: 'Indonesian', flag: '🇮🇩' }, + { code: 'ms', name: 'Malay', flag: '🇲🇾' }, + { code: 'he', name: 'Hebrew', flag: '🇮🇱' }, + { code: 'uk', name: 'Ukrainian', flag: '🇺🇦' }, +]; + +export function getLanguageByCode(code: string): Language | undefined { + return languages.find((lang) => lang.code === code); +} diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts new file mode 100644 index 0000000..75ab07d --- /dev/null +++ b/frontend/src/lib/utils.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { cn, formatFileSize, getFileExtension, isValidExcelFile, generateOutputFilename } from './utils'; + +describe('cn (className utility)', () => { + it('merges class names', () => { + expect(cn('foo', 'bar')).toBe('foo bar'); + }); + + it('handles conditional classes', () => { + expect(cn('base', false && 'hidden', true && 'visible')).toBe('base visible'); + }); + + it('handles undefined and null', () => { + expect(cn('base', undefined, null, 'end')).toBe('base end'); + }); +}); + +describe('formatFileSize', () => { + it('formats 0 bytes', () => { + expect(formatFileSize(0)).toBe('0 Bytes'); + }); + + it('formats bytes', () => { + expect(formatFileSize(500)).toBe('500 Bytes'); + }); + + it('formats kilobytes', () => { + expect(formatFileSize(1024)).toBe('1 KB'); + expect(formatFileSize(1536)).toBe('1.5 KB'); + }); + + it('formats megabytes', () => { + expect(formatFileSize(1024 * 1024)).toBe('1 MB'); + expect(formatFileSize(5.5 * 1024 * 1024)).toBe('5.5 MB'); + }); + + it('formats gigabytes', () => { + expect(formatFileSize(1024 * 1024 * 1024)).toBe('1 GB'); + }); +}); + +describe('getFileExtension', () => { + it('extracts xlsx extension', () => { + expect(getFileExtension('document.xlsx')).toBe('xlsx'); + }); + + it('extracts xlsm extension', () => { + expect(getFileExtension('macro-enabled.xlsm')).toBe('xlsm'); + }); + + it('handles multiple dots in filename', () => { + expect(getFileExtension('my.file.name.xlsx')).toBe('xlsx'); + }); + + it('returns lowercase extension', () => { + expect(getFileExtension('FILE.XLSX')).toBe('xlsx'); + }); + + it('handles no extension', () => { + expect(getFileExtension('noextension')).toBe(''); + }); + + it('handles hidden files', () => { + expect(getFileExtension('.hidden')).toBe(''); + }); +}); + +describe('isValidExcelFile', () => { + it('accepts xlsx files by MIME type', () => { + const file = new File(['test'], 'test.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + expect(isValidExcelFile(file)).toBe(true); + }); + + it('accepts xlsx files by extension', () => { + const file = new File(['test'], 'test.xlsx', { type: '' }); + expect(isValidExcelFile(file)).toBe(true); + }); + + it('accepts xlsm files', () => { + const file = new File(['test'], 'test.xlsm', { type: '' }); + expect(isValidExcelFile(file)).toBe(true); + }); + + it('accepts xltx files', () => { + const file = new File(['test'], 'template.xltx', { type: '' }); + expect(isValidExcelFile(file)).toBe(true); + }); + + it('accepts xltm files', () => { + const file = new File(['test'], 'macro-template.xltm', { type: '' }); + expect(isValidExcelFile(file)).toBe(true); + }); + + it('rejects txt files', () => { + const file = new File(['test'], 'test.txt', { type: 'text/plain' }); + expect(isValidExcelFile(file)).toBe(false); + }); + + it('rejects csv files', () => { + const file = new File(['test'], 'data.csv', { type: 'text/csv' }); + expect(isValidExcelFile(file)).toBe(false); + }); + + it('rejects old xls format', () => { + const file = new File(['test'], 'old.xls', { type: 'application/vnd.ms-excel' }); + expect(isValidExcelFile(file)).toBe(false); + }); +}); + +describe('generateOutputFilename', () => { + it('appends language code to filename', () => { + expect(generateOutputFilename('document.xlsx', 'french')).toBe('document_french.xlsx'); + }); + + it('handles complex filenames', () => { + expect(generateOutputFilename('my.complex.file.xlsx', 'spanish')).toBe('my.complex.file_spanish.xlsx'); + }); + + it('works with different extensions', () => { + expect(generateOutputFilename('data.xlsm', 'german')).toBe('data_german.xlsm'); + }); +}); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..3a300ad --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,37 @@ +import { clsx, type ClassValue } from 'clsx'; + +export function cn(...inputs: ClassValue[]): string { + return clsx(inputs); +} + +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +export function getFileExtension(filename: string): string { + return filename.slice(((filename.lastIndexOf('.') - 1) >>> 0) + 2).toLowerCase(); +} + +export function isValidExcelFile(file: File): boolean { + const validTypes = [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]; + const validExtensions = ['xlsx', 'xlsm', 'xltx', 'xltm']; + + const extension = getFileExtension(file.name); + return validTypes.includes(file.type) || validExtensions.includes(extension); +} + +export function generateOutputFilename(originalName: string, targetLanguage: string): string { + const lastDot = originalName.lastIndexOf('.'); + const baseName = originalName.substring(0, lastDot); + const extension = originalName.substring(lastDot); + + return `${baseName}_${targetLanguage}${extension}`; +}