diff --git a/Cargo.lock b/Cargo.lock index 3bd9b42..0f55b55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "atk" version = "0.18.2" @@ -2355,6 +2361,7 @@ name = "ltk-manager" version = "0.4.0" dependencies = [ "anyhow", + "assert_matches", "byteorder", "camino", "chrono", @@ -2381,6 +2388,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-updater", + "tempfile", "thiserror 2.0.18", "tokio", "toml 0.8.23", diff --git a/package.json b/package.json index e896a15..6573379 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:check": "prettier --check .", - "check": "npm run typecheck && npm run lint && npm run format:check", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check": "npm run typecheck && npm run lint && npm run format:check && npm run test", "prepare": "husky", "generate:types": "cd src-tauri && cargo test export_bindings --no-fail-fast" }, @@ -59,10 +62,14 @@ "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.139.11", "@tauri-apps/cli": "^2.10.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.0", @@ -70,13 +77,15 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^16.0.0", "husky": "^9.0.0", + "jsdom": "^28.1.0", "lint-staged": "^15.0.0", "prettier": "^3.7.2", "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^4.0.0", "typescript": "^5.6.0", "typescript-eslint": "^8.0.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.0.18" }, "packageManager": "pnpm@9.14.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9213df3..67f1db6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,15 @@ importers: '@tauri-apps/cli': specifier: ^2.10.0 version: 2.10.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^22.0.0 version: 22.19.1 @@ -102,6 +111,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.0 version: 4.7.0(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@22.19.1)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) eslint: specifier: ^9.0.0 version: 9.39.1(jiti@2.6.1) @@ -123,6 +135,9 @@ importers: husky: specifier: ^9.0.0 version: 9.1.7 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 lint-staged: specifier: ^15.0.0 version: 15.5.2 @@ -144,9 +159,28 @@ importers: vite: specifier: ^6.0.0 version: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.1)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -338,6 +372,45 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.28': + resolution: {integrity: sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -554,6 +627,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.14.1': + resolution: {integrity: sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==} + 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 + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -732,6 +814,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.1.17': resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} @@ -1008,6 +1093,35 @@ packages: '@tauri-apps/plugin-updater@2.10.0': resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + 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 + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@trivago/prettier-plugin-sort-imports@6.0.0': resolution: {integrity: sha512-Xarx55ow0R8oC7ViL5fPmDsg1EBa1dVhyZFVbFXNtPPJyW2w9bJADIla8YFSaNG9N06XfcklA9O9vmw4noNxkQ==} engines: {node: '>= 20'} @@ -1027,6 +1141,9 @@ packages: svelte: optional: true + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1039,6 +1156,12 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1121,6 +1244,44 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1131,6 +1292,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1138,6 +1303,10 @@ packages: resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} engines: {node: '>=18'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -1146,6 +1315,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1161,6 +1334,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1189,10 +1369,17 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1211,6 +1398,9 @@ packages: resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1249,6 +1439,10 @@ packages: caniuse-lite@1.0.30001757: resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1296,9 +1490,24 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@6.1.0: + resolution: {integrity: sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -1323,6 +1532,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1334,6 +1546,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1346,6 +1562,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1360,6 +1582,10 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1380,6 +1606,9 @@ packages: resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1475,6 +1704,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1486,6 +1718,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1630,6 +1866,21 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -1655,6 +1906,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1735,6 +1990,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1785,6 +2043,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -1796,6 +2066,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1803,6 +2076,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1937,16 +2219,34 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1962,6 +2262,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2019,6 +2323,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -2053,6 +2360,9 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2179,6 +2489,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2199,6 +2513,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2215,6 +2532,10 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2223,6 +2544,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -2261,6 +2586,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2324,6 +2653,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2348,6 +2680,12 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2387,6 +2725,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2399,6 +2741,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.3.0: resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} @@ -2418,14 +2763,40 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -2482,6 +2853,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -2546,9 +2921,59 @@ packages: yaml: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2570,6 +2995,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2578,6 +3008,13 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2613,6 +3050,28 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2624,7 +3083,6 @@ snapshots: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - optional: true '@babel/compat-data@7.28.5': {} @@ -2755,7 +3213,6 @@ snapshots: '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 - optional: true '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: @@ -2856,7 +3313,6 @@ snapshots: dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - optional: true '@base-ui-components/react@1.0.0-rc.0(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: @@ -2883,6 +3339,34 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.1.0 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.28': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': dependencies: react: 19.2.0 @@ -3032,6 +3516,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.14.1': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -3159,6 +3645,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.1.17': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3413,6 +3901,40 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@trivago/prettier-plugin-sort-imports@6.0.0(prettier@3.7.2)': dependencies: '@babel/generator': 7.29.1 @@ -3428,6 +3950,8 @@ snapshots: - supports-color optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -3449,6 +3973,13 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -3569,12 +4100,67 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.19.1)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.12 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.1)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3586,12 +4172,16 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@4.2.0: {} @@ -3603,6 +4193,12 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -3660,10 +4256,18 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -3683,6 +4287,10 @@ snapshots: baseline-browser-mapping@2.8.32: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} brace-expansion@1.1.12: @@ -3727,6 +4335,8 @@ snapshots: caniuse-lite@1.0.30001757: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3777,8 +4387,29 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssstyle@6.1.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.28 + css-tree: 3.1.0 + lru-cache: 11.2.6 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -3803,6 +4434,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -3817,6 +4450,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: {} diff@8.0.2: {} @@ -3825,6 +4460,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3840,6 +4479,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@6.0.1: {} + environment@1.1.0: {} es-abstract@1.24.0: @@ -3922,6 +4563,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4078,6 +4721,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@5.0.1: {} @@ -4094,6 +4741,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -4225,6 +4874,28 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.14.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} husky@9.1.7: {} @@ -4240,6 +4911,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -4325,6 +4998,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -4372,6 +5047,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -4386,12 +5074,41 @@ snapshots: jiti@2.6.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.14.1 + cssstyle: 6.1.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.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 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -4516,16 +5233,32 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.2.6: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + math-intrinsics@1.1.0: {} + mdn-data@2.12.2: {} + merge-stream@2.0.0: {} micromatch@4.0.8: @@ -4537,6 +5270,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -4595,6 +5330,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -4638,6 +5375,10 @@ snapshots: parse-statements@1.0.11: optional: true + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -4692,6 +5433,12 @@ snapshots: prettier@3.8.1: optional: true + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4711,6 +5458,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@19.2.0: {} @@ -4727,6 +5476,11 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -4747,6 +5501,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} @@ -4813,14 +5569,17 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} semver@7.7.3: {} - semver@7.7.4: - optional: true + semver@7.7.4: {} seroval-plugins@1.4.0(seroval@1.4.0): dependencies: @@ -4884,6 +5643,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} slice-ansi@5.0.0: @@ -4902,6 +5663,10 @@ snapshots: source-map@0.7.6: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4965,6 +5730,10 @@ snapshots: strip-final-newline@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -4973,6 +5742,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tabbable@6.3.0: {} tailwind-merge@3.4.0: {} @@ -4985,15 +5756,35 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.1.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5068,6 +5859,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.22.0: {} + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -5110,8 +5903,62 @@ snapshots: tsx: 4.20.6 yaml: 2.8.2 + vitest@4.0.18(@types/node@22.19.1)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.1 + jsdom: 28.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.14.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -5157,6 +6004,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@9.0.2: @@ -5165,6 +6017,10 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml@2.8.2: {} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d011ba3..3fe9a32 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -59,6 +59,10 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" +[dev-dependencies] +tempfile = "3" +assert_matches = "1.5" + [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index df8ae5e..ec7c0a9 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -291,3 +291,161 @@ impl MutexResultExt for Result> { self.map_err(|_| AppError::MutexLockFailed) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_code_serializes_as_screaming_snake_case() { + assert_eq!(serde_json::to_string(&ErrorCode::Io).unwrap(), "\"IO\""); + assert_eq!( + serde_json::to_string(&ErrorCode::LeagueNotFound).unwrap(), + "\"LEAGUE_NOT_FOUND\"" + ); + assert_eq!( + serde_json::to_string(&ErrorCode::ModNotFound).unwrap(), + "\"MOD_NOT_FOUND\"" + ); + assert_eq!( + serde_json::to_string(&ErrorCode::InvalidPath).unwrap(), + "\"INVALID_PATH\"" + ); + assert_eq!( + serde_json::to_string(&ErrorCode::WorkshopNotConfigured).unwrap(), + "\"WORKSHOP_NOT_CONFIGURED\"" + ); + assert_eq!( + serde_json::to_string(&ErrorCode::ProjectAlreadyExists).unwrap(), + "\"PROJECT_ALREADY_EXISTS\"" + ); + assert_eq!( + serde_json::to_string(&ErrorCode::PatcherRunning).unwrap(), + "\"PATCHER_RUNNING\"" + ); + } + + #[test] + fn error_code_round_trips() { + for code in [ + ErrorCode::Io, + ErrorCode::Serialization, + ErrorCode::Modpkg, + ErrorCode::LeagueNotFound, + ErrorCode::InvalidPath, + ErrorCode::ModNotFound, + ErrorCode::ValidationFailed, + ErrorCode::InternalState, + ErrorCode::MutexLockFailed, + ErrorCode::Unknown, + ErrorCode::WorkshopNotConfigured, + ErrorCode::ProjectNotFound, + ErrorCode::ProjectAlreadyExists, + ErrorCode::PackFailed, + ErrorCode::Fantome, + ErrorCode::Wad, + ErrorCode::PatcherRunning, + ErrorCode::Zip, + ] { + let json = serde_json::to_string(&code).unwrap(); + let deserialized: ErrorCode = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, code); + } + } + + #[test] + fn app_error_response_new() { + let resp = AppErrorResponse::new(ErrorCode::Io, "disk full"); + assert_eq!(resp.code, ErrorCode::Io); + assert_eq!(resp.message, "disk full"); + assert!(resp.context.is_none()); + } + + #[test] + fn app_error_response_with_context() { + let resp = AppErrorResponse::new(ErrorCode::InvalidPath, "bad path") + .with_context(serde_json::json!({ "path": "/foo" })); + assert_eq!(resp.context.unwrap()["path"], "/foo"); + } + + #[test] + fn app_error_to_response_invalid_path_preserves_context() { + let error = AppError::InvalidPath("/bad/path".to_string()); + let resp: AppErrorResponse = error.into(); + assert_eq!(resp.code, ErrorCode::InvalidPath); + assert_eq!(resp.context.unwrap()["path"], "/bad/path"); + } + + #[test] + fn app_error_to_response_mod_not_found_preserves_context() { + let error = AppError::ModNotFound("mod123".to_string()); + let resp: AppErrorResponse = error.into(); + assert_eq!(resp.code, ErrorCode::ModNotFound); + assert_eq!(resp.context.unwrap()["modId"], "mod123"); + } + + #[test] + fn app_error_to_response_project_not_found_preserves_context() { + let error = AppError::ProjectNotFound("my-project".to_string()); + let resp: AppErrorResponse = error.into(); + assert_eq!(resp.code, ErrorCode::ProjectNotFound); + assert_eq!(resp.context.unwrap()["projectName"], "my-project"); + } + + #[test] + fn app_error_to_response_patcher_running() { + let error = AppError::PatcherRunning; + let resp: AppErrorResponse = error.into(); + assert_eq!(resp.code, ErrorCode::PatcherRunning); + assert!(resp.context.is_none()); + } + + #[test] + fn ipc_result_ok_serialization() { + let result: IpcResult = IpcResult::ok("hello".to_string()); + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["value"], "hello"); + } + + #[test] + fn ipc_result_err_serialization() { + let resp = AppErrorResponse::new(ErrorCode::Io, "disk full"); + let result: IpcResult = IpcResult::err(resp); + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["ok"], false); + assert_eq!(json["error"]["code"], "IO"); + assert_eq!(json["error"]["message"], "disk full"); + } + + #[test] + fn ipc_result_from_ok() { + let result: IpcResult = Ok::(42).into(); + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["value"], 42); + } + + #[test] + fn ipc_result_from_err() { + let err = AppErrorResponse::new(ErrorCode::Unknown, "oops"); + let result: IpcResult = Err::(err).into(); + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["ok"], false); + assert_eq!(json["error"]["code"], "UNKNOWN"); + } + + #[test] + fn mutex_result_ext_ok() { + let mutex = std::sync::Mutex::new(42); + let guard = mutex.lock().mutex_err().unwrap(); + assert_eq!(*guard, 42); + } + + #[test] + fn app_error_response_context_skipped_when_none() { + let resp = AppErrorResponse::new(ErrorCode::Io, "err"); + let json = serde_json::to_value(&resp).unwrap(); + assert!(json.get("context").is_none()); + } +} diff --git a/src-tauri/src/mods/library.rs b/src-tauri/src/mods/library.rs index 4d64c6e..fb32390 100644 --- a/src-tauri/src/mods/library.rs +++ b/src-tauri/src/mods/library.rs @@ -711,3 +711,345 @@ fn extract_modpkg_thumbnail( Err(_) => Ok(None), } } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use std::collections::HashMap; + use std::io::Write; + + fn make_test_mod_config_json() -> String { + serde_json::to_string_pretty(<k_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test Mod".to_string(), + version: "1.0.0".to_string(), + description: "A test mod".to_string(), + authors: vec![ltk_mod_project::ModProjectAuthor::Name( + "Author".to_string(), + )], + license: None, + tags: Vec::new(), + champions: vec!["Aatrox".to_string()], + maps: Vec::new(), + transformers: Vec::new(), + layers: ltk_mod_project::default_layers(), + thumbnail: None, + }) + .unwrap() + } + + fn make_test_fantome_zip(dir: &Path, include_thumbnail: bool, include_readme: bool) -> PathBuf { + let info = ltk_fantome::FantomeInfo { + name: "Test Mod".to_string(), + author: "Author".to_string(), + version: "1.0.0".to_string(), + description: "Description".to_string(), + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + layers: HashMap::new(), + }; + + let zip_path = dir.join("test.fantome"); + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + + zip.start_file("META/info.json", options).unwrap(); + zip.write_all(serde_json::to_string_pretty(&info).unwrap().as_bytes()) + .unwrap(); + + if include_thumbnail { + zip.start_file("META/image.png", options).unwrap(); + zip.write_all(b"fake png data").unwrap(); + } + + if include_readme { + zip.start_file("META/readme.md", options).unwrap(); + zip.write_all(b"# Test Mod\nReadme content").unwrap(); + } + + zip.finish().unwrap(); + zip_path + } + + #[test] + fn load_mod_project_valid_json() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("mod.config.json"), + make_test_mod_config_json(), + ) + .unwrap(); + let project = load_mod_project(dir.path()).unwrap(); + assert_eq!(project.name, "test-mod"); + assert_eq!(project.version, "1.0.0"); + assert_eq!(project.display_name, "Test Mod"); + } + + #[test] + fn load_mod_project_invalid_json() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("mod.config.json"), "not valid json").unwrap(); + assert!(load_mod_project(dir.path()).is_err()); + } + + #[test] + fn load_mod_project_missing_file() { + let dir = tempfile::tempdir().unwrap(); + assert!(load_mod_project(dir.path()).is_err()); + } + + #[test] + fn read_installed_mod_populates_all_fields() { + let storage = tempfile::tempdir().unwrap(); + let id = "test-id"; + let mods_dir = storage.path().join("mods").join(id); + fs::create_dir_all(&mods_dir).unwrap(); + fs::write( + mods_dir.join("mod.config.json"), + make_test_mod_config_json(), + ) + .unwrap(); + + let entry = LibraryModEntry { + id: id.to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Fantome, + }; + + let result = read_installed_mod(&entry, true, storage.path()).unwrap(); + assert_eq!(result.id, id); + assert_eq!(result.name, "test-mod"); + assert_eq!(result.display_name, "Test Mod"); + assert_eq!(result.version, "1.0.0"); + assert_eq!(result.description.as_deref(), Some("A test mod")); + assert_eq!(result.authors, vec!["Author"]); + assert!(result.enabled); + assert!(!result.layers.is_empty()); + assert_eq!(result.champions, vec!["Aatrox"]); + } + + #[test] + fn read_installed_mod_empty_description_becomes_none() { + let storage = tempfile::tempdir().unwrap(); + let id = "test-id-2"; + let mods_dir = storage.path().join("mods").join(id); + fs::create_dir_all(&mods_dir).unwrap(); + + let config = serde_json::to_string_pretty(<k_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test Mod".to_string(), + version: "1.0.0".to_string(), + description: "".to_string(), + authors: Vec::new(), + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: ltk_mod_project::default_layers(), + thumbnail: None, + }) + .unwrap(); + fs::write(mods_dir.join("mod.config.json"), config).unwrap(); + + let entry = LibraryModEntry { + id: id.to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Fantome, + }; + + let result = read_installed_mod(&entry, false, storage.path()).unwrap(); + assert!(result.description.is_none()); + assert!(!result.enabled); + } + + #[test] + fn read_installed_mod_missing_config_returns_error() { + let storage = tempfile::tempdir().unwrap(); + let entry = LibraryModEntry { + id: "nonexistent".to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Fantome, + }; + assert!(read_installed_mod(&entry, true, storage.path()).is_err()); + } + + #[test] + fn extract_fantome_thumbnail_with_image() { + let dir = tempfile::tempdir().unwrap(); + let archive_path = make_test_fantome_zip(dir.path(), true, false); + let metadata_dir = dir.path().join("metadata"); + fs::create_dir_all(&metadata_dir).unwrap(); + + let result = extract_fantome_thumbnail(&archive_path, &metadata_dir).unwrap(); + assert!(result.is_some()); + assert!(result.unwrap().exists()); + } + + #[test] + fn extract_fantome_thumbnail_without_image() { + let dir = tempfile::tempdir().unwrap(); + let archive_path = make_test_fantome_zip(dir.path(), false, false); + let metadata_dir = dir.path().join("metadata"); + fs::create_dir_all(&metadata_dir).unwrap(); + + let result = extract_fantome_thumbnail(&archive_path, &metadata_dir).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn extract_fantome_metadata_valid() { + let dir = tempfile::tempdir().unwrap(); + let archive_path = make_test_fantome_zip(dir.path(), false, false); + let metadata_dir = dir.path().join("metadata"); + fs::create_dir_all(&metadata_dir).unwrap(); + + extract_fantome_metadata(&archive_path, &metadata_dir).unwrap(); + + let config_path = metadata_dir.join("mod.config.json"); + assert!(config_path.exists()); + let project = load_mod_project(&metadata_dir).unwrap(); + assert_eq!(project.display_name, "Test Mod"); + } + + #[test] + fn extract_fantome_metadata_missing_info_json() { + let dir = tempfile::tempdir().unwrap(); + + let zip_path = dir.path().join("empty.fantome"); + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + zip.start_file("WAD/test.wad.client/file.bin", options) + .unwrap(); + zip.write_all(b"data").unwrap(); + zip.finish().unwrap(); + + let metadata_dir = dir.path().join("metadata"); + fs::create_dir_all(&metadata_dir).unwrap(); + + assert!(extract_fantome_metadata(&zip_path, &metadata_dir).is_err()); + } + + #[test] + fn extract_fantome_metadata_with_bom() { + let dir = tempfile::tempdir().unwrap(); + + let info_json = format!( + "\u{feff}{}", + serde_json::to_string(<k_fantome::FantomeInfo { + name: "BOM Mod".to_string(), + author: "Author".to_string(), + version: "2.0.0".to_string(), + description: "Has BOM".to_string(), + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + layers: HashMap::new(), + }) + .unwrap() + ); + + let zip_path = dir.path().join("bom.fantome"); + let file = std::fs::File::create(&zip_path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + zip.start_file("META/info.json", options).unwrap(); + zip.write_all(info_json.as_bytes()).unwrap(); + zip.finish().unwrap(); + + let metadata_dir = dir.path().join("metadata"); + fs::create_dir_all(&metadata_dir).unwrap(); + + extract_fantome_metadata(&zip_path, &metadata_dir).unwrap(); + let project = load_mod_project(&metadata_dir).unwrap(); + assert_eq!(project.display_name, "BOM Mod"); + } + + #[test] + fn extract_fantome_metadata_extracts_readme() { + let dir = tempfile::tempdir().unwrap(); + let archive_path = make_test_fantome_zip(dir.path(), false, true); + let metadata_dir = dir.path().join("metadata"); + fs::create_dir_all(&metadata_dir).unwrap(); + + extract_fantome_metadata(&archive_path, &metadata_dir).unwrap(); + let readme = metadata_dir.join("README.md"); + assert!(readme.exists()); + let contents = fs::read_to_string(readme).unwrap(); + assert!(contents.contains("Test Mod")); + } + + #[test] + fn install_single_mod_to_index_missing_file() { + let storage = tempfile::tempdir().unwrap(); + let mut index = LibraryIndex::default(); + let result = + install_single_mod_to_index(storage.path(), &mut index, "/nonexistent/file.fantome"); + assert_matches!(result, Err(AppError::InvalidPath(_))); + } + + #[test] + fn install_single_mod_to_index_adds_to_index() { + let storage = tempfile::tempdir().unwrap(); + let source = tempfile::tempdir().unwrap(); + let archive_path = make_test_fantome_zip(source.path(), false, false); + let mut index = LibraryIndex::default(); + assert!(index.mods.is_empty()); + + let (_entry, installed) = + install_single_mod_to_index(storage.path(), &mut index, archive_path.to_str().unwrap()) + .unwrap(); + + assert_eq!(index.mods.len(), 1); + assert_eq!(installed.display_name, "Test Mod"); + assert!(installed.enabled); + + let profile = index + .profiles + .iter() + .find(|p| p.id == index.active_profile_id) + .unwrap(); + assert_eq!(profile.mod_order[0], index.mods[0].id); + assert_eq!(profile.enabled_mods[0], index.mods[0].id); + } + + #[test] + fn install_single_mod_format_detection_fantome() { + let storage = tempfile::tempdir().unwrap(); + let source = tempfile::tempdir().unwrap(); + let archive_path = make_test_fantome_zip(source.path(), false, false); + let mut index = LibraryIndex::default(); + + let (entry, _) = + install_single_mod_to_index(storage.path(), &mut index, archive_path.to_str().unwrap()) + .unwrap(); + + assert_eq!(entry.format, ModArchiveFormat::Fantome); + } + + #[test] + fn mod_archive_format_extension() { + assert_eq!(ModArchiveFormat::Fantome.extension(), "fantome"); + assert_eq!(ModArchiveFormat::Modpkg.extension(), "modpkg"); + } + + #[test] + fn library_mod_entry_paths() { + let storage_dir = Path::new("/storage"); + let entry = LibraryModEntry { + id: "abc-123".to_string(), + installed_at: Utc::now(), + format: ModArchiveFormat::Fantome, + }; + + let metadata_dir = entry.metadata_dir(storage_dir); + assert!(metadata_dir.ends_with("mods/abc-123")); + + let archive_path = entry.archive_path(storage_dir); + assert!(archive_path.ends_with("archives/abc-123.fantome")); + } +} diff --git a/src-tauri/src/mods/migration.rs b/src-tauri/src/mods/migration.rs index 1011b92..46a2c06 100644 --- a/src-tauri/src/mods/migration.rs +++ b/src-tauri/src/mods/migration.rs @@ -185,3 +185,167 @@ fn read_cslol_info(path: &Path) -> AppResult { serde_json::from_str(content).map_err(AppError::Serialization) } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use std::collections::HashMap; + + fn make_info_json_str(name: &str, author: &str) -> String { + serde_json::to_string(<k_fantome::FantomeInfo { + name: name.to_string(), + author: author.to_string(), + version: "1.0.0".to_string(), + description: "Desc".to_string(), + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + layers: HashMap::new(), + }) + .unwrap() + } + + #[test] + fn read_cslol_info_valid_json() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("info.json"); + fs::write(&path, make_info_json_str("Test", "Author")).unwrap(); + let info = read_cslol_info(&path).unwrap(); + assert_eq!(info.name, "Test"); + assert_eq!(info.author, "Author"); + } + + #[test] + fn read_cslol_info_bom_prefixed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("info.json"); + let content = format!("\u{feff}{}", make_info_json_str("BOM Test", "Author")); + fs::write(&path, content).unwrap(); + let info = read_cslol_info(&path).unwrap(); + assert_eq!(info.name, "BOM Test"); + } + + #[test] + fn read_cslol_info_invalid_json() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("info.json"); + fs::write(&path, "not json").unwrap(); + assert_matches!(read_cslol_info(&path), Err(AppError::Serialization(_))); + } + + #[test] + fn read_cslol_info_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nonexistent.json"); + assert_matches!(read_cslol_info(&path), Err(AppError::Io(_))); + } + + #[test] + fn scan_cslol_directory_missing_installed_dir() { + let dir = tempfile::tempdir().unwrap(); + assert_matches!( + scan_cslol_directory(dir.path()), + Err(AppError::InvalidPath(_)) + ); + } + + #[test] + fn scan_cslol_directory_empty_installed() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("installed")).unwrap(); + let mods = scan_cslol_directory(dir.path()).unwrap(); + assert!(mods.is_empty()); + } + + #[test] + fn scan_cslol_directory_valid_mods_sorted() { + let dir = tempfile::tempdir().unwrap(); + let installed = dir.path().join("installed"); + + for (folder, name) in [("mod-b", "Zeta Mod"), ("mod-a", "Alpha Mod")] { + let meta_dir = installed.join(folder).join("META"); + fs::create_dir_all(&meta_dir).unwrap(); + fs::write( + meta_dir.join("info.json"), + make_info_json_str(name, "Author"), + ) + .unwrap(); + } + + let mods = scan_cslol_directory(dir.path()).unwrap(); + assert_eq!(mods.len(), 2); + assert_eq!(mods[0].name, "Alpha Mod"); + assert_eq!(mods[1].name, "Zeta Mod"); + } + + #[test] + fn scan_cslol_directory_skips_without_info_json() { + let dir = tempfile::tempdir().unwrap(); + let installed = dir.path().join("installed"); + + let good = installed.join("good-mod").join("META"); + fs::create_dir_all(&good).unwrap(); + fs::write(good.join("info.json"), make_info_json_str("Good", "Author")).unwrap(); + + let bad = installed.join("bad-mod"); + fs::create_dir_all(&bad).unwrap(); + + let mods = scan_cslol_directory(dir.path()).unwrap(); + assert_eq!(mods.len(), 1); + assert_eq!(mods[0].name, "Good"); + } + + #[test] + fn create_fantome_zip_creates_valid_archive() { + let dir = tempfile::tempdir().unwrap(); + + let mod_dir = dir.path().join("mod-source"); + let meta_dir = mod_dir.join("META"); + let wad_dir = mod_dir.join("WAD").join("test.wad.client"); + fs::create_dir_all(&meta_dir).unwrap(); + fs::create_dir_all(&wad_dir).unwrap(); + fs::write( + meta_dir.join("info.json"), + make_info_json_str("Test", "Author"), + ) + .unwrap(); + fs::write(wad_dir.join("file.bin"), b"test data").unwrap(); + + let output = dir.path().join("output.fantome"); + create_fantome_zip(&mod_dir, &output).unwrap(); + + assert!(output.exists()); + let file = fs::File::open(&output).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + assert!(!archive.is_empty()); + } + + #[test] + fn add_dir_to_zip_uses_forward_slashes() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("source"); + let sub = source.join("sub").join("nested"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("file.txt"), b"content").unwrap(); + + let output = dir.path().join("test.zip"); + let file = fs::File::create(&output).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + + add_dir_to_zip(&mut zip, &source, &source, options).unwrap(); + zip.finish().unwrap(); + + let file = fs::File::open(&output).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + for i in 0..archive.len() { + let entry = archive.by_index(i).unwrap(); + assert!( + !entry.name().contains('\\'), + "Entry name '{}' contains backslash", + entry.name() + ); + } + } +} diff --git a/src-tauri/src/mods/mod.rs b/src-tauri/src/mods/mod.rs index a302d5a..c58a163 100644 --- a/src-tauri/src/mods/mod.rs +++ b/src-tauri/src/mods/mod.rs @@ -358,3 +358,199 @@ pub(super) fn resolve_profile_dirs( let cache_dir = profile_dir.join("cache"); (overlay_dir, cache_dir) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_slug_from_name_normal() { + let slug = ProfileSlug::from_name("My Profile").unwrap(); + assert_eq!(slug.as_str(), "my-profile"); + } + + #[test] + fn profile_slug_from_name_special_chars() { + let slug = ProfileSlug::from_name("Test & Profile #1").unwrap(); + assert!(!slug.as_str().is_empty()); + assert!(slug + .as_str() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + } + + #[test] + fn profile_slug_from_name_empty_returns_none() { + assert!(ProfileSlug::from_name("").is_none()); + } + + #[test] + fn profile_slug_from_name_whitespace_only_returns_none() { + assert!(ProfileSlug::from_name(" ").is_none()); + } + + #[test] + fn profile_slug_from_name_symbols_only_returns_none() { + assert!(ProfileSlug::from_name("!!!").is_none()); + } + + #[test] + fn profile_slug_is_unique_in_no_profiles() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: Vec::new(), + active_profile_id: String::new(), + }; + let slug = ProfileSlug("test".to_string()); + assert!(slug.is_unique_in(&index, None)); + } + + #[test] + fn profile_slug_is_unique_in_with_different_slugs() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: vec![Profile { + id: "p1".to_string(), + name: "Default".to_string(), + slug: ProfileSlug("default".to_string()), + enabled_mods: Vec::new(), + mod_order: Vec::new(), + created_at: Utc::now(), + last_used: Utc::now(), + }], + active_profile_id: "p1".to_string(), + }; + let slug = ProfileSlug("my-profile".to_string()); + assert!(slug.is_unique_in(&index, None)); + } + + #[test] + fn profile_slug_is_not_unique_when_duplicate() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: vec![Profile { + id: "p1".to_string(), + name: "Default".to_string(), + slug: ProfileSlug("default".to_string()), + enabled_mods: Vec::new(), + mod_order: Vec::new(), + created_at: Utc::now(), + last_used: Utc::now(), + }], + active_profile_id: "p1".to_string(), + }; + let slug = ProfileSlug("default".to_string()); + assert!(!slug.is_unique_in(&index, None)); + } + + #[test] + fn profile_slug_is_unique_when_excluded() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: vec![Profile { + id: "p1".to_string(), + name: "Default".to_string(), + slug: ProfileSlug("default".to_string()), + enabled_mods: Vec::new(), + mod_order: Vec::new(), + created_at: Utc::now(), + last_used: Utc::now(), + }], + active_profile_id: "p1".to_string(), + }; + let slug = ProfileSlug("default".to_string()); + assert!(slug.is_unique_in(&index, Some("p1"))); + } + + #[test] + fn library_index_default_has_one_profile() { + let index = LibraryIndex::default(); + assert_eq!(index.profiles.len(), 1); + assert_eq!(index.profiles[0].name, "Default"); + assert_eq!(index.profiles[0].slug.as_str(), "default"); + assert_eq!(index.active_profile_id, index.profiles[0].id); + assert!(index.mods.is_empty()); + } + + #[test] + fn library_index_save_and_load_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let index = LibraryIndex::default(); + save_library_index(dir.path(), &index).unwrap(); + let loaded = load_library_index(dir.path()).unwrap(); + assert_eq!(loaded.profiles.len(), 1); + assert_eq!(loaded.profiles[0].name, "Default"); + assert_eq!(loaded.active_profile_id, loaded.profiles[0].id); + } + + #[test] + fn load_library_index_returns_default_when_no_file() { + let dir = tempfile::tempdir().unwrap(); + let index = load_library_index(dir.path()).unwrap(); + assert_eq!(index.profiles.len(), 1); + assert_eq!(index.profiles[0].name, "Default"); + } + + #[test] + fn get_active_profile_finds_profile() { + let index = LibraryIndex::default(); + let profile = get_active_profile(&index).unwrap(); + assert_eq!(profile.name, "Default"); + } + + #[test] + fn get_active_profile_returns_error_when_missing() { + let index = LibraryIndex { + mods: Vec::new(), + profiles: Vec::new(), + active_profile_id: "nonexistent".to_string(), + }; + assert!(get_active_profile(&index).is_err()); + } + + #[test] + fn resolve_profile_dirs_produces_correct_paths() { + let storage_dir = Path::new("/storage"); + let slug = ProfileSlug("my-profile".to_string()); + let (overlay_dir, cache_dir) = resolve_profile_dirs(storage_dir, &slug); + assert!(overlay_dir.ends_with("profiles/my-profile/overlay")); + assert!(cache_dir.ends_with("profiles/my-profile/cache")); + } + + // ── Profile slug and lookup ── + + #[test] + fn create_profile_slug_generation() { + let slug = ProfileSlug::from_name("My Test Profile").unwrap(); + assert_eq!(slug.as_str(), "my-test-profile"); + } + + #[test] + fn create_profile_symbols_only_name_rejected() { + assert!(ProfileSlug::from_name("!!!").is_none()); + } + + #[test] + fn profile_slug_uniqueness_check() { + let mut index = LibraryIndex::default(); + index.profiles.push(Profile { + id: "p2".to_string(), + name: "My Profile".to_string(), + slug: ProfileSlug::from_name("My Profile").unwrap(), + enabled_mods: Vec::new(), + mod_order: Vec::new(), + created_at: Utc::now(), + last_used: Utc::now(), + }); + + let slug = ProfileSlug::from_name("My Profile").unwrap(); + assert!(!slug.is_unique_in(&index, None)); + assert!(slug.is_unique_in(&index, Some("p2"))); + } + + #[test] + fn get_profile_by_id_not_found() { + let index = LibraryIndex::default(); + assert!(get_profile_by_id(&index, "nonexistent-id").is_err()); + } +} diff --git a/src-tauri/src/overlay/fantome_content.rs b/src-tauri/src/overlay/fantome_content.rs index fa8147e..37bc9f1 100644 --- a/src-tauri/src/overlay/fantome_content.rs +++ b/src-tauri/src/overlay/fantome_content.rs @@ -221,3 +221,150 @@ fn is_wad_file_name(name: &str) -> bool { let lower = name.to_ascii_lowercase(); lower.ends_with(".wad.client") || lower.ends_with(".wad") || lower.ends_with(".wad.mobile") } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Cursor, Write}; + + fn make_fantome_zip(entries: &[(&str, &[u8])]) -> Cursor> { + let buffer = Vec::new(); + let cursor = Cursor::new(buffer); + let mut zip = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default(); + for (name, data) in entries { + zip.start_file(*name, options).unwrap(); + zip.write_all(data).unwrap(); + } + let mut cursor = zip.finish().unwrap(); + cursor.set_position(0); + cursor + } + + fn make_info_json(name: &str) -> Vec { + serde_json::to_vec(<k_fantome::FantomeInfo { + name: name.to_string(), + author: "Author".to_string(), + version: "1.0.0".to_string(), + description: "Desc".to_string(), + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + layers: std::collections::HashMap::new(), + }) + .unwrap() + } + + #[test] + fn new_with_valid_zip() { + let cursor = make_fantome_zip(&[("META/info.json", &make_info_json("Test"))]); + assert!(FantomeContent::new(cursor).is_ok()); + } + + #[test] + fn new_with_invalid_data() { + let cursor = Cursor::new(b"not a zip".to_vec()); + assert!(FantomeContent::new(cursor).is_err()); + } + + #[test] + fn mod_project_reads_info_json() { + let cursor = make_fantome_zip(&[("META/info.json", &make_info_json("My Mod"))]); + let mut content = FantomeContent::new(cursor).unwrap(); + let project = content.mod_project().unwrap(); + assert_eq!(project.display_name, "My Mod"); + assert_eq!(project.version, "1.0.0"); + } + + #[test] + fn mod_project_missing_info_json() { + let cursor = make_fantome_zip(&[("WAD/test.wad.client/file", b"data")]); + let mut content = FantomeContent::new(cursor).unwrap(); + assert!(content.mod_project().is_err()); + } + + #[test] + fn mod_project_handles_bom() { + let info_str = format!( + "\u{feff}{}", + serde_json::to_string(<k_fantome::FantomeInfo { + name: "BOM Mod".to_string(), + author: "Author".to_string(), + version: "1.0.0".to_string(), + description: "Desc".to_string(), + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + layers: std::collections::HashMap::new(), + }) + .unwrap() + ); + let cursor = make_fantome_zip(&[("META/info.json", info_str.as_bytes())]); + let mut content = FantomeContent::new(cursor).unwrap(); + let project = content.mod_project().unwrap(); + assert_eq!(project.display_name, "BOM Mod"); + } + + #[test] + fn list_layer_wads_finds_directory_wads() { + let cursor = make_fantome_zip(&[ + ("META/info.json", &make_info_json("Test")), + ("WAD/Aatrox.wad.client/file1", b"data1"), + ("WAD/Aatrox.wad.client/file2", b"data2"), + ]); + let mut content = FantomeContent::new(cursor).unwrap(); + let wads = content.list_layer_wads("base").unwrap(); + assert_eq!(wads.len(), 1); + assert!(wads.contains(&"Aatrox.wad.client".to_string())); + } + + #[test] + fn list_layer_wads_non_base_returns_empty() { + let cursor = make_fantome_zip(&[ + ("META/info.json", &make_info_json("Test")), + ("WAD/Aatrox.wad.client/file1", b"data1"), + ]); + let mut content = FantomeContent::new(cursor).unwrap(); + let wads = content.list_layer_wads("chroma").unwrap(); + assert!(wads.is_empty()); + } + + #[test] + fn read_wad_overrides_directory_style() { + let cursor = make_fantome_zip(&[ + ("META/info.json", &make_info_json("Test")), + ("WAD/Aatrox.wad.client/file1.bin", b"data1"), + ("WAD/Aatrox.wad.client/sub/file2.bin", b"data2"), + ]); + let mut content = FantomeContent::new(cursor).unwrap(); + let overrides = content + .read_wad_overrides("base", "Aatrox.wad.client") + .unwrap(); + assert_eq!(overrides.len(), 2); + let paths: Vec<&str> = overrides.iter().map(|(p, _)| p.as_str()).collect(); + assert!(paths.contains(&"file1.bin")); + assert!(paths.contains(&"sub/file2.bin")); + } + + #[test] + fn read_wad_overrides_non_base_returns_empty() { + let cursor = make_fantome_zip(&[ + ("META/info.json", &make_info_json("Test")), + ("WAD/Aatrox.wad.client/file1.bin", b"data1"), + ]); + let mut content = FantomeContent::new(cursor).unwrap(); + let overrides = content + .read_wad_overrides("chroma", "Aatrox.wad.client") + .unwrap(); + assert!(overrides.is_empty()); + } + + #[test] + fn is_wad_file_name_variants() { + assert!(is_wad_file_name("test.wad.client")); + assert!(is_wad_file_name("test.wad")); + assert!(is_wad_file_name("test.wad.mobile")); + assert!(!is_wad_file_name("test.txt")); + assert!(!is_wad_file_name("")); + } +} diff --git a/src-tauri/src/overlay/mod.rs b/src-tauri/src/overlay/mod.rs index f99fe40..c94b526 100644 --- a/src-tauri/src/overlay/mod.rs +++ b/src-tauri/src/overlay/mod.rs @@ -155,3 +155,96 @@ fn resolve_game_dir(settings: &Settings) -> AppResult { league_root.display() ))) } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + #[test] + fn resolve_game_dir_no_league_path() { + let settings = Settings::default(); + assert_matches!( + resolve_game_dir(&settings), + Err(AppError::ValidationFailed(_)) + ); + } + + #[test] + fn resolve_game_dir_with_game_subdir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("Game")).unwrap(); + + let settings = Settings { + league_path: Some(dir.path().to_path_buf()), + ..Settings::default() + }; + let result = resolve_game_dir(&settings).unwrap(); + assert!(result.ends_with("Game")); + } + + #[test] + fn resolve_game_dir_with_data_dir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("DATA")).unwrap(); + + let settings = Settings { + league_path: Some(dir.path().to_path_buf()), + ..Settings::default() + }; + let result = resolve_game_dir(&settings).unwrap(); + assert_eq!(result, dir.path().to_path_buf()); + } + + #[test] + fn resolve_game_dir_neither_dir() { + let dir = tempfile::tempdir().unwrap(); + let settings = Settings { + league_path: Some(dir.path().to_path_buf()), + ..Settings::default() + }; + assert_matches!( + resolve_game_dir(&settings), + Err(AppError::ValidationFailed(_)) + ); + } + + #[test] + fn overlay_stage_serialization() { + assert_eq!( + serde_json::to_string(&OverlayStage::Indexing).unwrap(), + "\"indexing\"" + ); + assert_eq!( + serde_json::to_string(&OverlayStage::Collecting).unwrap(), + "\"collecting\"" + ); + assert_eq!( + serde_json::to_string(&OverlayStage::Patching).unwrap(), + "\"patching\"" + ); + assert_eq!( + serde_json::to_string(&OverlayStage::Strings).unwrap(), + "\"strings\"" + ); + assert_eq!( + serde_json::to_string(&OverlayStage::Complete).unwrap(), + "\"complete\"" + ); + } + + #[test] + fn overlay_progress_serialization() { + let progress = OverlayProgress { + stage: OverlayStage::Patching, + current_file: Some("test.wad.client".to_string()), + current: 5, + total: 10, + }; + let json = serde_json::to_value(&progress).unwrap(); + assert_eq!(json["stage"], "patching"); + assert_eq!(json["currentFile"], "test.wad.client"); + assert_eq!(json["current"], 5); + assert_eq!(json["total"], 10); + } +} diff --git a/src-tauri/src/patcher/mod.rs b/src-tauri/src/patcher/mod.rs index f536abd..f52992e 100644 --- a/src-tauri/src/patcher/mod.rs +++ b/src-tauri/src/patcher/mod.rs @@ -66,3 +66,46 @@ impl Default for PatcherStateInner { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn patcher_state_inner_defaults_to_idle() { + let inner = PatcherStateInner::new(); + assert_eq!(inner.phase, PatcherPhase::Idle); + assert!(inner.thread_handle.is_none()); + assert!(inner.config_path.is_none()); + } + + #[test] + fn is_running_false_when_no_thread() { + let inner = PatcherStateInner::new(); + assert!(!inner.is_running()); + } + + #[test] + fn patcher_phase_serialization() { + assert_eq!( + serde_json::to_string(&PatcherPhase::Idle).unwrap(), + "\"idle\"" + ); + assert_eq!( + serde_json::to_string(&PatcherPhase::Building).unwrap(), + "\"building\"" + ); + assert_eq!( + serde_json::to_string(&PatcherPhase::Patching).unwrap(), + "\"patching\"" + ); + } + + #[test] + fn patcher_state_new_creates_valid_state() { + let state = PatcherState::new(); + let inner = state.0.lock().unwrap(); + assert!(!inner.is_running()); + assert_eq!(inner.phase, PatcherPhase::Idle); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b9bdf76..3424737 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -137,3 +137,78 @@ pub struct Settings { #[serde(default)] pub migration_dismissed: bool, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_default_values() { + let settings = Settings::default(); + assert!(settings.league_path.is_none()); + assert!(settings.mod_storage_path.is_none()); + assert!(settings.workshop_path.is_none()); + assert!(!settings.first_run_complete); + assert_eq!(settings.theme, Theme::System); + assert!(!settings.patch_tft); + assert!(!settings.migration_dismissed); + } + + #[test] + fn settings_json_round_trip() { + let settings = Settings { + league_path: Some(PathBuf::from("/game")), + mod_storage_path: Some(PathBuf::from("/mods")), + workshop_path: None, + first_run_complete: true, + theme: Theme::Dark, + accent_color: AccentColor { + preset: Some("purple".to_string()), + custom_hue: None, + }, + backdrop_image: None, + backdrop_blur: Some(40), + library_view_mode: Some("list".to_string()), + patch_tft: true, + migration_dismissed: false, + }; + let json = serde_json::to_string(&settings).unwrap(); + let deserialized: Settings = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.league_path.unwrap(), PathBuf::from("/game")); + assert!(deserialized.first_run_complete); + assert_eq!(deserialized.theme, Theme::Dark); + assert!(deserialized.patch_tft); + } + + #[test] + fn theme_serialization() { + assert_eq!(serde_json::to_string(&Theme::System).unwrap(), "\"system\""); + assert_eq!(serde_json::to_string(&Theme::Dark).unwrap(), "\"dark\""); + assert_eq!(serde_json::to_string(&Theme::Light).unwrap(), "\"light\""); + } + + #[test] + fn theme_deserialization() { + assert_eq!( + serde_json::from_str::("\"system\"").unwrap(), + Theme::System + ); + assert_eq!( + serde_json::from_str::("\"dark\"").unwrap(), + Theme::Dark + ); + assert_eq!( + serde_json::from_str::("\"light\"").unwrap(), + Theme::Light + ); + } + + #[test] + fn settings_deserializes_with_missing_optional_fields() { + let json = r#"{"firstRunComplete": false, "theme": "system", "accentColor": {}, "patchTft": false, "migrationDismissed": false}"#; + let settings: Settings = serde_json::from_str(json).unwrap(); + assert!(settings.league_path.is_none()); + assert!(settings.mod_storage_path.is_none()); + assert!(!settings.first_run_complete); + } +} diff --git a/src-tauri/src/workshop/layers.rs b/src-tauri/src/workshop/layers.rs index 4bb7ad9..c5995fe 100644 --- a/src-tauri/src/workshop/layers.rs +++ b/src-tauri/src/workshop/layers.rs @@ -6,7 +6,205 @@ use crate::error::{AppError, AppResult}; use ltk_mod_project::ModProjectLayer; use std::collections::HashMap; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +/// Create a new layer in a project at the given path. +pub(crate) fn create_layer_at_path( + path: &Path, + name: &str, + description: Option, +) -> AppResult { + let name = name.trim().to_string(); + + if !is_valid_project_name(&name) { + return Err(AppError::ValidationFailed( + "Layer name must be lowercase alphanumeric with hyphens only".to_string(), + )); + } + + let config_path = find_config_file(path) + .ok_or_else(|| AppError::ProjectNotFound(path.display().to_string()))?; + let mut mod_project = load_mod_project(&config_path)?; + + if mod_project.layers.iter().any(|l| l.name == name) { + return Err(AppError::ValidationFailed(format!( + "A layer named '{}' already exists", + name + ))); + } + + let max_priority = mod_project + .layers + .iter() + .map(|l| l.priority) + .max() + .unwrap_or(-1); + + mod_project.layers.push(ModProjectLayer { + name: name.clone(), + priority: max_priority + 1, + description, + string_overrides: HashMap::new(), + }); + + let json_config_path = path.join("mod.config.json"); + let config_content = serde_json::to_string_pretty(&mod_project)?; + fs::write(&json_config_path, config_content)?; + + let layer_content_dir = path.join("content").join(&name); + fs::create_dir_all(&layer_content_dir)?; + + load_workshop_project(path) +} + +/// Delete a layer from a project at the given path. +pub(crate) fn delete_layer_at_path(path: &Path, layer_name: &str) -> AppResult { + if layer_name == "base" { + return Err(AppError::ValidationFailed( + "Cannot delete the base layer".to_string(), + )); + } + + let config_path = find_config_file(path) + .ok_or_else(|| AppError::ProjectNotFound(path.display().to_string()))?; + let mut mod_project = load_mod_project(&config_path)?; + + let layer_index = mod_project + .layers + .iter() + .position(|l| l.name == layer_name) + .ok_or_else(|| { + AppError::ValidationFailed(format!("Layer '{}' not found in project", layer_name)) + })?; + + mod_project.layers.remove(layer_index); + + let json_config_path = path.join("mod.config.json"); + let config_content = serde_json::to_string_pretty(&mod_project)?; + fs::write(&json_config_path, config_content)?; + + let layer_content_dir = path.join("content").join(layer_name); + if layer_content_dir.exists() { + let _ = fs::remove_dir_all(&layer_content_dir); + } + + load_workshop_project(path) +} + +/// Update a layer's description in a project at the given path. +pub(crate) fn update_layer_description_at_path( + path: &Path, + layer_name: &str, + description: Option, +) -> AppResult { + let config_path = find_config_file(path) + .ok_or_else(|| AppError::ProjectNotFound(path.display().to_string()))?; + let mut mod_project = load_mod_project(&config_path)?; + + let layer = mod_project + .layers + .iter_mut() + .find(|l| l.name == layer_name) + .ok_or_else(|| { + AppError::ValidationFailed(format!("Layer '{}' not found in project", layer_name)) + })?; + + layer.description = description; + + let json_config_path = path.join("mod.config.json"); + let config_content = serde_json::to_string_pretty(&mod_project)?; + fs::write(&json_config_path, config_content)?; + + load_workshop_project(path) +} + +/// Reorder layers in a project at the given path by reassigning priorities. +pub(crate) fn reorder_layers_at_path( + path: &Path, + layer_names: Vec, +) -> AppResult { + let config_path = find_config_file(path) + .ok_or_else(|| AppError::ProjectNotFound(path.display().to_string()))?; + let mut mod_project = load_mod_project(&config_path)?; + + if layer_names.contains(&"base".to_string()) { + return Err(AppError::ValidationFailed( + "Base layer cannot be reordered".to_string(), + )); + } + + let mut current_non_base: Vec = mod_project + .layers + .iter() + .filter(|l| l.name != "base") + .map(|l| l.name.clone()) + .collect(); + let mut provided_names = layer_names.clone(); + current_non_base.sort(); + provided_names.sort(); + + if current_non_base != provided_names { + return Err(AppError::ValidationFailed( + "Provided layer names must match exactly the existing non-base layers".to_string(), + )); + } + + let mut reordered = Vec::with_capacity(mod_project.layers.len()); + if let Some(mut base) = mod_project + .layers + .iter() + .find(|l| l.name == "base") + .cloned() + { + base.priority = 0; + reordered.push(base); + } + for (i, name) in layer_names.iter().enumerate() { + let mut layer = mod_project + .layers + .iter() + .find(|l| &l.name == name) + .cloned() + .expect("layer existence validated above"); + layer.priority = (i + 1) as i32; + reordered.push(layer); + } + mod_project.layers = reordered; + + let json_config_path = path.join("mod.config.json"); + let config_content = serde_json::to_string_pretty(&mod_project)?; + fs::write(&json_config_path, config_content)?; + + load_workshop_project(path) +} + +/// Save string overrides for a specific layer in a project at the given path. +pub(crate) fn save_layer_string_overrides_at_path( + path: &Path, + layer_name: &str, + string_overrides: HashMap>, +) -> AppResult { + let config_path = find_config_file(path) + .ok_or_else(|| AppError::ProjectNotFound(path.display().to_string()))?; + + let mut mod_project = load_mod_project(&config_path)?; + + let layer = mod_project + .layers + .iter_mut() + .find(|l| l.name == layer_name) + .ok_or_else(|| { + AppError::ValidationFailed(format!("Layer '{}' not found in project", layer_name)) + })?; + + layer.string_overrides = string_overrides; + + let json_config_path = path.join("mod.config.json"); + let config_content = serde_json::to_string_pretty(&mod_project)?; + fs::write(&json_config_path, config_content)?; + + load_workshop_project(path) +} impl Workshop { /// Save string overrides for a specific layer in a workshop project. @@ -20,31 +218,7 @@ impl Workshop { if !path.exists() { return Err(AppError::ProjectNotFound(project_path.to_string())); } - - let config_path = find_config_file(&path) - .ok_or_else(|| AppError::ProjectNotFound(project_path.to_string()))?; - - // Load existing config - let mut mod_project = load_mod_project(&config_path)?; - - // Find the target layer - let layer = mod_project - .layers - .iter_mut() - .find(|l| l.name == layer_name) - .ok_or_else(|| { - AppError::ValidationFailed(format!("Layer '{}' not found in project", layer_name)) - })?; - - // Update string overrides - layer.string_overrides = string_overrides; - - // Save as JSON - let json_config_path = path.join("mod.config.json"); - let config_content = serde_json::to_string_pretty(&mod_project)?; - fs::write(&json_config_path, config_content)?; - - load_workshop_project(&path) + save_layer_string_overrides_at_path(&path, layer_name, string_overrides) } /// Create a new layer in a workshop project. @@ -54,97 +228,20 @@ impl Workshop { name: &str, description: Option, ) -> AppResult { - let name = name.trim().to_string(); - - if !is_valid_project_name(&name) { - return Err(AppError::ValidationFailed( - "Layer name must be lowercase alphanumeric with hyphens only".to_string(), - )); - } - let path = PathBuf::from(project_path); if !path.exists() { return Err(AppError::ProjectNotFound(project_path.to_string())); } - - let config_path = find_config_file(&path) - .ok_or_else(|| AppError::ProjectNotFound(project_path.to_string()))?; - let mut mod_project = load_mod_project(&config_path)?; - - // Check for duplicate layer name - if mod_project.layers.iter().any(|l| l.name == name) { - return Err(AppError::ValidationFailed(format!( - "A layer named '{}' already exists", - name - ))); - } - - // Assign priority = max existing priority + 1 - let max_priority = mod_project - .layers - .iter() - .map(|l| l.priority) - .max() - .unwrap_or(-1); - - mod_project.layers.push(ModProjectLayer { - name: name.clone(), - priority: max_priority + 1, - description, - string_overrides: HashMap::new(), - }); - - // Save config - let json_config_path = path.join("mod.config.json"); - let config_content = serde_json::to_string_pretty(&mod_project)?; - fs::write(&json_config_path, config_content)?; - - // Create content directory for the layer - let layer_content_dir = path.join("content").join(&name); - fs::create_dir_all(&layer_content_dir)?; - - load_workshop_project(&path) + create_layer_at_path(&path, name, description) } /// Delete a layer from a workshop project. pub fn delete_layer(&self, project_path: &str, layer_name: &str) -> AppResult { - if layer_name == "base" { - return Err(AppError::ValidationFailed( - "Cannot delete the base layer".to_string(), - )); - } - let path = PathBuf::from(project_path); if !path.exists() { return Err(AppError::ProjectNotFound(project_path.to_string())); } - - let config_path = find_config_file(&path) - .ok_or_else(|| AppError::ProjectNotFound(project_path.to_string()))?; - let mut mod_project = load_mod_project(&config_path)?; - - let layer_index = mod_project - .layers - .iter() - .position(|l| l.name == layer_name) - .ok_or_else(|| { - AppError::ValidationFailed(format!("Layer '{}' not found in project", layer_name)) - })?; - - mod_project.layers.remove(layer_index); - - // Save config - let json_config_path = path.join("mod.config.json"); - let config_content = serde_json::to_string_pretty(&mod_project)?; - fs::write(&json_config_path, config_content)?; - - // Remove content directory (best-effort) - let layer_content_dir = path.join("content").join(layer_name); - if layer_content_dir.exists() { - let _ = fs::remove_dir_all(&layer_content_dir); - } - - load_workshop_project(&path) + delete_layer_at_path(&path, layer_name) } /// Update a layer's description in a workshop project. @@ -158,26 +255,7 @@ impl Workshop { if !path.exists() { return Err(AppError::ProjectNotFound(project_path.to_string())); } - - let config_path = find_config_file(&path) - .ok_or_else(|| AppError::ProjectNotFound(project_path.to_string()))?; - let mut mod_project = load_mod_project(&config_path)?; - - let layer = mod_project - .layers - .iter_mut() - .find(|l| l.name == layer_name) - .ok_or_else(|| { - AppError::ValidationFailed(format!("Layer '{}' not found in project", layer_name)) - })?; - - layer.description = description; - - let json_config_path = path.join("mod.config.json"); - let config_content = serde_json::to_string_pretty(&mod_project)?; - fs::write(&json_config_path, config_content)?; - - load_workshop_project(&path) + update_layer_description_at_path(&path, layer_name, description) } /// Reorder layers in a workshop project by reassigning priorities. @@ -190,63 +268,254 @@ impl Workshop { if !path.exists() { return Err(AppError::ProjectNotFound(project_path.to_string())); } + reorder_layers_at_path(&path, layer_names) + } +} - let config_path = find_config_file(&path) - .ok_or_else(|| AppError::ProjectNotFound(project_path.to_string()))?; - let mut mod_project = load_mod_project(&config_path)?; +#[cfg(test)] +mod tests { + use super::*; + use crate::error::AppError; + use std::collections::HashMap; + + fn make_project_with_layers(dir: &std::path::Path, layers: Vec) { + let mod_project = ltk_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test Mod".to_string(), + version: "1.0.0".to_string(), + description: "".to_string(), + authors: Vec::new(), + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers, + thumbnail: None, + }; + fs::write( + dir.join("mod.config.json"), + serde_json::to_string_pretty(&mod_project).unwrap(), + ) + .unwrap(); + } - // layer_names should only contain non-base layers - if layer_names.contains(&"base".to_string()) { - return Err(AppError::ValidationFailed( - "Base layer cannot be reordered".to_string(), - )); - } + fn load_layers(dir: &std::path::Path) -> Vec { + let config_path = find_config_file(dir).unwrap(); + let project = load_mod_project(&config_path).unwrap(); + project.layers + } - // Validate that layer_names contains exactly the non-base layers - let mut current_non_base: Vec = mod_project - .layers - .iter() - .filter(|l| l.name != "base") - .map(|l| l.name.clone()) - .collect(); - let mut provided_names = layer_names.clone(); - current_non_base.sort(); - provided_names.sort(); - - if current_non_base != provided_names { - return Err(AppError::ValidationFailed( - "Provided layer names must match exactly the existing non-base layers".to_string(), - )); - } + #[test] + fn create_layer_adds_to_config() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); - // Build reordered list: base first at priority 0, then the rest starting at 1 - let mut reordered = Vec::with_capacity(mod_project.layers.len()); - if let Some(mut base) = mod_project - .layers - .iter() - .find(|l| l.name == "base") - .cloned() - { - base.priority = 0; - reordered.push(base); - } - for (i, name) in layer_names.iter().enumerate() { - let mut layer = mod_project - .layers - .iter() - .find(|l| &l.name == name) - .cloned() - .expect("layer existence validated above"); - layer.priority = (i + 1) as i32; - reordered.push(layer); + let project = create_layer_at_path(dir.path(), "chroma", None).unwrap(); + + assert_eq!(project.layers.len(), 2); + assert_eq!(project.layers[1].name, "chroma"); + assert_eq!(project.layers[1].priority, 1); + + let layers = load_layers(dir.path()); + assert_eq!(layers.len(), 2); + assert_eq!(layers[1].name, "chroma"); + + let chroma_content_dir = dir.path().join("content").join("chroma"); + assert!(chroma_content_dir.exists()); + } + + #[test] + fn create_layer_invalid_name_rejected() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + assert!(matches!( + create_layer_at_path(dir.path(), "Bad Name", None), + Err(AppError::ValidationFailed(_)) + )); + assert!(matches!( + create_layer_at_path(dir.path(), "UPPER", None), + Err(AppError::ValidationFailed(_)) + )); + } + + #[test] + fn create_layer_duplicate_name_detected() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + assert!(matches!( + create_layer_at_path(dir.path(), "base", None), + Err(AppError::ValidationFailed(msg)) if msg.contains("already exists") + )); + } + + #[test] + fn delete_base_layer_rejected() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + assert!(matches!( + delete_layer_at_path(dir.path(), "base"), + Err(AppError::ValidationFailed(msg)) if msg.contains("base") + )); + } + + #[test] + fn delete_nonexistent_layer_detected() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + assert!(matches!( + delete_layer_at_path(dir.path(), "nonexistent"), + Err(AppError::ValidationFailed(msg)) if msg.contains("not found") + )); + } + + #[test] + fn delete_layer_removes_from_config() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers( + dir.path(), + vec![ + ModProjectLayer::base(), + ModProjectLayer { + name: "chroma".to_string(), + priority: 1, + description: None, + string_overrides: HashMap::new(), + }, + ], + ); + fs::create_dir_all(dir.path().join("content").join("chroma")).unwrap(); + + let project = delete_layer_at_path(dir.path(), "chroma").unwrap(); + + assert_eq!(project.layers.len(), 1); + assert_eq!(project.layers[0].name, "base"); + + let layers = load_layers(dir.path()); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].name, "base"); + } + + #[test] + fn reorder_layers_base_included_rejected() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers( + dir.path(), + vec![ + ModProjectLayer::base(), + ModProjectLayer { + name: "chroma".to_string(), + priority: 1, + description: None, + string_overrides: HashMap::new(), + }, + ], + ); + + let result = + reorder_layers_at_path(dir.path(), vec!["base".to_string(), "chroma".to_string()]); + match result { + Err(AppError::ValidationFailed(msg)) => { + assert!( + msg.to_lowercase().contains("base"), + "expected 'base' in message, got: {msg}" + ); + } + other => panic!("expected ValidationFailed, got: {:?}", other), } - mod_project.layers = reordered; + } - // Save config - let json_config_path = path.join("mod.config.json"); - let config_content = serde_json::to_string_pretty(&mod_project)?; - fs::write(&json_config_path, config_content)?; + #[test] + fn reorder_layers_wrong_set_rejected() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers( + dir.path(), + vec![ + ModProjectLayer::base(), + ModProjectLayer { + name: "chroma".to_string(), + priority: 1, + description: None, + string_overrides: HashMap::new(), + }, + ModProjectLayer { + name: "vfx".to_string(), + priority: 2, + description: None, + string_overrides: HashMap::new(), + }, + ], + ); + + assert!(matches!( + reorder_layers_at_path(dir.path(), vec!["chroma".to_string(), "wrong".to_string()]), + Err(AppError::ValidationFailed(_)) + )); + } + + #[test] + fn reorder_layers_reassigns_priorities() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers( + dir.path(), + vec![ + ModProjectLayer::base(), + ModProjectLayer { + name: "chroma".to_string(), + priority: 1, + description: None, + string_overrides: HashMap::new(), + }, + ModProjectLayer { + name: "vfx".to_string(), + priority: 2, + description: None, + string_overrides: HashMap::new(), + }, + ], + ); + + let project = + reorder_layers_at_path(dir.path(), vec!["vfx".to_string(), "chroma".to_string()]) + .unwrap(); + + assert_eq!(project.layers[0].name, "base"); + assert_eq!(project.layers[0].priority, 0); + assert_eq!(project.layers[1].name, "vfx"); + assert_eq!(project.layers[1].priority, 1); + assert_eq!(project.layers[2].name, "chroma"); + assert_eq!(project.layers[2].priority, 2); + + let layers = load_layers(dir.path()); + assert_eq!(layers[1].name, "vfx"); + assert_eq!(layers[1].priority, 1); + } - load_workshop_project(&path) + #[test] + fn update_layer_description_persists() { + let dir = tempfile::tempdir().unwrap(); + make_project_with_layers(dir.path(), ltk_mod_project::default_layers()); + + let project = update_layer_description_at_path( + dir.path(), + "base", + Some("Updated description".to_string()), + ) + .unwrap(); + + assert_eq!( + project.layers[0].description.as_deref(), + Some("Updated description") + ); + + let layers = load_layers(dir.path()); + assert_eq!( + layers[0].description.as_deref(), + Some("Updated description") + ); } } diff --git a/src-tauri/src/workshop/mod.rs b/src-tauri/src/workshop/mod.rs index acc8f6e..61f3a35 100644 --- a/src-tauri/src/workshop/mod.rs +++ b/src-tauri/src/workshop/mod.rs @@ -335,3 +335,88 @@ pub(crate) fn is_valid_project_name(name: &str) -> bool { && !name.starts_with('-') && !name.ends_with('-') } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_project_name_simple() { + assert!(is_valid_project_name("my-mod")); + } + + #[test] + fn valid_project_name_with_numbers() { + assert!(is_valid_project_name("mod-v2")); + } + + #[test] + fn valid_project_name_single_char() { + assert!(is_valid_project_name("a")); + } + + #[test] + fn invalid_project_name_empty() { + assert!(!is_valid_project_name("")); + } + + #[test] + fn invalid_project_name_uppercase() { + assert!(!is_valid_project_name("MyMod")); + } + + #[test] + fn invalid_project_name_spaces() { + assert!(!is_valid_project_name("my mod")); + } + + #[test] + fn invalid_project_name_leading_dash() { + assert!(!is_valid_project_name("-my-mod")); + } + + #[test] + fn invalid_project_name_trailing_dash() { + assert!(!is_valid_project_name("my-mod-")); + } + + #[test] + fn invalid_project_name_special_chars() { + assert!(!is_valid_project_name("my_mod")); + assert!(!is_valid_project_name("my.mod")); + assert!(!is_valid_project_name("my@mod")); + } + + #[test] + fn find_config_file_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("mod.config.json"), "{}").unwrap(); + let result = find_config_file(dir.path()); + assert!(result.is_some()); + assert!(result.unwrap().ends_with("mod.config.json")); + } + + #[test] + fn find_config_file_toml() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("mod.config.toml"), "").unwrap(); + let result = find_config_file(dir.path()); + assert!(result.is_some()); + assert!(result.unwrap().ends_with("mod.config.toml")); + } + + #[test] + fn find_config_file_prefers_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("mod.config.json"), "{}").unwrap(); + std::fs::write(dir.path().join("mod.config.toml"), "").unwrap(); + let result = find_config_file(dir.path()); + assert!(result.unwrap().ends_with("mod.config.json")); + } + + #[test] + fn find_config_file_none_when_empty() { + let dir = tempfile::tempdir().unwrap(); + assert!(find_config_file(dir.path()).is_none()); + } +} diff --git a/src-tauri/src/workshop/packing.rs b/src-tauri/src/workshop/packing.rs index d089d61..8a14757 100644 --- a/src-tauri/src/workshop/packing.rs +++ b/src-tauri/src/workshop/packing.rs @@ -5,7 +5,80 @@ use super::{ use crate::error::{AppError, AppResult}; use camino::Utf8PathBuf; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +/// Validate a project at the given path. +pub(crate) fn validate_project_at_path(path: &Path) -> AppResult { + if !path.exists() { + return Err(AppError::ProjectNotFound(path.display().to_string())); + } + + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + let config_path = match find_config_file(path) { + Some(p) => p, + None => { + errors.push("No mod.config.json or mod.config.toml found".to_string()); + return Ok(ValidationResult { + valid: false, + errors, + warnings, + }); + } + }; + + let mod_project = match load_mod_project(&config_path) { + Ok(p) => p, + Err(e) => { + errors.push(format!("Failed to parse config: {}", e)); + return Ok(ValidationResult { + valid: false, + errors, + warnings, + }); + } + }; + + if !is_valid_project_name(&mod_project.name) { + errors.push("Project name must be lowercase alphanumeric with hyphens only".to_string()); + } + + if semver::Version::parse(&mod_project.version).is_err() { + errors.push(format!( + "Invalid version format: {} (expected semver like 1.0.0)", + mod_project.version + )); + } + + let content_dir = path.join("content"); + if !content_dir.exists() { + errors.push("content/ directory not found".to_string()); + } else { + for layer in &mod_project.layers { + let layer_dir = content_dir.join(&layer.name); + if !layer_dir.exists() { + errors.push(format!("Layer directory content/{} not found", layer.name)); + } else if layer_dir.read_dir().map(|d| d.count() == 0).unwrap_or(true) { + warnings.push(format!("Layer content/{} is empty", layer.name)); + } + } + } + + if !mod_project.layers.iter().any(|l| l.name == "base") { + warnings.push("No 'base' layer defined".to_string()); + } + + if !path.join("thumbnail.webp").exists() && !path.join("thumbnail.png").exists() { + warnings.push("No thumbnail found (thumbnail.webp or thumbnail.png)".to_string()); + } + + Ok(ValidationResult { + valid: errors.is_empty(), + errors, + warnings, + }) +} impl Workshop { /// Pack a workshop project to .modpkg or .fantome format. @@ -80,85 +153,7 @@ impl Workshop { /// Validate a project before packing. pub fn validate_project(&self, project_path: &str) -> AppResult { - let path = PathBuf::from(project_path); - if !path.exists() { - return Err(AppError::ProjectNotFound(project_path.to_string())); - } - - let mut errors = Vec::new(); - let mut warnings = Vec::new(); - - // Check for config file - let config_path = match find_config_file(&path) { - Some(p) => p, - None => { - errors.push("No mod.config.json or mod.config.toml found".to_string()); - return Ok(ValidationResult { - valid: false, - errors, - warnings, - }); - } - }; - - // Try to load config - let mod_project = match load_mod_project(&config_path) { - Ok(p) => p, - Err(e) => { - errors.push(format!("Failed to parse config: {}", e)); - return Ok(ValidationResult { - valid: false, - errors, - warnings, - }); - } - }; - - // Validate name - if !is_valid_project_name(&mod_project.name) { - errors - .push("Project name must be lowercase alphanumeric with hyphens only".to_string()); - } - - // Validate version - if semver::Version::parse(&mod_project.version).is_err() { - errors.push(format!( - "Invalid version format: {} (expected semver like 1.0.0)", - mod_project.version - )); - } - - // Check content directory exists - let content_dir = path.join("content"); - if !content_dir.exists() { - errors.push("content/ directory not found".to_string()); - } else { - // Check layer directories - for layer in &mod_project.layers { - let layer_dir = content_dir.join(&layer.name); - if !layer_dir.exists() { - errors.push(format!("Layer directory content/{} not found", layer.name)); - } else if layer_dir.read_dir().map(|d| d.count() == 0).unwrap_or(true) { - warnings.push(format!("Layer content/{} is empty", layer.name)); - } - } - } - - // Check for base layer - if !mod_project.layers.iter().any(|l| l.name == "base") { - warnings.push("No 'base' layer defined".to_string()); - } - - // Check thumbnail - if !path.join("thumbnail.webp").exists() && !path.join("thumbnail.png").exists() { - warnings.push("No thumbnail found (thumbnail.webp or thumbnail.png)".to_string()); - } - - Ok(ValidationResult { - valid: errors.is_empty(), - errors, - warnings, - }) + validate_project_at_path(&PathBuf::from(project_path)) } /// Set a project's thumbnail image. @@ -248,3 +243,265 @@ impl Workshop { Ok(thumbnail_path.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_valid_project(dir: &std::path::Path) { + let mod_project = ltk_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test Mod".to_string(), + version: "1.0.0".to_string(), + description: "A valid test mod".to_string(), + authors: vec![ltk_mod_project::ModProjectAuthor::Name( + "Author".to_string(), + )], + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: ltk_mod_project::default_layers(), + thumbnail: None, + }; + fs::write( + dir.join("mod.config.json"), + serde_json::to_string_pretty(&mod_project).unwrap(), + ) + .unwrap(); + fs::create_dir_all(dir.join("content").join("base")).unwrap(); + fs::write( + dir.join("content").join("base").join("test.wad.client"), + b"data", + ) + .unwrap(); + fs::write(dir.join("thumbnail.webp"), b"fake thumbnail").unwrap(); + } + + #[test] + fn validate_missing_config_file() { + let dir = tempfile::tempdir().unwrap(); + let result = validate_project_at_path(dir.path()).unwrap(); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.contains("mod.config.json"))); + } + + #[test] + fn validate_invalid_config() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("mod.config.json"), "invalid json").unwrap(); + let result = validate_project_at_path(dir.path()).unwrap(); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.contains("parse config"))); + } + + #[test] + fn validate_invalid_project_name() { + let dir = tempfile::tempdir().unwrap(); + let mod_project = ltk_mod_project::ModProject { + name: "BadName".to_string(), + display_name: "Bad".to_string(), + version: "1.0.0".to_string(), + description: "".to_string(), + authors: Vec::new(), + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: ltk_mod_project::default_layers(), + thumbnail: None, + }; + fs::write( + dir.path().join("mod.config.json"), + serde_json::to_string_pretty(&mod_project).unwrap(), + ) + .unwrap(); + fs::create_dir_all(dir.path().join("content").join("base")).unwrap(); + + let result = validate_project_at_path(dir.path()).unwrap(); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.contains("lowercase"))); + } + + #[test] + fn validate_invalid_version() { + let dir = tempfile::tempdir().unwrap(); + let mod_project = ltk_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test".to_string(), + version: "not-semver".to_string(), + description: "".to_string(), + authors: Vec::new(), + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: ltk_mod_project::default_layers(), + thumbnail: None, + }; + fs::write( + dir.path().join("mod.config.json"), + serde_json::to_string_pretty(&mod_project).unwrap(), + ) + .unwrap(); + fs::create_dir_all(dir.path().join("content").join("base")).unwrap(); + + let result = validate_project_at_path(dir.path()).unwrap(); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.contains("version"))); + } + + #[test] + fn validate_missing_content_dir() { + let dir = tempfile::tempdir().unwrap(); + let mod_project = ltk_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test".to_string(), + version: "1.0.0".to_string(), + description: "".to_string(), + authors: Vec::new(), + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: ltk_mod_project::default_layers(), + thumbnail: None, + }; + fs::write( + dir.path().join("mod.config.json"), + serde_json::to_string_pretty(&mod_project).unwrap(), + ) + .unwrap(); + + let result = validate_project_at_path(dir.path()).unwrap(); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.contains("content/"))); + } + + #[test] + fn validate_empty_layer_dir_warns() { + let dir = tempfile::tempdir().unwrap(); + let mod_project = ltk_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test".to_string(), + version: "1.0.0".to_string(), + description: "".to_string(), + authors: Vec::new(), + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: ltk_mod_project::default_layers(), + thumbnail: None, + }; + fs::write( + dir.path().join("mod.config.json"), + serde_json::to_string_pretty(&mod_project).unwrap(), + ) + .unwrap(); + fs::create_dir_all(dir.path().join("content").join("base")).unwrap(); + + let result = validate_project_at_path(dir.path()).unwrap(); + assert!(result.valid); + assert!(result.warnings.iter().any(|w| w.contains("empty"))); + } + + #[test] + fn validate_missing_thumbnail_warns() { + let dir = tempfile::tempdir().unwrap(); + let mod_project = ltk_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test".to_string(), + version: "1.0.0".to_string(), + description: "".to_string(), + authors: Vec::new(), + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: ltk_mod_project::default_layers(), + thumbnail: None, + }; + fs::write( + dir.path().join("mod.config.json"), + serde_json::to_string_pretty(&mod_project).unwrap(), + ) + .unwrap(); + fs::create_dir_all(dir.path().join("content").join("base")).unwrap(); + fs::write( + dir.path().join("content").join("base").join("file"), + b"data", + ) + .unwrap(); + + let result = validate_project_at_path(dir.path()).unwrap(); + assert!(result.valid); + assert!(result.warnings.iter().any(|w| w.contains("thumbnail"))); + } + + #[test] + fn validate_valid_project_passes() { + let dir = tempfile::tempdir().unwrap(); + make_valid_project(dir.path()); + let result = validate_project_at_path(dir.path()).unwrap(); + assert!( + result.valid, + "errors: {:?}, warnings: {:?}", + result.errors, result.warnings + ); + assert!(result.errors.is_empty()); + } + + #[test] + fn validate_no_base_layer_warns() { + let dir = tempfile::tempdir().unwrap(); + let mod_project = ltk_mod_project::ModProject { + name: "test-mod".to_string(), + display_name: "Test".to_string(), + version: "1.0.0".to_string(), + description: "".to_string(), + authors: Vec::new(), + license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), + transformers: Vec::new(), + layers: vec![ltk_mod_project::ModProjectLayer { + name: "chroma".to_string(), + priority: 1, + description: None, + string_overrides: HashMap::new(), + }], + thumbnail: None, + }; + fs::write( + dir.path().join("mod.config.json"), + serde_json::to_string_pretty(&mod_project).unwrap(), + ) + .unwrap(); + fs::create_dir_all(dir.path().join("content").join("chroma")).unwrap(); + fs::write( + dir.path().join("content").join("chroma").join("file"), + b"data", + ) + .unwrap(); + + let result = validate_project_at_path(dir.path()).unwrap(); + assert!(result.warnings.iter().any(|w| w.contains("base"))); + } + + #[test] + fn pack_format_deserialization() { + let modpkg: PackFormat = serde_json::from_str("\"modpkg\"").unwrap(); + assert_eq!(modpkg, PackFormat::Modpkg); + let fantome: PackFormat = serde_json::from_str("\"fantome\"").unwrap(); + assert_eq!(fantome, PackFormat::Fantome); + } +} diff --git a/src-tauri/src/workshop/projects.rs b/src-tauri/src/workshop/projects.rs index ace47d1..1bad523 100644 --- a/src-tauri/src/workshop/projects.rs +++ b/src-tauri/src/workshop/projects.rs @@ -791,3 +791,112 @@ fn extract_fantome_file( } } } + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + + // ── parse_github_url ── + + #[test] + fn parse_github_url_valid_https() { + let (owner, repo) = parse_github_url("https://github.com/owner/repo").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn parse_github_url_trailing_slash() { + let (owner, repo) = parse_github_url("https://github.com/owner/repo/").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn parse_github_url_with_git_suffix() { + let (owner, repo) = parse_github_url("https://github.com/owner/repo.git").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn parse_github_url_with_whitespace() { + let (owner, repo) = parse_github_url(" https://github.com/owner/repo ").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn parse_github_url_http_also_works() { + let (owner, repo) = parse_github_url("http://github.com/owner/repo").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + #[test] + fn parse_github_url_non_github_host_rejected() { + let result = parse_github_url("https://gitlab.com/owner/repo"); + assert_matches!(result, Err(AppError::ValidationFailed(_))); + } + + #[test] + fn parse_github_url_missing_repo() { + let result = parse_github_url("https://github.com/owner"); + assert_matches!(result, Err(AppError::ValidationFailed(_))); + } + + #[test] + fn parse_github_url_empty_string() { + let result = parse_github_url(""); + assert_matches!(result, Err(AppError::ValidationFailed(_))); + } + + #[test] + fn parse_github_url_extra_path_segments_rejected() { + let result = parse_github_url("https://github.com/owner/repo/tree/main"); + assert_matches!(result, Err(AppError::ValidationFailed(_))); + } + + #[test] + fn parse_github_url_trailing_slash_and_git() { + let (owner, repo) = parse_github_url("https://github.com/owner/repo.git/").unwrap(); + assert_eq!(owner, "owner"); + assert_eq!(repo, "repo"); + } + + // ── is_wad_file_name ── + + #[test] + fn is_wad_file_name_client() { + assert!(is_wad_file_name("Aatrox.wad.client")); + } + + #[test] + fn is_wad_file_name_plain() { + assert!(is_wad_file_name("Map1.wad")); + } + + #[test] + fn is_wad_file_name_mobile() { + assert!(is_wad_file_name("Aatrox.wad.mobile")); + } + + #[test] + fn is_wad_file_name_case_insensitive() { + assert!(is_wad_file_name("AATROX.WAD.CLIENT")); + assert!(is_wad_file_name("Map1.WAD")); + } + + #[test] + fn is_wad_file_name_not_wad() { + assert!(!is_wad_file_name("readme.txt")); + assert!(!is_wad_file_name("mod.config.json")); + assert!(!is_wad_file_name("thumbnail.png")); + } + + #[test] + fn is_wad_file_name_empty() { + assert!(!is_wad_file_name("")); + } +} diff --git a/src/lib/tauri.test.ts b/src/lib/tauri.test.ts new file mode 100644 index 0000000..5a2e72e --- /dev/null +++ b/src/lib/tauri.test.ts @@ -0,0 +1,110 @@ +import { mockInvoke } from "@/test/mocks/tauri"; + +import { api } from "./tauri"; + +describe("api", () => { + beforeEach(() => { + mockInvoke.mockReset(); + }); + + describe("settings", () => { + it("getSettings invokes get_settings", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: { theme: "dark" } }); + const result = await api.getSettings(); + expect(mockInvoke).toHaveBeenCalledWith("get_settings", undefined); + expect(result).toEqual({ ok: true, value: { theme: "dark" } }); + }); + + it("saveSettings invokes save_settings with settings arg", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: undefined }); + const settings = { theme: "dark" } as Parameters[0]; + await api.saveSettings(settings); + expect(mockInvoke).toHaveBeenCalledWith("save_settings", { settings }); + }); + + it("validateLeaguePath invokes with path arg", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: true }); + await api.validateLeaguePath("/some/path"); + expect(mockInvoke).toHaveBeenCalledWith("validate_league_path", { path: "/some/path" }); + }); + }); + + describe("mods", () => { + it("getInstalledMods invokes get_installed_mods", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: [] }); + const result = await api.getInstalledMods(); + expect(mockInvoke).toHaveBeenCalledWith("get_installed_mods", undefined); + expect(result).toEqual({ ok: true, value: [] }); + }); + + it("installMod invokes with filePath", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: { id: "mod1" } }); + await api.installMod("/path/to/mod.modpkg"); + expect(mockInvoke).toHaveBeenCalledWith("install_mod", { filePath: "/path/to/mod.modpkg" }); + }); + + it("toggleMod invokes with modId and enabled", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: undefined }); + await api.toggleMod("mod1", false); + expect(mockInvoke).toHaveBeenCalledWith("toggle_mod", { modId: "mod1", enabled: false }); + }); + + it("uninstallMod invokes with modId", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: undefined }); + await api.uninstallMod("mod1"); + expect(mockInvoke).toHaveBeenCalledWith("uninstall_mod", { modId: "mod1" }); + }); + }); + + describe("profiles", () => { + it("createModProfile invokes with name", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: { id: "p1", name: "My Profile" } }); + await api.createModProfile("My Profile"); + expect(mockInvoke).toHaveBeenCalledWith("create_mod_profile", { name: "My Profile" }); + }); + + it("switchModProfile invokes with profileId", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: { id: "p1" } }); + await api.switchModProfile("p1"); + expect(mockInvoke).toHaveBeenCalledWith("switch_mod_profile", { profileId: "p1" }); + }); + }); + + describe("workshop", () => { + it("getWorkshopProjects invokes get_workshop_projects", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: [] }); + await api.getWorkshopProjects(); + expect(mockInvoke).toHaveBeenCalledWith("get_workshop_projects", undefined); + }); + + it("deleteWorkshopProject invokes with projectPath", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: undefined }); + await api.deleteWorkshopProject("/path/to/project"); + expect(mockInvoke).toHaveBeenCalledWith("delete_workshop_project", { + projectPath: "/path/to/project", + }); + }); + }); + + describe("patcher", () => { + it("stopPatcher invokes stop_patcher", async () => { + mockInvoke.mockResolvedValue({ ok: true, value: undefined }); + await api.stopPatcher(); + expect(mockInvoke).toHaveBeenCalledWith("stop_patcher", undefined); + }); + }); + + describe("error handling", () => { + it("wraps IPC error responses into Result Err", async () => { + mockInvoke.mockResolvedValue({ + ok: false, + error: { code: "MOD_NOT_FOUND", message: "Not found" }, + }); + const result = await api.getInstalledMods(); + expect(result).toEqual({ + ok: false, + error: { code: "MOD_NOT_FOUND", message: "Not found" }, + }); + }); + }); +}); diff --git a/src/stores/libraryFilter.test.ts b/src/stores/libraryFilter.test.ts new file mode 100644 index 0000000..9425bce --- /dev/null +++ b/src/stores/libraryFilter.test.ts @@ -0,0 +1,96 @@ +import { useLibraryFilterStore } from "./libraryFilter"; + +describe("libraryFilter store", () => { + beforeEach(() => { + useLibraryFilterStore.setState({ + selectedTags: new Set(), + selectedChampions: new Set(), + selectedMaps: new Set(), + sort: { field: "priority", direction: "desc" }, + }); + }); + + describe("toggleTag", () => { + it("adds a tag when not present", () => { + useLibraryFilterStore.getState().toggleTag("skin"); + expect(useLibraryFilterStore.getState().selectedTags).toEqual(new Set(["skin"])); + }); + + it("removes a tag when already present", () => { + useLibraryFilterStore.setState({ selectedTags: new Set(["skin"]) }); + useLibraryFilterStore.getState().toggleTag("skin"); + expect(useLibraryFilterStore.getState().selectedTags).toEqual(new Set()); + }); + }); + + describe("toggleChampion", () => { + it("adds a champion when not present", () => { + useLibraryFilterStore.getState().toggleChampion("Ahri"); + expect(useLibraryFilterStore.getState().selectedChampions).toEqual(new Set(["Ahri"])); + }); + + it("removes a champion when already present", () => { + useLibraryFilterStore.setState({ selectedChampions: new Set(["Ahri"]) }); + useLibraryFilterStore.getState().toggleChampion("Ahri"); + expect(useLibraryFilterStore.getState().selectedChampions).toEqual(new Set()); + }); + }); + + describe("toggleMap", () => { + it("adds a map when not present", () => { + useLibraryFilterStore.getState().toggleMap("SR"); + expect(useLibraryFilterStore.getState().selectedMaps).toEqual(new Set(["SR"])); + }); + + it("removes a map when already present", () => { + useLibraryFilterStore.setState({ selectedMaps: new Set(["SR"]) }); + useLibraryFilterStore.getState().toggleMap("SR"); + expect(useLibraryFilterStore.getState().selectedMaps).toEqual(new Set()); + }); + }); + + describe("setTags / setChampions / setMaps", () => { + it("replaces selected tags", () => { + useLibraryFilterStore.getState().setTags(new Set(["a", "b"])); + expect(useLibraryFilterStore.getState().selectedTags).toEqual(new Set(["a", "b"])); + }); + + it("replaces selected champions", () => { + useLibraryFilterStore.getState().setChampions(new Set(["Ahri", "Zed"])); + expect(useLibraryFilterStore.getState().selectedChampions).toEqual(new Set(["Ahri", "Zed"])); + }); + + it("replaces selected maps", () => { + useLibraryFilterStore.getState().setMaps(new Set(["SR"])); + expect(useLibraryFilterStore.getState().selectedMaps).toEqual(new Set(["SR"])); + }); + }); + + describe("clearFilters", () => { + it("clears all filter sets", () => { + useLibraryFilterStore.setState({ + selectedTags: new Set(["skin"]), + selectedChampions: new Set(["Ahri"]), + selectedMaps: new Set(["SR"]), + }); + useLibraryFilterStore.getState().clearFilters(); + const state = useLibraryFilterStore.getState(); + expect(state.selectedTags.size).toBe(0); + expect(state.selectedChampions.size).toBe(0); + expect(state.selectedMaps.size).toBe(0); + }); + + it("does not reset sort config", () => { + useLibraryFilterStore.setState({ sort: { field: "name", direction: "asc" } }); + useLibraryFilterStore.getState().clearFilters(); + expect(useLibraryFilterStore.getState().sort).toEqual({ field: "name", direction: "asc" }); + }); + }); + + describe("setSort", () => { + it("updates sort config", () => { + useLibraryFilterStore.getState().setSort({ field: "name", direction: "asc" }); + expect(useLibraryFilterStore.getState().sort).toEqual({ field: "name", direction: "asc" }); + }); + }); +}); diff --git a/src/stores/notifications.test.ts b/src/stores/notifications.test.ts new file mode 100644 index 0000000..f05de62 --- /dev/null +++ b/src/stores/notifications.test.ts @@ -0,0 +1,86 @@ +import { useNotificationStore } from "./notifications"; + +describe("notifications store", () => { + beforeEach(() => { + useNotificationStore.setState({ notifications: [], unreadCount: 0 }); + }); + + describe("addNotification", () => { + it("adds a notification with id, timestamp, and read=false", () => { + useNotificationStore.getState().addNotification({ + title: "Test", + type: "success", + }); + const { notifications } = useNotificationStore.getState(); + expect(notifications).toHaveLength(1); + expect(notifications[0].title).toBe("Test"); + expect(notifications[0].type).toBe("success"); + expect(notifications[0].read).toBe(false); + expect(notifications[0].id).toBeDefined(); + expect(notifications[0].timestamp).toBeGreaterThan(0); + }); + + it("prepends new notifications", () => { + const store = useNotificationStore.getState(); + store.addNotification({ title: "First", type: "info" }); + store.addNotification({ title: "Second", type: "info" }); + const { notifications } = useNotificationStore.getState(); + expect(notifications[0].title).toBe("Second"); + expect(notifications[1].title).toBe("First"); + }); + + it("updates unread count", () => { + useNotificationStore.getState().addNotification({ title: "A", type: "info" }); + useNotificationStore.getState().addNotification({ title: "B", type: "info" }); + expect(useNotificationStore.getState().unreadCount).toBe(2); + }); + + it("caps at 100 notifications", () => { + for (let i = 0; i < 110; i++) { + useNotificationStore.getState().addNotification({ title: `N${i}`, type: "info" }); + } + expect(useNotificationStore.getState().notifications).toHaveLength(100); + }); + }); + + describe("markAllRead", () => { + it("marks all notifications as read and resets unread count", () => { + useNotificationStore.getState().addNotification({ title: "A", type: "info" }); + useNotificationStore.getState().addNotification({ title: "B", type: "info" }); + useNotificationStore.getState().markAllRead(); + const state = useNotificationStore.getState(); + expect(state.unreadCount).toBe(0); + expect(state.notifications.every((n) => n.read)).toBe(true); + }); + }); + + describe("dismissAll", () => { + it("clears all notifications", () => { + useNotificationStore.getState().addNotification({ title: "A", type: "info" }); + useNotificationStore.getState().dismissAll(); + const state = useNotificationStore.getState(); + expect(state.notifications).toHaveLength(0); + expect(state.unreadCount).toBe(0); + }); + }); + + describe("dismissOne", () => { + it("removes a specific notification by id", () => { + useNotificationStore.getState().addNotification({ title: "A", type: "info" }); + useNotificationStore.getState().addNotification({ title: "B", type: "info" }); + const id = useNotificationStore.getState().notifications[0].id; + useNotificationStore.getState().dismissOne(id); + const state = useNotificationStore.getState(); + expect(state.notifications).toHaveLength(1); + expect(state.notifications[0].id).not.toBe(id); + }); + + it("updates unread count after dismiss", () => { + useNotificationStore.getState().addNotification({ title: "A", type: "info" }); + useNotificationStore.getState().addNotification({ title: "B", type: "info" }); + const id = useNotificationStore.getState().notifications[0].id; + useNotificationStore.getState().dismissOne(id); + expect(useNotificationStore.getState().unreadCount).toBe(1); + }); + }); +}); diff --git a/src/stores/patcherSession.test.ts b/src/stores/patcherSession.test.ts new file mode 100644 index 0000000..009dca8 --- /dev/null +++ b/src/stores/patcherSession.test.ts @@ -0,0 +1,36 @@ +import { usePatcherSessionStore } from "./patcherSession"; + +describe("patcherSession store", () => { + beforeEach(() => { + usePatcherSessionStore.setState({ testingProjects: [] }); + }); + + describe("setTestingProjects", () => { + it("sets the testing projects list", () => { + const projects = [ + { path: "/p1", displayName: "Project 1" }, + { path: "/p2", displayName: "Project 2" }, + ]; + usePatcherSessionStore.getState().setTestingProjects(projects); + expect(usePatcherSessionStore.getState().testingProjects).toEqual(projects); + }); + + it("replaces previous projects", () => { + usePatcherSessionStore.getState().setTestingProjects([{ path: "/p1", displayName: "Old" }]); + usePatcherSessionStore.getState().setTestingProjects([{ path: "/p2", displayName: "New" }]); + expect(usePatcherSessionStore.getState().testingProjects).toEqual([ + { path: "/p2", displayName: "New" }, + ]); + }); + }); + + describe("clearTestingProjects", () => { + it("clears the testing projects list", () => { + usePatcherSessionStore + .getState() + .setTestingProjects([{ path: "/p1", displayName: "Project 1" }]); + usePatcherSessionStore.getState().clearTestingProjects(); + expect(usePatcherSessionStore.getState().testingProjects).toEqual([]); + }); + }); +}); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 0000000..f0e3ada --- /dev/null +++ b/src/test/fixtures.ts @@ -0,0 +1,50 @@ +import type { InstalledMod, Profile, Settings } from "@/lib/bindings"; + +export function createMockSettings(overrides?: Partial): Settings { + return { + leaguePath: null, + modStoragePath: null, + workshopPath: null, + firstRunComplete: false, + theme: "system", + accentColor: { preset: "blue", customHue: null }, + backdropImage: null, + backdropBlur: null, + libraryViewMode: "grid", + patchTft: false, + migrationDismissed: false, + ...overrides, + }; +} + +export function createMockInstalledMod(overrides?: Partial): InstalledMod { + return { + id: "test-mod-id", + name: "test-mod", + displayName: "Test Mod", + version: "1.0.0", + description: "A test mod", + authors: ["Test Author"], + enabled: true, + installedAt: "2025-01-01T00:00:00.000Z", + layers: [{ name: "base", priority: 0, enabled: true }], + tags: [], + champions: [], + maps: [], + modDir: "/path/to/mod", + ...overrides, + }; +} + +export function createMockProfile(overrides?: Partial): Profile { + return { + id: "test-profile-id", + name: "Test Profile", + slug: "test-profile", + enabledMods: [], + modOrder: [], + createdAt: "2025-01-01T00:00:00.000Z", + lastUsed: "2025-01-01T00:00:00.000Z", + ...overrides, + }; +} diff --git a/src/test/globals.d.ts b/src/test/globals.d.ts new file mode 100644 index 0000000..9896c47 --- /dev/null +++ b/src/test/globals.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/test/mocks/tauri.ts b/src/test/mocks/tauri.ts new file mode 100644 index 0000000..f9217c8 --- /dev/null +++ b/src/test/mocks/tauri.ts @@ -0,0 +1,41 @@ +import { vi } from "vitest"; + +export const mockInvoke = vi.fn(); +export const mockListen = vi.fn(() => Promise.resolve(vi.fn())); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mockInvoke, +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: mockListen, +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), + save: vi.fn(), + message: vi.fn(), + ask: vi.fn(), + confirm: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-fs", () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + readDir: vi.fn(), + exists: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn(), + Command: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-process", () => ({ + exit: vi.fn(), + relaunch: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-updater", () => ({ + check: vi.fn(), +})); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/src/test/utils.tsx b/src/test/utils.tsx new file mode 100644 index 0000000..21f3f71 --- /dev/null +++ b/src/test/utils.tsx @@ -0,0 +1,28 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, type RenderOptions } from "@testing-library/react"; +import { type ReactElement, type ReactNode, useState } from "react"; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); +} + +function TestProviders({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => createTestQueryClient()); + return {children}; +} + +export function renderWithProviders(ui: ReactElement, options?: Omit) { + return render(ui, { wrapper: TestProviders, ...options }); +} + +export { createTestQueryClient }; diff --git a/src/utils/errors.test.ts b/src/utils/errors.test.ts new file mode 100644 index 0000000..38edb48 --- /dev/null +++ b/src/utils/errors.test.ts @@ -0,0 +1,69 @@ +import type { AppError } from "@/lib/bindings"; + +import { getInvalidPathContext, getModNotFoundContext, hasErrorCode } from "./errors"; + +function makeError(code: string, context?: unknown): AppError { + return { + code: code as AppError["code"], + message: `Test error: ${code}`, + context, + }; +} + +describe("hasErrorCode", () => { + it("returns true when codes match", () => { + const error = makeError("INVALID_PATH"); + expect(hasErrorCode(error, "INVALID_PATH")).toBe(true); + }); + + it("returns false when codes differ", () => { + const error = makeError("IO"); + expect(hasErrorCode(error, "INVALID_PATH")).toBe(false); + }); +}); + +describe("getInvalidPathContext", () => { + it("returns context for INVALID_PATH with valid context", () => { + const error = makeError("INVALID_PATH", { path: "/some/path" }); + const ctx = getInvalidPathContext(error); + expect(ctx).toEqual({ path: "/some/path" }); + }); + + it("returns undefined for wrong error code", () => { + const error = makeError("IO", { path: "/some/path" }); + expect(getInvalidPathContext(error)).toBeUndefined(); + }); + + it("returns undefined when context is missing", () => { + const error = makeError("INVALID_PATH"); + expect(getInvalidPathContext(error)).toBeUndefined(); + }); + + it("returns undefined for malformed context", () => { + const error = makeError("INVALID_PATH", { wrongKey: 123 }); + expect(getInvalidPathContext(error)).toBeUndefined(); + }); +}); + +describe("getModNotFoundContext", () => { + it("returns context for MOD_NOT_FOUND with valid context", () => { + const error = makeError("MOD_NOT_FOUND", { modId: "test-mod" }); + const ctx = getModNotFoundContext(error); + expect(ctx).toEqual({ modId: "test-mod" }); + }); + + it("returns undefined for wrong error code", () => { + const error = makeError("IO", { modId: "test-mod" }); + expect(getModNotFoundContext(error)).toBeUndefined(); + }); + + it("returns undefined when context is missing", () => { + const error = makeError("MOD_NOT_FOUND"); + expect(getModNotFoundContext(error)).toBeUndefined(); + }); + + it("returns undefined for malformed context", () => { + const error = makeError("MOD_NOT_FOUND", { wrongKey: 123 }); + expect(getModNotFoundContext(error)).toBeUndefined(); + }); +}); diff --git a/src/utils/query.test.ts b/src/utils/query.test.ts new file mode 100644 index 0000000..a570ac7 --- /dev/null +++ b/src/utils/query.test.ts @@ -0,0 +1,67 @@ +import { mutationFn, queryFn, queryFnWithArgs, unwrapForQuery } from "./query"; +import type { Result } from "./result"; + +const ok = (value: T): Result => ({ ok: true, value }); +const err = (error: string): Result => ({ ok: false, error }); + +describe("unwrapForQuery", () => { + it("returns the value for Ok", () => { + expect(unwrapForQuery(ok(42))).toBe(42); + }); + + it("throws the error for Err", () => { + expect(() => unwrapForQuery(err("fail"))).toThrow("fail"); + }); +}); + +describe("queryFn", () => { + it("returns a function that unwraps Ok results", async () => { + const apiFn = vi.fn(() => Promise.resolve(ok("data"))); + const wrapped = queryFn(apiFn); + await expect(wrapped()).resolves.toBe("data"); + expect(apiFn).toHaveBeenCalledOnce(); + }); + + it("returns a function that throws on Err results", async () => { + const apiFn = vi.fn(() => Promise.resolve(err("fail"))); + const wrapped = queryFn(apiFn); + await expect(wrapped()).rejects.toBe("fail"); + }); +}); + +describe("queryFnWithArgs", () => { + it("passes arguments to the wrapped function", async () => { + const apiFn = vi.fn((id: string) => Promise.resolve(ok(`data-${id}`))); + const wrapped = queryFnWithArgs(apiFn, "abc"); + await expect(wrapped()).resolves.toBe("data-abc"); + expect(apiFn).toHaveBeenCalledWith("abc"); + }); + + it("passes multiple arguments", async () => { + const apiFn = vi.fn((a: string, b: number) => Promise.resolve(ok(`${a}-${b}`))); + const wrapped = queryFnWithArgs(apiFn, "x", 42); + await expect(wrapped()).resolves.toBe("x-42"); + expect(apiFn).toHaveBeenCalledWith("x", 42); + }); + + it("throws on Err results", async () => { + const apiFn = vi.fn((_id: string) => Promise.resolve(err("not found"))); + const wrapped = queryFnWithArgs(apiFn, "abc"); + await expect(wrapped()).rejects.toBe("not found"); + }); +}); + +describe("mutationFn", () => { + it("returns a function that unwraps Ok results", async () => { + const apiFn = vi.fn((vars: { name: string }) => Promise.resolve(ok(`created-${vars.name}`))); + const wrapped = mutationFn(apiFn); + await expect(wrapped({ name: "test" })).resolves.toBe("created-test"); + expect(apiFn).toHaveBeenCalledWith({ name: "test" }); + }); + + it("throws on Err results", async () => { + const apiFn = vi.fn((_vars: { name: string }) => Promise.resolve(err("fail"))); + const wrapped = mutationFn(apiFn); + await expect(wrapped({ name: "test" })).rejects.toBe("fail"); + }); +}); diff --git a/src/utils/result.test.ts b/src/utils/result.test.ts new file mode 100644 index 0000000..88d9106 --- /dev/null +++ b/src/utils/result.test.ts @@ -0,0 +1,114 @@ +import type { Result } from "./result"; +import { andThen, isErr, isOk, map, mapErr, match, unwrap, unwrapOr, unwrapOrElse } from "./result"; + +const ok = (value: T): Result => ({ ok: true, value }); +const err = (error: string): Result => ({ ok: false, error }); + +describe("isOk", () => { + it("returns true for Ok", () => { + expect(isOk(ok(42))).toBe(true); + }); + + it("returns false for Err", () => { + expect(isOk(err("fail"))).toBe(false); + }); +}); + +describe("isErr", () => { + it("returns true for Err", () => { + expect(isErr(err("fail"))).toBe(true); + }); + + it("returns false for Ok", () => { + expect(isErr(ok(42))).toBe(false); + }); +}); + +describe("unwrap", () => { + it("returns the value for Ok", () => { + expect(unwrap(ok(42))).toBe(42); + }); + + it("throws the error for Err", () => { + expect(() => unwrap(err("fail"))).toThrow("fail"); + }); +}); + +describe("unwrapOr", () => { + it("returns the value for Ok", () => { + expect(unwrapOr(ok(42), 0)).toBe(42); + }); + + it("returns the default for Err", () => { + expect(unwrapOr(err("fail"), 0)).toBe(0); + }); +}); + +describe("unwrapOrElse", () => { + it("returns the value for Ok", () => { + expect(unwrapOrElse(ok(42), () => 0)).toBe(42); + }); + + it("calls the function for Err", () => { + expect(unwrapOrElse(err("fail"), (e) => e.length)).toBe(4); + }); +}); + +describe("map", () => { + it("transforms the Ok value", () => { + const result = map(ok(2), (v) => v * 3); + expect(result).toEqual({ ok: true, value: 6 }); + }); + + it("passes through Err unchanged", () => { + const result = map(err("fail"), (v) => v * 3); + expect(result).toEqual({ ok: false, error: "fail" }); + }); +}); + +describe("mapErr", () => { + it("transforms the Err value", () => { + const result = mapErr(err("fail"), (e) => e.toUpperCase()); + expect(result).toEqual({ ok: false, error: "FAIL" }); + }); + + it("passes through Ok unchanged", () => { + const result = mapErr(ok(42), (e: string) => e.toUpperCase()); + expect(result).toEqual({ ok: true, value: 42 }); + }); +}); + +describe("andThen", () => { + it("chains Ok results", () => { + const result = andThen(ok(2), (v) => ok(v * 3)); + expect(result).toEqual({ ok: true, value: 6 }); + }); + + it("short-circuits on Err", () => { + const result = andThen(err("fail"), (v) => ok(v * 3)); + expect(result).toEqual({ ok: false, error: "fail" }); + }); + + it("propagates Err from the chain function", () => { + const result = andThen(ok(2), () => err("chained error")); + expect(result).toEqual({ ok: false, error: "chained error" }); + }); +}); + +describe("match", () => { + it("calls ok handler for Ok", () => { + const result = match(ok(42), { + ok: (v) => `value: ${v}`, + err: (e) => `error: ${e}`, + }); + expect(result).toBe("value: 42"); + }); + + it("calls err handler for Err", () => { + const result = match(err("fail"), { + ok: (v) => `value: ${v}`, + err: (e) => `error: ${e}`, + }); + expect(result).toBe("error: fail"); + }); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index 97ede7e..8e3fc38 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -7,5 +7,5 @@ "allowSyntheticDefaultImports": true, "strict": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2128d6d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vitest/config"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.test.{ts,tsx}"], + coverage: { + provider: "v8", + exclude: ["src/routeTree.gen.ts", "src/lib/bindings/**", "src/test/**", "**/*.config.*"], + }, + }, +});