From 10437c02cad64339d769c5a5b71053f8f1bd835c Mon Sep 17 00:00:00 2001 From: dillonj Date: Wed, 6 May 2026 14:40:58 -0600 Subject: [PATCH] added tests --- frontend/package-lock.json | 523 ++++++++++++++++++++++++ frontend/package.json | 1 + frontend/src/lib/assert.test.ts | 26 ++ frontend/src/store/aiStore.test.ts | 127 ++++++ frontend/src/store/editorStore.test.ts | 401 +++++++++++++++++- frontend/src/store/licenseStore.test.ts | 183 +++++++++ frontend/vitest.config.ts | 8 + src-tauri/src/models.rs | 49 +++ 8 files changed, 1303 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/assert.test.ts create mode 100644 frontend/src/store/aiStore.test.ts create mode 100644 frontend/src/store/licenseStore.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index daff210..3bc5fa8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", @@ -51,6 +52,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -333,6 +385,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/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -932,6 +1137,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "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/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2365,6 +2588,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/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2601,6 +2834,20 @@ "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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2621,6 +2868,20 @@ "devOptional": true, "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", @@ -2639,6 +2900,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/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2667,6 +2935,19 @@ "dev": true, "license": "ISC" }, + "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/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -3183,6 +3464,19 @@ "hermes-estree": "0.25.1" } }, + "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/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3282,6 +3576,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3319,6 +3620,57 @@ "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/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/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3462,6 +3814,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3655,6 +4014,19 @@ "node": ">=6" } }, + "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/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3997,6 +4369,16 @@ "node": ">=8.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/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4108,6 +4490,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4240,6 +4635,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -4376,6 +4778,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/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4389,6 +4811,32 @@ "node": ">=8.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/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -4460,6 +4908,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "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/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4717,12 +5175,60 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wavesurfer.js": { "version": "7.12.1", "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz", "integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==", "license": "BSD-3-Clause" }, + "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": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4766,6 +5272,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2e8cf2f..28f21fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", + "jsdom": "^29.1.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", diff --git a/frontend/src/lib/assert.test.ts b/frontend/src/lib/assert.test.ts new file mode 100644 index 0000000..c4181ed --- /dev/null +++ b/frontend/src/lib/assert.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; + +import { assert } from './assert'; + +describe('assert', () => { + test('does not throw for true condition', () => { + expect(() => assert(true, 'should not throw')).not.toThrow(); + }); + + test('throws in dev mode for false condition', () => { + expect(() => assert(false, 'should throw')).toThrow('Assertion failed: should throw'); + }); + + test('includes message in error', () => { + try { + assert(false, 'custom message here'); + } catch (e: any) { + expect(e.message).toContain('custom message here'); + } + }); + + test('does not throw for truthy values', () => { + expect(() => assert(1 === 1, 'math works')).not.toThrow(); + expect(() => assert('hello' === 'hello', 'strings work')).not.toThrow(); + }); +}); diff --git a/frontend/src/store/aiStore.test.ts b/frontend/src/store/aiStore.test.ts new file mode 100644 index 0000000..bdcd9be --- /dev/null +++ b/frontend/src/store/aiStore.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useAIStore } from './aiStore'; + + +function mockElectronAPI() { + (window as any).electronAPI = { + encryptString: vi.fn().mockResolvedValue('encrypted-value'), + decryptString: vi.fn().mockResolvedValue('decrypted-key'), + }; +} + +describe('aiStore', () => { + beforeEach(() => { + mockElectronAPI(); + useAIStore.setState({ + providers: { + ollama: { provider: 'ollama', baseUrl: 'http://localhost:11434', model: 'llama3' }, + openai: { provider: 'openai', apiKey: '', model: 'gpt-4o' }, + claude: { provider: 'claude', apiKey: '', model: 'claude-sonnet-4-20250514' }, + }, + defaultProvider: 'ollama', + customFillerWords: '', + fillerResult: null, + clipSuggestions: [], + isProcessing: false, + processingMessage: '', + _keysHydrated: false, + }); + }); + + describe('setProviderConfig', () => { + test('updates Ollama base URL', () => { + useAIStore.getState().setProviderConfig('ollama', { baseUrl: 'http://custom:11434' }); + expect(useAIStore.getState().providers.ollama.baseUrl).toBe('http://custom:11434'); + }); + + test('updates Ollama model', () => { + useAIStore.getState().setProviderConfig('ollama', { model: 'llama3.2' }); + expect(useAIStore.getState().providers.ollama.model).toBe('llama3.2'); + }); + + test('updates OpenAI apiKey and encrypts', async () => { + useAIStore.getState().setProviderConfig('openai', { apiKey: 'sk-test123' }); + expect(useAIStore.getState().providers.openai.apiKey).toBe('sk-test123'); + expect((window as any).electronAPI.encryptString).toHaveBeenCalledWith('sk-test123'); + }); + + test('updates Claude model', () => { + useAIStore.getState().setProviderConfig('claude', { model: 'claude-opus-4-20250514' }); + expect(useAIStore.getState().providers.claude.model).toBe('claude-opus-4-20250514'); + }); + + test('preserves existing config when updating partial fields', () => { + useAIStore.getState().setProviderConfig('openai', { apiKey: 'sk-new', model: 'gpt-4o-mini' }); + expect(useAIStore.getState().providers.openai.apiKey).toBe('sk-new'); + expect(useAIStore.getState().providers.openai.model).toBe('gpt-4o-mini'); + }); + }); + + describe('setDefaultProvider', () => { + test('changes default provider', () => { + useAIStore.getState().setDefaultProvider('openai'); + expect(useAIStore.getState().defaultProvider).toBe('openai'); + }); + + test('can switch to claude', () => { + useAIStore.getState().setDefaultProvider('claude'); + expect(useAIStore.getState().defaultProvider).toBe('claude'); + }); + }); + + describe('setCustomFillerWords', () => { + test('sets custom filler words', () => { + useAIStore.getState().setCustomFillerWords('okay, alright, anyway'); + expect(useAIStore.getState().customFillerWords).toBe('okay, alright, anyway'); + }); + + test('clears custom filler words', () => { + useAIStore.getState().setCustomFillerWords('test'); + useAIStore.getState().setCustomFillerWords(''); + expect(useAIStore.getState().customFillerWords).toBe(''); + }); + }); + + describe('setFillerResult', () => { + test('sets filler result', () => { + const result = { fillers: [{ word: 'um', start: 1.0, end: 1.3 }], totalCount: 1 }; + useAIStore.getState().setFillerResult(result as any); + expect(useAIStore.getState().fillerResult).toEqual(result); + }); + + test('clears filler result', () => { + useAIStore.getState().setFillerResult({ fillers: [], totalCount: 0 } as any); + useAIStore.getState().setFillerResult(null); + expect(useAIStore.getState().fillerResult).toBeNull(); + }); + }); + + describe('setProcessing', () => { + test('sets processing true with message', () => { + useAIStore.getState().setProcessing(true, 'Analyzing transcript...'); + expect(useAIStore.getState().isProcessing).toBe(true); + expect(useAIStore.getState().processingMessage).toBe('Analyzing transcript...'); + }); + + test('sets processing false', () => { + useAIStore.getState().setProcessing(true, 'Working...'); + useAIStore.getState().setProcessing(false); + expect(useAIStore.getState().isProcessing).toBe(false); + }); + }); + + describe('setClipSuggestions', () => { + test('sets clip suggestions', () => { + const clips = [{ title: 'Best moment', start: 10, end: 40, reason: 'Engaging' }]; + useAIStore.getState().setClipSuggestions(clips as any); + expect(useAIStore.getState().clipSuggestions).toEqual(clips); + }); + + test('clears clip suggestions', () => { + useAIStore.getState().setClipSuggestions([{ title: 'x', start: 0, end: 10, reason: 'y' }] as any); + useAIStore.getState().setClipSuggestions([]); + expect(useAIStore.getState().clipSuggestions).toEqual([]); + }); + }); +}); diff --git a/frontend/src/store/editorStore.test.ts b/frontend/src/store/editorStore.test.ts index c87aac1..4ecedcc 100644 --- a/frontend/src/store/editorStore.test.ts +++ b/frontend/src/store/editorStore.test.ts @@ -3,30 +3,401 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { useEditorStore } from './editorStore'; -describe('editorStore basics', () => { +function seedWords(count: number) { + const words: { word: string; start: number; end: number; confidence: number }[] = []; + for (let i = 0; i < count; i++) { + words.push({ word: `word${i}`, start: i * 0.5, end: i * 0.5 + 0.4, confidence: 0.95 }); + } + const segments = [{ + id: 0, start: 0, end: count * 0.5, + text: words.map(w => w.word).join(' '), + words, + globalStartIndex: 0, + }]; + useEditorStore.getState().setTranscription({ words, segments, language: 'en' }); +} + +describe('editorStore', () => { beforeEach(() => { useEditorStore.getState().reset(); }); - test('clamps global gain to valid bounds', () => { - const state = useEditorStore.getState(); + describe('global gain', () => { + test('clamps to upper bound', () => { + useEditorStore.getState().setGlobalGainDb(100); + expect(useEditorStore.getState().globalGainDb).toBe(24); + }); - state.setGlobalGainDb(100); - expect(useEditorStore.getState().globalGainDb).toBe(24); + test('clamps to lower bound', () => { + useEditorStore.getState().setGlobalGainDb(-100); + expect(useEditorStore.getState().globalGainDb).toBe(-24); + }); - state.setGlobalGainDb(-100); - expect(useEditorStore.getState().globalGainDb).toBe(-24); + test('rejects NaN by falling back to 0', () => { + useEditorStore.getState().setGlobalGainDb(NaN); + expect(useEditorStore.getState().globalGainDb).toBe(0); + }); + + test('rejects Infinity', () => { + useEditorStore.getState().setGlobalGainDb(Infinity); + expect(useEditorStore.getState().globalGainDb).toBe(0); + }); + + test('accepts value in range', () => { + useEditorStore.getState().setGlobalGainDb(6); + expect(useEditorStore.getState().globalGainDb).toBe(6); + }); }); - test('adds gain range to store', () => { - const state = useEditorStore.getState(); + describe('zone ranges', () => { + beforeEach(() => { + useEditorStore.getState().setDuration(100); + }); - state.addGainRange(1.2, 2.4, 3.5); + test('addCutRange creates a zone with correct times', () => { + useEditorStore.getState().addCutRange(1, 5); + const ranges = useEditorStore.getState().cutRanges; + expect(ranges.length).toBe(1); + expect(ranges[0].start).toBe(1); + expect(ranges[0].end).toBe(5); + }); - const ranges = useEditorStore.getState().gainRanges; - expect(ranges.length).toBe(1); - expect(ranges[0].start).toBe(1.2); - expect(ranges[0].end).toBe(2.4); - expect(ranges[0].gainDb).toBe(3.5); + test('addCutRange generates unique ids', () => { + useEditorStore.getState().addCutRange(1, 2); + useEditorStore.getState().addCutRange(3, 4); + const ranges = useEditorStore.getState().cutRanges; + expect(ranges[0].id).not.toBe(ranges[1].id); + }); + + test('addCutRange rejects start >= end', () => { + useEditorStore.getState().addCutRange(5, 5); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + + test('addCutRange rejects start > end', () => { + useEditorStore.getState().addCutRange(5, 1); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + + test('addCutRange rejects duration < 0.01s', () => { + useEditorStore.getState().addCutRange(0, 0.005); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + + test('addCutRange rejects negative start', () => { + useEditorStore.getState().addCutRange(-1, 5); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + + test('addCutRange rejects NaN values', () => { + useEditorStore.getState().addCutRange(NaN, 5); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + + test('addMuteRange creates a zone', () => { + useEditorStore.getState().addMuteRange(2, 6); + const ranges = useEditorStore.getState().muteRanges; + expect(ranges.length).toBe(1); + expect(ranges[0].start).toBe(2); + expect(ranges[0].end).toBe(6); + }); + + test('addGainRange creates a zone with gain value', () => { + useEditorStore.getState().addGainRange(1, 4, 3.5); + const ranges = useEditorStore.getState().gainRanges; + expect(ranges.length).toBe(1); + expect(ranges[0].gainDb).toBe(3.5); + }); + + test('addSpeedRange creates a zone with speed value', () => { + useEditorStore.getState().addSpeedRange(0, 10, 1.5); + const ranges = useEditorStore.getState().speedRanges; + expect(ranges.length).toBe(1); + expect(ranges[0].speed).toBe(1.5); + }); + + test('removeCutRange removes by id', () => { + useEditorStore.getState().addCutRange(1, 2); + const id = useEditorStore.getState().cutRanges[0].id; + useEditorStore.getState().removeCutRange(id); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + + test('removeCutRange does nothing for missing id', () => { + useEditorStore.getState().addCutRange(1, 2); + useEditorStore.getState().removeCutRange('nonexistent'); + expect(useEditorStore.getState().cutRanges.length).toBe(1); + }); + + test('updateCutRange updates bounds', () => { + useEditorStore.getState().addCutRange(1, 5); + const id = useEditorStore.getState().cutRanges[0].id; + useEditorStore.getState().updateCutRange(id, 2, 8); + const range = useEditorStore.getState().cutRanges[0]; + expect(range.start).toBe(2); + expect(range.end).toBe(8); + }); + + test('removeMuteRange, removeGainRange, removeSpeedRange work', () => { + useEditorStore.getState().addMuteRange(1, 2); + useEditorStore.getState().addGainRange(2, 4, 3); + useEditorStore.getState().addSpeedRange(3, 6, 1.2); + + useEditorStore.getState().removeMuteRange(useEditorStore.getState().muteRanges[0].id); + useEditorStore.getState().removeGainRange(useEditorStore.getState().gainRanges[0].id); + useEditorStore.getState().removeSpeedRange(useEditorStore.getState().speedRanges[0].id); + + expect(useEditorStore.getState().muteRanges.length).toBe(0); + expect(useEditorStore.getState().gainRanges.length).toBe(0); + expect(useEditorStore.getState().speedRanges.length).toBe(0); + }); + + test('rejects zones beyond duration', () => { + useEditorStore.getState().setDuration(10); + useEditorStore.getState().addCutRange(5, 20); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + + test('rejects zone with end beyond duration', () => { + useEditorStore.getState().setDuration(5); + useEditorStore.getState().addCutRange(1, 10); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + }); + + describe('word selection', () => { + beforeEach(() => { seedWords(10); }); + + test('setSelectedWordIndices updates selection', () => { + useEditorStore.getState().setSelectedWordIndices([0, 1, 2]); + expect(useEditorStore.getState().selectedWordIndices).toEqual([0, 1, 2]); + }); + + test('setSelectedWordIndices handles empty', () => { + useEditorStore.getState().setSelectedWordIndices([0]); + useEditorStore.getState().setSelectedWordIndices([]); + expect(useEditorStore.getState().selectedWordIndices).toEqual([]); + }); + + test('updateWordText updates the word at index', () => { + useEditorStore.getState().updateWordText(0, 'hello'); + expect(useEditorStore.getState().words[0].word).toBe('hello'); + }); + + test('updateWordText preserves timing', () => { + const origStart = useEditorStore.getState().words[3].start; + useEditorStore.getState().updateWordText(3, 'changed'); + expect(useEditorStore.getState().words[3].start).toBe(origStart); + }); + + test('updateWordText rejects out-of-bounds index', () => { + useEditorStore.getState().updateWordText(999, 'oops'); + expect(useEditorStore.getState().words.length).toBe(10); + }); + + test('updateWordText rejects empty string', () => { + useEditorStore.getState().updateWordText(0, ''); + expect(useEditorStore.getState().words[0].word).toBe('word0'); + }); + + test('replaceWordRange replaces words in middle', () => { + const newWords = [ + { word: 'new1', start: 1.5, end: 1.9, confidence: 0.99 }, + { word: 'new2', start: 2.0, end: 2.4, confidence: 0.99 }, + ]; + useEditorStore.getState().replaceWordRange(3, 5, newWords); + const words = useEditorStore.getState().words; + expect(words.length).toBe(10 - (5 - 3 + 1) + 2); + expect(words[3].word).toBe('new1'); + expect(words[4].word).toBe('new2'); + }); + + test('getWordAtTime returns correct index', () => { + const idx = useEditorStore.getState().getWordAtTime(1.0); + expect(idx).toBe(2); + }); + + test('getWordAtTime returns 0 for time before first word', () => { + const idx = useEditorStore.getState().getWordAtTime(-1); + expect(idx).toBe(0); + }); + + test('getWordAtTime returns -1 for no words', () => { + useEditorStore.getState().reset(); + expect(useEditorStore.getState().getWordAtTime(0)).toBe(-1); + }); + }); + + describe('markers', () => { + beforeEach(() => { + useEditorStore.getState().setDuration(120); + }); + + test('setMarkInTime sets and clears', () => { + useEditorStore.getState().setMarkInTime(10); + expect(useEditorStore.getState().markInTime).toBe(10); + useEditorStore.getState().setMarkInTime(null); + expect(useEditorStore.getState().markInTime).toBeNull(); + }); + + test('setMarkInTime rejects NaN', () => { + useEditorStore.getState().setMarkInTime(NaN); + expect(useEditorStore.getState().markInTime).toBeNull(); + }); + + test('clearMarkRange clears both', () => { + useEditorStore.getState().setMarkInTime(5); + useEditorStore.getState().setMarkOutTime(10); + useEditorStore.getState().clearMarkRange(); + expect(useEditorStore.getState().markInTime).toBeNull(); + expect(useEditorStore.getState().markOutTime).toBeNull(); + }); + + test('addTimelineMarker adds with correct data', () => { + useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444'); + const markers = useEditorStore.getState().timelineMarkers; + expect(markers.length).toBe(1); + expect(markers[0].time).toBe(5); + expect(markers[0].label).toBe('Intro'); + expect(markers[0].color).toBe('#ef4444'); + }); + + test('addTimelineMarker defaults empty label to Marker', () => { + useEditorStore.getState().addTimelineMarker(10, '', '#6366f1'); + expect(useEditorStore.getState().timelineMarkers[0].label).toBe('Marker'); + }); + + test('addTimelineMarker rejects NaN time', () => { + useEditorStore.getState().addTimelineMarker(NaN, 'test', '#6366f1'); + expect(useEditorStore.getState().timelineMarkers.length).toBe(0); + }); + + test('removeTimelineMarker removes by id', () => { + useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444'); + const id = useEditorStore.getState().timelineMarkers[0].id; + useEditorStore.getState().removeTimelineMarker(id); + expect(useEditorStore.getState().timelineMarkers.length).toBe(0); + }); + + test('updateTimelineMarker updates label and color', () => { + useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444'); + const id = useEditorStore.getState().timelineMarkers[0].id; + useEditorStore.getState().updateTimelineMarker(id, { label: 'Chapter 1', color: '#22c55e' }); + const m = useEditorStore.getState().timelineMarkers[0]; + expect(m.label).toBe('Chapter 1'); + expect(m.color).toBe('#22c55e'); + }); + }); + + describe('transcription', () => { + test('setTranscription sets words and segments', () => { + seedWords(5); + expect(useEditorStore.getState().words.length).toBe(5); + expect(useEditorStore.getState().segments.length).toBe(1); + }); + + test('setTranscription clears segments when words are empty', () => { + useEditorStore.getState().setTranscription({ words: [], segments: [], language: 'en' }); + expect(useEditorStore.getState().segments.length).toBe(0); + }); + + test('setTranscriptionModel ignores null', () => { + useEditorStore.getState().setTranscriptionModel('base'); + useEditorStore.getState().setTranscriptionModel(null); + expect(useEditorStore.getState().transcriptionModel).toBe('base'); + }); + + test('setTranscriptionModel ignores empty string', () => { + useEditorStore.getState().setTranscriptionModel('base'); + useEditorStore.getState().setTranscriptionModel(''); + expect(useEditorStore.getState().transcriptionModel).toBe('base'); + }); + + test('setTranscribing toggles state and status', () => { + useEditorStore.getState().setTranscribing(true, 50, 'Loading...'); + expect(useEditorStore.getState().isTranscribing).toBe(true); + expect(useEditorStore.getState().transcriptionProgress).toBe(50); + expect(useEditorStore.getState().transcriptionStatus).toBe('Loading...'); + }); + }); + + describe('project file', () => { + test('saveProject includes all zone types', () => { + useEditorStore.getState().loadVideo('test.mp4'); + useEditorStore.getState().setDuration(100); + useEditorStore.getState().addCutRange(1, 2); + useEditorStore.getState().addMuteRange(2, 3); + useEditorStore.getState().addGainRange(3, 4, 3); + useEditorStore.getState().addSpeedRange(4, 5, 1.5); + + const project = useEditorStore.getState().saveProject(); + expect(project.cutRanges.length).toBe(1); + expect(project.muteRanges.length).toBe(1); + expect(project.gainRanges.length).toBe(1); + expect(project.speedRanges.length).toBe(1); + }); + + test('setProjectFilePath sets and reads back', () => { + useEditorStore.getState().setProjectFilePath('/path/to/project.aive'); + expect(useEditorStore.getState().projectFilePath).toBe('/path/to/project.aive'); + }); + }); + + describe('duration and current time', () => { + test('setDuration sets duration value', () => { + useEditorStore.getState().setDuration(120); + expect(useEditorStore.getState().duration).toBe(120); + }); + + test('setCurrentTime sets time without clamping', () => { + useEditorStore.getState().setDuration(60); + useEditorStore.getState().setCurrentTime(120); + expect(useEditorStore.getState().currentTime).toBe(120); + }); + + test('setCurrentTime accepts negative values', () => { + useEditorStore.getState().setCurrentTime(-10); + expect(useEditorStore.getState().currentTime).toBe(-10); + }); + + test('setIsPlaying toggles', () => { + useEditorStore.getState().setIsPlaying(true); + expect(useEditorStore.getState().isPlaying).toBe(true); + useEditorStore.getState().setIsPlaying(false); + expect(useEditorStore.getState().isPlaying).toBe(false); + }); + }); + + describe('loadVideo', () => { + test('loadVideo rejects empty path', () => { + useEditorStore.getState().loadVideo(''); + expect(useEditorStore.getState().videoUrl).toBeNull(); + }); + + test('loadVideo resets state', () => { + seedWords(5); + useEditorStore.getState().addCutRange(1, 2); + useEditorStore.getState().loadVideo('new-video.mp4'); + expect(useEditorStore.getState().words.length).toBe(0); + expect(useEditorStore.getState().cutRanges.length).toBe(0); + }); + }); + + describe('zone preview padding', () => { + test('sets padding value', () => { + useEditorStore.getState().setZonePreviewPaddingSeconds(3); + expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(3); + }); + + test('rejects NaN', () => { + useEditorStore.getState().setZonePreviewPaddingSeconds(2); + useEditorStore.getState().setZonePreviewPaddingSeconds(NaN); + expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(2); + }); + + test('clamps to upper bound', () => { + useEditorStore.getState().setZonePreviewPaddingSeconds(20); + expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(10); + }); }); }); diff --git a/frontend/src/store/licenseStore.test.ts b/frontend/src/store/licenseStore.test.ts new file mode 100644 index 0000000..37f4892 --- /dev/null +++ b/frontend/src/store/licenseStore.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useLicenseStore } from './licenseStore'; + + +function mockElectronAPI(overrides: Record = {}) { + (window as any).electronAPI = { + getAppStatus: vi.fn().mockResolvedValue({ tag: 'Expired' }), + activateLicense: vi.fn().mockResolvedValue(null), + deactivateLicense: vi.fn().mockResolvedValue(undefined), + hasLicenseFeature: vi.fn().mockResolvedValue(false), + ...overrides, + }; +} + +describe('licenseStore', () => { + beforeEach(() => { + mockElectronAPI(); + useLicenseStore.setState({ status: null, isLoaded: false, canEdit: true, showDialog: false }); + }); + + describe('canEdit', () => { + test('is true for Licensed status', async () => { + mockElectronAPI({ + getAppStatus: vi.fn().mockResolvedValue({ tag: 'Licensed', license: { license_id: 'x', tier: 'pro' } }), + }); + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().canEdit).toBe(true); + }); + + test('is true for Trial status', async () => { + mockElectronAPI({ + getAppStatus: vi.fn().mockResolvedValue({ tag: 'Trial', days_remaining: 20, started_at: Date.now() }), + }); + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().canEdit).toBe(true); + }); + + test('is false for Expired status', async () => { + mockElectronAPI({ + getAppStatus: vi.fn().mockResolvedValue({ tag: 'Expired' }), + }); + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().canEdit).toBe(false); + }); + + test('is false when status is null', () => { + useLicenseStore.setState({ status: null, canEdit: true }); + useLicenseStore.getState().setStatus(null); + expect(useLicenseStore.getState().canEdit).toBe(false); + }); + }); + + describe('checkStatus', () => { + test('sets status to Licensed when backend returns Licensed', async () => { + const license = { license_id: 'l1', tier: 'pro', customer_email: 'a@b.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 1 }; + mockElectronAPI({ getAppStatus: vi.fn().mockResolvedValue({ tag: 'Licensed', license }) }); + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().status?.tag).toBe('Licensed'); + }); + + test('sets status to Trial when backend returns Trial', async () => { + mockElectronAPI({ getAppStatus: vi.fn().mockResolvedValue({ tag: 'Trial', days_remaining: 15, started_at: Date.now() }) }); + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().status?.tag).toBe('Trial'); + }); + + test('sets status to Expired when backend returns Expired', async () => { + mockElectronAPI({ getAppStatus: vi.fn().mockResolvedValue({ tag: 'Expired' }) }); + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().status?.tag).toBe('Expired'); + }); + + test('handles API error gracefully', async () => { + mockElectronAPI({ getAppStatus: vi.fn().mockRejectedValue(new Error('network error')) }); + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().status?.tag).toBe('Expired'); + expect(useLicenseStore.getState().canEdit).toBe(false); + }); + + test('handles missing electronAPI', async () => { + delete (window as any).electronAPI; + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().status?.tag).toBe('Expired'); + expect(useLicenseStore.getState().canEdit).toBe(false); + }); + + test('sets isLoaded to true after check', async () => { + await useLicenseStore.getState().checkStatus(); + expect(useLicenseStore.getState().isLoaded).toBe(true); + }); + }); + + describe('activateLicense', () => { + test('sets Licensed on valid key', async () => { + const license = { license_id: 'l2', tier: 'pro', customer_email: 'x@y.com', expires_at: 9999999999, features: ['bg_removal'], issued_at: 1, max_activations: 1 }; + mockElectronAPI({ activateLicense: vi.fn().mockResolvedValue(license) }); + const result = await useLicenseStore.getState().activateLicense('talkedit_v1_validKey'); + expect(result).toBe(true); + expect(useLicenseStore.getState().status?.tag).toBe('Licensed'); + expect(useLicenseStore.getState().canEdit).toBe(true); + }); + + test('returns false on invalid key', async () => { + mockElectronAPI({ activateLicense: vi.fn().mockResolvedValue(null) }); + const result = await useLicenseStore.getState().activateLicense('invalid-key'); + expect(result).toBe(false); + }); + + test('returns false on API error', async () => { + mockElectronAPI({ activateLicense: vi.fn().mockRejectedValue(new Error('bad key')) }); + const result = await useLicenseStore.getState().activateLicense('bad-key'); + expect(result).toBe(false); + }); + + test('closes dialog on success', async () => { + useLicenseStore.setState({ showDialog: true }); + const license = { license_id: 'l3', tier: 'business', customer_email: 'z@z.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 5 }; + mockElectronAPI({ activateLicense: vi.fn().mockResolvedValue(license) }); + await useLicenseStore.getState().activateLicense('talkedit_v1_key'); + expect(useLicenseStore.getState().showDialog).toBe(false); + }); + }); + + describe('deactivateLicense', () => { + test('sets Expired when trial is over', async () => { + mockElectronAPI({ + deactivateLicense: vi.fn().mockResolvedValue(undefined), + getAppStatus: vi.fn().mockResolvedValue({ tag: 'Expired' }), + }); + await useLicenseStore.getState().deactivateLicense(); + expect(useLicenseStore.getState().status?.tag).toBe('Expired'); + expect(useLicenseStore.getState().canEdit).toBe(false); + }); + + test('restores Trial when trial is still valid', async () => { + mockElectronAPI({ + deactivateLicense: vi.fn().mockResolvedValue(undefined), + getAppStatus: vi.fn().mockResolvedValue({ tag: 'Trial', days_remaining: 5, started_at: Date.now() }), + }); + await useLicenseStore.getState().deactivateLicense(); + expect(useLicenseStore.getState().status?.tag).toBe('Trial'); + expect(useLicenseStore.getState().canEdit).toBe(true); + }); + + test('handles API error', async () => { + mockElectronAPI({ deactivateLicense: vi.fn().mockRejectedValue(new Error('fail')) }); + useLicenseStore.setState({ status: { tag: 'Licensed', license: {} as any }, canEdit: true }); + await useLicenseStore.getState().deactivateLicense(); + expect(useLicenseStore.getState().status?.tag).toBe('Expired'); + expect(useLicenseStore.getState().canEdit).toBe(false); + }); + }); + + describe('hasFeature', () => { + test('returns true when feature exists', async () => { + mockElectronAPI({ hasLicenseFeature: vi.fn().mockResolvedValue(true) }); + const result = await useLicenseStore.getState().hasFeature('bg_removal'); + expect(result).toBe(true); + }); + + test('returns false when feature missing', async () => { + mockElectronAPI({ hasLicenseFeature: vi.fn().mockResolvedValue(false) }); + const result = await useLicenseStore.getState().hasFeature('nonexistent'); + expect(result).toBe(false); + }); + + test('returns false on API error', async () => { + mockElectronAPI({ hasLicenseFeature: vi.fn().mockRejectedValue(new Error('fail')) }); + const result = await useLicenseStore.getState().hasFeature('bg_removal'); + expect(result).toBe(false); + }); + }); + + describe('setShowDialog', () => { + test('toggles dialog', () => { + useLicenseStore.getState().setShowDialog(true); + expect(useLicenseStore.getState().showDialog).toBe(true); + useLicenseStore.getState().setShowDialog(false); + expect(useLicenseStore.getState().showDialog).toBe(false); + }); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..c4588ab --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + }, +}); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 6135b84..22a1a09 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -139,3 +139,52 @@ pub fn delete_model(path: &str) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_dir_size_empty() { + let size = dir_size(&PathBuf::from("/nonexistent/path/12345")); + assert_eq!(size, 0); + } + + #[test] + fn test_scan_whisper_models_empty() { + let models = scan_whisper_models(); + // In CI there won't be any whisper models + // Just verify it doesn't panic + assert!(models.len() >= 0); + } + + #[test] + fn test_scan_llm_models_empty() { + let models = scan_llm_models(&PathBuf::from("/nonexistent/app_data")); + assert!(models.is_empty()); + } + + #[test] + fn test_list_models_empty() { + let models = list_models(&PathBuf::from("/nonexistent/app_data")); + // No models should be found in a non-existent directory + let whisper_models = models.iter().filter(|m| m.kind == "whisper").count(); + let llm_models = models.iter().filter(|m| m.kind == "llm").count(); + assert_eq!(llm_models, 0); + // whisper models may or may not exist on dev machine + assert!(whisper_models >= 0); + } + + #[test] + fn test_delete_model_nonexistent() { + let result = delete_model("/nonexistent/model/path.gguf"); + assert!(result.is_err()); + } + + #[test] + fn test_delete_model_empty_path() { + let result = delete_model(""); + assert!(result.is_err()); + } +}