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'],
},
})