diff --git a/docs/commands.md b/docs/commands.md index ea6c4aa..dc91712 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -18,12 +18,13 @@ npm install npm run dev npm run build npm run lint -npm test +npm run test +npm run test:run ``` ### Full verification ```sh cd backend && bundle exec rspec -cd ../frontend && npm run build && npm run lint +cd ../frontend && npm run test:run && npm run build && npm run lint ``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 092408a..8f17eab 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( - { ignores: ['dist'] }, + { ignores: ['dist', 'tailwind.config.js'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6123d0e..799f788 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,6 +43,10 @@ "devDependencies": { "@eslint/js": "^9.25.0", "@tailwindcss/typography": "^0.5.19", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/axios": "^0.14.4", "@types/markdown-it": "^14.1.2", "@types/mdx": "^2.0.13", @@ -56,13 +60,22 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "jsdom": "^26.1.0", "postcss": "^8.5.3", "tailwindcss": "^3.4.13", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.5" } }, + "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, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -90,6 +103,27 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -381,6 +415,121 @@ "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" + } + ], + "license": "MIT-0", + "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" + } + ], + "license": "MIT", + "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" + } + ], + "license": "MIT", + "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" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "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" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1155,9 +1304,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -2208,6 +2357,13 @@ "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, + "license": "MIT" + }, "node_modules/@tailwindcss/typography": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", @@ -2261,6 +2417,112 @@ "react": "^18 || ^19" } }, + "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, + "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/dom/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/@testing-library/dom/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/@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, + "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": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "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/axios": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz", @@ -2317,6 +2579,17 @@ "@babel/types": "^7.20.7" } }, + "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/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2326,6 +2599,13 @@ "@types/ms": "*" } }, + "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/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2724,6 +3004,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "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 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -2745,6 +3138,16 @@ "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, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -2837,6 +3240,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "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/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -3082,6 +3505,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3295,6 +3728,13 @@ "node": ">= 8" } }, + "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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3308,6 +3748,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -3315,6 +3769,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3332,6 +3800,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/decode-named-character-reference": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", @@ -3403,6 +3878,13 @@ "dev": true, "license": "MIT" }, + "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/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3468,6 +3950,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3866,6 +4355,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "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, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3932,6 +4431,24 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4380,6 +4897,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4390,12 +4920,53 @@ "url": "https://opencollective.com/unified" } }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/humps": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==", "license": "MIT" }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4433,6 +5004,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/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -4576,6 +5157,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -4628,6 +5216,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "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", @@ -4795,6 +5423,26 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "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": "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, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/map-obj": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz", @@ -5922,6 +6570,16 @@ "node": ">= 0.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, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6031,6 +6689,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6050,6 +6715,17 @@ "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" + ], + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6145,6 +6821,32 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/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, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6206,6 +6908,13 @@ "url": "https://opencollective.com/express" } }, + "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, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6419,6 +7128,51 @@ "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, + "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/pretty-format/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/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, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/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/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6808,6 +7562,20 @@ "url": "https://opencollective.com/unified" } }, + "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/rehype-recma": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", @@ -6990,6 +7758,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7014,6 +7789,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "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.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -7059,6 +7854,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7107,6 +7909,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -7225,6 +8041,19 @@ "node": ">=8" } }, + "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/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -7305,6 +8134,13 @@ "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, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz", @@ -7376,15 +8212,32 @@ "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, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", - "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -7393,21 +8246,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -7421,6 +8259,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7434,6 +8302,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7858,22 +8752,110 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", - "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.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": { - "picomatch": "^3 || ^4" + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "@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/vite/node_modules/picomatch": { + "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", @@ -7886,6 +8868,67 @@ "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, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7902,6 +8945,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8007,6 +9067,45 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "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, + "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/frontend/package.json b/frontend/package.json index 1745d0f..2d5d533 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,8 @@ "build": "tsc -b && vite build", "postbuild": "node scripts/generate-sitemap.js", "lint": "eslint .", + "test": "vitest", + "test:run": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -45,6 +47,10 @@ "devDependencies": { "@eslint/js": "^9.25.0", "@tailwindcss/typography": "^0.5.19", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/axios": "^0.14.4", "@types/markdown-it": "^14.1.2", "@types/mdx": "^2.0.13", @@ -58,11 +64,13 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "jsdom": "^26.1.0", "postcss": "^8.5.3", "tailwindcss": "^3.4.13", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.5" }, "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.", "main": "eslint.config.js", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da11ba2..2eeaf79 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -93,7 +93,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => { } -export default (() => { +const App: FC = () => { const [user, setUser] = useState (null) const [status, setStatus] = useState (200) @@ -156,4 +156,6 @@ export default (() => { ) -}) satisfies FC +} + +export default App \ No newline at end of file diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index 55bc4d4..e6c49ff 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -19,7 +19,7 @@ type Props = { sp?: boolean } -export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => { +const DraggableDroppableTagRow: FC = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { const dndId = `tag-node:${ pathKey }` const downPosRef = useRef<{ x: number; y: number } | null> (null) @@ -96,4 +96,6 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: ) -}) satisfies FC +} + +export default DraggableDroppableTagRow \ No newline at end of file diff --git a/frontend/src/components/ErrorScreen.test.tsx b/frontend/src/components/ErrorScreen.test.tsx new file mode 100644 index 0000000..2f3bca3 --- /dev/null +++ b/frontend/src/components/ErrorScreen.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import { HelmetProvider } from 'react-helmet-async' +import { describe, expect, it } from 'vitest' + +import ErrorScreen from '@/components/ErrorScreen' + +describe ('ErrorScreen', () => { + it.each ([ + [403, '権限ないよ(笑)'], + [404, 'ページないよ(笑)'], + [500, '鯖でエラー出たって(嘲笑)'], + [503, '鯖死んでるよ(泣)'], + ]) ('renders status %s', (status, message) => { + render ( + + + , + ) + + expect (screen.getByText (String (status))).toBeInTheDocument () + expect (screen.getByText (message)).toBeInTheDocument () + expect (screen.getByAltText ('逃げたギター')).toBeInTheDocument () + }) + + it ('throws for unsupported statuses', () => { + expect (() => render ( + + + , + )).toThrow () + }) +}) diff --git a/frontend/src/components/ErrorScreen.tsx b/frontend/src/components/ErrorScreen.tsx index b77c7f9..8335e56 100644 --- a/frontend/src/components/ErrorScreen.tsx +++ b/frontend/src/components/ErrorScreen.tsx @@ -10,7 +10,7 @@ import type { FC } from 'react' type Props = { status: number } -export default (({ status }: Props) => { +const ErrorScreen: FC = ({ status }) => { const [message, rightMsg, leftMsg]: [string, string, string] = (() => { switch (status) { @@ -58,4 +58,6 @@ export default (({ status }: Props) => {

