From e8f67256c8140e934cb3b159737e17a8e5d52349 Mon Sep 17 00:00:00 2001 From: 1837669410 <1837669410@qq.com> Date: Tue, 12 May 2026 15:57:44 +0800 Subject: [PATCH] Expand test coverage for analysis and layout features --- package-lock.json | 762 ++++++++++++++++++ package.json | 4 + .../backend/__tests__/deepseekClient.test.ts | 132 +++ .../__tests__/paperAnalyzerFlow.test.ts | 137 ++++ src/renderer/App.tsx | 20 +- src/renderer/__tests__/App.analysis.test.tsx | 156 ++++ src/renderer/__tests__/App.resizable.test.tsx | 112 +++ src/renderer/__tests__/i18n.test.ts | 28 + src/renderer/components/OutputPanel.tsx | 29 +- .../components/__tests__/OutputPanel.test.tsx | 84 ++ .../components/__tests__/PDFPanel.test.tsx | 85 ++ .../utils/__tests__/analysisMetrics.test.ts | 36 + .../utils/__tests__/resizableLayout.test.ts | 31 + src/renderer/utils/analysisMetrics.ts | 27 + src/renderer/utils/resizableLayout.ts | 22 + src/test/setup.ts | 1 + vitest.config.ts | 3 +- 17 files changed, 1621 insertions(+), 48 deletions(-) create mode 100644 src/main/backend/__tests__/deepseekClient.test.ts create mode 100644 src/main/backend/__tests__/paperAnalyzerFlow.test.ts create mode 100644 src/renderer/__tests__/App.analysis.test.tsx create mode 100644 src/renderer/__tests__/App.resizable.test.tsx create mode 100644 src/renderer/__tests__/i18n.test.ts create mode 100644 src/renderer/components/__tests__/OutputPanel.test.tsx create mode 100644 src/renderer/components/__tests__/PDFPanel.test.tsx create mode 100644 src/renderer/utils/__tests__/analysisMetrics.test.ts create mode 100644 src/renderer/utils/__tests__/resizableLayout.test.ts create mode 100644 src/renderer/utils/analysisMetrics.ts create mode 100644 src/renderer/utils/resizableLayout.ts create mode 100644 src/test/setup.ts diff --git a/package-lock.json b/package-lock.json index 07ce5b2..2181b3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,18 +20,80 @@ "remark-math": "^6.0.0" }, "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.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.0", "electron": "^33.2.1", "electron-builder": "^26.8.1", + "jsdom": "^29.1.1", "typescript": "^5.6.3", "vite": "^6.0.3", "vitest": "^4.1.5", "wait-on": "^8.0.1" } }, + "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": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -266,6 +328,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -314,6 +386,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -1213,6 +1438,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "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/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -2036,6 +2279,104 @@ "node": ">=10" } }, + "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", @@ -2686,6 +3027,16 @@ "dev": true, "license": "Python-2.0" }, + "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/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -2818,6 +3169,16 @@ "node": ">=6.0.0" } }, + "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/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -3394,12 +3755,47 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3417,6 +3813,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -3671,6 +4074,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -4837,6 +5248,19 @@ "dev": true, "license": "ISC" }, + "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-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4949,6 +5373,16 @@ "license": "BSD-3-Clause", "optional": true }, + "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/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5040,6 +5474,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -5129,6 +5570,93 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5278,6 +5806,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "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", @@ -5623,6 +6162,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -6251,6 +6797,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -6766,6 +7322,36 @@ "node": "^12.20.0 || >=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/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -6891,6 +7477,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -6941,6 +7535,20 @@ "read-binary-file-arch": "cli.js" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rehype-katex": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", @@ -7052,6 +7660,16 @@ "node": ">=0.10.0" } }, + "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/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -7216,6 +7834,19 @@ "node": ">=11.0.0" } }, + "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", @@ -7479,6 +8110,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -7526,6 +8170,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "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/tar": { "version": "7.5.15", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", @@ -7681,6 +8332,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -7701,6 +8372,32 @@ "tmp": "^0.2.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "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/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -8189,6 +8886,19 @@ } } }, + "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/wait-on": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", @@ -8219,6 +8929,41 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -8277,6 +9022,16 @@ "dev": true, "license": "ISC" }, + "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/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -8287,6 +9042,13 @@ "node": ">=8.0" } }, + "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/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index ea4b767..7815c48 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,16 @@ "remark-math": "^6.0.0" }, "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.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.1.0", "electron": "^33.2.1", "electron-builder": "^26.8.1", + "jsdom": "^29.1.1", "typescript": "^5.6.3", "vite": "^6.0.3", "vitest": "^4.1.5", diff --git a/src/main/backend/__tests__/deepseekClient.test.ts b/src/main/backend/__tests__/deepseekClient.test.ts new file mode 100644 index 0000000..30bb210 --- /dev/null +++ b/src/main/backend/__tests__/deepseekClient.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AppError, ErrorCodes } from '../errors' +import { callDeepSeek } from '../deepseekClient' +import { getActiveSettings } from '../settingsStore' + +vi.mock('../settingsStore', () => ({ + getActiveSettings: vi.fn(), +})) + +const mockedGetActiveSettings = vi.mocked(getActiveSettings) + +function mockSettings(provider = 'deepseek') { + mockedGetActiveSettings.mockReturnValue({ + apiKey: 'test-key', + provider, + model: provider === 'kimi' ? 'kimi-k2.6' : 'deepseek-v4-flash', + language: 'zh-CN', + }) +} + +function createStreamResponse(chunks: string[], status = 200): Response { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)) + } + controller.close() + }, + }) + + return new Response(stream, { status }) +} + +describe('callDeepSeek', () => { + beforeEach(() => { + vi.restoreAllMocks() + mockSettings() + vi.stubGlobal('fetch', vi.fn()) + }) + + it('streams content and normalizes usage from SSE chunks', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValue(createStreamResponse([ + 'data: {"choices":[{"delta":{"content":"hello "}}]}\n', + 'data: {"choices":[{"delta":{"content":"world"}}],"usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13}}\n', + 'data: [DONE]\n', + ])) + const updates: string[] = [] + + const result = await callDeepSeek([ + { role: 'user', content: 'Summarize' }, + ], (chunk) => updates.push(chunk)) + + expect(result.content).toBe('hello world') + expect(updates).toEqual(['hello ', 'world']) + expect(result.usage).toEqual({ promptTokens: 10, completionTokens: 3, totalTokens: 13 }) + expect(result.rawUsage).toEqual({ prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 }) + }) + + it('adds include_usage stream option only for compatible providers', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation(async () => createStreamResponse([ + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n', + ])) + + await callDeepSeek([{ role: 'user', content: 'test' }]) + let body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string) + expect(body.stream_options).toEqual({ include_usage: true }) + + fetchMock.mockClear() + mockSettings('minimax') + await callDeepSeek([{ role: 'user', content: 'test' }]) + body = JSON.parse(fetchMock.mock.calls[0][1]?.body as string) + expect(body.stream_options).toBeUndefined() + }) + + it('skips malformed SSE JSON chunks', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValue(createStreamResponse([ + 'data: {bad json}\n', + 'data: {"choices":[{"delta":{"content":"valid"}}]}\n', + ])) + + await expect(callDeepSeek([{ role: 'user', content: 'test' }])).resolves.toMatchObject({ + content: 'valid', + }) + }) + + it('maps API response errors to app error codes', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValue(new Response('nope', { status: 429 })) + + await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({ + code: ErrorCodes.API_RATE_LIMITED, + }) + + fetchMock.mockResolvedValue(new Response('server exploded', { status: 500 })) + await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({ + code: ErrorCodes.API_SERVER_ERROR, + detail: 'server exploded', + }) + }) + + it('throws API_RESPONSE_INVALID for empty streamed content', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValue(createStreamResponse([ + 'data: {"usage":{"prompt_tokens":1,"completion_tokens":0,"total_tokens":1}}\n', + ])) + + await expect(callDeepSeek([{ role: 'user', content: 'test' }])).rejects.toMatchObject({ + code: ErrorCodes.API_RESPONSE_INVALID, + }) + }) + + it('maps external abort to ANALYSIS_CANCELLED', async () => { + const controller = new AbortController() + controller.abort() + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation(async (_url, init) => { + if ((init?.signal as AbortSignal).aborted) { + throw Object.assign(new Error('aborted'), { name: 'AbortError' }) + } + return createStreamResponse([]) + }) + + await expect(callDeepSeek([{ role: 'user', content: 'test' }], undefined, controller.signal)) + .rejects.toBeInstanceOf(AppError) + await expect(callDeepSeek([{ role: 'user', content: 'test' }], undefined, controller.signal)) + .rejects.toMatchObject({ code: ErrorCodes.ANALYSIS_CANCELLED }) + }) +}) diff --git a/src/main/backend/__tests__/paperAnalyzerFlow.test.ts b/src/main/backend/__tests__/paperAnalyzerFlow.test.ts new file mode 100644 index 0000000..e7f2667 --- /dev/null +++ b/src/main/backend/__tests__/paperAnalyzerFlow.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { analyzePaper } from '../paperAnalyzer' +import { ErrorCodes } from '../errors' +import { getCachedCodeBundle } from '../codeCache' +import { callDeepSeek } from '../deepseekClient' +import { parsePDF } from '../pdfParser' +import { getActiveSettings } from '../settingsStore' + +vi.mock('../pdfParser', () => ({ + parsePDF: vi.fn(), +})) + +vi.mock('../deepseekClient', () => ({ + callDeepSeek: vi.fn(), +})) + +vi.mock('../settingsStore', () => ({ + getActiveSettings: vi.fn(), +})) + +const mockedParsePDF = vi.mocked(parsePDF) +const mockedCallDeepSeek = vi.mocked(callDeepSeek) +const mockedGetActiveSettings = vi.mocked(getActiveSettings) + +const usage = { promptTokens: 100, completionTokens: 20, totalTokens: 120 } +const rawUsage = { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } + +function outputWithoutCode(summary = 'Paper summary'): string { + return [ + '', + summary, + '', + '{"needed": false}', + ].join('') +} + +function outputWithCode(): string { + return [ + 'Paper summary', + '{"needed": true}', + '', + JSON.stringify({ + coreContribution: 'a minimal loss function', + minimalImplementationBoundary: 'only the loss function', + files: [{ + path: 'core_code/loss.py', + purpose: 'implements the proposed loss function', + mainSymbols: ['loss'], + mustInclude: ['loss'], + mustNotInclude: ['training loop'], + }], + }), + '', + 'def loss():\n return 0', + ].join('') +} + +function mockLlmOutput(content: string) { + mockedCallDeepSeek.mockImplementation(async (_messages, onUpdate) => { + onUpdate?.(content) + return { content, usage, rawUsage } + }) +} + +describe('analyzePaper flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockedGetActiveSettings.mockReturnValue({ + apiKey: 'key', + provider: 'deepseek', + model: 'deepseek-v4-flash', + language: 'zh-CN', + }) + mockedParsePDF.mockResolvedValue({ text: 'paper text', pageCount: 3 }) + }) + + it('returns the new success shape with usage for summary-only analysis', async () => { + mockLlmOutput(outputWithoutCode('A compact summary')) + const progress: string[] = [] + const summaryChunks: string[] = [] + + const result = await analyzePaper( + 'paper.pdf', + (item) => progress.push(item.stage), + (chunk) => summaryChunks.push(chunk) + ) + + expect(result).toEqual({ + ok: true, + result: { summary: 'A compact summary', hasCoreCode: false }, + usage, + rawUsage, + }) + expect(summaryChunks.join('')).toBe('A compact summary') + expect(progress).toEqual(expect.arrayContaining(['parsing', 'summarizing', 'generating_code', 'done'])) + }) + + it('caches generated core code and returns hasCoreCode for valid code output', async () => { + mockLlmOutput(outputWithCode()) + + const result = await analyzePaper('paper.pdf', () => {}) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.result.hasCoreCode).toBe(true) + } + expect(getCachedCodeBundle()?.files).toEqual([ + { path: 'core_code/loss.py', content: 'def loss():\n return 0' }, + ]) + }) + + it('keeps usage when parsing the model output fails after the LLM call', async () => { + mockLlmOutput('missing end tag') + + const result = await analyzePaper('paper.pdf', () => {}) + + expect(result).toMatchObject({ + ok: false, + error: { code: ErrorCodes.API_RESPONSE_INVALID }, + usage, + rawUsage, + }) + }) + + it('returns ANALYSIS_CANCELLED when aborted before parsing starts', async () => { + const controller = new AbortController() + controller.abort() + + const result = await analyzePaper('paper.pdf', () => {}, undefined, controller.signal) + + expect(result).toMatchObject({ + ok: false, + error: { code: ErrorCodes.ANALYSIS_CANCELLED }, + }) + expect(mockedParsePDF).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3ccf1ab..9f2e4e3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3,6 +3,7 @@ import { Language, t } from './i18n' import SettingsPanel from './components/SettingsPanel' import PDFPanel from './components/PDFPanel' import OutputPanel from './components/OutputPanel' +import { clamp, readStoredNumber } from './utils/resizableLayout' const SIDEBAR_WIDTH_KEY = 'paper2corecode.sidebarWidth' const UPLOAD_HEIGHT_KEY = 'paper2corecode.uploadHeight' @@ -16,25 +17,6 @@ const UPLOAD_MAX_HEIGHT = 420 const RESULT_MIN_HEIGHT = 260 const RESIZE_HANDLE_SIZE = 16 -function clamp(value: number, min: number, max: number): number { - if (max < min) return min - return Math.min(Math.max(value, min), max) -} - -function readStoredNumber(key: string, fallback: number): number { - let stored: string | null = null - try { - stored = window.localStorage.getItem(key) - } catch { - return fallback - } - - if (!stored) return fallback - - const parsed = Number(stored) - return Number.isFinite(parsed) ? parsed : fallback -} - function getContentWidth(element: HTMLElement): number { const styles = window.getComputedStyle(element) return element.clientWidth - parseFloat(styles.paddingLeft) - parseFloat(styles.paddingRight) diff --git a/src/renderer/__tests__/App.analysis.test.tsx b/src/renderer/__tests__/App.analysis.test.tsx new file mode 100644 index 0000000..14f40b8 --- /dev/null +++ b/src/renderer/__tests__/App.analysis.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import App from '../App' + +type ElectronMock = ElectronAPI & { + selectPDF: ReturnType + saveSettings: ReturnType + getSettings: ReturnType + analyzePaper: ReturnType + cancelAnalysis: ReturnType + downloadCoreCode: ReturnType + onAnalysisProgress: ReturnType + onSummaryChunk: ReturnType +} + +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function createElectronMock(): ElectronMock { + let progressCallback: ((progress: { stage: string; message: string }) => void) | null = null + let summaryCallback: ((chunk: string) => void) | null = null + + const api = { + selectPDF: vi.fn().mockResolvedValue('C:\\papers\\paper.pdf'), + saveSettings: vi.fn().mockResolvedValue(true), + getSettings: vi.fn().mockResolvedValue({ + apiKey: 'key', + provider: 'deepseek', + model: 'deepseek-v4-flash', + language: 'en-US', + }), + analyzePaper: vi.fn(), + cancelAnalysis: vi.fn().mockResolvedValue(true), + downloadCoreCode: vi.fn().mockResolvedValue({ ok: true, path: 'out' }), + onAnalysisProgress: vi.fn((callback) => { + progressCallback = callback + return vi.fn(() => { progressCallback = null }) + }), + onSummaryChunk: vi.fn((callback) => { + summaryCallback = callback + return vi.fn(() => { summaryCallback = null }) + }), + emitProgress(progress: { stage: string; message: string }) { + progressCallback?.(progress) + }, + emitSummary(chunk: string) { + summaryCallback?.(chunk) + }, + } + + return api as unknown as ElectronMock +} + +describe('App analysis flow', () => { + let electronAPI: ElectronMock & { + emitProgress: (progress: { stage: string; message: string }) => void + emitSummary: (chunk: string) => void + } + + beforeEach(() => { + localStorage.clear() + electronAPI = createElectronMock() as typeof electronAPI + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: electronAPI, + }) + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + paddingLeft: '0px', + paddingRight: '0px', + getPropertyValue: (property: string) => property === 'padding-left' || property === 'padding-right' ? '0px' : '', + } as CSSStyleDeclaration) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(760) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('moves through analysis statuses, renders streamed summary, and shows returned token usage', async () => { + const user = userEvent.setup() + const deferred = createDeferred() + electronAPI.analyzePaper.mockReturnValue(deferred.promise) + + render() + await screen.findByText('API Key configured') + + await user.click(screen.getByRole('button', { name: /Select PDF/ })) + expect(await screen.findByText('paper.pdf')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /Start Analysis/ })) + expect(electronAPI.analyzePaper).toHaveBeenCalledWith('C:\\papers\\paper.pdf') + expect(screen.getByText(/Status: Parsing/)).toBeInTheDocument() + + act(() => { + electronAPI.emitProgress({ stage: 'summarizing', message: 'Calling model...' }) + electronAPI.emitSummary('partial summary') + }) + expect(screen.getByText(/Status: Calling model/)).toBeInTheDocument() + expect(screen.getByText('partial summary')).toBeInTheDocument() + + await act(async () => { + deferred.resolve({ + ok: true, + result: { summary: 'Final summary', hasCoreCode: false }, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + }) + await deferred.promise + }) + + await waitFor(() => expect(screen.getByText(/Status: Completed/)).toBeInTheDocument()) + expect(screen.getByText('Final summary')).toBeInTheDocument() + expect(screen.getByText(/Tokens:/)).toHaveTextContent('15') + }) + + it('calls cancelAnalysis from the analyzing primary action', async () => { + const user = userEvent.setup() + const deferred = createDeferred() + electronAPI.analyzePaper.mockReturnValue(deferred.promise) + + render() + await screen.findByText('API Key configured') + await user.click(screen.getByRole('button', { name: /Select PDF/ })) + await user.click(screen.getByRole('button', { name: /Start Analysis/ })) + await user.click(screen.getByRole('button', { name: /Cancel Analysis/ })) + + expect(electronAPI.cancelAnalysis).toHaveBeenCalledTimes(1) + }) + + it('sets error status and message for failed analysis responses', async () => { + const user = userEvent.setup() + electronAPI.analyzePaper.mockResolvedValue({ + ok: false, + error: { code: 'API_RATE_LIMITED', message: 'API rate limit exceeded' }, + }) + + render() + await screen.findByText('API Key configured') + await user.click(screen.getByRole('button', { name: /Select PDF/ })) + await user.click(screen.getByRole('button', { name: /Start Analysis/ })) + + await waitFor(() => expect(screen.getByText(/Status: Failed/)).toBeInTheDocument()) + expect(screen.getByText('API rate limit exceeded')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/__tests__/App.resizable.test.tsx b/src/renderer/__tests__/App.resizable.test.tsx new file mode 100644 index 0000000..5a9e787 --- /dev/null +++ b/src/renderer/__tests__/App.resizable.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import App from '../App' + +function installElectronMock() { + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + selectPDF: vi.fn().mockResolvedValue(null), + saveSettings: vi.fn().mockResolvedValue(true), + getSettings: vi.fn().mockResolvedValue({ + apiKey: 'key', + provider: 'deepseek', + model: 'deepseek-v4-flash', + language: 'en-US', + }), + analyzePaper: vi.fn(), + cancelAnalysis: vi.fn().mockResolvedValue(true), + downloadCoreCode: vi.fn(), + onAnalysisProgress: vi.fn(() => vi.fn()), + onSummaryChunk: vi.fn(() => vi.fn()), + } satisfies ElectronAPI, + }) +} + +describe('App resizable layout', () => { + beforeEach(() => { + localStorage.clear() + installElectronMock() + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ + paddingLeft: '0px', + paddingRight: '0px', + getPropertyValue: (property: string) => property === 'padding-left' || property === 'padding-right' ? '0px' : '', + } as CSSStyleDeclaration) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000) + vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(760) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('initializes panel sizes from localStorage', async () => { + localStorage.setItem('paper2corecode.sidebarWidth', '340') + localStorage.setItem('paper2corecode.uploadHeight', '310') + + render() + await screen.findByText('API Key configured') + + const sidebarHandle = screen.getByLabelText('Resize sidebar') + const appBody = sidebarHandle.parentElement as HTMLElement + const uploadHandle = screen.getByLabelText('Resize upload and summary panels') + const mainContent = uploadHandle.parentElement as HTMLElement + + await waitFor(() => { + expect(appBody.style.gridTemplateColumns).toContain('340px') + expect(mainContent.style.gridTemplateRows).toContain('310px') + }) + }) + + it('updates and persists sidebar width during drag', async () => { + render() + await screen.findByText('API Key configured') + + const sidebarHandle = screen.getByLabelText('Resize sidebar') + const appBody = sidebarHandle.parentElement as HTMLElement + + fireEvent.pointerDown(sidebarHandle, { clientX: 280 }) + fireEvent.pointerMove(window, { clientX: 360 }) + fireEvent.pointerUp(window, { clientX: 360 }) + + expect(appBody.style.gridTemplateColumns).toContain('360px') + expect(localStorage.getItem('paper2corecode.sidebarWidth')).toBe('360') + expect(document.body).not.toHaveClass('is-resizing-layout') + }) + + it('updates and persists upload height during drag', async () => { + render() + await screen.findByText('API Key configured') + + const uploadHandle = screen.getByLabelText('Resize upload and summary panels') + const mainContent = uploadHandle.parentElement as HTMLElement + + fireEvent.pointerDown(uploadHandle, { clientY: 260 }) + fireEvent.pointerMove(window, { clientY: 330 }) + fireEvent.pointerUp(window, { clientY: 330 }) + + expect(mainContent.style.gridTemplateRows).toContain('330px') + expect(localStorage.getItem('paper2corecode.uploadHeight')).toBe('330') + expect(document.body).not.toHaveClass('is-resizing-layout') + }) + + it('clamps dragged sizes to configured bounds', async () => { + render() + await screen.findByText('API Key configured') + + const sidebarHandle = screen.getByLabelText('Resize sidebar') + const appBody = sidebarHandle.parentElement as HTMLElement + fireEvent.pointerDown(sidebarHandle, { clientX: 280 }) + fireEvent.pointerUp(window, { clientX: 1000 }) + expect(appBody.style.gridTemplateColumns).toContain('420px') + + const uploadHandle = screen.getByLabelText('Resize upload and summary panels') + const mainContent = uploadHandle.parentElement as HTMLElement + fireEvent.pointerDown(uploadHandle, { clientY: 260 }) + fireEvent.pointerUp(window, { clientY: 0 }) + expect(mainContent.style.gridTemplateRows).toContain('230px') + }) +}) diff --git a/src/renderer/__tests__/i18n.test.ts b/src/renderer/__tests__/i18n.test.ts new file mode 100644 index 0000000..03cf57f --- /dev/null +++ b/src/renderer/__tests__/i18n.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { Language, t } from '../i18n' + +const languages: Language[] = ['zh-CN', 'en-US'] +const requiredKeys = [ + 'result.status', + 'result.statusIdle', + 'result.statusParsing', + 'result.statusAnalyzing', + 'result.statusSuccess', + 'result.statusError', + 'result.elapsed', + 'result.tokenTotal', + 'result.tokenUnavailable', + 'result.promptTokens', + 'result.completionTokens', + 'result.totalTokens', + 'upload.cancelAnalysis', +] + +describe('i18n coverage for analysis metrics', () => { + it.each(languages)('has all required keys for %s', (language) => { + for (const key of requiredKeys) { + expect(t(language, key)).not.toBe(key) + expect(t(language, key).trim()).not.toBe('') + } + }) +}) diff --git a/src/renderer/components/OutputPanel.tsx b/src/renderer/components/OutputPanel.tsx index 242c8fb..a5c52f4 100644 --- a/src/renderer/components/OutputPanel.tsx +++ b/src/renderer/components/OutputPanel.tsx @@ -6,6 +6,7 @@ import rehypeKatex from 'rehype-katex' import 'katex/dist/katex.min.css' import { FileText, Download, CheckCircle2, AlertCircle, LoaderCircle, FileSearch } from 'lucide-react' import { t, Language } from '../i18n' +import { formatElapsedTime, formatTokenCount, getTokenTotal } from '../utils/analysisMetrics' interface OutputPanelProps { result: { summary: string; hasCoreCode: boolean } | null @@ -167,25 +168,6 @@ export default function OutputPanel({ result, analyzing, streamingSummary, error ) } -function formatElapsedTime(totalSeconds: number): string { - const safeSeconds = Math.max(0, totalSeconds) - const hours = Math.floor(safeSeconds / 3600) - const minutes = Math.floor((safeSeconds % 3600) / 60) - const seconds = safeSeconds % 60 - const mm = String(minutes).padStart(2, '0') - const ss = String(seconds).padStart(2, '0') - - if (hours > 0) { - return `${String(hours).padStart(2, '0')}:${mm}:${ss}` - } - - return `${mm}:${ss}` -} - -function formatTokenCount(value: number | undefined): string { - return value === undefined ? '-' : value.toLocaleString() -} - function getStatusLabel(language: Language, status: AnalysisStatus): string { const statusKey = status === 'idle' ? 'result.statusIdle' @@ -200,15 +182,6 @@ function getStatusLabel(language: Language, status: AnalysisStatus): string { return t(language, statusKey) } -function getTokenTotal(usage: TokenUsage | null): number | undefined { - if (!usage) return undefined - if (usage.totalTokens !== undefined) return usage.totalTokens - if (usage.promptTokens !== undefined && usage.completionTokens !== undefined) { - return usage.promptTokens + usage.completionTokens - } - return undefined -} - function AnalysisStatusBar({ language, status, elapsedTime, tokenUsage }: { language: Language status: AnalysisStatus diff --git a/src/renderer/components/__tests__/OutputPanel.test.tsx b/src/renderer/components/__tests__/OutputPanel.test.tsx new file mode 100644 index 0000000..770661b --- /dev/null +++ b/src/renderer/components/__tests__/OutputPanel.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import OutputPanel from '../OutputPanel' +import { cleanup } from '@testing-library/react' + +afterEach(() => cleanup()) + +function renderOutputPanel(overrides: Partial> = {}) { + return render( + + ) +} + +describe('OutputPanel', () => { + it('shows idle status, elapsed time, and unavailable token usage', () => { + renderOutputPanel() + + expect(screen.getByText(/Status: Waiting/)).toBeInTheDocument() + expect(screen.getByText(/Elapsed:/)).toHaveTextContent('00:00') + expect(screen.getByText(/Tokens: No token usage yet/)).toBeInTheDocument() + }) + + it('shows token details when usage is available', () => { + renderOutputPanel({ + analysisStatus: 'success', + elapsedTime: 85, + tokenUsage: { promptTokens: 100, completionTokens: 25, totalTokens: 125 }, + }) + + expect(screen.getByText(/Status: Completed/)).toBeInTheDocument() + expect(screen.getByText('01:25')).toBeInTheDocument() + expect(screen.getByText(/Tokens:/)).toHaveTextContent('125') + expect(screen.getByText(/Input tokens:/)).toHaveTextContent('100') + expect(screen.getByText(/Output tokens:/)).toHaveTextContent('25') + expect(screen.getByText(/Total tokens:/)).toHaveTextContent('125') + }) + + it('renders streaming summary while analyzing', () => { + renderOutputPanel({ + analyzing: true, + analysisStatus: 'analyzing', + streamingSummary: 'partial summary', + }) + + expect(screen.getByText('Generating summary')).toBeInTheDocument() + expect(screen.getByText('partial summary')).toBeInTheDocument() + }) + + it('renders completed summary and download action for generated core code', () => { + renderOutputPanel({ + result: { summary: 'final **summary**', hasCoreCode: true }, + }) + + expect(screen.getByText('Analysis completed')).toBeInTheDocument() + expect(screen.getByText('final')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Download Core Code/ })).toBeInTheDocument() + }) + + it('renders error state', () => { + renderOutputPanel({ + analysisStatus: 'error', + error: 'API rate limit exceeded', + }) + + expect(screen.getByText('Could not analyze this paper')).toBeInTheDocument() + expect(screen.getByText('API rate limit exceeded')).toBeInTheDocument() + expect(screen.getByText(/Please wait a moment/)).toBeInTheDocument() + }) +}) diff --git a/src/renderer/components/__tests__/PDFPanel.test.tsx b/src/renderer/components/__tests__/PDFPanel.test.tsx new file mode 100644 index 0000000..561e3e3 --- /dev/null +++ b/src/renderer/components/__tests__/PDFPanel.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import PDFPanel from '../PDFPanel' + +afterEach(() => cleanup()) + +function renderPDFPanel(overrides: Partial> = {}) { + const props: React.ComponentProps = { + pdfPath: null, + analyzing: false, + progress: [], + apiKeyConfigured: true, + hasResult: false, + language: 'en-US', + onSelectPDF: vi.fn(), + onSetPDFPath: vi.fn(), + onAnalyze: vi.fn(), + onCancelAnalyze: vi.fn(), + ...overrides, + } + + return { ...render(), props } +} + +describe('PDFPanel', () => { + it('disables analysis until a PDF is selected', () => { + renderPDFPanel() + + expect(screen.getByRole('button', { name: /Start Analysis/ })).toBeDisabled() + }) + + it('shows API key requirement when a PDF exists but settings are incomplete', () => { + renderPDFPanel({ pdfPath: 'C:\\papers\\paper.pdf', apiKeyConfigured: false }) + + expect(screen.getByRole('button', { name: /API Key required/ })).toBeDisabled() + expect(screen.getByText('Set API Key in sidebar to start')).toBeInTheDocument() + }) + + it('starts analysis from the action button', async () => { + const user = userEvent.setup() + const { props } = renderPDFPanel({ pdfPath: 'C:\\papers\\paper.pdf' }) + + await user.click(screen.getByRole('button', { name: /Start Analysis/ })) + + expect(props.onAnalyze).toHaveBeenCalledTimes(1) + }) + + it('switches the primary action to cancel while analyzing', async () => { + const user = userEvent.setup() + const { props } = renderPDFPanel({ + pdfPath: 'C:\\papers\\paper.pdf', + analyzing: true, + progress: ['Reading PDF file...'], + }) + + expect(screen.getByRole('button', { name: /Select PDF/ })).toBeDisabled() + await user.click(screen.getByRole('button', { name: /Cancel Analysis/ })) + + expect(props.onCancelAnalyze).toHaveBeenCalledTimes(1) + }) + + it('uses analyze-again wording when previous results exist', () => { + renderPDFPanel({ pdfPath: 'C:\\papers\\paper.pdf', hasResult: true }) + + expect(screen.getByRole('button', { name: /Analyze Again/ })).toBeInTheDocument() + }) + + it('accepts dropped PDF files and ignores non-PDF files', () => { + const { props } = renderPDFPanel() + const dropZone = screen.getByText('Drop a research paper here').closest('div')! + const pdf = new File(['pdf'], 'paper.pdf', { type: 'application/pdf' }) + Object.defineProperty(pdf, 'path', { value: 'C:\\papers\\paper.pdf' }) + const text = new File(['text'], 'paper.txt', { type: 'text/plain' }) + Object.defineProperty(text, 'path', { value: 'C:\\papers\\paper.txt' }) + + fireEvent.drop(dropZone, { dataTransfer: { files: [pdf] } }) + fireEvent.drop(dropZone, { dataTransfer: { files: [text] } }) + + expect(props.onSetPDFPath).toHaveBeenCalledTimes(1) + expect(props.onSetPDFPath).toHaveBeenCalledWith('C:\\papers\\paper.pdf') + }) +}) diff --git a/src/renderer/utils/__tests__/analysisMetrics.test.ts b/src/renderer/utils/__tests__/analysisMetrics.test.ts new file mode 100644 index 0000000..1095f8c --- /dev/null +++ b/src/renderer/utils/__tests__/analysisMetrics.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { formatElapsedTime, formatTokenCount, getTokenTotal } from '../analysisMetrics' + +describe('analysis metrics helpers', () => { + it('formats elapsed seconds as mm:ss before one hour', () => { + expect(formatElapsedTime(0)).toBe('00:00') + expect(formatElapsedTime(85)).toBe('01:25') + expect(formatElapsedTime(728)).toBe('12:08') + }) + + it('formats elapsed seconds as hh:mm:ss after one hour', () => { + expect(formatElapsedTime(4350)).toBe('01:12:30') + }) + + it('clamps negative elapsed time to zero', () => { + expect(formatElapsedTime(-10)).toBe('00:00') + }) + + it('uses provided total tokens first', () => { + expect(getTokenTotal({ promptTokens: 10, completionTokens: 5, totalTokens: 20 })).toBe(20) + }) + + it('calculates total tokens from prompt and completion counts', () => { + expect(getTokenTotal({ promptTokens: 10, completionTokens: 5 })).toBe(15) + }) + + it('returns undefined when token total is unavailable', () => { + expect(getTokenTotal(null)).toBeUndefined() + expect(getTokenTotal({ promptTokens: 10 })).toBeUndefined() + }) + + it('formats missing token counts as a placeholder', () => { + expect(formatTokenCount(undefined)).toBe('-') + expect(formatTokenCount(1234)).toBe((1234).toLocaleString()) + }) +}) diff --git a/src/renderer/utils/__tests__/resizableLayout.test.ts b/src/renderer/utils/__tests__/resizableLayout.test.ts new file mode 100644 index 0000000..3e7ef4d --- /dev/null +++ b/src/renderer/utils/__tests__/resizableLayout.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest' +import { clamp, readStoredNumber } from '../resizableLayout' + +describe('resizable layout helpers', () => { + it('clamps values between min and max', () => { + expect(clamp(100, 220, 420)).toBe(220) + expect(clamp(300, 220, 420)).toBe(300) + expect(clamp(500, 220, 420)).toBe(420) + }) + + it('falls back to min when max is below min', () => { + expect(clamp(300, 220, 100)).toBe(220) + }) + + it('reads finite stored numbers', () => { + const storage = { getItem: vi.fn().mockReturnValue('320') } + expect(readStoredNumber('width', 280, storage)).toBe(320) + }) + + it('returns fallback for missing or invalid stored values', () => { + expect(readStoredNumber('width', 280, { getItem: vi.fn().mockReturnValue(null) })).toBe(280) + expect(readStoredNumber('width', 280, { getItem: vi.fn().mockReturnValue('wide') })).toBe(280) + }) + + it('returns fallback when storage access throws', () => { + const storage = { getItem: vi.fn(() => { throw new Error('blocked') }) } + expect(readStoredNumber('width', 280, storage)).toBe(280) + }) +}) diff --git a/src/renderer/utils/analysisMetrics.ts b/src/renderer/utils/analysisMetrics.ts new file mode 100644 index 0000000..20fc317 --- /dev/null +++ b/src/renderer/utils/analysisMetrics.ts @@ -0,0 +1,27 @@ +export function formatElapsedTime(totalSeconds: number): string { + const safeSeconds = Math.max(0, totalSeconds) + const hours = Math.floor(safeSeconds / 3600) + const minutes = Math.floor((safeSeconds % 3600) / 60) + const seconds = safeSeconds % 60 + const mm = String(minutes).padStart(2, '0') + const ss = String(seconds).padStart(2, '0') + + if (hours > 0) { + return `${String(hours).padStart(2, '0')}:${mm}:${ss}` + } + + return `${mm}:${ss}` +} + +export function formatTokenCount(value: number | undefined): string { + return value === undefined ? '-' : value.toLocaleString() +} + +export function getTokenTotal(usage: TokenUsage | null): number | undefined { + if (!usage) return undefined + if (usage.totalTokens !== undefined) return usage.totalTokens + if (usage.promptTokens !== undefined && usage.completionTokens !== undefined) { + return usage.promptTokens + usage.completionTokens + } + return undefined +} diff --git a/src/renderer/utils/resizableLayout.ts b/src/renderer/utils/resizableLayout.ts new file mode 100644 index 0000000..578c3ea --- /dev/null +++ b/src/renderer/utils/resizableLayout.ts @@ -0,0 +1,22 @@ +export function clamp(value: number, min: number, max: number): number { + if (max < min) return min + return Math.min(Math.max(value, min), max) +} + +export function readStoredNumber( + key: string, + fallback: number, + storage: Pick = window.localStorage +): number { + let stored: string | null = null + try { + stored = storage.getItem(key) + } catch { + return fallback + } + + if (!stored) return fallback + + const parsed = Number(stored) + return Number.isFinite(parsed) ? parsed : fallback +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/vitest.config.ts b/vitest.config.ts index d133f7d..01d1ff3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - include: ['src/**/__tests__/**/*.test.ts'], + include: ['src/**/__tests__/**/*.test.{ts,tsx}'], environment: 'node', + setupFiles: ['src/test/setup.ts'], }, })