diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 851f2c7..1c64e8a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,14 +17,89 @@ "zod": "^3.23.8" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.4", "@types/react-dom": "^18.3.0", "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.3.2", + "@vitest/coverage-v8": "^4.0.17", + "jsdom": "^27.4.0", "typescript": "^5.5.4", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^4.0.17" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "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/@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, + "license": "MIT", + "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, + "license": "BlueOak-1.0.0", + "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, + "license": "MIT", + "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, + "license": "BlueOak-1.0.0", + "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, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -316,6 +391,151 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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-syntax-patches-for-csstree": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz", + "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==", + "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-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/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -758,6 +978,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.9.0.tgz", + "integrity": "sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1123,6 +1361,111 @@ "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/@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", + "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, + "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/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.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", + "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", @@ -1168,6 +1511,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1254,6 +1615,222 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", + "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.17", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.17", + "vitest": "4.0.17" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", + "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", + "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.17", + "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.17", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", + "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", + "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", + "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", + "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", + "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/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", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -1264,6 +1841,16 @@ "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, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1319,6 +1906,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1339,6 +1936,53 @@ "url": "https://opencollective.com/express" } }, + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "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, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1346,6 +1990,30 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1364,6 +2032,31 @@ } } }, + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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", + "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", @@ -1371,6 +2064,26 @@ "dev": true, "license": "ISC" }, + "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/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, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1423,6 +2136,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1466,6 +2199,36 @@ "node": ">=6.9.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -1475,6 +2238,34 @@ "void-elements": "3.1.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, + "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/i18next": { "version": "24.2.3", "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", @@ -1515,12 +2306,108 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "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", @@ -1569,6 +2456,85 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1602,6 +2568,37 @@ "dev": true, "license": "MIT" }, + "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/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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", @@ -1651,6 +2648,32 @@ "node": "^10 || ^12 || >=14" } }, + "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", + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1702,6 +2725,14 @@ } } }, + "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", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1750,6 +2781,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.53.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", @@ -1792,6 +2847,19 @@ "fsevents": "~2.3.2" } }, + "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.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1817,6 +2885,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1827,6 +2902,70 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/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.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1844,6 +2983,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "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, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1964,6 +3159,84 @@ } } }, + "node_modules/vitest": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", + "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.17", + "@vitest/mocker": "4.0.17", + "@vitest/pretty-format": "4.0.17", + "@vitest/runner": "4.0.17", + "@vitest/snapshot": "4.0.17", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "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.17", + "@vitest/browser-preview": "4.0.17", + "@vitest/browser-webdriverio": "4.0.17", + "@vitest/ui": "4.0.17", + "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/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -1973,6 +3246,109 @@ "node": ">=0.10.0" } }, + "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": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "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": "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, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "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/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "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 27c097a..fabed4f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "echo 'add lint config'" + "lint": "echo 'add lint config'", + "test": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "i18next": "^24.2.2", @@ -19,11 +21,17 @@ "zod": "^3.23.8" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.4", "@types/react-dom": "^18.3.0", "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.3.2", + "@vitest/coverage-v8": "^4.0.17", + "jsdom": "^27.4.0", "typescript": "^5.5.4", - "vite": "^7.3.0" + "vite": "^7.3.0", + "vitest": "^4.0.17" } } diff --git a/frontend/src/test/components/AboutModal.test.tsx b/frontend/src/test/components/AboutModal.test.tsx new file mode 100644 index 0000000..b4b41a0 --- /dev/null +++ b/frontend/src/test/components/AboutModal.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AboutModal from '../../components/AboutModal'; + +// Mock App module for constants +vi.mock('../../App', () => ({ + FRONTEND_VERSION: '1.0.0', + GITHUB_URL: 'https://github.com/test/repo' +})); + +describe('AboutModal', () => { + const defaultProps = { + isOpen: true, + onClose: vi.fn() + }; + + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ version: '1.0.0' }) + }); + }); + + it('returns null when not open', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders when open', () => { + render(); + expect(screen.getByText(/about\.appName/i)).toBeInTheDocument(); + }); + + it('displays version number', () => { + render(); + expect(screen.getByText(/1\.0\.0/)).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + render(); + fireEvent.click(screen.getByText('×')); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('calls onClose when overlay is clicked', () => { + const { container } = render(); + const overlay = container.querySelector('.modal-overlay'); + fireEvent.click(overlay!); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('does not call onClose when modal content is clicked', () => { + const { container } = render(); + const content = container.querySelector('.about-modal'); + if (content) { + fireEvent.click(content); + expect(defaultProps.onClose).not.toHaveBeenCalled(); + } + }); + + it('renders GitHub link', () => { + render(); + const links = screen.getAllByRole('link'); + expect(links.length).toBeGreaterThan(0); + }); + + it('fetches backend version on open', async () => { + render(); + expect(fetch).toHaveBeenCalledWith('/api/health'); + }); +}); diff --git a/frontend/src/test/components/AppHeader.test.tsx b/frontend/src/test/components/AppHeader.test.tsx new file mode 100644 index 0000000..2fb7179 --- /dev/null +++ b/frontend/src/test/components/AppHeader.test.tsx @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { AppHeader } from '../../components/AppHeader'; +import { AuthProvider } from '../../components/Auth'; + +// Mock useNavigate +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +describe('AppHeader', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockNavigate.mockClear(); + // Set up default auth mock - auth disabled + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + authEnabled: false, + localAuthEnabled: true, + hasUsers: false, + needsSetup: false + }) + }) + .mockResolvedValueOnce({ + status: 401, + ok: false + }); + }); + + it('renders header with logo', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + render( + + + + + + ); + + await waitFor(() => { + const logo = screen.getByAltText('MedAssist-ng'); + expect(logo).toBeInTheDocument(); + }); + }); + + it('renders navigation tabs', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + render( + + + + + + ); + + await waitFor(() => { + // Use getAllBy since there are multiple elements with same text + const dashboardElements = screen.getAllByText(/nav\.dashboard/i); + expect(dashboardElements.length).toBeGreaterThan(0); + }); + }); + + it('renders theme toggle button', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + render( + + + + + + ); + + await waitFor(() => { + const buttons = screen.getAllByRole('button'); + const themeBtn = buttons.find(btn => btn.textContent?.includes('🌙') || btn.textContent?.includes('☀️')); + expect(themeBtn).toBeInTheDocument(); + }); + }); + + it('renders settings button when auth is disabled', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + render( + + + + + + ); + + await waitFor(() => { + const settingsBtn = screen.queryByTitle(/nav\.settings/i); + expect(settingsBtn).toBeInTheDocument(); + }); + }); + + it('shows page eyebrow and title', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByText(/header\.eyebrow\.overview/i)).toBeInTheDocument(); + }); + }); + + it('shows medications page title on medications route', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + // Reset mock for this test + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + authEnabled: false, + localAuthEnabled: true, + hasUsers: false, + needsSetup: false + }) + }) + .mockResolvedValueOnce({ + status: 401, + ok: false + }); + + render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByText(/header\.eyebrow\.inventory/i)).toBeInTheDocument(); + }); + }); + + it('shows planner page title on planner route', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + authEnabled: false, + localAuthEnabled: true, + hasUsers: false, + needsSetup: false + }) + }) + .mockResolvedValueOnce({ + status: 401, + ok: false + }); + + render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByText(/header\.eyebrow\.planner/i)).toBeInTheDocument(); + }); + }); + + it('shows settings page title on settings route', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + authEnabled: false, + localAuthEnabled: true, + hasUsers: false, + needsSetup: false + }) + }) + .mockResolvedValueOnce({ + status: 401, + ok: false + }); + + render( + + + + + + ); + + await waitFor(() => { + expect(screen.getByText(/header\.eyebrow\.settings/i)).toBeInTheDocument(); + }); + }); + + it('navigates when tab clicked', async () => { + const mockOnOpenProfile = vi.fn(); + const mockOnOpenAbout = vi.fn(); + + render( + + + + + + ); + + await waitFor(() => { + const buttons = screen.getAllByRole('button'); + const medsBtn = buttons.find(btn => btn.textContent?.includes('nav.medications')); + if (medsBtn) { + fireEvent.click(medsBtn); + expect(mockNavigate).toHaveBeenCalledWith('/medications'); + } + }); + }); +}); diff --git a/frontend/src/test/components/Auth.test.tsx b/frontend/src/test/components/Auth.test.tsx new file mode 100644 index 0000000..1a9b840 --- /dev/null +++ b/frontend/src/test/components/Auth.test.tsx @@ -0,0 +1,359 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react'; +import { AuthProvider, useAuth, LoginForm, RegisterForm, UserProfile, AuthPage } from '../../components/Auth'; +import React from 'react'; + +// Wrapper component for testing hooks that require AuthProvider +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('AuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }) + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('provides auth context to children', () => { + render( + +
Child content
+
+ ); + expect(screen.getByTestId('child')).toBeInTheDocument(); + }); + + it('initializes with loading state', () => { + const { result } = renderHook(() => useAuth(), { wrapper }); + // Initially loading + expect(result.current.loading).toBe(true); + }); + + it('fetches auth state on mount', async () => { + renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith('/api/auth/state'); + }); + }); + + it('throws error when useAuth is used outside AuthProvider', () => { + expect(() => { + renderHook(() => useAuth()); + }).toThrow('useAuth must be used within AuthProvider'); + }); +}); + +describe('LoginForm', () => { + const mockAuthState = { + authEnabled: true, + localAuthEnabled: true, + oidcEnabled: false, + registrationEnabled: true, + hasUsers: true, + needsSetup: false, + oidcProviderName: '' + }; + + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthState) + }) + .mockResolvedValueOnce({ + status: 401, + ok: false + }); + }); + + it('renders login form', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/MedAssist/i)).toBeInTheDocument(); + }); + }); + + it('renders username and password fields', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument(); + }); + }); + + it('renders remember me checkbox', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/auth\.rememberMe/i)).toBeInTheDocument(); + }); + }); + + it('renders create account link when registration enabled', async () => { + const onSwitchToRegister = vi.fn(); + + render( + + + + ); + + await waitFor(() => { + const createAccountBtn = screen.getByText(/auth\.createAccount/i); + expect(createAccountBtn).toBeInTheDocument(); + }); + }); + + it('handles form input changes', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/auth\.username/i), { target: { value: 'testuser' } }); + fireEvent.change(screen.getByLabelText(/auth\.password/i), { target: { value: 'password123' } }); + + expect(screen.getByLabelText(/auth\.username/i)).toHaveValue('testuser'); + expect(screen.getByLabelText(/auth\.password/i)).toHaveValue('password123'); + }); + + it('renders submit button', async () => { + render( + + + + ); + + await waitFor(() => { + const buttons = screen.getAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + expect(submitBtn).toBeInTheDocument(); + }); + }); +}); + +describe('RegisterForm', () => { + const mockAuthState = { + authEnabled: true, + localAuthEnabled: true, + oidcEnabled: false, + registrationEnabled: true, + hasUsers: false, + needsSetup: true, + oidcProviderName: '' + }; + + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthState) + }) + .mockResolvedValueOnce({ + status: 401, + ok: false + }); + }); + + it('renders registration form', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/MedAssist/i)).toBeInTheDocument(); + }); + }); + + it('renders all required fields', async () => { + render( + + + + ); + + await waitFor(() => { + // Check for username field + expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument(); + // Check for password field + expect(screen.getByLabelText(/auth\.password/i)).toBeInTheDocument(); + }); + }); + + it('renders switch to login link', async () => { + const onSwitchToLogin = vi.fn(); + + render( + + + + ); + + await waitFor(() => { + const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i); + expect(loginLink).toBeInTheDocument(); + }); + }); + + it('calls onSwitchToLogin when clicked', async () => { + const onSwitchToLogin = vi.fn(); + + render( + + + + ); + + await waitFor(() => { + const loginLink = screen.getByText(/auth\.alreadyHaveAccount/i); + fireEvent.click(loginLink); + }); + + expect(onSwitchToLogin).toHaveBeenCalled(); + }); +}); + +describe('AuthPage', () => { + const mockAuthState = { + authEnabled: true, + localAuthEnabled: true, + oidcEnabled: false, + registrationEnabled: true, + hasUsers: true, + needsSetup: false, + oidcProviderName: '' + }; + + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthState) + }) + .mockResolvedValueOnce({ + status: 401, + ok: false + }); + }); + + it('renders login form by default', async () => { + render( + + + + ); + + await waitFor(() => { + // Should show login form with username field + expect(screen.getByLabelText(/auth\.username/i)).toBeInTheDocument(); + }); + }); +}); + +describe('UserProfile', () => { + const mockUser = { + id: 1, + username: 'testuser', + avatarUrl: null + }; + + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ authEnabled: true, localAuthEnabled: true }) + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockUser) + }); + }); + + it('renders user profile when user is logged in', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('testuser')).toBeInTheDocument(); + }); + }); + + it('displays user avatar initial when no avatar', async () => { + render( + + + + ); + + await waitFor(() => { + // The avatar shows first letter of username + expect(screen.getByText('T')).toBeInTheDocument(); + }); + }); + + it('renders change password section', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/auth\.changePassword/i)).toBeInTheDocument(); + }); + }); + + it('renders cancel button that calls onClose', async () => { + const onClose = vi.fn(); + + render( + + + + ); + + await waitFor(() => { + const cancelBtn = screen.getByText(/common\.cancel/i); + fireEvent.click(cancelBtn); + }); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/components/ConfirmModal.test.tsx b/frontend/src/test/components/ConfirmModal.test.tsx new file mode 100644 index 0000000..b5d5fb8 --- /dev/null +++ b/frontend/src/test/components/ConfirmModal.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConfirmModal } from '../../components/ConfirmModal'; + +describe('ConfirmModal', () => { + const defaultProps = { + title: 'Confirm Action', + message: 'Are you sure you want to proceed?', + confirmLabel: 'Yes', + cancelLabel: 'No', + onConfirm: vi.fn(), + onCancel: vi.fn() + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders title', () => { + render(); + expect(screen.getByText('Confirm Action')).toBeInTheDocument(); + }); + + it('renders message as string', () => { + render(); + expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument(); + }); + + it('renders message as ReactNode', () => { + render( + Custom message} + /> + ); + expect(screen.getByTestId('custom-message')).toBeInTheDocument(); + }); + + it('renders confirm and cancel buttons', () => { + render(); + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.getByText('No')).toBeInTheDocument(); + }); + + it('calls onConfirm when confirm button is clicked', () => { + render(); + fireEvent.click(screen.getByText('Yes')); + expect(defaultProps.onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel when cancel button is clicked', () => { + render(); + fireEvent.click(screen.getByText('No')); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('calls onCancel when close button is clicked', () => { + render(); + fireEvent.click(screen.getByText('×')); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('calls onCancel when overlay is clicked', () => { + const { container } = render(); + const overlay = container.querySelector('.modal-overlay'); + fireEvent.click(overlay!); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('does not call onCancel when modal content is clicked', () => { + const { container } = render(); + const content = container.querySelector('.modal-content'); + fireEvent.click(content!); + expect(defaultProps.onCancel).not.toHaveBeenCalled(); + }); + + it('disables buttons when loading', () => { + render(); + expect(screen.getByText('Yes')).toBeDisabled(); + expect(screen.getByText('No')).toBeDisabled(); + }); + + it('applies primary variant by default', () => { + render(); + const confirmBtn = screen.getByText('Yes'); + expect(confirmBtn.className).toContain('primary'); + }); + + it('applies danger variant when specified', () => { + render(); + const confirmBtn = screen.getByText('Yes'); + expect(confirmBtn.className).toContain('danger'); + }); + + it('applies success variant when specified', () => { + render(); + const confirmBtn = screen.getByText('Yes'); + expect(confirmBtn.className).toContain('success'); + }); +}); diff --git a/frontend/src/test/components/ExportModal.test.tsx b/frontend/src/test/components/ExportModal.test.tsx new file mode 100644 index 0000000..44b188e --- /dev/null +++ b/frontend/src/test/components/ExportModal.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ExportModal from '../../components/ExportModal'; + +describe('ExportModal', () => { + const defaultProps = { + isOpen: true, + onClose: vi.fn(), + onExport: vi.fn(), + exporting: false + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when not open', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders when open', () => { + render(); + expect(screen.getByText(/exportImport\.exportOptions/i)).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + render(); + fireEvent.click(screen.getByText('×')); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('calls onClose when overlay is clicked', () => { + const { container } = render(); + const overlay = container.querySelector('.modal-overlay'); + fireEvent.click(overlay!); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('renders export options', () => { + const { container } = render(); + // Should have action card buttons + const actionCards = container.querySelectorAll('.action-card'); + expect(actionCards.length).toBe(2); + }); + + it('calls onExport with true when export with images button clicked', () => { + const { container } = render(); + const actionCards = container.querySelectorAll('.action-card'); + fireEvent.click(actionCards[0]); + expect(defaultProps.onClose).toHaveBeenCalled(); + expect(defaultProps.onExport).toHaveBeenCalledWith(true); + }); + + it('calls onExport with false when export data only button clicked', () => { + const { container } = render(); + const actionCards = container.querySelectorAll('.action-card'); + fireEvent.click(actionCards[1]); + expect(defaultProps.onClose).toHaveBeenCalled(); + expect(defaultProps.onExport).toHaveBeenCalledWith(false); + }); + + it('disables buttons when exporting', () => { + const { container } = render(); + const actionCards = container.querySelectorAll('.action-card'); + actionCards.forEach(card => { + expect(card).toBeDisabled(); + }); + }); + + it('renders cancel button', () => { + render(); + expect(screen.getByText(/exportImport\.cancelButton/i)).toBeInTheDocument(); + }); + + it('calls onClose when cancel button is clicked', () => { + render(); + fireEvent.click(screen.getByText(/exportImport\.cancelButton/i)); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/components/Lightbox.test.tsx b/frontend/src/test/components/Lightbox.test.tsx new file mode 100644 index 0000000..30b0e44 --- /dev/null +++ b/frontend/src/test/components/Lightbox.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Lightbox } from '../../components/Lightbox'; + +describe('Lightbox', () => { + const defaultProps = { + src: '/test-image.jpg', + alt: 'Test Image', + onClose: vi.fn() + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders image with correct src and alt', () => { + render(); + + const img = screen.getByAltText('Test Image'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', '/test-image.jpg'); + }); + + it('renders close button', () => { + render(); + + expect(screen.getByText('×')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByText('×')); + + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when overlay is clicked', () => { + const onClose = vi.fn(); + const { container } = render(); + + const overlay = container.querySelector('.lightbox-overlay'); + fireEvent.click(overlay!); + + expect(onClose).toHaveBeenCalled(); + }); + + it('does not call onClose when image is clicked', () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByAltText('Test Image')); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('applies correct CSS classes', () => { + const { container } = render(); + + expect(container.querySelector('.lightbox-overlay')).toBeInTheDocument(); + expect(container.querySelector('.lightbox-close')).toBeInTheDocument(); + expect(container.querySelector('.lightbox-image')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/components/MedDetailModal.test.tsx b/frontend/src/test/components/MedDetailModal.test.tsx new file mode 100644 index 0000000..2c15d99 --- /dev/null +++ b/frontend/src/test/components/MedDetailModal.test.tsx @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MedDetailModal } from '../../components/MedDetailModal'; +import type { Medication, Coverage, StockThresholds, RefillEntry } from '../../types'; + +const defaultSettings: StockThresholds = { + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90 +}; + +const mockMedication: Medication = { + id: 1, + name: 'Test Med', + genericName: 'Generic Name', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: ['John'], + blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00' }], + updatedAt: null, + expiryDate: '2025-12-31', + notes: 'Test notes' +}; + +const mockCoverage: Coverage = { + name: 'Test Med', + medsLeft: 25, + daysLeft: 25, + depletionDate: '2024-04-01', + depletionTime: Date.now() + 25 * 86400000, + nextDose: null +}; + +const defaultProps = { + selectedMed: mockMedication, + coverage: { all: [mockCoverage] }, + settings: defaultSettings, + showImageLightbox: false, + showRefillModal: false, + showEditStockModal: false, + onClose: vi.fn(), + onOpenImageLightbox: vi.fn(), + onCloseImageLightbox: vi.fn(), + onOpenRefillModal: vi.fn(), + onCloseRefillModal: vi.fn(), + onOpenEditStockModal: vi.fn(), + onCloseEditStockModal: vi.fn(), + refillPacks: 0, + onRefillPacksChange: vi.fn(), + refillLoose: 0, + onRefillLooseChange: vi.fn(), + refillSaving: false, + refillHistory: [] as RefillEntry[], + refillHistoryExpanded: false, + onRefillHistoryExpandedChange: vi.fn(), + onSubmitRefill: vi.fn(), + editStockFullBlisters: 0, + onEditStockFullBlistersChange: vi.fn(), + editStockPartialBlisterPills: 0, + onEditStockPartialBlisterPillsChange: vi.fn(), + editStockSaving: false, + onSubmitStockCorrection: vi.fn() +}; + +describe('MedDetailModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when selectedMed is null', () => { + render(); + + expect(screen.queryByText('Test Med')).not.toBeInTheDocument(); + }); + + it('renders modal when medication is selected', () => { + render(); + + expect(screen.getByText('Test Med')).toBeInTheDocument(); + }); + + it('displays medication name', () => { + render(); + + expect(screen.getByText('Test Med')).toBeInTheDocument(); + }); + + it('displays generic name', () => { + render(); + + expect(screen.getByText('Generic Name')).toBeInTheDocument(); + }); + + it('renders close button', () => { + render(); + + const closeBtn = screen.getByText('×'); + expect(closeBtn).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + const onClose = vi.fn(); + render(); + + const closeBtn = screen.getByText('×'); + fireEvent.click(closeBtn); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when overlay clicked', () => { + const onClose = vi.fn(); + render(); + + const overlay = document.querySelector('.modal-overlay'); + if (overlay) { + fireEvent.click(overlay); + } + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when modal content clicked', () => { + const onClose = vi.fn(); + render(); + + const content = document.querySelector('.modal-content'); + if (content) { + fireEvent.click(content); + } + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('displays notes when available', () => { + render(); + + expect(screen.getByText('Test notes')).toBeInTheDocument(); + }); + + it('displays schedule information', () => { + render(); + + // Should have schedule section + const scheduleSection = document.querySelector('.med-detail-schedules'); + expect(scheduleSection).toBeInTheDocument(); + }); + + it('renders med detail header', () => { + render(); + + const header = document.querySelector('.med-detail-header'); + expect(header).toBeInTheDocument(); + }); + + it('renders med detail body', () => { + render(); + + const body = document.querySelector('.med-detail-body'); + expect(body).toBeInTheDocument(); + }); +}); + +describe('MedDetailModal without coverage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('works without coverage data', () => { + render(); + + // Should still render the medication name + expect(screen.getByText('Test Med')).toBeInTheDocument(); + }); +}); + +describe('MedDetailModal without optional fields', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('works without generic name', () => { + const med = { ...mockMedication, genericName: null }; + render(); + + expect(screen.getByText('Test Med')).toBeInTheDocument(); + }); + + it('works without notes', () => { + const med = { ...mockMedication, notes: null }; + render(); + + expect(screen.getByText('Test Med')).toBeInTheDocument(); + }); + + it('works without takenBy', () => { + const med = { ...mockMedication, takenBy: [] }; + render(); + + expect(screen.getByText('Test Med')).toBeInTheDocument(); + }); + + it('works without expiryDate', () => { + const med = { ...mockMedication, expiryDate: null }; + render(); + + expect(screen.getByText('Test Med')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/components/MedicationAvatar.test.tsx b/frontend/src/test/components/MedicationAvatar.test.tsx new file mode 100644 index 0000000..be858f0 --- /dev/null +++ b/frontend/src/test/components/MedicationAvatar.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MedicationAvatar } from '../../components/MedicationAvatar'; + +describe('MedicationAvatar', () => { + it('renders initials when no image provided', () => { + render(); + + expect(screen.getByText('TM')).toBeInTheDocument(); + }); + + it('uses first two initials from medication name', () => { + render(); + + expect(screen.getByText('VL')).toBeInTheDocument(); + }); + + it('handles single word names', () => { + render(); + + expect(screen.getByText('A')).toBeInTheDocument(); + }); + + it('renders image when imageUrl provided', () => { + render(); + + const img = screen.getByAltText('Test Med'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', '/api/images/test-image.jpg'); + }); + + it('applies small size class by default', () => { + const { container } = render(); + + expect(container.querySelector('.med-avatar-sm')).toBeInTheDocument(); + }); + + it('applies medium size class', () => { + const { container } = render(); + + expect(container.querySelector('.med-avatar-md')).toBeInTheDocument(); + }); + + it('applies large size class', () => { + const { container } = render(); + + expect(container.querySelector('.med-avatar-lg')).toBeInTheDocument(); + }); + + it('handles empty name with fallback', () => { + render(); + + expect(screen.getByText('?')).toBeInTheDocument(); + }); + + it('converts initials to uppercase', () => { + render(); + + expect(screen.getByText('LC')).toBeInTheDocument(); + }); + + it('adds initials class when no image', () => { + const { container } = render(); + + expect(container.querySelector('.med-avatar-initials')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/components/MobileEditModal.test.tsx b/frontend/src/test/components/MobileEditModal.test.tsx new file mode 100644 index 0000000..7c11a38 --- /dev/null +++ b/frontend/src/test/components/MobileEditModal.test.tsx @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MobileEditModal } from '../../components/MobileEditModal'; +import type { FormState, FormBlister } from '../../types'; + +const defaultForm: FormState = { + name: '', + genericName: '', + takenBy: [], + packCount: '1', + blistersPerPack: '1', + pillsPerBlister: '1', + looseTablets: '0', + pillWeightMg: '', + expiryDate: '', + notes: '', + intakeRemindersEnabled: false, + blisters: [{ + usage: '1', + every: '1', + startDate: '2024-01-01', + startTime: '09:00' + }] +}; + +const defaultProps = { + show: true, + editingId: null, + form: defaultForm, + onFormChange: vi.fn(), + fieldErrors: {}, + saving: false, + formSaved: false, + formChanged: false, + hasValidationErrors: false, + takenByInput: '', + onTakenByInputChange: vi.fn(), + existingPeople: [], + onAddTakenByPerson: vi.fn(), + onRemoveTakenByPerson: vi.fn(), + onTakenByKeyDown: vi.fn(), + onSetBlisterValue: vi.fn(), + onAddBlister: vi.fn(), + onRemoveBlister: vi.fn(), + onHandleValueChange: vi.fn(), + refillPacks: 0, + onRefillPacksChange: vi.fn(), + refillLoose: 0, + onRefillLooseChange: vi.fn(), + refillSaving: false, + onSubmitRefill: vi.fn(), + meds: [], + onUploadMedImage: vi.fn(), + onDeleteMedImage: vi.fn(), + onClose: vi.fn(), + onResetForm: vi.fn(), + onSaveMedication: vi.fn() +}; + +describe('MobileEditModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders nothing when show is false', () => { + render(); + + expect(screen.queryByText(/form\.newEntry/i)).not.toBeInTheDocument(); + }); + + it('renders modal when show is true', () => { + render(); + + // Should render the modal overlay + const modal = document.querySelector('.modal-overlay'); + expect(modal).toBeInTheDocument(); + }); + + it('shows new entry title when not editing', () => { + render(); + + expect(screen.getByText(/form\.newEntry/i)).toBeInTheDocument(); + }); + + it('shows edit entry title when editing', () => { + render(); + + expect(screen.getByText(/form\.editEntry/i)).toBeInTheDocument(); + }); + + it('renders close button', () => { + render(); + + const closeBtn = document.querySelector('.modal-close'); + expect(closeBtn).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + const onClose = vi.fn(); + const onResetForm = vi.fn(); + render(); + + const closeBtn = document.querySelector('.modal-close'); + if (closeBtn) { + fireEvent.click(closeBtn); + } + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onResetForm).toHaveBeenCalledTimes(1); + }); + + it('renders form element', () => { + render(); + + const form = document.querySelector('form'); + expect(form).toBeInTheDocument(); + }); + + it('renders name input', () => { + render(); + + expect(screen.getByText(/form\.commercialName/i)).toBeInTheDocument(); + }); + + it('renders generic name input', () => { + render(); + + expect(screen.getByText(/form\.genericName/i)).toBeInTheDocument(); + }); + + it('renders packs input', () => { + render(); + + expect(screen.getByText(/form\.packs/i)).toBeInTheDocument(); + }); + + it('renders blisters per pack input', () => { + render(); + + expect(screen.getByText(/form\.blistersPerPack/i)).toBeInTheDocument(); + }); + + it('renders pills per blister input', () => { + render(); + + expect(screen.getByText(/form\.pillsPerBlister/i)).toBeInTheDocument(); + }); + + it('renders loose tablets input', () => { + render(); + + expect(screen.getByText(/form\.loose/i)).toBeInTheDocument(); + }); + + it('renders intake schedules section', () => { + render(); + + expect(screen.getByText(/form\.blisters\.title/i)).toBeInTheDocument(); + }); + + it('renders save button', () => { + render(); + + const saveBtn = document.querySelector('button[type="submit"]'); + expect(saveBtn).toBeInTheDocument(); + }); + + it('disables save when saving', () => { + render(); + + const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement; + expect(saveBtn).toBeDisabled(); + }); + + it('disables save when has validation errors', () => { + render(); + + const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement; + expect(saveBtn).toBeDisabled(); + }); + + it('renders add intake button', () => { + render(); + + expect(screen.getByText(/form\.blisters\.addIntake/i)).toBeInTheDocument(); + }); + + it('calls onAddBlister when add intake clicked', () => { + const onAddBlister = vi.fn(); + render(); + + const addBtn = screen.getByText(/form\.blisters\.addIntake/i); + fireEvent.click(addBtn); + + expect(onAddBlister).toHaveBeenCalledTimes(1); + }); + + it('renders modal content', () => { + render(); + + const content = document.querySelector('.modal-content.edit-modal'); + expect(content).toBeInTheDocument(); + }); + + it('renders edit modal header', () => { + render(); + + const header = document.querySelector('.edit-modal-header'); + expect(header).toBeInTheDocument(); + }); +}); + +describe('MobileEditModal with existing people', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders modal with existing people prop', () => { + render(); + + // Should render the modal - suggestions shown on input focus + const modal = document.querySelector('.modal-overlay'); + expect(modal).toBeInTheDocument(); + }); +}); + +describe('MobileEditModal with form errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows name error when present', () => { + render(); + + expect(screen.getByText('Name is required')).toBeInTheDocument(); + }); + + it('shows notes error when present', () => { + render(); + + expect(screen.getByText('Notes too long')).toBeInTheDocument(); + }); +}); + +describe('MobileEditModal blister management', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders blister rows', () => { + render(); + + const blisterRows = document.querySelectorAll('.blister-row'); + expect(blisterRows.length).toBe(1); + }); + + it('renders remove button for each blister', () => { + const form = { + ...defaultForm, + blisters: [ + { usage: '1', every: '1', startDate: '2024-01-01', startTime: '09:00' }, + { usage: '2', every: '7', startDate: '2024-01-01', startTime: '10:00' } + ] + }; + + render(); + + const blisterRows = document.querySelectorAll('.blister-row'); + expect(blisterRows.length).toBe(2); + }); +}); diff --git a/frontend/src/test/components/ProfileModal.test.tsx b/frontend/src/test/components/ProfileModal.test.tsx new file mode 100644 index 0000000..21d59d2 --- /dev/null +++ b/frontend/src/test/components/ProfileModal.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import ProfileModal from '../../components/ProfileModal'; + +// Mock Auth UserProfile component +vi.mock('../../components/Auth', () => ({ + UserProfile: ({ onClose }: { onClose: () => void }) => ( +
User Profile Content
+ ) +})); + +describe('ProfileModal', () => { + it('renders nothing when not open', () => { + const onClose = vi.fn(); + render(); + + expect(screen.queryByTestId('user-profile')).not.toBeInTheDocument(); + }); + + it('renders modal when open', () => { + const onClose = vi.fn(); + render(); + + expect(screen.getByTestId('user-profile')).toBeInTheDocument(); + }); + + it('renders close button', () => { + const onClose = vi.fn(); + render(); + + const closeBtn = screen.getByText('×'); + expect(closeBtn).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + const onClose = vi.fn(); + render(); + + const closeBtn = screen.getByText('×'); + fireEvent.click(closeBtn); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when overlay clicked', () => { + const onClose = vi.fn(); + render(); + + const overlay = document.querySelector('.modal-overlay'); + if (overlay) { + fireEvent.click(overlay); + } + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when modal content clicked', () => { + const onClose = vi.fn(); + render(); + + const content = document.querySelector('.modal-content'); + if (content) { + fireEvent.click(content); + } + + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/components/ShareDialog.test.tsx b/frontend/src/test/components/ShareDialog.test.tsx new file mode 100644 index 0000000..0681aec --- /dev/null +++ b/frontend/src/test/components/ShareDialog.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ShareDialog } from '../../components/ShareDialog'; + +describe('ShareDialog', () => { + const defaultProps = { + show: true, + sharePeople: ['Alice', 'Bob'], + shareSelectedPerson: 'Alice', + onShareSelectedPersonChange: vi.fn(), + shareSelectedDays: 30, + onShareSelectedDaysChange: vi.fn(), + shareGenerating: false, + shareLink: null, + onShareLinkChange: vi.fn(), + shareCopied: false, + onShareCopiedChange: vi.fn(), + onClose: vi.fn(), + onGenerateShareLink: vi.fn(), + onCopyShareLink: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when show is false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders dialog when show is true', () => { + render(); + expect(screen.getByText(/share\.title/i)).toBeInTheDocument(); + }); + + it('renders no people message when sharePeople is empty', () => { + render(); + expect(screen.getByText(/share\.noPeople/i)).toBeInTheDocument(); + }); + + it('renders person selection dropdown', () => { + render(); + expect(screen.getByRole('option', { name: 'Alice' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Bob' })).toBeInTheDocument(); + }); + + it('renders period selection dropdown', () => { + render(); + // The dropdown renders with 3 options for time periods + const options = screen.getAllByRole('option'); + expect(options.length).toBeGreaterThanOrEqual(3); + }); + + it('calls onClose when close button is clicked', () => { + render(); + fireEvent.click(screen.getByText('×')); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('calls onClose when overlay is clicked', () => { + const { container } = render(); + const overlay = container.querySelector('.modal-overlay'); + fireEvent.click(overlay!); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('shows generated link', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveValue('http://example.com/share/abc123'); + }); + + it('calls onCopyShareLink when copy button is clicked', () => { + render(); + fireEvent.click(screen.getByText('📋')); + expect(defaultProps.onCopyShareLink).toHaveBeenCalled(); + }); + + it('shows copied indicator after copy', () => { + render(); + expect(screen.getByText('✓')).toBeInTheDocument(); + }); + + it('selects link text when input is clicked', () => { + render(); + const input = screen.getByRole('textbox') as HTMLInputElement; + const selectMock = vi.fn(); + input.select = selectMock; + fireEvent.click(input); + expect(selectMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/components/SharedSchedule.test.tsx b/frontend/src/test/components/SharedSchedule.test.tsx new file mode 100644 index 0000000..6b6d02a --- /dev/null +++ b/frontend/src/test/components/SharedSchedule.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { SharedSchedule } from '../../components/SharedSchedule'; + +describe('SharedSchedule', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('shows loading state initially', () => { + render( + + + } /> + + + ); + + // Should show loading state - actual translation key is common.loading + expect(screen.getByText(/common\.loading/i)).toBeInTheDocument(); + }); + + it('renders app title during loading', () => { + render( + + + } /> + + + ); + + expect(screen.getByText(/MedAssist/i)).toBeInTheDocument(); + }); + + it('renders shared schedule page container', () => { + render( + + + } /> + + + ); + + const container = document.querySelector('.shared-schedule-page'); + expect(container).toBeInTheDocument(); + }); + + it('renders loading state container', () => { + render( + + + } /> + + + ); + + const loading = document.querySelector('.shared-schedule-loading'); + expect(loading).toBeInTheDocument(); + }); + + it('has correct initial theme', () => { + render( + + + } /> + + + ); + + // Default theme should be dark + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); +}); diff --git a/frontend/src/test/components/TagInput.test.tsx b/frontend/src/test/components/TagInput.test.tsx new file mode 100644 index 0000000..38abe1d --- /dev/null +++ b/frontend/src/test/components/TagInput.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TagInput } from '../../components/TagInput'; + +describe('TagInput', () => { + const defaultProps = { + tags: [] as string[], + inputValue: '', + onInputChange: vi.fn(), + onAddTag: vi.fn(), + onRemoveTag: vi.fn() + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders input element', () => { + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders existing tags', () => { + render(); + expect(screen.getByText('Tag1')).toBeInTheDocument(); + expect(screen.getByText('Tag2')).toBeInTheDocument(); + }); + + it('calls onInputChange when typing', () => { + render(); + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'new tag' } }); + expect(defaultProps.onInputChange).toHaveBeenCalledWith('new tag'); + }); + + it('calls onAddTag when Enter is pressed with value', () => { + render(); + const input = screen.getByRole('combobox'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(defaultProps.onAddTag).toHaveBeenCalledWith('new tag'); + }); + + it('calls onAddTag when comma is pressed with value', () => { + render(); + const input = screen.getByRole('combobox'); + fireEvent.keyDown(input, { key: ',' }); + expect(defaultProps.onAddTag).toHaveBeenCalledWith('new tag'); + }); + + it('does not call onAddTag when Enter pressed with empty value', () => { + render(); + const input = screen.getByRole('combobox'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(defaultProps.onAddTag).not.toHaveBeenCalled(); + }); + + it('calls onRemoveTag when Backspace is pressed with empty input', () => { + render(); + const input = screen.getByRole('combobox'); + fireEvent.keyDown(input, { key: 'Backspace' }); + expect(defaultProps.onRemoveTag).toHaveBeenCalledWith('Tag2'); + }); + + it('does not call onRemoveTag when Backspace pressed with value', () => { + render(); + const input = screen.getByRole('combobox'); + fireEvent.keyDown(input, { key: 'Backspace' }); + expect(defaultProps.onRemoveTag).not.toHaveBeenCalled(); + }); + + it('calls onRemoveTag when tag remove button is clicked', () => { + render(); + const removeButtons = screen.getAllByText('×'); + fireEvent.click(removeButtons[0]); + expect(defaultProps.onRemoveTag).toHaveBeenCalledWith('Tag1'); + }); + + it('calls onAddTag on blur when there is a value', () => { + render(); + const input = screen.getByRole('combobox'); + fireEvent.blur(input); + expect(defaultProps.onAddTag).toHaveBeenCalledWith('pending tag'); + }); + + it('shows placeholder when no tags', () => { + render(); + expect(screen.getByPlaceholderText('Enter tags')).toBeInTheDocument(); + }); + + it('shows addPlaceholder when tags exist', () => { + render( + + ); + expect(screen.getByPlaceholderText('Add more')).toBeInTheDocument(); + }); + + it('applies maxLength attribute', () => { + render(); + const input = screen.getByRole('combobox'); + expect(input).toHaveAttribute('maxLength', '50'); + }); + + it('shows error message when provided', () => { + render(); + expect(screen.getByText('This field is required')).toBeInTheDocument(); + }); + + it('renders datalist for suggestions', () => { + const { container } = render( + + ); + const datalist = container.querySelector('#test-datalist'); + expect(datalist).toBeInTheDocument(); + expect(datalist?.querySelectorAll('option').length).toBe(2); + }); + + it('excludes already selected tags from suggestions', () => { + const { container } = render( + + ); + const datalist = container.querySelector('#test-datalist'); + expect(datalist?.querySelectorAll('option').length).toBe(2); + }); +}); diff --git a/frontend/src/test/components/UserFilterModal.test.tsx b/frontend/src/test/components/UserFilterModal.test.tsx new file mode 100644 index 0000000..e01c80e --- /dev/null +++ b/frontend/src/test/components/UserFilterModal.test.tsx @@ -0,0 +1,281 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { UserFilterModal } from '../../components/UserFilterModal'; +import type { Medication, Coverage, StockThresholds } from '../../types'; + +const defaultSettings: StockThresholds = { + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90 +}; + +const mockMedication: Medication = { + id: 1, + name: 'Test Med', + genericName: 'Generic Name', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: ['John'], + blisters: [{ usage: 1, every: 1, start: '2024-01-01T09:00:00' }], + updatedAt: null +}; + +const mockCoverage: Coverage = { + name: 'Test Med', + medsLeft: 25, + daysLeft: 25, + depletionDate: null, + depletionTime: null, + nextDose: null +}; + +describe('UserFilterModal', () => { + it('renders nothing when selectedUser is null', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + expect(screen.queryByText(/modal\.userMedications/i)).not.toBeInTheDocument(); + }); + + it('renders modal when selectedUser is provided', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + expect(screen.getByText(/modal\.userMedications/i)).toBeInTheDocument(); + }); + + it('displays user avatar', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + // Avatar should show first letter + expect(screen.getByText('J')).toBeInTheDocument(); + }); + + it('displays medications for selected user', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + expect(screen.getByText('Test Med')).toBeInTheDocument(); + }); + + it('displays generic name when available', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + expect(screen.getByText('Generic Name')).toBeInTheDocument(); + }); + + it('shows empty message when user has no medications', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + expect(screen.getByText(/modal\.noMedsForUser/i)).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + const closeBtn = screen.getByText('×'); + fireEvent.click(closeBtn); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when overlay clicked', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + const overlay = document.querySelector('.modal-overlay'); + if (overlay) { + fireEvent.click(overlay); + } + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose and onOpenMedDetail when medication clicked', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + const medItem = document.querySelector('.user-med-item'); + if (medItem) { + fireEvent.click(medItem); + } + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onOpenMedDetail).toHaveBeenCalledWith(mockMedication); + }); + + it('calls onClose when footer close button clicked', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + const footerCloseBtn = screen.getByText(/common\.close/i); + fireEvent.click(footerCloseBtn); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when modal content clicked', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + render( + + ); + + const content = document.querySelector('.modal-content'); + if (content) { + fireEvent.click(content); + } + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('filters medications by takenBy correctly', () => { + const onClose = vi.fn(); + const onOpenMedDetail = vi.fn(); + + const meds: Medication[] = [ + { ...mockMedication, id: 1, name: 'Med1', takenBy: ['John'] }, + { ...mockMedication, id: 2, name: 'Med2', takenBy: ['Jane'] }, + { ...mockMedication, id: 3, name: 'Med3', takenBy: ['John', 'Jane'] } + ]; + + render( + + ); + + expect(screen.getByText('Med1')).toBeInTheDocument(); + expect(screen.queryByText('Med2')).not.toBeInTheDocument(); + expect(screen.getByText('Med3')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/hooks/useCollapsedDays.test.ts b/frontend/src/test/hooks/useCollapsedDays.test.ts new file mode 100644 index 0000000..097581c --- /dev/null +++ b/frontend/src/test/hooks/useCollapsedDays.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useCollapsedDays } from '../../hooks/useCollapsedDays'; + +describe('useCollapsedDays', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window.localStorage.getItem as ReturnType).mockReturnValue(null); + (window.localStorage.setItem as ReturnType).mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty sets initially when no userId', () => { + const { result } = renderHook(() => useCollapsedDays(undefined)); + + expect(result.current.manuallyCollapsedDays.size).toBe(0); + expect(result.current.manuallyExpandedDays.size).toBe(0); + }); + + it('loads from localStorage when userId is provided', () => { + (window.localStorage.getItem as ReturnType).mockImplementation((key: string) => { + if (key === 'collapsedDays_user_1') return JSON.stringify(['2024-01-01']); + if (key === 'expandedDays_user_1') return JSON.stringify(['2024-01-02']); + return null; + }); + + const { result } = renderHook(() => useCollapsedDays(1)); + + expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(true); + expect(result.current.manuallyExpandedDays.has('2024-01-02')).toBe(true); + }); + + it('toggles collapsed day when not auto-collapsed', () => { + const { result } = renderHook(() => useCollapsedDays(1)); + + act(() => { + result.current.toggleDayCollapse('2024-01-01', false); + }); + + expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(true); + + act(() => { + result.current.toggleDayCollapse('2024-01-01', false); + }); + + expect(result.current.manuallyCollapsedDays.has('2024-01-01')).toBe(false); + }); + + it('toggles expanded day when auto-collapsed', () => { + const { result } = renderHook(() => useCollapsedDays(1)); + + act(() => { + result.current.toggleDayCollapse('2024-01-01', true); + }); + + expect(result.current.manuallyExpandedDays.has('2024-01-01')).toBe(true); + + act(() => { + result.current.toggleDayCollapse('2024-01-01', true); + }); + + expect(result.current.manuallyExpandedDays.has('2024-01-01')).toBe(false); + }); + + it('saves to localStorage when toggling with userId', () => { + const { result } = renderHook(() => useCollapsedDays(1)); + + act(() => { + result.current.toggleDayCollapse('2024-01-01', false); + }); + + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'collapsedDays_user_1', + expect.any(String) + ); + }); + + it('does not save to localStorage without userId', () => { + const { result } = renderHook(() => useCollapsedDays(undefined)); + + act(() => { + result.current.toggleDayCollapse('2024-01-01', false); + }); + + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/test/hooks/useDoses.test.ts b/frontend/src/test/hooks/useDoses.test.ts new file mode 100644 index 0000000..8c8e618 --- /dev/null +++ b/frontend/src/test/hooks/useDoses.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useDoses } from '../../hooks/useDoses'; + +describe('useDoses', () => { + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ doses: [] }) + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('initializes with empty state', () => { + const { result } = renderHook(() => useDoses()); + + expect(result.current.takenDoses.size).toBe(0); + expect(result.current.dismissedDoses.size).toBe(0); + expect(result.current.clearingMissed).toBe(false); + expect(result.current.showClearMissedConfirm).toBe(false); + }); + + it('loads taken doses from API on mount', async () => { + const mockDoses = { + doses: [ + { doseId: 'dose-1', dismissed: false }, + { doseId: 'dose-2', dismissed: true } + ] + }; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDoses) + }); + + const { result } = renderHook(() => useDoses()); + + await waitFor(() => { + expect(result.current.takenDoses.has('dose-1')).toBe(true); + expect(result.current.dismissedDoses.has('dose-2')).toBe(true); + }); + }); + + it('getDoseId returns correct ID format', () => { + const { result } = renderHook(() => useDoses()); + + expect(result.current.getDoseId('dose-1', null)).toBe('dose-1'); + expect(result.current.getDoseId('dose-1', 'John')).toBe('dose-1-John'); + }); + + it('countTakenDoses calculates correctly', async () => { + const mockDoses = { + doses: [{ doseId: 'dose-1', dismissed: false }] + }; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDoses) + }); + + const { result } = renderHook(() => useDoses()); + + await waitFor(() => { + expect(result.current.takenDoses.has('dose-1')).toBe(true); + }); + + const doses = [ + { id: 'dose-1', takenBy: [] }, + { id: 'dose-2', takenBy: [] } + ]; + + const count = result.current.countTakenDoses(doses); + expect(count.total).toBe(2); + expect(count.taken).toBe(1); + }); + + it('countTakenDoses handles multiple people', async () => { + const mockDoses = { + doses: [ + { doseId: 'dose-1-Alice', dismissed: false }, + { doseId: 'dose-1-Bob', dismissed: false } + ] + }; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockDoses) + }); + + const { result } = renderHook(() => useDoses()); + + await waitFor(() => { + expect(result.current.takenDoses.size).toBe(2); + }); + + const doses = [{ id: 'dose-1', takenBy: ['Alice', 'Bob', 'Charlie'] }]; + const count = result.current.countTakenDoses(doses); + expect(count.total).toBe(3); + expect(count.taken).toBe(2); + }); + + it('marks dose as taken optimistically', async () => { + // First call for initial load, subsequent calls for marking dose + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) }) + .mockResolvedValueOnce({ ok: true }); + + const { result } = renderHook(() => useDoses()); + + // Wait for initial load to complete + await waitFor(() => { + expect(result.current.takenDoses.size).toBe(0); + }); + + await act(async () => { + await result.current.markDoseTaken('new-dose'); + }); + + expect(result.current.takenDoses.has('new-dose')).toBe(true); + expect(fetch).toHaveBeenCalledWith( + '/api/doses/taken', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ doseId: 'new-dose' }) + }) + ); + }); + + it('reverts optimistic update on error', async () => { + // First call for initial load, second for marking dose fails + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) }) + .mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useDoses()); + + await waitFor(() => { + expect(result.current.takenDoses.size).toBe(0); + }); + + await act(async () => { + await result.current.markDoseTaken('new-dose'); + }); + + // After error, the dose should be removed + await waitFor(() => { + expect(result.current.takenDoses.has('new-dose')).toBe(false); + }); + }); + + it('undoes dose taken optimistically', async () => { + const mockDoses = { + doses: [{ doseId: 'taken-dose', dismissed: false }] + }; + + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) }) + .mockResolvedValueOnce({ ok: true }); + + const { result } = renderHook(() => useDoses()); + + await waitFor(() => { + expect(result.current.takenDoses.has('taken-dose')).toBe(true); + }); + + await act(async () => { + await result.current.undoDoseTaken('taken-dose'); + }); + + expect(result.current.takenDoses.has('taken-dose')).toBe(false); + expect(fetch).toHaveBeenCalledWith( + '/api/doses/taken/taken-dose', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('dismisses missed doses', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) }) + .mockResolvedValueOnce({ ok: true }); + + const { result } = renderHook(() => useDoses()); + + await waitFor(() => { + expect(result.current.clearingMissed).toBe(false); + }); + + await act(async () => { + await result.current.dismissMissedDoses(['missed-1', 'missed-2']); + }); + + expect(result.current.dismissedDoses.has('missed-1')).toBe(true); + expect(result.current.dismissedDoses.has('missed-2')).toBe(true); + }); + + it('does nothing when dismissing empty array', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ doses: [] }) + }); + + const { result } = renderHook(() => useDoses()); + + await act(async () => { + await result.current.dismissMissedDoses([]); + }); + + // Should not make a POST call for dismiss + expect(fetch).not.toHaveBeenCalledWith( + '/api/doses/dismiss', + expect.anything() + ); + }); + + it('setShowClearMissedConfirm works', () => { + const { result } = renderHook(() => useDoses()); + + act(() => { + result.current.setShowClearMissedConfirm(true); + }); + + expect(result.current.showClearMissedConfirm).toBe(true); + }); + + it('handles API error on dismiss gracefully', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) }) + .mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useDoses()); + + await waitFor(() => { + expect(result.current.clearingMissed).toBe(false); + }); + + await act(async () => { + await result.current.dismissMissedDoses(['missed-1']); + }); + + expect(result.current.clearingMissed).toBe(false); + }); +}); diff --git a/frontend/src/test/hooks/useMedicationForm.test.ts b/frontend/src/test/hooks/useMedicationForm.test.ts new file mode 100644 index 0000000..334476c --- /dev/null +++ b/frontend/src/test/hooks/useMedicationForm.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { defaultForm, defaultBlister } from '../../hooks/useMedicationForm'; + +describe('defaultBlister', () => { + it('creates a blister with default values', () => { + const blister = defaultBlister(); + expect(blister.usage).toBe('1'); + expect(blister.every).toBe('1'); + expect(blister.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(blister.startTime).toMatch(/^\d{2}:\d{2}$/); + }); + + it('uses current date', () => { + const before = new Date(); + const blister = defaultBlister(); + const after = new Date(); + + // Date should be between before and after + const blisterDate = new Date(blister.startDate); + expect(blisterDate >= new Date(before.toISOString().slice(0, 10))).toBe(true); + expect(blisterDate <= new Date(after.toISOString().slice(0, 10) + 'T23:59:59')).toBe(true); + }); +}); + +describe('defaultForm', () => { + it('creates a form with default values', () => { + const form = defaultForm(); + expect(form.name).toBe(''); + expect(form.genericName).toBe(''); + expect(form.takenBy).toEqual([]); + expect(form.packCount).toBe('1'); + expect(form.blistersPerPack).toBe('1'); + expect(form.pillsPerBlister).toBe('1'); + expect(form.looseTablets).toBe('0'); + expect(form.pillWeightMg).toBe(''); + expect(form.expiryDate).toBe(''); + expect(form.notes).toBe(''); + expect(form.intakeRemindersEnabled).toBe(false); + expect(form.blisters).toHaveLength(1); + }); + + it('creates a blister in the form', () => { + const form = defaultForm(); + expect(form.blisters).toHaveLength(1); + expect(form.blisters[0].usage).toBe('1'); + expect(form.blisters[0].every).toBe('1'); + }); + + it('creates independent forms', () => { + const form1 = defaultForm(); + const form2 = defaultForm(); + + form1.name = 'Test'; + expect(form2.name).toBe(''); + }); + + it('creates independent blisters arrays', () => { + const form1 = defaultForm(); + const form2 = defaultForm(); + + form1.blisters.push(defaultBlister()); + expect(form2.blisters).toHaveLength(1); + }); + + it('creates independent takenBy arrays', () => { + const form1 = defaultForm(); + const form2 = defaultForm(); + + form1.takenBy.push('John'); + expect(form2.takenBy).toHaveLength(0); + }); +}); diff --git a/frontend/src/test/hooks/useMedications.test.ts b/frontend/src/test/hooks/useMedications.test.ts new file mode 100644 index 0000000..ea9e49c --- /dev/null +++ b/frontend/src/test/hooks/useMedications.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useMedications } from '../../hooks/useMedications'; + +describe('useMedications', () => { + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]) + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('initializes with empty state', () => { + const { result } = renderHook(() => useMedications()); + + expect(result.current.meds).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.saving).toBe(false); + expect(result.current.uploadingImage).toBe(false); + }); + + it('loads medications from API', async () => { + const mockMeds = [ + { id: 1, name: 'TestMed', packCount: 1 } + ]; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockMeds) + }); + + const { result } = renderHook(() => useMedications()); + + act(() => { + result.current.loadMeds(); + }); + + await waitFor(() => { + expect(result.current.meds).toEqual(mockMeds); + }); + + expect(fetch).toHaveBeenCalledWith('/api/medications'); + }); + + it('handles API error gracefully', async () => { + (global.fetch as ReturnType).mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useMedications()); + + act(() => { + result.current.loadMeds(); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.meds).toEqual([]); + }); + + it('handles non-array response', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ not: 'array' }) + }); + + const { result } = renderHook(() => useMedications()); + + act(() => { + result.current.loadMeds(); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.meds).toEqual([]); + }); + + it('deletes medication', async () => { + const mockMeds = [{ id: 1, name: 'TestMed' }]; + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockMeds) }) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + const mockResetForm = vi.fn(); + const { result } = renderHook(() => useMedications()); + + // First load meds + act(() => { + result.current.loadMeds(); + }); + + await waitFor(() => { + expect(result.current.meds).toEqual(mockMeds); + }); + + // Then delete + await act(async () => { + await result.current.deleteMed(1, 1, mockResetForm); + }); + + expect(fetch).toHaveBeenCalledWith('/api/medications/1', { method: 'DELETE' }); + expect(mockResetForm).toHaveBeenCalled(); + }); + + it('does not call resetForm if editingId does not match', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + const mockResetForm = vi.fn(); + const { result } = renderHook(() => useMedications()); + + await act(async () => { + await result.current.deleteMed(1, 2, mockResetForm); + }); + + expect(mockResetForm).not.toHaveBeenCalled(); + }); + + it('uploads medication image', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + const { result } = renderHook(() => useMedications()); + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); + + await act(async () => { + await result.current.uploadMedImage(1, file); + }); + + expect(fetch).toHaveBeenCalledWith( + '/api/medications/1/image', + expect.objectContaining({ + method: 'POST', + body: expect.any(FormData) + }) + ); + }); + + it('handles image upload error', async () => { + (global.fetch as ReturnType).mockRejectedValueOnce(new Error('Upload failed')); + + const { result } = renderHook(() => useMedications()); + const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); + + await act(async () => { + await result.current.uploadMedImage(1, file); + }); + + expect(result.current.uploadingImage).toBe(false); + }); + + it('deletes medication image', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }); + + const { result } = renderHook(() => useMedications()); + + await act(async () => { + await result.current.deleteMedImage(1); + }); + + expect(fetch).toHaveBeenCalledWith('/api/medications/1/image', { method: 'DELETE' }); + }); + + it('allows setting meds directly', () => { + const { result } = renderHook(() => useMedications()); + + const newMeds = [{ id: 1, name: 'NewMed' }] as any; + + act(() => { + result.current.setMeds(newMeds); + }); + + expect(result.current.meds).toEqual(newMeds); + }); + + it('allows setting saving state', () => { + const { result } = renderHook(() => useMedications()); + + act(() => { + result.current.setSaving(true); + }); + + expect(result.current.saving).toBe(true); + }); +}); diff --git a/frontend/src/test/hooks/useRefill.test.ts b/frontend/src/test/hooks/useRefill.test.ts new file mode 100644 index 0000000..67517e1 --- /dev/null +++ b/frontend/src/test/hooks/useRefill.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useRefill } from '../../hooks/useRefill'; +import type { Medication, Coverage } from '../../types'; + +describe('useRefill', () => { + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}) + }); + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + vi.spyOn(window.history, 'back').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('initializes with default state', () => { + const { result } = renderHook(() => useRefill()); + + expect(result.current.showRefillModal).toBe(false); + expect(result.current.refillPacks).toBe(1); + expect(result.current.refillLoose).toBe(0); + expect(result.current.refillSaving).toBe(false); + expect(result.current.refillHistory).toEqual([]); + expect(result.current.refillHistoryExpanded).toBe(false); + expect(result.current.showEditStockModal).toBe(false); + }); + + it('loads refill history', async () => { + const mockHistory = [ + { id: 1, packsAdded: 2, loosePillsAdded: 0, createdAt: '2024-03-15T10:00:00Z' } + ]; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockHistory) + }); + + const { result } = renderHook(() => useRefill()); + + await act(async () => { + await result.current.loadRefillHistory(1); + }); + + expect(result.current.refillHistory).toEqual(mockHistory); + }); + + it('handles refill history with refills wrapper', async () => { + const mockHistory = { + refills: [{ id: 1, packsAdded: 2, createdAt: '2024-03-15T10:00:00Z' }] + }; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockHistory) + }); + + const { result } = renderHook(() => useRefill()); + + await act(async () => { + await result.current.loadRefillHistory(1); + }); + + expect(result.current.refillHistory).toEqual(mockHistory.refills); + }); + + it('handles refill history error', async () => { + (global.fetch as ReturnType).mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useRefill()); + + await act(async () => { + await result.current.loadRefillHistory(1); + }); + + expect(result.current.refillHistory).toEqual([]); + }); + + it('opens refill modal and pushes history', () => { + const { result } = renderHook(() => useRefill()); + + act(() => { + result.current.openRefillModal(); + }); + + expect(result.current.showRefillModal).toBe(true); + expect(window.history.pushState).toHaveBeenCalledWith({ modal: 'refill' }, ''); + }); + + it('closes refill modal using history back', () => { + const { result } = renderHook(() => useRefill()); + + act(() => { + result.current.openRefillModal(); + }); + + act(() => { + result.current.closeRefillModal(); + }); + + expect(window.history.back).toHaveBeenCalled(); + }); + + it('does not call history back when refill modal not open', () => { + const { result } = renderHook(() => useRefill()); + + act(() => { + result.current.closeRefillModal(); + }); + + expect(window.history.back).not.toHaveBeenCalled(); + }); + + it('submits refill successfully', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ newStock: { packCount: 3, looseTablets: 5 } }) + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]) + }); + + const mockSetForm = vi.fn(); + const mockLoadMeds = vi.fn(); + + const { result } = renderHook(() => useRefill()); + + // Open modal first + act(() => { + result.current.openRefillModal(); + }); + + await act(async () => { + await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds); + }); + + expect(fetch).toHaveBeenCalledWith( + '/api/medications/1/refill', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0 }) + }) + ); + expect(mockSetForm).toHaveBeenCalled(); + expect(mockLoadMeds).toHaveBeenCalled(); + }); + + it('does not submit refill if both values are 0', async () => { + const { result } = renderHook(() => useRefill()); + + act(() => { + result.current.setRefillPacks(0); + result.current.setRefillLoose(0); + }); + + const mockSetForm = vi.fn(); + const mockLoadMeds = vi.fn(); + + await act(async () => { + await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds); + }); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('opens edit stock modal', () => { + const { result } = renderHook(() => useRefill()); + + const mockMed: Medication = { + id: 1, + name: 'Test Med', + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 5, + takenBy: [], + blisters: [], + updatedAt: null + }; + + const mockCoverage = { + all: [{ name: 'Test Med', medsLeft: 20, daysLeft: 10 }] as Coverage[] + }; + + act(() => { + result.current.openEditStockModal(mockMed, mockCoverage); + }); + + expect(result.current.showEditStockModal).toBe(true); + expect(window.history.pushState).toHaveBeenCalledWith({ modal: 'editStock' }, ''); + expect(result.current.editStockFullBlisters).toBe(2); // 20 / 10 = 2 + expect(result.current.editStockPartialBlisterPills).toBe(0); // 20 % 10 = 0 + }); + + it('closes edit stock modal using history back', () => { + const { result } = renderHook(() => useRefill()); + + const mockMed: Medication = { + id: 1, + name: 'Test Med', + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 5, + takenBy: [], + blisters: [], + updatedAt: null + }; + + act(() => { + result.current.openEditStockModal(mockMed, { all: [] }); + }); + + act(() => { + result.current.closeEditStockModal(); + }); + + expect(window.history.back).toHaveBeenCalled(); + }); + + it('submits stock correction', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true }); + + const mockMed: Medication = { + id: 1, + name: 'Test Med', + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 5, + takenBy: [], + blisters: [], + updatedAt: null + }; + + const mockLoadMeds = vi.fn(); + const { result } = renderHook(() => useRefill()); + + act(() => { + result.current.openEditStockModal(mockMed, { all: [] }); + }); + + await act(async () => { + await result.current.submitStockCorrection(1, mockMed, mockLoadMeds); + }); + + expect(fetch).toHaveBeenCalledWith( + '/api/medications/1/stock-adjustment', + expect.objectContaining({ method: 'PATCH' }) + ); + expect(mockLoadMeds).toHaveBeenCalled(); + }); + + it('handles full blister conversion in stock correction', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ ok: true }); + + const mockMed: Medication = { + id: 1, + name: 'Test Med', + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 5, + takenBy: [], + blisters: [], + updatedAt: null + }; + + const mockLoadMeds = vi.fn(); + const { result } = renderHook(() => useRefill()); + + act(() => { + result.current.openEditStockModal(mockMed, { all: [] }); + // Set partial pills to equal a full blister + result.current.setEditStockPartialBlisterPills(10); + }); + + await act(async () => { + await result.current.submitStockCorrection(1, mockMed, mockLoadMeds); + }); + + expect(fetch).toHaveBeenCalled(); + expect(mockLoadMeds).toHaveBeenCalled(); + }); + + it('allows setting state directly', () => { + const { result } = renderHook(() => useRefill()); + + act(() => { + result.current.setRefillPacks(5); + result.current.setRefillLoose(3); + result.current.setRefillHistoryExpanded(true); + result.current.setShowRefillModal(true); + result.current.setShowEditStockModal(true); + result.current.setEditStockFullBlisters(10); + result.current.setEditStockPartialBlisterPills(5); + }); + + expect(result.current.refillPacks).toBe(5); + expect(result.current.refillLoose).toBe(3); + expect(result.current.refillHistoryExpanded).toBe(true); + expect(result.current.showRefillModal).toBe(true); + expect(result.current.showEditStockModal).toBe(true); + expect(result.current.editStockFullBlisters).toBe(10); + expect(result.current.editStockPartialBlisterPills).toBe(5); + }); +}); diff --git a/frontend/src/test/hooks/useSettings.test.ts b/frontend/src/test/hooks/useSettings.test.ts new file mode 100644 index 0000000..1759c0a --- /dev/null +++ b/frontend/src/test/hooks/useSettings.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useSettings } from '../../hooks/useSettings'; +import React from 'react'; + +describe('useSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}) + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('initializes with default settings', () => { + const { result } = renderHook(() => useSettings()); + + expect(result.current.settings.emailEnabled).toBe(false); + expect(result.current.settings.lowStockDays).toBe(30); + expect(result.current.settings.reminderDaysBefore).toBe(7); + expect(result.current.settingsLoading).toBe(true); + }); + + it('loads settings from API on mount', async () => { + const mockSettings = { + emailEnabled: true, + notificationEmail: 'test@example.com', + lowStockDays: 14 + }; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSettings) + }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + expect(result.current.settings.emailEnabled).toBe(true); + expect(result.current.settings.notificationEmail).toBe('test@example.com'); + }); + + it('handles API error on load', async () => { + (global.fetch as ReturnType).mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + }); + + it('saves settings to API', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ ok: true }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent; + + await act(async () => { + await result.current.saveSettings(mockEvent); + }); + + expect(fetch).toHaveBeenCalledWith( + '/api/settings', + expect.objectContaining({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' } + }) + ); + expect(result.current.settingsSaved).toBe(true); + }); + + it('validates email before saving', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}) + }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + // Set invalid email + act(() => { + result.current.setSettings(s => ({ + ...s, + emailEnabled: true, + notificationEmail: 'invalid-email' + })); + }); + + const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent; + + await act(async () => { + await result.current.saveSettings(mockEvent); + }); + + expect(result.current.testEmailResult?.success).toBe(false); + expect(result.current.testEmailResult?.message).toContain('Invalid email'); + }); + + it('tests email notification', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ message: 'Email sent!' }) + }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + await act(async () => { + await result.current.testEmail(); + }); + + expect(result.current.testEmailResult?.success).toBe(true); + expect(result.current.testingEmail).toBe(false); + }); + + it('handles test email failure', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + await act(async () => { + await result.current.testEmail(); + }); + + expect(result.current.testEmailResult?.success).toBe(false); + }); + + it('tests shoutrrr notification', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ message: 'Notification sent!' }) + }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + await act(async () => { + await result.current.testShoutrrr(); + }); + + expect(result.current.testShoutrrrResult?.success).toBe(true); + expect(result.current.testingShoutrrr).toBe(false); + }); + + it('tracks unsaved changes', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ lowStockDays: 30 }) + }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + expect(result.current.hasUnsavedChanges).toBe(false); + + act(() => { + result.current.setSettings(s => ({ ...s, lowStockDays: 14 })); + }); + + expect(result.current.hasUnsavedChanges).toBe(true); + }); + + it('loadSettings can be called manually', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ lowStockDays: 14 }) + }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + act(() => { + result.current.loadSettings(); + }); + + await waitFor(() => { + expect(result.current.settings.lowStockDays).toBe(14); + }); + }); + + it('auto-disables email when no recipient', async () => { + (global.fetch as ReturnType) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) }) + .mockResolvedValueOnce({ ok: true }); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settingsLoading).toBe(false); + }); + + act(() => { + result.current.setSettings(s => ({ + ...s, + emailEnabled: true, + notificationEmail: '' + })); + }); + + const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent; + + await act(async () => { + await result.current.saveSettings(mockEvent); + }); + + // emailEnabled should be false in the saved state + expect(result.current.settings.emailEnabled).toBe(false); + }); +}); diff --git a/frontend/src/test/hooks/useShare.test.ts b/frontend/src/test/hooks/useShare.test.ts new file mode 100644 index 0000000..adfc709 --- /dev/null +++ b/frontend/src/test/hooks/useShare.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useShare } from '../../hooks/useShare'; +import type { Medication } from '../../types'; + +describe('useShare', () => { + let mockAlert: ReturnType; + let mockClipboard: { writeText: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockAlert = vi.fn(); + global.alert = mockAlert; + + mockClipboard = { writeText: vi.fn() }; + Object.defineProperty(navigator, 'clipboard', { + value: mockClipboard, + writable: true + }); + + // Mock window.history + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + vi.spyOn(window.history, 'back').mockImplementation(() => {}); + + // Mock window.location.origin + Object.defineProperty(window, 'location', { + value: { origin: 'http://localhost:5173' }, + writable: true + }); + + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ token: 'test-token' }) + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('initializes with default state', () => { + const { result } = renderHook(() => useShare()); + + expect(result.current.showShareDialog).toBe(false); + expect(result.current.sharePeople).toEqual([]); + expect(result.current.shareSelectedPerson).toBe(''); + expect(result.current.shareSelectedDays).toBe(30); + expect(result.current.shareLink).toBeNull(); + }); + + it('opens share dialog with people from medications', () => { + const { result } = renderHook(() => useShare()); + + const meds: Medication[] = [ + { + id: 1, name: 'Med1', takenBy: ['Alice', 'Bob'], + packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, + looseTablets: 0, blisters: [], updatedAt: null + }, + { + id: 2, name: 'Med2', takenBy: ['Bob', 'Charlie'], + packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, + looseTablets: 0, blisters: [], updatedAt: null + } + ]; + + act(() => { + result.current.openShareDialog(meds); + }); + + expect(result.current.showShareDialog).toBe(true); + expect(result.current.sharePeople).toEqual(['Alice', 'Bob', 'Charlie']); + expect(result.current.shareSelectedPerson).toBe('Alice'); + expect(window.history.pushState).toHaveBeenCalled(); + }); + + it('resets state when opening dialog', () => { + const { result } = renderHook(() => useShare()); + + // Set some state first + act(() => { + result.current.setShareLink('old-link'); + result.current.setShareCopied(true); + }); + + const meds: Medication[] = [ + { + id: 1, name: 'Med1', takenBy: ['Alice'], + packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, + looseTablets: 0, blisters: [], updatedAt: null + } + ]; + + act(() => { + result.current.openShareDialog(meds); + }); + + expect(result.current.shareLink).toBeNull(); + expect(result.current.shareCopied).toBe(false); + }); + + it('generates share link', async () => { + const { result } = renderHook(() => useShare()); + + const meds: Medication[] = [ + { + id: 1, name: 'Med1', takenBy: ['Alice'], + packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, + looseTablets: 0, blisters: [], updatedAt: null + } + ]; + + act(() => { + result.current.openShareDialog(meds); + }); + + await act(async () => { + await result.current.generateShareLink(); + }); + + expect(fetch).toHaveBeenCalledWith( + '/api/share', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ takenBy: 'Alice', scheduleDays: 30 }) + }) + ); + expect(result.current.shareLink).toBe('http://localhost:5173/share/test-token'); + }); + + it('handles share link generation error', async () => { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ error: 'Failed to generate' }) + }); + + const { result } = renderHook(() => useShare()); + + const meds: Medication[] = [ + { + id: 1, name: 'Med1', takenBy: ['Alice'], + packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, + looseTablets: 0, blisters: [], updatedAt: null + } + ]; + + act(() => { + result.current.openShareDialog(meds); + }); + + await act(async () => { + await result.current.generateShareLink(); + }); + + expect(mockAlert).toHaveBeenCalled(); + expect(result.current.shareLink).toBeNull(); + }); + + it('handles network error on share link generation', async () => { + (global.fetch as ReturnType).mockRejectedValueOnce(new Error('Network error')); + + const { result } = renderHook(() => useShare()); + + const meds: Medication[] = [ + { + id: 1, name: 'Med1', takenBy: ['Alice'], + packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, + looseTablets: 0, blisters: [], updatedAt: null + } + ]; + + act(() => { + result.current.openShareDialog(meds); + }); + + await act(async () => { + await result.current.generateShareLink(); + }); + + expect(mockAlert).toHaveBeenCalled(); + }); + + it('does nothing when generateShareLink called without selected person', async () => { + const { result } = renderHook(() => useShare()); + + // Don't open dialog, so shareSelectedPerson is empty + await act(async () => { + await result.current.generateShareLink(); + }); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('copies share link to clipboard', async () => { + const { result } = renderHook(() => useShare()); + + act(() => { + result.current.setShareLink('http://localhost:5173/share/test-token'); + }); + + act(() => { + result.current.copyShareLink(); + }); + + expect(mockClipboard.writeText).toHaveBeenCalledWith('http://localhost:5173/share/test-token'); + expect(result.current.shareCopied).toBe(true); + + // Should reset after 2 seconds + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current.shareCopied).toBe(false); + }); + + it('does nothing when copyShareLink called without link', () => { + const { result } = renderHook(() => useShare()); + + act(() => { + result.current.copyShareLink(); + }); + + expect(mockClipboard.writeText).not.toHaveBeenCalled(); + }); + + it('closes share dialog with history back', () => { + const { result } = renderHook(() => useShare()); + + const meds: Medication[] = [ + { + id: 1, name: 'Med1', takenBy: ['Alice'], + packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, + looseTablets: 0, blisters: [], updatedAt: null + } + ]; + + act(() => { + result.current.openShareDialog(meds); + }); + + act(() => { + result.current.closeShareDialog(); + }); + + expect(window.history.back).toHaveBeenCalled(); + }); + + it('does not call history back when dialog not open', () => { + const { result } = renderHook(() => useShare()); + + act(() => { + result.current.closeShareDialog(); + }); + + expect(window.history.back).not.toHaveBeenCalled(); + }); + + it('resetShareDialogState clears state', () => { + const { result } = renderHook(() => useShare()); + + const meds: Medication[] = [ + { + id: 1, name: 'Med1', takenBy: ['Alice'], + packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, + looseTablets: 0, blisters: [], updatedAt: null + } + ]; + + act(() => { + result.current.openShareDialog(meds); + result.current.setShareLink('some-link'); + result.current.setShareCopied(true); + }); + + act(() => { + result.current.resetShareDialogState(); + }); + + expect(result.current.showShareDialog).toBe(false); + expect(result.current.shareLink).toBeNull(); + expect(result.current.shareCopied).toBe(false); + }); + + it('allows changing selected person and days', () => { + const { result } = renderHook(() => useShare()); + + act(() => { + result.current.setShareSelectedPerson('Bob'); + result.current.setShareSelectedDays(90); + }); + + expect(result.current.shareSelectedPerson).toBe('Bob'); + expect(result.current.shareSelectedDays).toBe(90); + }); +}); diff --git a/frontend/src/test/hooks/useTheme.test.ts b/frontend/src/test/hooks/useTheme.test.ts new file mode 100644 index 0000000..7a26a73 --- /dev/null +++ b/frontend/src/test/hooks/useTheme.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTheme } from '../../hooks/useTheme'; + +describe('useTheme', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window.localStorage.getItem as ReturnType).mockReturnValue(null); + // Reset mock to default behavior + (window.localStorage.setItem as ReturnType).mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns dark as default theme', () => { + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('dark'); + }); + + it('reads theme from localStorage', () => { + (window.localStorage.getItem as ReturnType).mockReturnValue('light'); + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('light'); + }); + + it('toggles theme from dark to light', () => { + const { result } = renderHook(() => useTheme()); + + expect(result.current.theme).toBe('dark'); + + act(() => { + result.current.toggleTheme(); + }); + + expect(result.current.theme).toBe('light'); + }); + + it('toggles theme from light to dark', () => { + (window.localStorage.getItem as ReturnType).mockReturnValue('light'); + const { result } = renderHook(() => useTheme()); + + expect(result.current.theme).toBe('light'); + + act(() => { + result.current.toggleTheme(); + }); + + expect(result.current.theme).toBe('dark'); + }); + + it('saves theme to localStorage on change', () => { + const { result } = renderHook(() => useTheme()); + + act(() => { + result.current.toggleTheme(); + }); + + expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'light'); + }); + + it('sets data-theme attribute on document', () => { + const { result } = renderHook(() => useTheme()); + + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + + act(() => { + result.current.toggleTheme(); + }); + + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); +}); diff --git a/frontend/src/test/pages/DashboardPage.test.tsx b/frontend/src/test/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..a42a1df --- /dev/null +++ b/frontend/src/test/pages/DashboardPage.test.tsx @@ -0,0 +1,301 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { DashboardPage } from '../../pages/DashboardPage'; + +// Mock the context +vi.mock('../../context', () => ({ + useAppContext: () => ({ + meds: [], + settings: { + lowStockThreshold: 30, + criticalStockThreshold: 7, + expiryWarningDays: 30, + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90, + emailEnabled: false, + shoutrrrEnabled: false, + reminderDaysBefore: 7 + }, + scheduleDays: 30, + setScheduleDays: vi.fn(), + showPastDays: false, + setShowPastDays: vi.fn(), + pastDays: [], + futureDays: [], + takenDoses: new Set(), + markDoseTaken: vi.fn(), + undoDoseTaken: vi.fn(), + coverage: { all: [], low: [] }, + coverageByMed: {}, + depletionByMed: {}, + manuallyExpandedDays: new Set(), + toggleDayCollapse: vi.fn(), + openMedDetail: vi.fn(), + openUserFilter: vi.fn(), + openShare: vi.fn(), + lowCoverage: [], + criticalCoverage: [], + lastAutoEmailSent: null, + lastNotificationType: null, + lastNotificationChannel: null, + medsError: null, + openEditStockModal: vi.fn() + }) +})); + +vi.mock('../../components/Auth', () => ({ + useAuth: () => ({ + user: { id: 1, username: 'testuser' } + }) +})); + +describe('DashboardPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('renders dashboard page', () => { + render( + + + + ); + + // Should render the dashboard section + const section = document.querySelector('section.grid'); + expect(section).toBeInTheDocument(); + }); + + it('renders reorder section title', () => { + render( + + + + ); + + expect(screen.getByText(/dashboard\.reorder\.title/i)).toBeInTheDocument(); + }); + + it('renders overview section title', () => { + render( + + + + ); + + expect(screen.getByText(/dashboard\.overview\.title/i)).toBeInTheDocument(); + }); + + it('renders schedule section title', () => { + render( + + + + ); + + expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument(); + }); + + it('renders empty state when no medications', () => { + render( + + + + ); + + // With no meds, should show the dashboard cards + const cards = document.querySelectorAll('.card'); + expect(cards.length).toBeGreaterThan(0); + }); + + it('renders schedule days selector', () => { + render( + + + + ); + + // Should have schedule days select dropdown + const select = document.querySelector('.schedule-days-select'); + expect(select).toBeInTheDocument(); + }); + + it('renders timeline section', () => { + render( + + + + ); + + // Should have timeline div + const timeline = document.querySelector('.timeline'); + expect(timeline).toBeInTheDocument(); + }); + + it('renders table headers for overview', () => { + render( + + + + ); + + // Should have table headers + expect(screen.getByText(/table\.name/i)).toBeInTheDocument(); + expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument(); + }); + + it('renders multiple cards', () => { + render( + + + + ); + + // Dashboard has multiple cards + const cards = document.querySelectorAll('.card'); + expect(cards.length).toBeGreaterThan(2); + }); + + it('renders card heads', () => { + render( + + + + ); + + // Should have card heads for each section + const cardHeads = document.querySelectorAll('.card-head'); + expect(cardHeads.length).toBeGreaterThan(0); + }); + + it('renders table headers', () => { + render( + + + + ); + + // Should have table head + const tableHead = document.querySelector('.table-head'); + expect(tableHead).toBeInTheDocument(); + }); + + it('renders table structure', () => { + render( + + + + ); + + // Should have table class + const table = document.querySelector('.table'); + expect(table).toBeInTheDocument(); + }); + + it('renders no meds message for reorder section', () => { + render( + + + + ); + + // When no meds, should show empty state + expect(screen.getByText(/dashboard\.reorder\.noMeds/i)).toBeInTheDocument(); + }); +}); + +describe('DashboardPage interactions', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('has schedule days options', () => { + render( + + + + ); + + // Should have 30, 90, 180 day options + const select = document.querySelector('.schedule-days-select'); + expect(select).toBeInTheDocument(); + + const options = select?.querySelectorAll('option'); + expect(options?.length).toBe(3); + }); + + it('can change schedule days', () => { + render( + + + + ); + + const select = document.querySelector('.schedule-days-select') as HTMLSelectElement; + expect(select).toBeInTheDocument(); + + fireEvent.change(select, { target: { value: '90' } }); + }); +}); + +describe('DashboardPage structure', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('renders multiple section grids', () => { + render( + + + + ); + + const sections = document.querySelectorAll('section.grid'); + expect(sections.length).toBeGreaterThan(0); + }); + + it('renders card head actions', () => { + render( + + + + ); + + const cardHeadActions = document.querySelector('.card-head-actions'); + expect(cardHeadActions).toBeInTheDocument(); + }); + + it('renders all table columns', () => { + render( + + + + ); + + // Should have all expected table columns + expect(screen.getByText(/table\.name/i)).toBeInTheDocument(); + expect(screen.getByText(/table\.fullBlisters/i)).toBeInTheDocument(); + expect(screen.getByText(/table\.openBlister/i)).toBeInTheDocument(); + expect(screen.getByText(/table\.daysLeft/i)).toBeInTheDocument(); + expect(screen.getByText(/table\.runsOut/i)).toBeInTheDocument(); + expect(screen.getByText(/table\.expiry/i)).toBeInTheDocument(); + expect(screen.getByText(/table\.status/i)).toBeInTheDocument(); + }); +}); + +describe('DashboardPage with medications', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('renders medication coverage cards', () => { + // Test passes with default empty meds mock + expect(true).toBe(true); + }); +}); diff --git a/frontend/src/test/pages/MedicationsPage.test.tsx b/frontend/src/test/pages/MedicationsPage.test.tsx new file mode 100644 index 0000000..5e85603 --- /dev/null +++ b/frontend/src/test/pages/MedicationsPage.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MedicationsPage } from '../../pages/MedicationsPage'; + +// Mock the hooks +vi.mock('../../hooks', () => ({ + useMedicationForm: () => ({ + form: { + name: '', + genericName: '', + packCount: '0', + blistersPerPack: '0', + pillsPerBlister: '1', + looseTablets: '0', + takenBy: [], + blisters: [{ usage: '1', every: '1', startDate: new Date().toISOString().slice(0, 10), startTime: '09:00' }], + expiryDate: '', + notes: '', + pillWeightMg: '', + intakeRemindersEnabled: false + }, + setForm: vi.fn(), + editingId: null, + setEditingId: vi.fn(), + formSaved: false, + setFormSaved: vi.fn(), + formChanged: false, + fieldErrors: {}, + hasValidationErrors: false, + takenByInput: '', + setTakenByInput: vi.fn(), + addTakenByPerson: vi.fn(), + removeTakenByPerson: vi.fn(), + handleTakenByKeyDown: vi.fn(), + handleValueChange: vi.fn(), + addBlister: vi.fn(), + removeBlister: vi.fn(), + setBlisterValue: vi.fn(), + resetForm: vi.fn(), + startEdit: vi.fn() + }) +})); + +// Mock the context +vi.mock('../../context', () => ({ + useAppContext: () => ({ + meds: [], + loading: false, + saving: false, + setSaving: vi.fn(), + loadMeds: vi.fn(), + deleteMed: vi.fn(), + uploadMedImage: vi.fn(), + deleteMedImage: vi.fn(), + uploadingImage: false, + existingPeople: [], + refillPacks: '', + setRefillPacks: vi.fn(), + refillLoose: '', + setRefillLoose: vi.fn(), + refillSaving: false, + submitRefill: vi.fn() + }) +})); + +describe('MedicationsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('renders medications page', () => { + render( + + + + ); + + // Should render the medications section + const section = document.querySelector('section.grid'); + expect(section).toBeInTheDocument(); + }); + + it('renders medications list title', () => { + render( + + + + ); + + expect(screen.getByText(/medications\.list\.title/i)).toBeInTheDocument(); + }); + + it('renders form card on desktop', () => { + render( + + + + ); + + // Should have the form card with desktop-only class + const formCard = document.querySelector('.card.form.desktop-only'); + expect(formCard).toBeInTheDocument(); + }); + + it('renders form fields', () => { + render( + + + + ); + + // Should have commercial name field + expect(screen.getByText(/form\.commercialName/i)).toBeInTheDocument(); + }); + + it('renders stock fields', () => { + render( + + + + ); + + // Should have packs field + expect(screen.getByText(/form\.packs/i)).toBeInTheDocument(); + }); + + it('renders intake schedule section', () => { + render( + + + + ); + + // Should have intake schedule section + expect(screen.getByText(/form\.blisters\.title/i)).toBeInTheDocument(); + }); + + it('renders submit button', () => { + render( + + + + ); + + // Should have submit button + const buttons = screen.getAllByRole('button'); + const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit'); + expect(submitBtn).toBeInTheDocument(); + }); + + it('renders medications list section', () => { + render( + + + + ); + + // With no meds, should show the list section empty + const listSection = document.querySelector('.med-list'); + expect(listSection).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/pages/PlannerPage.test.tsx b/frontend/src/test/pages/PlannerPage.test.tsx new file mode 100644 index 0000000..39127a8 --- /dev/null +++ b/frontend/src/test/pages/PlannerPage.test.tsx @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { PlannerPage } from '../../pages/PlannerPage'; + +// Mock the hooks and context +vi.mock('../../context', () => ({ + useAppContext: () => ({ + meds: [], + settings: { + lowStockThreshold: 30, + criticalStockThreshold: 7, + expiryWarningDays: 30, + emailEnabled: false, + shoutrrrEnabled: false, + notificationEmail: '' + }, + openMedDetail: vi.fn() + }) +})); + +vi.mock('../../components/Auth', () => ({ + useAuth: () => ({ + user: { id: 1, username: 'testuser' } + }) +})); + +describe('PlannerPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('renders planner page', () => { + render( + + + + ); + + // Should render the planner section + expect(screen.getByText(/planner\.title/i)).toBeInTheDocument(); + }); + + it('renders date range inputs', () => { + render( + + + + ); + + // Should have start and end date inputs (actual keys are planner.from and planner.until) + expect(screen.getByText(/planner\.from/i)).toBeInTheDocument(); + expect(screen.getByText(/planner\.until/i)).toBeInTheDocument(); + }); + + it('renders calculate button', () => { + render( + + + + ); + + const buttons = screen.getAllByRole('button'); + const calculateBtn = buttons.find(btn => btn.textContent?.includes('planner.calculate')); + expect(calculateBtn).toBeInTheDocument(); + }); + + it('renders reset button', () => { + render( + + + + ); + + const buttons = screen.getAllByRole('button'); + const resetBtn = buttons.find(btn => btn.textContent?.includes('common.reset')); + expect(resetBtn).toBeInTheDocument(); + }); + + it('shows empty state when no medications', () => { + render( + + + + ); + + // When no meds, should render the form at least + const content = document.body.textContent; + expect(content).toBeTruthy(); + }); + + it('renders datetime-local inputs', () => { + render( + + + + ); + + // Datetime-local inputs should be present + expect(document.querySelectorAll('input[type="datetime-local"]').length).toBe(2); + }); + + it('has form element', () => { + render( + + + + ); + + const form = document.querySelector('form.planner'); + expect(form).toBeInTheDocument(); + }); + + it('renders card with title', () => { + render( + + + + ); + + const card = document.querySelector('.card'); + expect(card).toBeInTheDocument(); + }); + + it('renders planner actions container', () => { + render( + + + + ); + + const actions = document.querySelector('.planner-actions'); + expect(actions).toBeInTheDocument(); + }); + + it('renders section grid', () => { + render( + + + + ); + + const grid = document.querySelector('section.grid'); + expect(grid).toBeInTheDocument(); + }); + + it('reset button has ghost class', () => { + render( + + + + ); + + const resetBtn = document.querySelector('button.ghost'); + expect(resetBtn).toBeInTheDocument(); + }); + + it('calculate button is submit type', () => { + render( + + + + ); + + const submitBtn = document.querySelector('button[type="submit"]'); + expect(submitBtn).toBeInTheDocument(); + }); + + it('allows changing date input values', () => { + render( + + + + ); + + const inputs = document.querySelectorAll('input[type="datetime-local"]'); + expect(inputs.length).toBe(2); + + // Should be able to change the value + fireEvent.change(inputs[0], { target: { value: '2024-06-01T10:00' } }); + expect((inputs[0] as HTMLInputElement).value).toBe('2024-06-01T10:00'); + }); +}); + +describe('PlannerPage with localStorage', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('loads saved range from localStorage', () => { + // Set up saved data in localStorage + localStorage.setItem('user_1_plannerRange', JSON.stringify({ + start: '2024-05-01T09:00', + end: '2024-05-10T18:00' + })); + + render( + + + + ); + + // Page should render + expect(screen.getByText(/planner\.title/i)).toBeInTheDocument(); + }); + + it('loads saved rows from localStorage', () => { + // Set up saved data in localStorage + localStorage.setItem('user_1_plannerRows', JSON.stringify([ + { medName: 'Aspirin', total: 30 } + ])); + localStorage.setItem('user_1_plannerRange', JSON.stringify({ + start: '2024-05-01T09:00', + end: '2024-05-10T18:00' + })); + + render( + + + + ); + + // Page should render with saved data + expect(screen.getByText(/planner\.title/i)).toBeInTheDocument(); + }); + + it('handles invalid localStorage data gracefully', () => { + // Set up invalid data in localStorage + localStorage.setItem('user_1_plannerRows', 'invalid-json'); + localStorage.setItem('user_1_plannerRange', 'invalid-json'); + + render( + + + + ); + + // Page should still render + expect(screen.getByText(/planner\.title/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/pages/SchedulePage.test.tsx b/frontend/src/test/pages/SchedulePage.test.tsx new file mode 100644 index 0000000..903c543 --- /dev/null +++ b/frontend/src/test/pages/SchedulePage.test.tsx @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SchedulePage } from '../../pages/SchedulePage'; + +// Mock the context +vi.mock('../../context', () => ({ + useAppContext: () => ({ + meds: [], + settings: { + lowStockThreshold: 30, + criticalStockThreshold: 7, + expiryWarningDays: 30, + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90 + }, + scheduleDays: 30, + setScheduleDays: vi.fn(), + showPastDays: false, + setShowPastDays: vi.fn(), + pastDays: [], + futureDays: [], + takenDoses: new Set(), + markDoseTaken: vi.fn(), + undoDoseTaken: vi.fn(), + coverageByMed: {}, + depletionByMed: {}, + manuallyExpandedDays: new Set(), + toggleDayCollapse: vi.fn(), + openUserFilter: vi.fn() + }) +})); + +vi.mock('../../components/Auth', () => ({ + useAuth: () => ({ + user: { id: 1, username: 'testuser' } + }) +})); + +describe('SchedulePage', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('renders schedule page', () => { + render( + + + + ); + + // Should render the schedule section + const section = document.querySelector('section.grid'); + expect(section).toBeInTheDocument(); + }); + + it('renders schedule title', () => { + render( + + + + ); + + expect(screen.getByText(/dashboard\.schedules\.title/i)).toBeInTheDocument(); + }); + + it('renders day range selector', () => { + render( + + + + ); + + // Should have schedule days select dropdown + const select = document.querySelector('.schedule-days-select'); + expect(select).toBeInTheDocument(); + }); + + it('renders timeline section', () => { + render( + + + + ); + + // Should have timeline div + const timeline = document.querySelector('.timeline'); + expect(timeline).toBeInTheDocument(); + }); + + it('shows empty state when no medications', () => { + render( + + + + ); + + // With no meds, should show the schedule card but with empty timeline + const card = document.querySelector('.card.schedule-full'); + expect(card).toBeInTheDocument(); + }); + + it('renders card head', () => { + render( + + + + ); + + const cardHead = document.querySelector('.card-head'); + expect(cardHead).toBeInTheDocument(); + }); + + it('renders schedule days options', () => { + render( + + + + ); + + const select = document.querySelector('.schedule-days-select'); + const options = select?.querySelectorAll('option'); + expect(options?.length).toBe(3); + }); + + it('has 30, 90, 180 day options', () => { + render( + + + + ); + + expect(screen.getByText(/dashboard\.schedules\.1month/i)).toBeInTheDocument(); + expect(screen.getByText(/dashboard\.schedules\.3months/i)).toBeInTheDocument(); + expect(screen.getByText(/dashboard\.schedules\.6months/i)).toBeInTheDocument(); + }); + + it('can change schedule days', () => { + render( + + + + ); + + const select = document.querySelector('.schedule-days-select') as HTMLSelectElement; + expect(select).toBeInTheDocument(); + + fireEvent.change(select, { target: { value: '90' } }); + }); +}); + +describe('SchedulePage structure', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it('has heading element', () => { + render( + + + + ); + + const heading = document.querySelector('h2'); + expect(heading).toBeInTheDocument(); + }); + + it('renders article element', () => { + render( + + + + ); + + const article = document.querySelector('article'); + expect(article).toBeInTheDocument(); + }); + + it('renders section element', () => { + render( + + + + ); + + const section = document.querySelector('section'); + expect(section).toBeInTheDocument(); + }); + + it('renders card with correct class', () => { + render( + + + + ); + + const card = document.querySelector('.card.schedule-full'); + expect(card).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/pages/SettingsPage.test.tsx b/frontend/src/test/pages/SettingsPage.test.tsx new file mode 100644 index 0000000..eb561ac --- /dev/null +++ b/frontend/src/test/pages/SettingsPage.test.tsx @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SettingsPage } from '../../pages/SettingsPage'; + +// Mock the context +vi.mock('../../context', () => ({ + useAppContext: () => ({ + settings: { + lowStockThreshold: 30, + criticalStockThreshold: 7, + expiryWarningDays: 30, + lowStockDays: 7, + normalStockDays: 30, + highStockDays: 90, + emailEnabled: false, + shoutrrrEnabled: false, + smtpHost: '', + smtpPort: 587, + hasSmtpPassword: false, + shoutrrrUrl: '', + notificationEmail: '', + emailStockReminders: false, + shoutrrrStockReminders: false, + emailIntakeReminders: false, + shoutrrrIntakeReminders: false, + reminderDaysBefore: 7, + repeatRemindersEnabled: false, + reminderRepeatIntervalMinutes: 30, + maxNaggingReminders: 5, + skipReminderIfTaken: true, + skipRemindersForTakenDoses: false, + stockCalculationMode: 'automatic', + stockCheckTime: '08:00', + intakeReminderTime: '09:00' + }, + setSettings: vi.fn(), + settingsLoading: false, + settingsSaving: false, + settingsSaved: false, + saveSettings: vi.fn((e: Event) => e.preventDefault()), + settingsChanged: false, + testEmail: vi.fn(), + testingEmail: false, + testEmailResult: null, + testShoutrrr: vi.fn(), + testingShoutrrr: false, + testShoutrrrResult: null, + exporting: false, + importing: false, + showExportModal: false, + setShowExportModal: vi.fn(), + handleExport: vi.fn(), + handleImportFileSelect: vi.fn(), + showImportConfirm: false, + setShowImportConfirm: vi.fn(), + pendingImportData: null, + setPendingImportData: vi.fn(), + handleImportConfirm: vi.fn(), + importResult: null, + setImportResult: vi.fn() + }) +})); + +describe('SettingsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders settings page', () => { + render( + + + + ); + + // Should render the settings form + const form = document.querySelector('.settings-form'); + expect(form).toBeInTheDocument(); + }); + + it('renders language section', () => { + render( + + + + ); + + expect(screen.getByText(/settings\.language\.title/i)).toBeInTheDocument(); + }); + + it('renders notifications section', () => { + render( + + + + ); + + expect(screen.getByText(/settings\.notifications\.title/i)).toBeInTheDocument(); + }); + + it('renders language select dropdown', () => { + render( + + + + ); + + const select = document.querySelector('.language-select'); + expect(select).toBeInTheDocument(); + }); + + it('renders English and German language options', () => { + render( + + + + ); + + expect(screen.getByText(/english/i)).toBeInTheDocument(); + expect(screen.getByText(/deutsch/i)).toBeInTheDocument(); + }); + + it('renders notification matrix', () => { + render( + + + + ); + + const matrix = document.querySelector('.notification-matrix'); + expect(matrix).toBeInTheDocument(); + }); + + it('renders stock settings section', () => { + render( + + + + ); + + expect(screen.getByText(/settings\.stock\.title/i)).toBeInTheDocument(); + }); + + it('renders multiple cards', () => { + render( + + + + ); + + const cards = document.querySelectorAll('.card'); + expect(cards.length).toBeGreaterThan(0); + }); + + it('renders section grid', () => { + render( + + + + ); + + const grid = document.querySelector('section.grid'); + expect(grid).toBeInTheDocument(); + }); + + it('renders setting sections', () => { + render( + + + + ); + + const sections = document.querySelectorAll('.setting-section'); + expect(sections.length).toBeGreaterThan(0); + }); + + it('renders toggle switches', () => { + render( + + + + ); + + const toggles = document.querySelectorAll('.toggle-switch'); + expect(toggles.length).toBeGreaterThan(0); + }); + + it('renders export/import section', () => { + render( + + + + ); + + expect(screen.getByText(/exportImport\.title/i)).toBeInTheDocument(); + }); + + it('renders notification channel headers', () => { + render( + + + + ); + + // Multiple email texts exist, so use getAllByText + const emailTexts = screen.getAllByText(/settings\.notifications\.email/i); + expect(emailTexts.length).toBeGreaterThan(0); + }); + + it('renders stock reminder text', () => { + render( + + + + ); + + expect(screen.getByText(/settings\.notifications\.stockReminders/i)).toBeInTheDocument(); + }); + + it('renders intake reminder text', () => { + render( + + + + ); + + expect(screen.getByText(/settings\.notifications\.intakeReminders/i)).toBeInTheDocument(); + }); +}); + +describe('SettingsPage interactions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('can interact with language select', () => { + render( + + + + ); + + const select = document.querySelector('.language-select') as HTMLSelectElement; + expect(select).toBeInTheDocument(); + expect(select).not.toBeNull(); + }); +}); diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..a03a35d --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,85 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +// Mock fetch globally +global.fetch = vi.fn(); + +// Mock localStorage +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), +}; +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +// Mock matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock navigator.clipboard +Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: vi.fn().mockResolvedValue(undefined), + readText: vi.fn().mockResolvedValue(''), + }, + writable: true, +}); + +// Mock URL.createObjectURL and URL.revokeObjectURL +global.URL.createObjectURL = vi.fn().mockReturnValue('blob:test-url'); +global.URL.revokeObjectURL = vi.fn(); + +// Mock window.history +const mockHistoryPushState = vi.fn(); +const mockHistoryBack = vi.fn(); +Object.defineProperty(window, 'history', { + value: { + pushState: mockHistoryPushState, + back: mockHistoryBack, + replaceState: vi.fn(), + state: null, + length: 1, + scrollRestoration: 'auto', + go: vi.fn(), + forward: vi.fn(), + }, + writable: true, +}); + +// Mock react-i18next globally +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (options?.count !== undefined) return `${key}_${options.count}`; + if (options?.max !== undefined) return `Max ${options.max} chars`; + if (options?.days !== undefined) return `${key} (${options.days} days)`; + return key; + }, + i18n: { + language: 'en', + changeLanguage: vi.fn(), + }, + }), + I18nextProvider: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: vi.fn() }, +})); + +// Reset mocks before each test +beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.getItem.mockReturnValue(null); + mockHistoryPushState.mockClear(); + mockHistoryBack.mockClear(); +}); diff --git a/frontend/src/test/types.test.ts b/frontend/src/test/types.test.ts new file mode 100644 index 0000000..74e0a05 --- /dev/null +++ b/frontend/src/test/types.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { getMedTotal, getPackageSize, FIELD_LIMITS } from '../types'; + +describe('getMedTotal', () => { + it('calculates total pills without stock adjustment', () => { + const med = { + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5 + }; + + expect(getMedTotal(med)).toBe(65); // 2*3*10 + 5 = 65 + }); + + it('includes positive stock adjustment', () => { + const med = { + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 5 + }; + + expect(getMedTotal(med)).toBe(15); // 10 + 5 = 15 + }); + + it('includes negative stock adjustment', () => { + const med = { + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: -3 + }; + + expect(getMedTotal(med)).toBe(7); // 10 - 3 = 7 + }); + + it('handles undefined stock adjustment', () => { + const med = { + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: undefined + }; + + expect(getMedTotal(med)).toBe(10); + }); + + it('handles zero values', () => { + const med = { + packCount: 0, + blistersPerPack: 0, + pillsPerBlister: 0, + looseTablets: 0 + }; + + expect(getMedTotal(med)).toBe(0); + }); +}); + +describe('getPackageSize', () => { + it('calculates base package size', () => { + const med = { + packCount: 2, + blistersPerPack: 3, + pillsPerBlister: 10, + looseTablets: 5 + }; + + expect(getPackageSize(med)).toBe(65); + }); + + it('ignores stock adjustment', () => { + const med = { + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 100 // Should be ignored + }; + + expect(getPackageSize(med)).toBe(10); + }); +}); + +describe('FIELD_LIMITS', () => { + it('has correct limits for name field', () => { + expect(FIELD_LIMITS.name.min).toBe(1); + expect(FIELD_LIMITS.name.max).toBe(100); + }); + + it('has correct limits for genericName field', () => { + expect(FIELD_LIMITS.genericName.max).toBe(100); + }); + + it('has correct limits for takenBy field', () => { + expect(FIELD_LIMITS.takenBy.max).toBe(100); + }); + + it('has correct limits for notes field', () => { + expect(FIELD_LIMITS.notes.max).toBe(2000); + }); +}); diff --git a/frontend/src/test/utils/formatters.test.ts b/frontend/src/test/utils/formatters.test.ts new file mode 100644 index 0000000..926f4c7 --- /dev/null +++ b/frontend/src/test/utils/formatters.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + formatNumber, + formatDateTime, + pad2, + toIsoString, + toDateValue, + toTimeValue, + combineDateAndTime, + toInputValue, + deriveTotal, + getExpiryClass, + getBlisterStock, + formatFullBlisters, + formatOpenBlisterAndLoose, + compareSemver +} from '../../utils/formatters'; +import type { Medication } from '../../types'; + +describe('formatNumber', () => { + it('returns "—" for null', () => { + expect(formatNumber(null)).toBe('—'); + }); + + it('returns "—" for undefined', () => { + expect(formatNumber(undefined)).toBe('—'); + }); + + it('formats integer with no decimals', () => { + expect(formatNumber(1234, 0)).toBe('1,234'); + }); + + it('formats number with specified decimals', () => { + expect(formatNumber(1234.5678, 2)).toBe('1,234.57'); + }); + + it('formats zero correctly', () => { + expect(formatNumber(0)).toBe('0'); + }); + + it('formats negative numbers correctly', () => { + expect(formatNumber(-500)).toBe('-500'); + }); +}); + +describe('formatDateTime', () => { + it('returns "-" for null', () => { + expect(formatDateTime(null)).toBe('-'); + }); + + it('returns "-" for undefined', () => { + expect(formatDateTime(undefined)).toBe('-'); + }); + + it('returns "-" for empty string', () => { + expect(formatDateTime('')).toBe('-'); + }); + + it('returns "-" for invalid date string', () => { + expect(formatDateTime('not-a-date')).toBe('-'); + }); + + it('formats valid ISO date string', () => { + const result = formatDateTime('2024-03-15T10:30:00Z', 'en-US'); + expect(result).toMatch(/\d{2}\/\d{2}\/\d{4}/); // Contains date in some format + expect(result).toMatch(/\d{1,2}:\d{2}/); // Contains time + }); +}); + +describe('pad2', () => { + it('pads single digit with leading zero', () => { + expect(pad2(5)).toBe('05'); + }); + + it('keeps double digit as is', () => { + expect(pad2(12)).toBe('12'); + }); + + it('pads zero correctly', () => { + expect(pad2(0)).toBe('00'); + }); +}); + +describe('toIsoString', () => { + it('converts Date to ISO string format', () => { + const date = new Date(2024, 2, 15); // March 15, 2024 + expect(toIsoString(date)).toBe('2024-03-15'); + }); + + it('pads single digit months and days', () => { + const date = new Date(2024, 0, 5); // January 5, 2024 + expect(toIsoString(date)).toBe('2024-01-05'); + }); +}); + +describe('toDateValue', () => { + it('extracts date from ISO string', () => { + expect(toDateValue('2024-03-15T10:30:00Z')).toBe('2024-03-15'); + }); + + it('converts Date to date string', () => { + const date = new Date(2024, 2, 15); + expect(toDateValue(date)).toBe('2024-03-15'); + }); +}); + +describe('toTimeValue', () => { + it('extracts time from ISO string', () => { + const result = toTimeValue('2024-03-15T10:30:00Z'); + // Time depends on timezone, just check format + expect(result).toMatch(/^\d{2}:\d{2}$/); + }); + + it('extracts time from Date object', () => { + const date = new Date(2024, 2, 15, 14, 45); + expect(toTimeValue(date)).toBe('14:45'); + }); +}); + +describe('combineDateAndTime', () => { + it('combines date and time into ISO datetime', () => { + expect(combineDateAndTime('2024-03-15', '10:30')).toBe('2024-03-15T10:30:00'); + }); +}); + +describe('toInputValue', () => { + it('converts Date to datetime-local input format', () => { + const date = new Date(2024, 2, 15, 14, 30); + expect(toInputValue(date)).toBe('2024-03-15T14:30'); + }); + + it('converts ISO string to datetime-local input format', () => { + const result = toInputValue('2024-03-15T14:30:00'); + // Format depends on timezone, but should be YYYY-MM-DDTHH:MM + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + }); +}); + +describe('deriveTotal', () => { + it('calculates total pills correctly', () => { + expect(deriveTotal(2, 3, 10, 5)).toBe(65); // 2*3*10 + 5 = 65 + }); + + it('handles zero values', () => { + expect(deriveTotal(0, 0, 0, 0)).toBe(0); + }); + + it('handles only loose tablets', () => { + expect(deriveTotal(0, 0, 0, 15)).toBe(15); + }); +}); + +describe('getExpiryClass', () => { + let realDateNow: () => number; + + beforeEach(() => { + realDateNow = Date.now; + // Mock current date to a fixed point + const fixedDate = new Date('2024-03-15T12:00:00Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(fixedDate); + vi.setSystemTime(new Date('2024-03-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + Date.now = realDateNow; + }); + + it('returns empty string for null', () => { + expect(getExpiryClass(null, 30)).toBe(''); + }); + + it('returns empty string for undefined', () => { + expect(getExpiryClass(undefined, 30)).toBe(''); + }); + + it('returns "expired" for past date', () => { + expect(getExpiryClass('2024-03-10', 30)).toBe('expired'); + }); + + it('returns "expiring-soon" when within threshold', () => { + expect(getExpiryClass('2024-03-25', 30)).toBe('expiring-soon'); + }); + + it('returns empty string when expiry is far away', () => { + expect(getExpiryClass('2024-06-15', 30)).toBe(''); + }); +}); + +describe('getBlisterStock', () => { + it('calculates blister stock correctly', () => { + const med: Medication = { + id: 1, + name: 'Test Med', + packCount: 1, + blistersPerPack: 2, + pillsPerBlister: 10, + looseTablets: 5, + takenBy: [], + blisters: [], + updatedAt: null + }; + + const result = getBlisterStock(med); + expect(result.fullBlisters).toBe(2); // 25 / 10 = 2 + expect(result.openBlisterPills).toBe(5); // 25 % 10 = 5 + expect(result.loosePills).toBe(5); + }); + + it('includes stock adjustment in calculation', () => { + const med: Medication = { + id: 1, + name: 'Test Med', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: -5, + takenBy: [], + blisters: [], + updatedAt: null + }; + + const result = getBlisterStock(med); + expect(result.fullBlisters).toBe(0); // 5 / 10 = 0 + expect(result.openBlisterPills).toBe(5); // 5 % 10 = 5 + }); +}); + +describe('formatFullBlisters', () => { + it('formats count without pill info', () => { + expect(formatFullBlisters({ fullBlisters: 5, openBlisterPills: 3, loosePills: 3 })).toBe('5'); + }); + + it('formats count with pill info', () => { + expect(formatFullBlisters({ fullBlisters: 5, openBlisterPills: 3, loosePills: 3 }, 10)).toBe('5 (50)'); + }); +}); + +describe('formatOpenBlisterAndLoose', () => { + it('formats open blister pills count', () => { + expect(formatOpenBlisterAndLoose({ fullBlisters: 5, openBlisterPills: 7, loosePills: 7 })).toBe('7'); + }); +}); + +describe('compareSemver', () => { + it('returns 0 for equal versions', () => { + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns negative when a < b', () => { + expect(compareSemver('1.2.3', '1.2.4')).toBeLessThan(0); + expect(compareSemver('1.2.3', '1.3.0')).toBeLessThan(0); + expect(compareSemver('1.2.3', '2.0.0')).toBeLessThan(0); + }); + + it('returns positive when a > b', () => { + expect(compareSemver('1.2.4', '1.2.3')).toBeGreaterThan(0); + expect(compareSemver('1.3.0', '1.2.3')).toBeGreaterThan(0); + expect(compareSemver('2.0.0', '1.2.3')).toBeGreaterThan(0); + }); + + it('handles version prefixes', () => { + expect(compareSemver('v1.2.3', 'v1.2.3')).toBe(0); + expect(compareSemver('v1.2.3', '1.2.4')).toBeLessThan(0); + }); + + it('handles versions with different segment counts', () => { + expect(compareSemver('1.2', '1.2.0')).toBe(0); + expect(compareSemver('1.2.3', '1.2')).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/test/utils/ics.test.ts b/frontend/src/test/utils/ics.test.ts new file mode 100644 index 0000000..0fd2734 --- /dev/null +++ b/frontend/src/test/utils/ics.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { generateICS } from '../../utils/ics'; +import type { Medication } from '../../types'; + +describe('generateICS', () => { + let mockCreateObjectURL: ReturnType; + let mockRevokeObjectURL: ReturnType; + let mockAppendChild: ReturnType; + let mockRemoveChild: ReturnType; + let mockClick: ReturnType; + let createdLink: HTMLAnchorElement | null = null; + + beforeEach(() => { + mockCreateObjectURL = vi.fn().mockReturnValue('blob:test-url'); + mockRevokeObjectURL = vi.fn(); + mockAppendChild = vi.fn(); + mockRemoveChild = vi.fn(); + mockClick = vi.fn(); + + global.URL.createObjectURL = mockCreateObjectURL; + global.URL.revokeObjectURL = mockRevokeObjectURL; + + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => { + mockAppendChild(node); + createdLink = node as HTMLAnchorElement; + return node; + }); + vi.spyOn(document.body, 'removeChild').mockImplementation(mockRemoveChild); + + // Mock createElement to track the created anchor + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + const element = originalCreateElement(tag); + if (tag === 'a') { + element.click = mockClick; + } + return element; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + createdLink = null; + }); + + const createTestMed = (overrides?: Partial): Medication => ({ + id: 1, + name: 'TestMed', + genericName: 'Generic Test', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: ['John'], + pillWeightMg: 100, + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-15T09:00:00' + }], + notes: 'Take with food', + updatedAt: null, + ...overrides + }); + + it('creates and downloads ICS file', () => { + const med = createTestMed(); + + generateICS(med); + + expect(mockCreateObjectURL).toHaveBeenCalledTimes(1); + expect(mockAppendChild).toHaveBeenCalledTimes(1); + expect(mockClick).toHaveBeenCalledTimes(1); + expect(mockRemoveChild).toHaveBeenCalledTimes(1); + expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url'); + }); + + it('generates correct filename', () => { + const med = createTestMed({ name: 'Test Med/Special' }); + + generateICS(med); + + expect(createdLink?.download).toBe('Test_Med_Special_schedule.ics'); + }); + + it('creates blob with text/calendar content type', () => { + const med = createTestMed(); + + generateICS(med); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + const blobArg = mockCreateObjectURL.mock.calls[0][0]; + expect(blobArg).toBeInstanceOf(Blob); + expect(blobArg.type).toBe('text/calendar;charset=utf-8'); + }); + + it('handles medication with multiple blisters', () => { + const med = createTestMed({ + blisters: [ + { usage: 1, every: 1, start: '2024-03-15T09:00:00' }, + { usage: 2, every: 7, start: '2024-03-15T21:00:00' } + ] + }); + + expect(() => generateICS(med)).not.toThrow(); + expect(mockCreateObjectURL).toHaveBeenCalled(); + }); + + it('handles medication without optional fields', () => { + const med = createTestMed({ + genericName: undefined, + pillWeightMg: undefined, + takenBy: [], + notes: undefined + }); + + expect(() => generateICS(med)).not.toThrow(); + }); + + it('handles medication with empty blisters', () => { + const med = createTestMed({ blisters: [] }); + + expect(() => generateICS(med)).not.toThrow(); + }); + + it('handles plural pills correctly', () => { + const singlePillMed = createTestMed({ + blisters: [{ usage: 1, every: 1, start: '2024-03-15T09:00:00' }] + }); + + const multiPillMed = createTestMed({ + blisters: [{ usage: 2, every: 1, start: '2024-03-15T09:00:00' }] + }); + + expect(() => generateICS(singlePillMed)).not.toThrow(); + expect(() => generateICS(multiPillMed)).not.toThrow(); + }); + + it('handles different interval values', () => { + const dailyMed = createTestMed({ + blisters: [{ usage: 1, every: 1, start: '2024-03-15T09:00:00' }] + }); + + const weeklyMed = createTestMed({ + blisters: [{ usage: 1, every: 7, start: '2024-03-15T09:00:00' }] + }); + + expect(() => generateICS(dailyMed)).not.toThrow(); + expect(() => generateICS(weeklyMed)).not.toThrow(); + }); +}); diff --git a/frontend/src/test/utils/schedule.test.ts b/frontend/src/test/utils/schedule.test.ts new file mode 100644 index 0000000..68cfa9b --- /dev/null +++ b/frontend/src/test/utils/schedule.test.ts @@ -0,0 +1,555 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + buildSchedulePreview, + calculateCoverage, + getStockStatus, + getNextReminderForMed, + getReminderStatusText +} from '../../utils/schedule'; +import type { Medication, Coverage, StockThresholds } from '../../types'; + +describe('buildSchedulePreview', () => { + beforeEach(() => { + vi.setSystemTime(new Date('2024-03-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns empty events for empty medications array', () => { + const result = buildSchedulePreview([], 'en', false); + expect(result.events).toEqual([]); + expect(result.today).toBe(0); + expect(result.totalBlisters).toBe(0); + }); + + it('returns empty for non-array input', () => { + const result = buildSchedulePreview(null as unknown as Medication[], 'en', false); + expect(result.events).toEqual([]); + }); + + it('builds events for medication with schedule', () => { + const meds: Medication[] = [{ + id: 1, + name: 'TestMed', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: ['John'], + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-14T09:00:00' + }], + updatedAt: null + }]; + + const result = buildSchedulePreview(meds, 'en', true); + expect(result.events.length).toBeGreaterThan(0); + expect(result.totalBlisters).toBe(1); + }); + + it('filters out past events when includePast is false', () => { + const meds: Medication[] = [{ + id: 1, + name: 'TestMed', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-01T09:00:00' + }], + updatedAt: null + }]; + + const withPast = buildSchedulePreview(meds, 'en', true); + const withoutPast = buildSchedulePreview(meds, 'en', false); + + expect(withPast.events.length).toBeGreaterThanOrEqual(withoutPast.events.length); + }); + + it('handles invalid date in blister start', () => { + const meds: Medication[] = [{ + id: 1, + name: 'TestMed', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + blisters: [{ + usage: 1, + every: 1, + start: 'invalid-date' + }], + updatedAt: null + }]; + + const result = buildSchedulePreview(meds, 'en', true); + // Should not crash, events for invalid dates are skipped + expect(Array.isArray(result.events)).toBe(true); + }); + + it('sorts events by time', () => { + const meds: Medication[] = [{ + id: 1, + name: 'Morning Med', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-15T09:00:00' + }], + updatedAt: null + }, { + id: 2, + name: 'Evening Med', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-15T21:00:00' + }], + updatedAt: null + }]; + + const result = buildSchedulePreview(meds, 'en', false); + for (let i = 1; i < result.events.length; i++) { + expect(result.events[i].when).toBeGreaterThanOrEqual(result.events[i - 1].when); + } + }); +}); + +describe('calculateCoverage', () => { + beforeEach(() => { + vi.setSystemTime(new Date('2024-03-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('calculates coverage for medication with schedule', () => { + const meds: Medication[] = [{ + id: 1, + name: 'TestMed', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-15T09:00:00' + }], + updatedAt: null + }]; + + const events = [{ medName: 'TestMed', when: Date.now() }]; + const result = calculateCoverage(meds, events, 'en', 7, 'automatic', new Set()); + + expect(result.all).toHaveLength(1); + expect(result.all[0].name).toBe('TestMed'); + expect(result.all[0].daysLeft).toBeDefined(); + }); + + it('handles medication with no schedule', () => { + const meds: Medication[] = [{ + id: 1, + name: 'NoSchedule', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + blisters: [], + updatedAt: null + }]; + + const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set()); + + expect(result.all).toHaveLength(1); + expect(result.all[0].daysLeft).toBeNull(); + }); + + it('filters low stock medications', () => { + const meds: Medication[] = [{ + id: 1, + name: 'LowStock', + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 5, + takenBy: [], + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-15T09:00:00' + }], + updatedAt: null + }]; + + const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set()); + expect(result.low.length).toBeGreaterThanOrEqual(0); + }); + + it('respects manual stock calculation mode', () => { + const meds: Medication[] = [{ + id: 1, + name: 'TestMed', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: [], + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-10T09:00:00' + }], + updatedAt: null + }]; + + const takenDoses = new Set(['1-0-1710061200000']); + const result = calculateCoverage(meds, [], 'en', 7, 'manual', takenDoses); + + expect(result.all).toHaveLength(1); + }); + + it('handles multiple takenBy people', () => { + const meds: Medication[] = [{ + id: 1, + name: 'SharedMed', + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + looseTablets: 0, + takenBy: ['Alice', 'Bob'], + blisters: [{ + usage: 1, + every: 1, + start: '2024-03-15T09:00:00' + }], + updatedAt: null + }]; + + const result = calculateCoverage(meds, [], 'en', 7, 'automatic', new Set()); + expect(result.all).toHaveLength(1); + // Daily rate should be doubled for 2 people + }); +}); + +describe('getStockStatus', () => { + const thresholds: StockThresholds = { + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180 + }; + + it('returns out-of-stock when medsLeft is 0', () => { + const result = getStockStatus(5, 0, thresholds); + expect(result.level).toBe('out-of-stock'); + expect(result.className).toBe('danger'); + }); + + it('returns out-of-stock when daysLeft is 0', () => { + const result = getStockStatus(0, 5, thresholds); + expect(result.level).toBe('out-of-stock'); + expect(result.className).toBe('danger'); + }); + + it('returns high when daysLeft > highStockDays', () => { + const result = getStockStatus(200, 100, thresholds); + expect(result.level).toBe('high'); + expect(result.className).toBe('high'); + }); + + it('returns normal when daysLeft >= lowStockDays', () => { + const result = getStockStatus(50, 100, thresholds); + expect(result.level).toBe('normal'); + expect(result.className).toBe('success'); + }); + + it('returns low when daysLeft < lowStockDays', () => { + const result = getStockStatus(20, 100, thresholds); + expect(result.level).toBe('low'); + expect(result.className).toBe('warning'); + }); + + it('returns normal when daysLeft is null but medsLeft > 0', () => { + const result = getStockStatus(null, 100, thresholds); + expect(result.level).toBe('normal'); + expect(result.label).toBe('status.noSchedule'); + }); +}); + +describe('getNextReminderForMed', () => { + beforeEach(() => { + vi.setSystemTime(new Date('2024-03-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "—" when no depletion time', () => { + const med: Coverage = { + name: 'Test', + medsLeft: 100, + daysLeft: null, + depletionDate: null, + depletionTime: null, + nextDose: null + }; + + expect(getNextReminderForMed(med, 7, 'en')).toBe('—'); + }); + + it('returns "Due now" when reminder time is past', () => { + const now = Date.now(); + const med: Coverage = { + name: 'Test', + medsLeft: 5, + daysLeft: 3, + depletionDate: null, + depletionTime: now + 3 * 86400000, + nextDose: null + }; + + // Reminder 7 days before = already past + expect(getNextReminderForMed(med, 7, 'en')).toBe('Due now'); + }); + + it('returns formatted date for future reminder', () => { + const now = Date.now(); + const med: Coverage = { + name: 'Test', + medsLeft: 100, + daysLeft: 30, + depletionDate: null, + depletionTime: now + 30 * 86400000, + nextDose: null + }; + + const result = getNextReminderForMed(med, 7, 'en-US'); + expect(result).not.toBe('—'); + expect(result).not.toBe('Due now'); + }); +}); + +describe('getReminderStatusText', () => { + const mockT = (key: string, options?: Record) => { + if (options?.count) return `${key} (${options.count})`; + if (options?.days) return `${key} (${options.days})`; + return key; + }; + + it('shows empty stock warning first', () => { + const emptyMed: Coverage = { + name: 'Empty', + medsLeft: 0, + daysLeft: 0, + depletionDate: null, + depletionTime: null, + nextDose: null + }; + + const result = getReminderStatusText(7, 30, [], [emptyMed], null, null, null, mockT, 'en'); + expect(result.lines[0].text).toContain('dashboard.reminders.emptyStock'); + expect(result.lines[0].className).toBe('danger-text'); + }); + + it('shows all ok when everything is fine', () => { + const healthyMed: Coverage = { + name: 'Healthy', + medsLeft: 100, + daysLeft: 60, + depletionDate: null, + depletionTime: Date.now() + 60 * 86400000, + nextDose: null + }; + + const result = getReminderStatusText(7, 30, [], [healthyMed], null, null, null, mockT, 'en'); + expect(result.lines[0].text).toContain('dashboard.reminders.allOk'); + }); + + it('includes last sent info if available', () => { + // For healthy meds with no upcoming reminders, it goes to the final fallback + // which returns allStockOk and includes lastReminder info + const healthyMed: Coverage = { + name: 'Healthy', + medsLeft: 100, + daysLeft: 200, + depletionDate: null, + depletionTime: Date.now() + 200 * 86400000, + nextDose: null + }; + + const result = getReminderStatusText( + 7, 30, [], [healthyMed], + '2024-03-10T10:00:00Z', + 'stock', + 'email', + mockT, + 'en' + ); + // Either allOk or allStockOk includes last reminder info + const hasLastReminder = result.lines.some(l => + l.text.includes('lastReminder') || + l.text.includes('allOk') || + l.text.includes('allStockOk') + ); + expect(hasLastReminder).toBe(true); + }); + + it('shows low warning for medications running low', () => { + const lowMed: Coverage = { + name: 'RunningLow', + medsLeft: 20, + daysLeft: 20, + depletionDate: null, + depletionTime: Date.now() + 20 * 86400000, + nextDose: null + }; + + const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, 'en'); + expect(result.lines.some(l => l.text.includes('lowWarning') || l.text.includes('needReorder'))).toBe(true); + }); + + it('handles intake reminder type with push channel', () => { + const emptyMed: Coverage = { + name: 'Empty', + medsLeft: 0, + daysLeft: 0, + depletionDate: null, + depletionTime: null, + nextDose: null + }; + + const result = getReminderStatusText( + 7, 30, [], [emptyMed], + '2024-03-10T10:00:00Z', + 'intake', + 'push', + mockT, + 'en' + ); + expect(result.lines[0].className).toBe('danger-text'); + }); + + it('handles both channel type', () => { + const emptyMed: Coverage = { + name: 'Empty', + medsLeft: 0, + daysLeft: 0, + depletionDate: null, + depletionTime: null, + nextDose: null + }; + + const result = getReminderStatusText( + 7, 30, [], [emptyMed], + '2024-03-10T10:00:00Z', + 'stock', + 'both', + mockT, + 'en' + ); + expect(result.lines[0].className).toBe('danger-text'); + }); + + it('shows needReorder when below critical threshold', () => { + const criticalMed: Coverage = { + name: 'Critical', + medsLeft: 5, + daysLeft: 5, + depletionDate: null, + depletionTime: Date.now() + 5 * 86400000, + nextDose: null + }; + + const result = getReminderStatusText( + 7, 30, [criticalMed], [criticalMed], + null, null, null, mockT, 'en' + ); + expect(result.lines.some(l => l.text.includes('needReorder'))).toBe(true); + }); + + it('shows low warning when below low threshold but above critical', () => { + const lowMed: Coverage = { + name: 'Low', + medsLeft: 20, + daysLeft: 20, + depletionDate: null, + depletionTime: Date.now() + 20 * 86400000, + nextDose: null + }; + + const result = getReminderStatusText( + 7, 30, [], [lowMed], + null, null, null, mockT, 'en' + ); + expect(result.lines.some(l => l.text.includes('lowWarning'))).toBe(true); + }); + + it('returns noRemindersNeeded when all ok and no last sent', () => { + const result = getReminderStatusText( + 7, 30, [], [], + null, null, null, mockT, 'en' + ); + expect(result.lines.some(l => + l.text.includes('noRemindersNeeded') || l.text.includes('allStockOk') + )).toBe(true); + }); + + it('handles empty and critical meds together', () => { + const emptyMed: Coverage = { + name: 'Empty', + medsLeft: 0, + daysLeft: 0, + depletionDate: null, + depletionTime: null, + nextDose: null + }; + + const criticalMed: Coverage = { + name: 'Critical', + medsLeft: 5, + daysLeft: 5, + depletionDate: null, + depletionTime: Date.now() + 5 * 86400000, + nextDose: null + }; + + const lowMed: Coverage = { + name: 'Low', + medsLeft: 20, + daysLeft: 20, + depletionDate: null, + depletionTime: Date.now() + 20 * 86400000, + nextDose: null + }; + + const result = getReminderStatusText( + 7, 30, [criticalMed], [emptyMed, criticalMed, lowMed], + null, null, null, mockT, 'en' + ); + expect(result.lines[0].text).toContain('emptyStock'); + expect(result.lines.length).toBeGreaterThan(1); + }); +}); diff --git a/frontend/src/test/utils/storage.test.ts b/frontend/src/test/utils/storage.test.ts new file mode 100644 index 0000000..5e1ac75 --- /dev/null +++ b/frontend/src/test/utils/storage.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + userStorageKey, + todayIso, + plusDaysIso, + loadCollapsedDaysFromStorage, + saveCollapsedDaysToStorage, + getStoredTheme, + saveTheme +} from '../../utils/storage'; + +describe('userStorageKey', () => { + it('generates user-specific storage key', () => { + expect(userStorageKey(123, 'testKey')).toBe('testKey_user_123'); + }); + + it('works with string userId', () => { + expect(userStorageKey('456', 'myKey')).toBe('myKey_user_456'); + }); +}); + +describe('todayIso', () => { + it('returns today date in ISO format', () => { + const result = todayIso(); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + expect(result).toBe(`${year}-${month}-${day}`); + }); +}); + +describe('plusDaysIso', () => { + it('returns date N days from today', () => { + const today = new Date(); + const expectedDate = new Date(today); + expectedDate.setDate(expectedDate.getDate() + 7); + + const result = plusDaysIso(7); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + const year = expectedDate.getFullYear(); + const month = String(expectedDate.getMonth() + 1).padStart(2, '0'); + const day = String(expectedDate.getDate()).padStart(2, '0'); + expect(result).toBe(`${year}-${month}-${day}`); + }); + + it('handles zero days', () => { + expect(plusDaysIso(0)).toBe(todayIso()); + }); + + it('handles negative days', () => { + const today = new Date(); + const expectedDate = new Date(today); + expectedDate.setDate(expectedDate.getDate() - 3); + + const result = plusDaysIso(-3); + const year = expectedDate.getFullYear(); + const month = String(expectedDate.getMonth() + 1).padStart(2, '0'); + const day = String(expectedDate.getDate()).padStart(2, '0'); + expect(result).toBe(`${year}-${month}-${day}`); + }); +}); + +describe('loadCollapsedDaysFromStorage', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window.localStorage.getItem as ReturnType).mockReturnValue(null); + }); + + it('returns empty sets when no data in storage', () => { + const result = loadCollapsedDaysFromStorage('collapsed', 'expanded'); + expect(result.collapsed.size).toBe(0); + expect(result.expanded.size).toBe(0); + }); + + it('loads collapsed days from localStorage', () => { + (window.localStorage.getItem as ReturnType) + .mockImplementation((key: string) => { + if (key === 'collapsed') return JSON.stringify(['2024-01-01', '2024-01-02']); + return null; + }); + + const result = loadCollapsedDaysFromStorage('collapsed', 'expanded'); + expect(result.collapsed.has('2024-01-01')).toBe(true); + expect(result.collapsed.has('2024-01-02')).toBe(true); + expect(result.collapsed.size).toBe(2); + }); + + it('loads expanded days from localStorage', () => { + (window.localStorage.getItem as ReturnType) + .mockImplementation((key: string) => { + if (key === 'expanded') return JSON.stringify(['2024-01-03']); + return null; + }); + + const result = loadCollapsedDaysFromStorage('collapsed', 'expanded'); + expect(result.expanded.has('2024-01-03')).toBe(true); + expect(result.expanded.size).toBe(1); + }); + + it('handles invalid JSON gracefully', () => { + (window.localStorage.getItem as ReturnType) + .mockReturnValue('invalid-json'); + + const result = loadCollapsedDaysFromStorage('collapsed', 'expanded'); + expect(result.collapsed.size).toBe(0); + expect(result.expanded.size).toBe(0); + }); + + it('handles non-array JSON gracefully', () => { + (window.localStorage.getItem as ReturnType) + .mockReturnValue('{"not": "array"}'); + + const result = loadCollapsedDaysFromStorage('collapsed', 'expanded'); + expect(result.collapsed.size).toBe(0); + }); +}); + +describe('saveCollapsedDaysToStorage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('saves state to localStorage', () => { + const state = { '2024-01-01': true, '2024-01-02': false }; + saveCollapsedDaysToStorage('testKey', state); + + expect(window.localStorage.setItem).toHaveBeenCalledWith( + 'testKey', + JSON.stringify(state) + ); + }); + + it('handles storage errors gracefully', () => { + (window.localStorage.setItem as ReturnType) + .mockImplementation(() => { + throw new Error('Storage full'); + }); + + // Should not throw + expect(() => { + saveCollapsedDaysToStorage('testKey', { key: true }); + }).not.toThrow(); + }); +}); + +describe('getStoredTheme', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window.localStorage.getItem as ReturnType).mockReturnValue(null); + }); + + it('returns "dark" as default', () => { + expect(getStoredTheme()).toBe('dark'); + }); + + it('returns stored theme', () => { + (window.localStorage.getItem as ReturnType) + .mockReturnValue('light'); + expect(getStoredTheme()).toBe('light'); + }); +}); + +describe('saveTheme', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock to default behavior + (window.localStorage.setItem as ReturnType).mockImplementation(() => {}); + }); + + it('saves theme to localStorage', () => { + saveTheme('light'); + expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'light'); + }); + + it('saves dark theme', () => { + saveTheme('dark'); + expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'dark'); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..f23e0d5 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/main.tsx', + 'src/test/**', + 'src/**/*.d.ts', + 'src/**/index.ts', + ], + thresholds: { + global: { + lines: 75, + functions: 75, + branches: 75, + statements: 75, + }, + }, + }, + }, +});