{message}

) -}) satisfies FC +} + +export default ErrorScreen \ No newline at end of file diff --git a/frontend/src/components/MaterialSidebar.tsx b/frontend/src/components/MaterialSidebar.tsx index 08bf2b2..875a967 100644 --- a/frontend/src/components/MaterialSidebar.tsx +++ b/frontend/src/components/MaterialSidebar.tsx @@ -31,7 +31,7 @@ const setChildrenById = ( })) -export default (() => { +const MaterialSidebar: FC = () => { const [tags, setTags] = useState ([]) const [openTags, setOpenTags] = useState> ({ }) const [tagFetchedFlags, setTagFetchedFlags] = useState> ({ }) @@ -94,4 +94,6 @@ export default (() => { {renderTags (tags)} ) -}) satisfies FC +} + +export default MaterialSidebar \ No newline at end of file diff --git a/frontend/src/components/MenuSeparator.tsx b/frontend/src/components/MenuSeparator.tsx index 8c7b5d3..f815185 100644 --- a/frontend/src/components/MenuSeparator.tsx +++ b/frontend/src/components/MenuSeparator.tsx @@ -1,9 +1,11 @@ import type { FC } from 'react' -export default (() => ( +const MenuSeparator: FC = () => ( <> |
- )) satisfies FC + ) + +export default MenuSeparator \ No newline at end of file diff --git a/frontend/src/components/PostEditForm.test.tsx b/frontend/src/components/PostEditForm.test.tsx new file mode 100644 index 0000000..1fc58a9 --- /dev/null +++ b/frontend/src/components/PostEditForm.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import PostEditForm from '@/components/PostEditForm' +import { buildPost, buildTag } from '@/test/factories' + +const postsApi = vi.hoisted (() => ({ + updatePost: vi.fn (), +})) + +const api = vi.hoisted (() => ({ + isApiError: vi.fn (() => false), +})) + +const toastApi = vi.hoisted (() => ({ + toast: vi.fn (), +})) + +vi.mock ('@/lib/posts', () => postsApi) +vi.mock ('@/lib/api', () => api) +vi.mock ('@/components/ui/use-toast', () => toastApi) +vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + useDialogue: () => ({ + choice: vi.fn (), + }), +})) + +describe ('PostEditForm', () => { + it ('submits edited post fields with the current base version', async () => { + const onSave = vi.fn () + const post = buildPost ({ + id: 8, + versionNo: 4, + title: 'old', + tags: [ + buildTag ({ name: 'general-tag', category: 'general' }), + buildTag ({ id: 2, name: 'nico-tag', category: 'nico' }), + ], + parentPosts: [buildPost ({ id: 2, title: 'parent' })], + }) + postsApi.updatePost.mockResolvedValueOnce ({ + ...post, + versionNo: 5, + title: 'new', + tags: [buildTag ({ name: 'new-tag' })], + }) + + render () + + const [title, parentIds] = screen.getAllByRole ('textbox') + fireEvent.change (title, { target: { value: 'new' } }) + fireEvent.change (parentIds, { target: { value: '3 4' } }) + fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!) + + await waitFor (() => { + expect (postsApi.updatePost).toHaveBeenCalledWith ( + expect.objectContaining ({ + id: 8, + title: 'new', + parentPostIds: '3 4', + tags: 'general-tag', + }), + { baseVersionNo: 4 }, + ) + }) + expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 })) + expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' }) + }) +}) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index d06b987..bc1048e 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -6,6 +6,7 @@ import Label from '@/components/common/Label' import { useDialogue } from '@/components/dialogues/DialogueProvider' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' +import { isApiError } from '@/lib/api' import { updatePost } from '@/lib/posts' import type { FC, FormEvent } from 'react' @@ -32,7 +33,7 @@ type Props = { post: Post onSave: (newPost: Post) => void } -export default (({ post, onSave }: Props) => { +const PostEditForm: FC = ({ post, onSave }) => { const [disabled, setDisabled] = useState (false) const [originalCreatedBefore, setOriginalCreatedBefore] = useState (post.originalCreatedBefore) @@ -62,7 +63,7 @@ export default (({ post, onSave }: Props) => { } catch (e) { - const response = (e as any)?.response + const response = isApiError<{ mergeable?: boolean }> (e) ? e.response : undefined if (response?.status !== 409) { @@ -164,4 +165,6 @@ export default (({ post, onSave }: Props) => { 更新 ) -}) satisfies FC +} + +export default PostEditForm \ No newline at end of file diff --git a/frontend/src/components/PostEmbed.test.tsx b/frontend/src/components/PostEmbed.test.tsx new file mode 100644 index 0000000..e34713e --- /dev/null +++ b/frontend/src/components/PostEmbed.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import PostEmbed from '@/components/PostEmbed' +import { buildPost } from '@/test/factories' + +const dialogue = vi.hoisted (() => ({ + confirm: vi.fn (), +})) + +vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + useDialogue: () => dialogue, +})) + +vi.mock ('@/components/NicoViewer', () => ({ + default: ({ id }: { id: string }) =>
Nico:{id}
, +})) + +vi.mock ('react-youtube', () => ({ + default: ({ videoId }: { videoId: string }) =>
YouTube:{videoId}
, +})) + +describe ('PostEmbed', () => { + beforeEach (() => { + vi.clearAllMocks () + }) + + it ('embeds nicovideo watch URLs', () => { + render () + + expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument () + }) + + it ('embeds x/twitter status URLs', () => { + render () + + expect (screen.getByRole ('link', { name: '@someone' })).toBeInTheDocument () + }) + + it ('embeds youtube watch URLs', () => { + render () + + expect (screen.getByText ('YouTube:abc123')).toBeInTheDocument () + }) + + it ('asks before framing unknown external pages', async () => { + dialogue.confirm.mockResolvedValueOnce (true) + render ( + , + ) + + fireEvent.click (screen.getByRole ('link', { name: '外部ページを表示' })) + + await waitFor (() => { + expect (dialogue.confirm).toHaveBeenCalled () + }) + expect (await screen.findByTitle ('external')).toHaveAttribute ( + 'src', + 'https://example.com/page', + ) + }) +}) diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 1be00ce..8ae3220 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -16,8 +16,9 @@ type Props = { onMetadataChange?: (meta: NiconicoMetadata) => void } -export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { +const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange }) => { const dialogue = useDialogue () + const [framed, setFramed] = useState (false) const url = new URL (post.url) @@ -44,7 +45,7 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { case 'twitter.com': case 'x.com': { - const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/) + const mUserId = url.pathname.match (/(?<=\/)[^/]+?(?=\/|$|\?)/) const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/) if (!(mUserId) || !(mStatusId)) break @@ -72,8 +73,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { } } - const [framed, setFramed] = useState (false) - return ( <> {framed @@ -101,4 +100,6 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { )} ) -}) satisfies FC +} + +export default PostEmbed diff --git a/frontend/src/components/PostFormTagsArea.test.tsx b/frontend/src/components/PostFormTagsArea.test.tsx new file mode 100644 index 0000000..b9586db --- /dev/null +++ b/frontend/src/components/PostFormTagsArea.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import PostFormTagsArea from '@/components/PostFormTagsArea' +import { buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +const api = vi.hoisted (() => ({ + apiGet: vi.fn (), +})) + +vi.mock ('@/lib/api', () => api) + +describe ('PostFormTagsArea', () => { + it ('updates text and fetches autocomplete for the selected token', async () => { + const setTags = vi.fn () + api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })]) + + renderWithProviders () + + const textarea = screen.getByRole ('textbox') + fireEvent.focus (textarea) + fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } }) + fireEvent.change (textarea, { target: { value: '虹夏' } }) + + await waitFor (() => { + expect (api.apiGet).toHaveBeenCalledWith ( + '/tags/autocomplete', + { params: { q: '虹', nico: '0' } }, + ) + }) + expect (setTags).toHaveBeenCalledWith ('虹夏') + }) +}) diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 7588202..513af9d 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -36,7 +36,7 @@ type Props = Omit, 'value' | 'onChange' | ' setTags: (tags: string) => void } -export default (({ tags, setTags, ...rest }: Props) => { +const PostFormTagsArea: FC = ({ tags, setTags, ...rest }) => { const ref = useRef (null) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) @@ -97,4 +97,6 @@ export default (({ tags, setTags, ...rest }: Props) => { activeIndex={-1} onSelect={handleTagSelect}/>)} ) -}) satisfies FC +} + +export default PostFormTagsArea \ No newline at end of file diff --git a/frontend/src/components/PostList.test.tsx b/frontend/src/components/PostList.test.tsx new file mode 100644 index 0000000..8180a6a --- /dev/null +++ b/frontend/src/components/PostList.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import PostList from '@/components/PostList' +import { buildPost } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +const prefetchers = vi.hoisted (() => ({ + prefetchForURL: vi.fn (), +})) + +vi.mock ('@/lib/prefetchers', () => prefetchers) + +describe ('PostList', () => { + beforeEach (() => { + prefetchers.prefetchForURL.mockResolvedValue (undefined) + }) + + it ('renders post thumbnails as links to post details', () => { + renderWithProviders ( + , + ) + + expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute ( + 'href', + '/posts/1', + ) + expect ( + screen.getByRole ('link', { name: 'https://example.com/second' }), + ).toHaveAttribute ('href', '/posts/2') + }) + + it ('calls the optional click handler', () => { + const onClick = vi.fn () + renderWithProviders () + + fireEvent.click (screen.getByRole ('link', { name: 'テスト投稿' })) + + expect (onClick).toHaveBeenCalledTimes (1) + }) +}) diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index c8818db..0fe33b8 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -14,7 +14,7 @@ type Props = { posts: Post[] onClick?: (event: MouseEvent) => void } -export default (({ posts, onClick }: Props) => { +const PostList: FC = ({ posts, onClick }) => { const location = useLocation () const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) @@ -70,4 +70,6 @@ export default (({ posts, onClick }: Props) => { ) })} ) -}) satisfies FC +} + +export default PostList \ No newline at end of file diff --git a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx new file mode 100644 index 0000000..a061d65 --- /dev/null +++ b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' + +describe ('PostOriginalCreatedTimeField', () => { + it ('updates from and before values', () => { + const setFrom = vi.fn () + const setBefore = vi.fn () + + render ( + , + ) + + const inputs = screen.getAllByDisplayValue ('') + fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } }) + fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } }) + + expect (setFrom).toHaveBeenCalledWith (expect.any (String)) + expect (setBefore).toHaveBeenCalledWith (expect.any (String)) + }) + + it ('infers an exclusive before value on blur', () => { + const setBefore = vi.fn () + + render ( + , + ) + + const input = screen.getAllByDisplayValue ('')[0] + fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } }) + + expect (setBefore).toHaveBeenCalledWith (expect.any (String)) + }) + + it ('resets both values', () => { + const setFrom = vi.fn () + const setBefore = vi.fn () + + render ( + , + ) + + const buttons = screen.getAllByRole ('button', { name: 'リセット' }) + fireEvent.click (buttons[0]) + fireEvent.click (buttons[1]) + + expect (setFrom).toHaveBeenCalledWith (null) + expect (setBefore).toHaveBeenCalledWith (null) + }) +}) diff --git a/frontend/src/components/PostOriginalCreatedTimeField.tsx b/frontend/src/components/PostOriginalCreatedTimeField.tsx index 9fbc232..625b5a3 100644 --- a/frontend/src/components/PostOriginalCreatedTimeField.tsx +++ b/frontend/src/components/PostOriginalCreatedTimeField.tsx @@ -12,11 +12,11 @@ type Props = { setOriginalCreatedBefore: (x: string | null) => void } -export default (({ disabled, +const PostOriginalCreatedTimeField: FC = ({ disabled, originalCreatedFrom, setOriginalCreatedFrom, originalCreatedBefore, - setOriginalCreatedBefore }: Props) => ( + setOriginalCreatedBefore }) => (
@@ -71,4 +71,6 @@ export default (({ disabled,
- )) satisfies FC + ) + +export default PostOriginalCreatedTimeField \ No newline at end of file diff --git a/frontend/src/components/RouteBlockerOverlay.test.tsx b/frontend/src/components/RouteBlockerOverlay.test.tsx new file mode 100644 index 0000000..1219e5b --- /dev/null +++ b/frontend/src/components/RouteBlockerOverlay.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import RouteBlockerOverlay, { useOverlayStore } from '@/components/RouteBlockerOverlay' + +describe ('RouteBlockerOverlay', () => { + afterEach (() => { + useOverlayStore.setState ({ active: false }) + document.body.style.overflow = '' + document.body.removeAttribute ('aria-busy') + }) + + it ('renders nothing while inactive', () => { + useOverlayStore.setState ({ active: false }) + + const { container } = render () + + expect (container).toBeEmptyDOMElement () + }) + + it ('renders a blocking progressbar and marks the body busy while active', () => { + useOverlayStore.setState ({ active: true }) + + render () + + expect (screen.getByRole ('progressbar', { name: 'Loading' })).toBeInTheDocument () + expect (document.body).toHaveAttribute ('aria-busy', 'true') + expect (document.body.style.overflow).toBe ('hidden') + }) +}) diff --git a/frontend/src/components/RouteBlockerOverlay.tsx b/frontend/src/components/RouteBlockerOverlay.tsx index 50f46f3..bd46e57 100644 --- a/frontend/src/components/RouteBlockerOverlay.tsx +++ b/frontend/src/components/RouteBlockerOverlay.tsx @@ -13,7 +13,7 @@ export const useOverlayStore = create (set => ({ setActive: v => set ({ active: v }) })) -export default (() => { +const RouteBlockerOverlay: FC = () => { const active = useOverlayStore (s => s.active) useEffect (() => { @@ -43,4 +43,6 @@ export default (() => { ) -}) satisfies FC +} + +export default RouteBlockerOverlay \ No newline at end of file diff --git a/frontend/src/components/SortHeader.test.tsx b/frontend/src/components/SortHeader.test.tsx new file mode 100644 index 0000000..f11b93e --- /dev/null +++ b/frontend/src/components/SortHeader.test.tsx @@ -0,0 +1,39 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import SortHeader from '@/components/SortHeader' +import { renderWithProviders } from '@/test/render' + +describe ('SortHeader', () => { + it ('toggles the active sort direction and resets the page', () => { + renderWithProviders ( + , + { route: '/posts?tags=x&page=4&order=title%3Aasc' }, + ) + + expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute ( + 'href', + '/posts?tags=x&page=1&order=title%3Adesc', + ) + }) + + it ('uses default direction for inactive fields', () => { + renderWithProviders ( + , + { route: '/posts?page=2' }, + ) + + expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute ( + 'href', + '/posts?page=1&order=updated_at%3Adesc', + ) + }) +}) diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index e195619..be95412 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -151,7 +151,7 @@ const DropSlot = ({ cat }: { cat: Category }) => { type Props = { post: Post; sp?: boolean } -export default (({ post, sp }: Props) => { +const TagDetailSidebar: FC = ({ post, sp }) => { sp = Boolean (sp) const qc = useQueryClient () @@ -376,4 +376,6 @@ export default (({ post, sp }: Props) => { ) -}) satisfies FC +} + +export default TagDetailSidebar \ No newline at end of file diff --git a/frontend/src/components/TagLink.test.tsx b/frontend/src/components/TagLink.test.tsx new file mode 100644 index 0000000..3c2a152 --- /dev/null +++ b/frontend/src/components/TagLink.test.tsx @@ -0,0 +1,45 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import TagLink from '@/components/TagLink' +import { buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +describe ('TagLink', () => { + it ('links tag names to post search and shows counts', () => { + renderWithProviders ( + , + ) + + expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute ( + 'href', + '/posts?tags=%E8%99%B9+%E5%A4%8F', + ) + expect (screen.getByText ('4')).toBeInTheDocument () + }) + + it ('links wiki markers to the correct detail route', () => { + renderWithProviders ( + , + ) + + expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute ( + 'href', + '/wiki/a%2Fb', + ) + }) + + it ('renders aliases and non-link tags when requested', () => { + renderWithProviders ( + , + ) + + expect (screen.getByText ('別名')).toBeInTheDocument () + expect (screen.getByText ('正式名')).toBeInTheDocument () + expect (screen.queryByRole ('link')).not.toBeInTheDocument () + }) +}) diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index a68f8a9..33bfc40 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -27,12 +27,12 @@ type Props = | PropsWithoutLink -export default (({ tag, +const TagLink: FC = ({ tag, nestLevel = 0, linkFlg = true, withWiki = true, withCount = true, - ...props }: Props) => { + ...props }) => { const spanClass = cn ( `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) @@ -126,4 +126,6 @@ export default (({ tag, {withCount && ( {tag.postCount})} ) -}) satisfies FC +} + +export default TagLink \ No newline at end of file diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index 6c89daa..e9833f1 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -12,7 +12,7 @@ import type { ChangeEvent, FC, KeyboardEvent } from 'react' import type { Tag } from '@/types' -export default (() => { +const TagSearch: FC = () => { const location = useLocation () const navigate = useNavigate () @@ -115,4 +115,6 @@ export default (() => { activeIndex={activeIndex} onSelect={handleTagSelect}/> ) -}) satisfies FC +} + +export default TagSearch \ No newline at end of file diff --git a/frontend/src/components/TagSearchBox.test.tsx b/frontend/src/components/TagSearchBox.test.tsx new file mode 100644 index 0000000..5b77518 --- /dev/null +++ b/frontend/src/components/TagSearchBox.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import TagSearchBox from '@/components/TagSearchBox' +import { buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +describe ('TagSearchBox', () => { + it ('renders suggestions and selects tags on mouse down', () => { + const handleSelect = vi.fn () + const tag = buildTag ({ id: 9, name: '候補', postCount: 2 }) + + renderWithProviders ( + , + ) + + fireEvent.mouseDown (screen.getByText ('候補')) + + expect (handleSelect).toHaveBeenCalledWith (tag) + expect (screen.getByText ('2')).toBeInTheDocument () + }) + + it ('renders nothing when suggestions are empty', () => { + const { container } = renderWithProviders ( + , + ) + + expect (container).toBeEmptyDOMElement () + }) +}) diff --git a/frontend/src/components/TagSearchBox.tsx b/frontend/src/components/TagSearchBox.tsx index cb62541..795daee 100644 --- a/frontend/src/components/TagSearchBox.tsx +++ b/frontend/src/components/TagSearchBox.tsx @@ -10,7 +10,7 @@ type Props = { suggestions: Tag[] onSelect: (tag: Tag) => void } -export default (({ suggestions, activeIndex, onSelect }: Props) => { +const TagSearchBox: FC = ({ suggestions, activeIndex, onSelect }) => { if (suggestions.length === 0) return @@ -26,4 +26,6 @@ export default (({ suggestions, activeIndex, onSelect }: Props) => { ))} ) -}) satisfies FC +} + +export default TagSearchBox \ No newline at end of file diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index 1b52c3d..9c08ccc 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -19,7 +19,7 @@ type Props = { posts: Post[] onClick?: (event: MouseEvent) => void } -export default (({ posts, onClick }: Props) => { +const TagSidebar: FC = ({ posts, onClick }) => { const navigate = useNavigate () const [tagsVsbl, setTagsVsbl] = useState (false) @@ -126,4 +126,6 @@ export default (({ posts, onClick }: Props) => { {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} ) -}) satisfies FC +} + +export default TagSidebar \ No newline at end of file diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 00cba7d..c7772bb 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -26,7 +26,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { pathName: string }): Menu => { const postCount = tag?.postCount ?? 0 - const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId) + const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) const wikiTitle = pathName.split ('/')[2] ?? '' const tagFlg = /^\/tags\/\d+/.test (pathName) @@ -80,7 +80,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { } -export default (({ user }: Props) => { +const TopNav: FC = ({ user }) => { const location = useLocation () const dirRef = useRef<(-1) | 1> (1) @@ -159,12 +159,12 @@ export default (({ user }: Props) => { useEffect (() => { const unsubscribe = WikiIdBus.subscribe (setWikiId) return () => unsubscribe () - }, [activeIdx]) + }, []) useEffect (() => { setMenuOpen (false) setOpenItemIdx (activeIdx) - }, [location]) + }, [activeIdx, location]) return ( <> @@ -433,4 +433,6 @@ export default (({ user }: Props) => { )} ) -}) satisfies FC +} + +export default TopNav diff --git a/frontend/src/components/TopNavUser.test.tsx b/frontend/src/components/TopNavUser.test.tsx new file mode 100644 index 0000000..49e1ee2 --- /dev/null +++ b/frontend/src/components/TopNavUser.test.tsx @@ -0,0 +1,29 @@ +import { screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import TopNavUser from '@/components/TopNavUser' +import { buildUser } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +describe ('TopNavUser', () => { + it ('renders nothing without a user', () => { + const { container } = renderWithProviders () + + expect (container).toBeEmptyDOMElement () + }) + + it ('links named users to settings', () => { + renderWithProviders () + + expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute ( + 'href', + '/users/settings', + ) + }) + + it ('uses the anonymous display name', () => { + renderWithProviders () + + expect (screen.getByRole ('link', { name: '名もなきニジラー' })).toBeInTheDocument () + }) +}) diff --git a/frontend/src/components/TopNavUser.tsx b/frontend/src/components/TopNavUser.tsx index 27008bf..04aff1b 100644 --- a/frontend/src/components/TopNavUser.tsx +++ b/frontend/src/components/TopNavUser.tsx @@ -10,7 +10,7 @@ type Props = { user: User | null, sp?: boolean } -export default (({ user, sp }: Props) => { +const TopNavUser: FC = ({ user, sp }) => { if (!(user)) return @@ -28,4 +28,6 @@ export default (({ user, sp }: Props) => { {user.name || '名もなきニジラー'} ) -}) satisfies FC +} + +export default TopNavUser \ No newline at end of file diff --git a/frontend/src/components/TwitterEmbed.test.tsx b/frontend/src/components/TwitterEmbed.test.tsx new file mode 100644 index 0000000..25f2c27 --- /dev/null +++ b/frontend/src/components/TwitterEmbed.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import TwitterEmbed from '@/components/TwitterEmbed' + +describe ('TwitterEmbed', () => { + it ('renders tweet and user links', () => { + render () + + expect (screen.getByRole ('link', { name: '@user_name' })).toHaveAttribute ( + 'href', + 'https://twitter.com/user_name?ref_src=twsrc%3Etfw', + ) + expect (screen.getByRole ('link', { name: /\d/ })).toHaveAttribute ( + 'href', + 'https://twitter.com/user_name/status/12345?ref_src=twsrc%5Etfw', + ) + }) +}) diff --git a/frontend/src/components/TwitterEmbed.tsx b/frontend/src/components/TwitterEmbed.tsx index dd640c8..f3b12c8 100644 --- a/frontend/src/components/TwitterEmbed.tsx +++ b/frontend/src/components/TwitterEmbed.tsx @@ -5,7 +5,7 @@ type Props = { statusId: string } -export default (({ userId, statusId }: Props) => { +const TwitterEmbed: FC = ({ userId, statusId }) => { const now = (new Date).toLocaleDateString () return ( @@ -18,4 +18,6 @@ export default (({ userId, statusId }: Props) => {