From 5004a2d01a0504e97caf44429c2c84f175970365 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 5 Jul 2026 21:19:31 +0100 Subject: [PATCH 1/4] test(frontend): add Vitest unit tests for store helpers and utilities Wire Vitest into the existing Vite config (scoped to src/**/*.test.ts so it never collides with the Playwright suite in tests/) and add test:unit, test:unit:watch, and test:rust scripts. Covers the pure logic layer: parseSearchValue prefix parsing, all twelve sort orders through mergeImages, merge/dedup/window helpers, filter matching, gallery request tokens, localStorage-backed initial settings, folder-picker path utilities, and the duplicate/lightbox/gallery/video formatters. Fixture factories live in src/test/factories.ts. --- package.json | 8 +- pnpm-lock.yaml | 242 ++++++++++++ src/components/duplicateFinder/format.test.ts | 61 +++ src/components/folderPicker/pathUtils.test.ts | 91 +++++ src/components/gallery/format.test.ts | 21 ++ src/components/lightbox/format.test.ts | 59 +++ src/components/videoPlayer/format.test.ts | 21 ++ src/store/helpers.test.ts | 349 ++++++++++++++++++ src/test/factories.ts | 73 ++++ vite.config.ts | 6 + 10 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 src/components/duplicateFinder/format.test.ts create mode 100644 src/components/folderPicker/pathUtils.test.ts create mode 100644 src/components/gallery/format.test.ts create mode 100644 src/components/lightbox/format.test.ts create mode 100644 src/components/videoPlayer/format.test.ts create mode 100644 src/store/helpers.test.ts create mode 100644 src/test/factories.ts diff --git a/package.json b/package.json index 115ce15..42c9ad9 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,10 @@ "format:rust:check": "cd src-tauri && cargo fmt --check", "preview": "vite preview", "tauri": "tauri", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:rust": "cd src-tauri && cargo test --no-default-features", + "test:unit": "vitest run", + "test:unit:watch": "vitest" }, "dependencies": { "@tanstack/react-virtual": "^3.13.23", @@ -53,6 +56,7 @@ "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.2.2", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^4.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 099476c..a0d2e1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: vite: specifier: ^7.0.4 version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.0.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) website: dependencies: @@ -705,6 +708,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -917,9 +923,15 @@ 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/d3-force@3.0.10': resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -940,6 +952,39 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + baseline-browser-mapping@2.10.14: resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==} engines: {node: '>=6.0.0'} @@ -953,6 +998,10 @@ packages: caniuse-lite@1.0.30001785: resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -995,6 +1044,9 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1007,6 +1059,13 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1165,6 +1224,13 @@ packages: node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1280,10 +1346,19 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} @@ -1291,10 +1366,21 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1358,6 +1444,52 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + 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.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + 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/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1784,6 +1916,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -1954,8 +2088,15 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-force@3.0.10': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/node@26.0.1': @@ -1982,6 +2123,49 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + baseline-browser-mapping@2.10.14: {} browserslist@4.28.2: @@ -1994,6 +2178,8 @@ snapshots: caniuse-lite@1.0.30001785: {} + chai@6.2.2: {} + convert-source-map@2.0.0: {} csstype@3.2.3: {} @@ -2023,6 +2209,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + es-module-lexer@2.3.0: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -2056,6 +2244,12 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2158,6 +2352,10 @@ snapshots: node-releases@2.0.37: {} + obug@2.1.3: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -2259,17 +2457,29 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + tailwindcss@4.2.2: {} tapable@2.3.2: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tslib@2.8.1: {} typescript@5.8.3: {} @@ -2305,6 +2515,38 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 + vitest@4.1.9(@types/node@26.0.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.0.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yallist@3.1.1: {} zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): diff --git a/src/components/duplicateFinder/format.test.ts b/src/components/duplicateFinder/format.test.ts new file mode 100644 index 0000000..2c3713d --- /dev/null +++ b/src/components/duplicateFinder/format.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { DuplicateScanProgress } from '../../store' +import { + duplicateProgressLabel, + duplicateProgressPercent, + formatBytes, + formatRelativeTime, +} from './format' + +function progress(overrides: Partial = {}): DuplicateScanProgress { + return { phase: 'checking', processed: 0, total: 0, skipped: 0, ...overrides } +} + +describe('formatBytes', () => { + it('formats each size tier', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2 KB') + expect(formatBytes(1_048_576)).toBe('1.0 MB') + expect(formatBytes(1_610_612_736)).toBe('1.5 GB') + }) +}) + +describe('formatRelativeTime', () => { + const now = () => Math.floor(Date.now() / 1000) + + it('formats each time bucket', () => { + expect(formatRelativeTime(now())).toBe('just now') + expect(formatRelativeTime(now() - 120)).toBe('2m ago') + expect(formatRelativeTime(now() - 7200)).toBe('2h ago') + expect(formatRelativeTime(now() - 172_800)).toBe('2d ago') + }) +}) + +describe('duplicateProgressLabel', () => { + it('returns null without progress', () => { + expect(duplicateProgressLabel(null)).toBeNull() + }) + + it('labels each phase', () => { + expect(duplicateProgressLabel(progress({ phase: 'checking' }))).toBe('Checking file sizes') + expect(duplicateProgressLabel(progress({ phase: 'hashing' }))).toBe( + 'Hashing duplicate candidates' + ) + expect(duplicateProgressLabel(progress({ phase: 'confirming' }))).toBe( + 'Confirming exact matches' + ) + }) +}) + +describe('duplicateProgressPercent', () => { + it('returns 0 for missing progress or zero totals', () => { + expect(duplicateProgressPercent(null)).toBe(0) + expect(duplicateProgressPercent(progress({ processed: 5, total: 0 }))).toBe(0) + }) + + it('rounds to whole percent', () => { + expect(duplicateProgressPercent(progress({ processed: 50, total: 200 }))).toBe(25) + expect(duplicateProgressPercent(progress({ processed: 1, total: 3 }))).toBe(33) + }) +}) diff --git a/src/components/folderPicker/pathUtils.test.ts b/src/components/folderPicker/pathUtils.test.ts new file mode 100644 index 0000000..7e4a757 --- /dev/null +++ b/src/components/folderPicker/pathUtils.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { + buildBreadcrumbs, + cleanAddressInput, + folderName, + friendlyDirectoryError, + normalizePath, +} from './pathUtils' + +describe('normalizePath', () => { + it('converts backslashes, strips trailing slashes, and lowercases', () => { + expect(normalizePath('C:\\Users\\Me\\Pictures\\')).toBe('c:/users/me/pictures') + expect(normalizePath('/home/User/photos///')).toBe('/home/user/photos') + expect(normalizePath('relative/path')).toBe('relative/path') + }) +}) + +describe('cleanAddressInput', () => { + it('strips matching surrounding quotes', () => { + expect(cleanAddressInput('"C:\\Photos"')).toBe('C:\\Photos') + expect(cleanAddressInput("'C:\\Photos'")).toBe('C:\\Photos') + }) + + it('leaves mismatched quotes alone', () => { + expect(cleanAddressInput('"C:\\Photos\'')).toBe('"C:\\Photos\'') + }) + + it('trims whitespace inside and outside quotes', () => { + expect(cleanAddressInput(' " C:\\Photos " ')).toBe('C:\\Photos') + expect(cleanAddressInput(' C:\\Photos ')).toBe('C:\\Photos') + }) +}) + +describe('friendlyDirectoryError', () => { + it('maps not-found style errors to a friendly message', () => { + expect(friendlyDirectoryError(new Error('The system cannot find the path specified.'))).toBe( + 'Folder not found. Check the path and try again.' + ) + expect(friendlyDirectoryError(new Error('os error 3'))).toBe( + 'Folder not found. Check the path and try again.' + ) + expect(friendlyDirectoryError('No such file or directory')).toBe( + 'Folder not found. Check the path and try again.' + ) + }) + + it('passes other messages through', () => { + expect(friendlyDirectoryError(new Error('Access denied'))).toBe('Access denied') + expect(friendlyDirectoryError(42)).toBe('42') + }) +}) + +describe('folderName', () => { + it('returns the final path component', () => { + expect(folderName('C:\\Users\\me\\Pictures')).toBe('Pictures') + expect(folderName('/home/user/photos/')).toBe('photos') + }) + + it('handles roots', () => { + expect(folderName('C:\\')).toBe('C:') + expect(folderName('/')).toBe('/') + }) +}) + +describe('buildBreadcrumbs', () => { + it('returns the home crumb for null paths', () => { + expect(buildBreadcrumbs(null)).toEqual([{ label: 'This PC / Home', path: null }]) + }) + + it('builds Windows drive breadcrumbs', () => { + expect(buildBreadcrumbs('C:\\Users\\me')).toEqual([ + { label: 'This PC', path: null }, + { label: 'C:', path: 'C:' }, + { label: 'Users', path: 'C:\\Users' }, + { label: 'me', path: 'C:\\Users\\me' }, + ]) + }) + + it('builds Unix breadcrumbs', () => { + expect(buildBreadcrumbs('/home/user')).toEqual([ + { label: '/', path: null }, + { label: 'home', path: '/home' }, + { label: 'user', path: '/home/user' }, + ]) + }) + + it('ignores trailing separators', () => { + const crumbs = buildBreadcrumbs('C:\\Users\\') + expect(crumbs.map((c) => c.label)).toEqual(['This PC', 'C:', 'Users']) + }) +}) diff --git a/src/components/gallery/format.test.ts b/src/components/gallery/format.test.ts new file mode 100644 index 0000000..9e1acf1 --- /dev/null +++ b/src/components/gallery/format.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { formatDuration } from './format' + +describe('formatDuration', () => { + it('returns null for missing or non-positive durations', () => { + expect(formatDuration(null)).toBeNull() + expect(formatDuration(0)).toBeNull() + expect(formatDuration(-100)).toBeNull() + }) + + it('formats sub-hour durations as M:SS', () => { + expect(formatDuration(1000)).toBe('0:01') + expect(formatDuration(59_999)).toBe('0:59') + expect(formatDuration(65_000)).toBe('1:05') + }) + + it('formats hour-plus durations as H:MM:SS', () => { + expect(formatDuration(3_600_000)).toBe('1:00:00') + expect(formatDuration(3_661_000)).toBe('1:01:01') + }) +}) diff --git a/src/components/lightbox/format.test.ts b/src/components/lightbox/format.test.ts new file mode 100644 index 0000000..8cde57b --- /dev/null +++ b/src/components/lightbox/format.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from './format' + +describe('formatBytes', () => { + it('formats each size tier', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB') + }) +}) + +describe('formatDate', () => { + it('returns Unknown for null', () => { + expect(formatDate(null)).toBe('Unknown') + }) + + it('formats ISO dates with a full year', () => { + // Exact output is locale-dependent; assert the stable parts. + const formatted = formatDate('2024-03-15T12:00:00Z') + expect(formatted).toContain('2024') + expect(formatted).not.toBe('Unknown') + }) +}) + +describe('formatDuration', () => { + it('reports pending for missing or zero durations', () => { + expect(formatDuration(null)).toBe('Pending / unavailable') + expect(formatDuration(0)).toBe('Pending / unavailable') + expect(formatDuration(-5)).toBe('Pending / unavailable') + }) + + it('formats minutes and hours', () => { + expect(formatDuration(65_000)).toBe('1:05') + expect(formatDuration(3_661_000)).toBe('1:01:01') + }) +}) + +describe('embeddingLabel', () => { + it('labels each status', () => { + expect(embeddingLabel('ready', 'clip-vit')).toBe('Ready (clip-vit)') + expect(embeddingLabel('ready', null)).toBe('Ready') + expect(embeddingLabel('failed', null)).toBe('Failed') + expect(embeddingLabel('processing', null)).toBe('Processing') + expect(embeddingLabel('pending', null)).toBe('Queued') + }) +}) + +describe('ratingPill', () => { + it('maps each AI rating to a label and tone', () => { + expect(ratingPill('general').label).toBe('General') + expect(ratingPill('general').className).toContain('emerald') + expect(ratingPill('sensitive').label).toBe('Sensitive') + expect(ratingPill('sensitive').className).toContain('sky') + expect(ratingPill('questionable').label).toBe('Questionable') + expect(ratingPill('questionable').className).toContain('amber') + expect(ratingPill('explicit').label).toBe('Explicit') + expect(ratingPill('explicit').className).toContain('red') + }) +}) diff --git a/src/components/videoPlayer/format.test.ts b/src/components/videoPlayer/format.test.ts new file mode 100644 index 0000000..6960088 --- /dev/null +++ b/src/components/videoPlayer/format.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { formatTime } from './format' + +describe('formatTime', () => { + it('returns 0:00 for invalid input', () => { + expect(formatTime(Number.NaN)).toBe('0:00') + expect(formatTime(Number.POSITIVE_INFINITY)).toBe('0:00') + expect(formatTime(-5)).toBe('0:00') + }) + + it('formats sub-hour times as M:SS', () => { + expect(formatTime(0)).toBe('0:00') + expect(formatTime(59)).toBe('0:59') + expect(formatTime(61.7)).toBe('1:01') + }) + + it('formats hour-plus times as H:MM:SS', () => { + expect(formatTime(3600)).toBe('1:00:00') + expect(formatTime(3661)).toBe('1:01:01') + }) +}) diff --git a/src/store/helpers.test.ts b/src/store/helpers.test.ts new file mode 100644 index 0000000..8809b52 --- /dev/null +++ b/src/store/helpers.test.ts @@ -0,0 +1,349 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeFolderProgress, makeImage } from '../test/factories' +import { + countNewImages, + imagesAffectScope, + initialAiCaptionsEnabled, + initialBoolSetting, + initialNumberSetting, + isCurrentGalleryRequest, + matchesFilters, + matchesSearch, + mergeImages, + mergeIntoVisibleWindow, + nextGalleryRequestToken, + parseSearchValue, + replaceExistingImages, + replaceImage, + scopeHasTaggingPending, + searchModeLabel, + taggingProgressAffectsScope, + tileSizeForZoom, +} from './helpers' + +describe('parseSearchValue', () => { + it('returns empty filename search for blank input', () => { + expect(parseSearchValue('')).toEqual({ mode: 'filename', query: '', prefix: null }) + expect(parseSearchValue(' ')).toEqual({ mode: 'filename', query: '', prefix: null }) + }) + + it('treats plain text as filename search', () => { + expect(parseSearchValue('sunset')).toEqual({ mode: 'filename', query: 'sunset', prefix: null }) + }) + + it('parses slash prefixes', () => { + expect(parseSearchValue('/s beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: '/s' }) + expect(parseSearchValue('/t cat')).toEqual({ mode: 'tag', query: 'cat', prefix: '/t' }) + expect(parseSearchValue('/f holiday')).toEqual({ + mode: 'filename', + query: 'holiday', + prefix: '/f', + }) + }) + + it('parses a bare slash prefix with no query yet', () => { + expect(parseSearchValue('/s')).toEqual({ mode: 'semantic', query: '', prefix: '/s' }) + expect(parseSearchValue('/t')).toEqual({ mode: 'tag', query: '', prefix: '/t' }) + }) + + it('is case-insensitive on slash prefixes', () => { + expect(parseSearchValue('/S beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: '/s' }) + }) + + it('keeps multi-word queries intact', () => { + expect(parseSearchValue('/s beach at sunset').query).toBe('beach at sunset') + }) + + it('falls back to filename for unknown slash prefixes', () => { + expect(parseSearchValue('/x foo')).toEqual({ mode: 'filename', query: 'foo', prefix: null }) + }) + + it('parses colon prefixes', () => { + expect(parseSearchValue('s: beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: 's:' }) + expect(parseSearchValue('t:cat')).toEqual({ mode: 'tag', query: 'cat', prefix: 't:' }) + expect(parseSearchValue('f: holiday')).toEqual({ + mode: 'filename', + query: 'holiday', + prefix: 'f:', + }) + expect(parseSearchValue('S: beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: 's:' }) + }) + + it('falls back to filename for unknown colon prefixes', () => { + expect(parseSearchValue('x: foo')).toEqual({ mode: 'filename', query: 'foo', prefix: null }) + }) + + it('does not treat multi-letter colon words as prefixes', () => { + expect(parseSearchValue('note: hello')).toEqual({ + mode: 'filename', + query: 'note: hello', + prefix: null, + }) + }) +}) + +describe('searchModeLabel / tileSizeForZoom', () => { + it('labels every search mode', () => { + expect(searchModeLabel('semantic')).toBe('Semantic Search') + expect(searchModeLabel('tag')).toBe('Tag Search') + expect(searchModeLabel('filename')).toBe('Filename Search') + }) + + it('maps zoom presets to tile sizes', () => { + expect(tileSizeForZoom('compact')).toBe(160) + expect(tileSizeForZoom('comfortable')).toBe(220) + expect(tileSizeForZoom('detail')).toBe(280) + }) +}) + +describe('matchesSearch', () => { + it('matches any image when search is empty', () => { + expect(matchesSearch(makeImage(), '')).toBe(true) + }) + + it('matches filename substrings case-insensitively', () => { + const image = makeImage({ filename: 'Beach_Sunset.JPG' }) + expect(matchesSearch(image, 'sunset')).toBe(true) + expect(matchesSearch(image, 'SUNSET')).toBe(true) + expect(matchesSearch(image, 'mountain')).toBe(false) + }) +}) + +describe('matchesFilters', () => { + const pass = (image = makeImage()) => + matchesFilters(image, null, 'all', false, 0, false, false, '') + + it('passes with no filters active', () => { + expect(pass()).toBe(true) + }) + + it('filters by folder', () => { + const image = makeImage({ folder_id: 2 }) + expect(matchesFilters(image, 2, 'all', false, 0, false, false, '')).toBe(true) + expect(matchesFilters(image, 3, 'all', false, 0, false, false, '')).toBe(false) + }) + + it('filters by media kind', () => { + const video = makeImage({ media_kind: 'video' }) + expect(matchesFilters(video, null, 'video', false, 0, false, false, '')).toBe(true) + expect(matchesFilters(video, null, 'image', false, 0, false, false, '')).toBe(false) + }) + + it('filters favorites and minimum rating', () => { + const image = makeImage({ favorite: false, rating: 2 }) + expect(matchesFilters(image, null, 'all', true, 0, false, false, '')).toBe(false) + expect(matchesFilters(image, null, 'all', false, 3, false, false, '')).toBe(false) + expect(matchesFilters(image, null, 'all', false, 2, false, false, '')).toBe(true) + }) + + it('filters failed embeddings and failed tagging', () => { + const healthy = makeImage({ embedding_status: 'ready', ai_tagger_error: null }) + const broken = makeImage({ embedding_status: 'failed', ai_tagger_error: 'boom' }) + expect(matchesFilters(healthy, null, 'all', false, 0, true, false, '')).toBe(false) + expect(matchesFilters(broken, null, 'all', false, 0, true, false, '')).toBe(true) + expect(matchesFilters(healthy, null, 'all', false, 0, false, true, '')).toBe(false) + expect(matchesFilters(broken, null, 'all', false, 0, false, true, '')).toBe(true) + }) + + it('applies the search term', () => { + const image = makeImage({ filename: 'cat.jpg' }) + expect(matchesFilters(image, null, 'all', false, 0, false, false, 'cat')).toBe(true) + expect(matchesFilters(image, null, 'all', false, 0, false, false, 'dog')).toBe(false) + }) +}) + +describe('mergeImages', () => { + it('deduplicates by path, letting new records win', () => { + const stale = makeImage({ path: 'C:/media/a.jpg', filename: 'a.jpg', rating: 0 }) + const fresh = makeImage({ path: 'C:/media/a.jpg', filename: 'a.jpg', rating: 5 }) + const merged = mergeImages([stale], [fresh], 'name_asc') + expect(merged).toHaveLength(1) + expect(merged[0].rating).toBe(5) + }) + + it('sorts by name in both directions', () => { + const a = makeImage({ path: 'a', filename: 'apple.jpg' }) + const b = makeImage({ path: 'b', filename: 'banana.jpg' }) + expect(mergeImages([b], [a], 'name_asc').map((i) => i.filename)).toEqual([ + 'apple.jpg', + 'banana.jpg', + ]) + expect(mergeImages([b], [a], 'name_desc').map((i) => i.filename)).toEqual([ + 'banana.jpg', + 'apple.jpg', + ]) + }) + + it('sorts by modified date, treating null as the epoch', () => { + const older = makeImage({ path: 'a', modified_at: '2025-01-01T00:00:00Z' }) + const newer = makeImage({ path: 'b', modified_at: '2026-01-01T00:00:00Z' }) + const undated = makeImage({ path: 'c', modified_at: null }) + const asc = mergeImages([newer, undated], [older], 'date_asc') + expect(asc.map((i) => i.path)).toEqual(['c', 'a', 'b']) + const desc = mergeImages([newer, undated], [older], 'date_desc') + expect(desc.map((i) => i.path)).toEqual(['b', 'a', 'c']) + }) + + it('sorts by size, rating, and duration', () => { + const small = makeImage({ path: 'a', file_size: 10, rating: 1, duration_ms: 100 }) + const large = makeImage({ path: 'b', file_size: 20, rating: 3, duration_ms: null }) + expect(mergeImages([large], [small], 'size_asc')[0].path).toBe('a') + expect(mergeImages([large], [small], 'size_desc')[0].path).toBe('b') + expect(mergeImages([large], [small], 'rating_asc')[0].path).toBe('a') + expect(mergeImages([large], [small], 'rating_desc')[0].path).toBe('b') + // null duration sorts as 0 + expect(mergeImages([large], [small], 'duration_asc')[0].path).toBe('b') + expect(mergeImages([large], [small], 'duration_desc')[0].path).toBe('a') + }) + + it('falls back to modified_at when taken_at is missing', () => { + const taken = makeImage({ + path: 'a', + taken_at: '2024-01-01T00:00:00Z', + modified_at: '2026-01-01T00:00:00Z', + }) + const untaken = makeImage({ + path: 'b', + taken_at: null, + modified_at: '2025-01-01T00:00:00Z', + }) + expect(mergeImages([taken], [untaken], 'taken_asc').map((i) => i.path)).toEqual(['a', 'b']) + expect(mergeImages([taken], [untaken], 'taken_desc').map((i) => i.path)).toEqual(['b', 'a']) + }) +}) + +describe('mergeIntoVisibleWindow', () => { + it('limits the merged list to the window size', () => { + const images = [1, 2, 3, 4].map((n) => makeImage({ path: `p${n}`, filename: `${n}.jpg` })) + const windowed = mergeIntoVisibleWindow(images.slice(0, 2), images.slice(2), 'name_asc', 3) + expect(windowed).toHaveLength(3) + expect(windowed.map((i) => i.filename)).toEqual(['1.jpg', '2.jpg', '3.jpg']) + }) + + it('clamps negative window sizes to zero', () => { + expect(mergeIntoVisibleWindow([makeImage()], [], 'name_asc', -1)).toEqual([]) + }) +}) + +describe('countNewImages', () => { + it('counts only paths not already present', () => { + const current = [makeImage({ path: 'a' })] + const incoming = [makeImage({ path: 'a' }), makeImage({ path: 'b' }), makeImage({ path: 'c' })] + expect(countNewImages(current, incoming)).toBe(2) + }) + + it('counts duplicate new paths once', () => { + const incoming = [makeImage({ path: 'b' }), makeImage({ path: 'b' })] + expect(countNewImages([], incoming)).toBe(1) + }) +}) + +describe('replaceImage / replaceExistingImages', () => { + it('replaceImage swaps the matching record and re-sorts', () => { + const a = makeImage({ path: 'a', filename: 'a.jpg' }) + const b = makeImage({ path: 'b', filename: 'b.jpg' }) + const renamed = makeImage({ path: 'a', filename: 'z.jpg' }) + const result = replaceImage([a, b], renamed, 'name_asc') + expect(result.map((i) => i.filename)).toEqual(['b.jpg', 'z.jpg']) + }) + + it('replaceExistingImages swaps in place without re-sorting', () => { + const a = makeImage({ path: 'a', rating: 0 }) + const b = makeImage({ path: 'b', rating: 0 }) + const updated = makeImage({ path: 'b', rating: 5 }) + const result = replaceExistingImages([b, a], [updated]) + expect(result.map((i) => i.path)).toEqual(['b', 'a']) + expect(result[0].rating).toBe(5) + }) + + it('replaceExistingImages ignores updates for unknown paths and returns the same array', () => { + const current = [makeImage({ path: 'a' })] + const result = replaceExistingImages(current, [makeImage({ path: 'zzz' })]) + expect(result).toBe(current) + }) +}) + +describe('localStorage-backed initial settings', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + function stubLocalStorage(entries: Record) { + const store = new Map(Object.entries(entries)) + vi.stubGlobal('window', { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + }, + }) + } + + it('returns the fallback when window is undefined', () => { + expect(initialBoolSetting('missing', true)).toBe(true) + expect(initialNumberSetting('missing', 7, 0, 10)).toBe(7) + expect(initialAiCaptionsEnabled('missing')).toBe(false) + }) + + it('initialAiCaptionsEnabled only enables on the literal string "true"', () => { + stubLocalStorage({ on: 'true', off: 'yes' }) + expect(initialAiCaptionsEnabled('on')).toBe(true) + expect(initialAiCaptionsEnabled('off')).toBe(false) + expect(initialAiCaptionsEnabled('absent')).toBe(false) + }) + + it('reads booleans from storage', () => { + stubLocalStorage({ on: 'true', off: 'false' }) + expect(initialBoolSetting('on', false)).toBe(true) + expect(initialBoolSetting('off', true)).toBe(false) + expect(initialBoolSetting('absent', true)).toBe(true) + }) + + it('clamps stored numbers to the allowed range', () => { + stubLocalStorage({ low: '-5', high: '999', ok: '4', junk: 'abc' }) + expect(initialNumberSetting('low', 5, 0, 10)).toBe(0) + expect(initialNumberSetting('high', 5, 0, 10)).toBe(10) + expect(initialNumberSetting('ok', 5, 0, 10)).toBe(4) + expect(initialNumberSetting('junk', 5, 0, 10)).toBe(5) + expect(initialNumberSetting('absent', 5, 0, 10)).toBe(5) + }) +}) + +describe('gallery request tokens', () => { + it('only the most recent token is current', () => { + const first = nextGalleryRequestToken() + expect(isCurrentGalleryRequest(first)).toBe(true) + const second = nextGalleryRequestToken() + expect(second).toBeGreaterThan(first) + expect(isCurrentGalleryRequest(first)).toBe(false) + expect(isCurrentGalleryRequest(second)).toBe(true) + }) +}) + +describe('tagging scope helpers', () => { + it('scopeHasTaggingPending checks a single folder scope', () => { + const progress = { 1: makeFolderProgress({ folder_id: 1, tagging_pending: 3 }) } + expect(scopeHasTaggingPending(progress, 1)).toBe(true) + expect(scopeHasTaggingPending(progress, 2)).toBe(false) + }) + + it('scopeHasTaggingPending checks all folders when scope is null', () => { + const progress = { + 1: makeFolderProgress({ folder_id: 1, tagging_pending: 0 }), + 2: makeFolderProgress({ folder_id: 2, tagging_pending: 1 }), + } + expect(scopeHasTaggingPending(progress, null)).toBe(true) + expect(scopeHasTaggingPending({}, null)).toBe(false) + }) + + it('taggingProgressAffectsScope matches null or same-folder scopes', () => { + expect(taggingProgressAffectsScope(1, null)).toBe(true) + expect(taggingProgressAffectsScope(1, 1)).toBe(true) + expect(taggingProgressAffectsScope(1, 2)).toBe(false) + }) + + it('imagesAffectScope checks folder membership', () => { + const images = [makeImage({ folder_id: 1 }), makeImage({ folder_id: 2 })] + expect(imagesAffectScope(images, null)).toBe(true) + expect(imagesAffectScope(images, 2)).toBe(true) + expect(imagesAffectScope(images, 3)).toBe(false) + }) +}) diff --git a/src/test/factories.ts b/src/test/factories.ts new file mode 100644 index 0000000..b4a7b9a --- /dev/null +++ b/src/test/factories.ts @@ -0,0 +1,73 @@ +import type { Folder, FolderJobProgress, ImageRecord } from '../store/types' + +let nextImageId = 1 + +export function makeImage(overrides: Partial = {}): ImageRecord { + const id = overrides.id ?? nextImageId++ + return { + id, + folder_id: 1, + path: `C:/media/image-${id}.jpg`, + filename: `image-${id}.jpg`, + thumbnail_path: null, + width: 1920, + height: 1080, + file_size: 1024, + created_at: null, + modified_at: '2026-01-01T00:00:00Z', + taken_at: null, + mime_type: 'image/jpeg', + media_kind: 'image', + duration_ms: null, + video_codec: null, + audio_codec: null, + metadata_updated_at: null, + metadata_error: null, + favorite: false, + rating: 0, + embedding_status: 'pending', + embedding_model: null, + embedding_updated_at: null, + embedding_error: null, + generated_caption: null, + caption_model: null, + caption_updated_at: null, + caption_error: null, + ai_rating: null, + ai_tagger_model: null, + ai_tagged_at: null, + ai_tagger_error: null, + ...overrides, + } +} + +export function makeFolder(overrides: Partial = {}): Folder { + return { + id: 1, + path: 'C:/media', + name: 'media', + image_count: 0, + indexed_at: null, + scan_error: null, + sort_order: 0, + ...overrides, + } +} + +export function makeFolderProgress(overrides: Partial = {}): FolderJobProgress { + return { + folder_id: 1, + thumbnail_pending: 0, + metadata_pending: 0, + embedding_pending: 0, + embedding_ready: 0, + embedding_failed: 0, + caption_pending: 0, + caption_ready: 0, + caption_failed: 0, + tagging_pending: 0, + tagging_ready: 0, + tagging_failed: 0, + ...overrides, + } +} diff --git a/vite.config.ts b/vite.config.ts index 9534a9f..bb13b6b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' @@ -9,6 +10,11 @@ const host = process.env.TAURI_DEV_HOST export default defineConfig(async () => ({ plugins: [react(), tailwindcss()], + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, + clearScreen: false, server: { port: 1420, From 782cf0ea08dd1571ee9f0aaf4691cac899396178 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 5 Jul 2026 21:19:42 +0100 Subject: [PATCH 2/4] test(backend): add in-memory SQLite test harness with db and vector tests A shared test_support module in db.rs provides the fixture: sqlite-vec registered via auto-extension, an in-memory connection with foreign keys on, and both migrations applied - no refactoring of production code needed since every query function already takes &Connection. db.rs coverage: folder idempotency, upsert_image update semantics (favorite/rating preserved, AI tag state invalidated), the get_images filter matrix with pagination and count_images agreement, tag merge/rename/delete, user-tag precedence over AI tags in update_ai_tags, album CRUD with FK cascade, the embedding job queue (backfill, retry, consistency repair), tag search, and delete_folder cascades. vector.rs coverage: pack/unpack round-trip, embedding upsert/delete with dimension validation, and find_similar_image_ids ranking on both the global KNN and folder-scoped brute-force paths. --- src-tauri/src/db.rs | 480 ++++++++++++++++++++++++++++++++++++++++ src-tauri/src/vector.rs | 78 +++++++ 2 files changed, 558 insertions(+) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index c93f798..7dfac71 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -3265,3 +3265,483 @@ fn folder_exclusion_clause( .join(","); format!("AND {image_alias}.folder_id NOT IN ({id_list})") } + +/// Shared fixtures for unit tests across modules (db, vector, …). +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + + pub(crate) fn test_conn() -> Connection { + vector::register_sqlite_vec(); + let conn = Connection::open_in_memory().unwrap(); + // The r2d2 pool customizer normally sets this; mirror it so FK cascades + // (album deletion, folder deletion) behave like production. + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + migrate(&conn).unwrap(); + vector::migrate(&conn).unwrap(); + conn + } + + pub(crate) fn test_image(folder_id: i64, path: &str) -> ImageRecord { + ImageRecord { + id: 0, + folder_id, + path: path.to_string(), + filename: path.rsplit('/').next().unwrap_or(path).to_string(), + thumbnail_path: None, + width: Some(100), + height: Some(100), + file_size: 1024, + created_at: None, + modified_at: Some("2026-01-01T00:00:00Z".into()), + taken_at: None, + mime_type: "image/jpeg".into(), + media_kind: "image".into(), + duration_ms: None, + video_codec: None, + audio_codec: None, + metadata_updated_at: None, + metadata_error: None, + favorite: false, + rating: 0, + embedding_status: "pending".into(), + embedding_model: None, + embedding_updated_at: None, + embedding_error: None, + generated_caption: None, + caption_model: None, + caption_updated_at: None, + caption_error: None, + ai_rating: None, + ai_tagger_model: None, + ai_tagged_at: None, + ai_tagger_error: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{test_conn, test_image}; + use super::*; + + #[test] + fn insert_folder_is_idempotent_per_path() { + let conn = test_conn(); + let first = insert_folder(&conn, "C:/media", "media").unwrap(); + let second = insert_folder(&conn, "C:/media", "media again").unwrap(); + assert_eq!(first, second); + + insert_folder(&conn, "C:/other", "other").unwrap(); + let folders = get_folders(&conn).unwrap(); + assert_eq!(folders.len(), 2); + assert_eq!(folders[0].name, "media"); + } + + #[test] + fn upsert_image_updates_in_place_and_preserves_user_state() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/media", "media").unwrap(); + let mut img = test_image(folder_id, "C:/media/a.jpg"); + let id = upsert_image(&conn, &img).unwrap(); + + // Simulate user state + AI state on the stored row. + conn.execute( + "UPDATE images SET favorite = 1, rating = 4, ai_tagger_model = 'wd' WHERE id = ?1", + [id], + ) + .unwrap(); + add_user_tag(&conn, id, "keeper").unwrap(); + conn.execute( + "INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at) + VALUES (?1, 'cat', 'ai', 'wd', 0.9, datetime('now'))", + [id], + ) + .unwrap(); + + img.file_size = 2048; + let same_id = upsert_image(&conn, &img).unwrap(); + assert_eq!(same_id, id); + + let stored = get_image_by_id(&conn, id).unwrap(); + assert_eq!(stored.file_size, 2048); + // favorite/rating survive re-index; AI tag state is invalidated. + assert!(stored.favorite); + assert_eq!(stored.rating, 4); + assert_eq!(stored.ai_tagger_model, None); + + let tags = get_image_tags(&conn, id).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].tag, "keeper"); + assert_eq!(tags[0].source, "user"); + } + + #[test] + fn get_images_applies_filters_and_agrees_with_count() { + let conn = test_conn(); + let folder_a = insert_folder(&conn, "C:/a", "a").unwrap(); + let folder_b = insert_folder(&conn, "C:/b", "b").unwrap(); + + upsert_image(&conn, &test_image(folder_a, "C:/a/cat.jpg")).unwrap(); + let dog_id = upsert_image(&conn, &test_image(folder_a, "C:/a/dog.jpg")).unwrap(); + let mut video = test_image(folder_b, "C:/b/clip.mp4"); + video.media_kind = "video".into(); + video.embedding_status = "failed".into(); + upsert_image(&conn, &video).unwrap(); + conn.execute( + "UPDATE images SET favorite = 1, rating = 5 WHERE id = ?1", + [dog_id], + ) + .unwrap(); + + let no_filter = get_images( + &conn, None, None, None, false, 0, false, false, None, "name_asc", 0, 100, + ) + .unwrap(); + assert_eq!(no_filter.len(), 3); + assert_eq!( + count_images(&conn, None, None, None, false, 0, false, false, None).unwrap(), + 3 + ); + + let by_folder = get_images( + &conn, + Some(folder_a), + None, + None, + false, + 0, + false, + false, + None, + "name_asc", + 0, + 100, + ) + .unwrap(); + assert_eq!(by_folder.len(), 2); + + let by_search = get_images( + &conn, + None, + Some("cat"), + None, + false, + 0, + false, + false, + None, + "name_asc", + 0, + 100, + ) + .unwrap(); + assert_eq!(by_search.len(), 1); + assert_eq!(by_search[0].filename, "cat.jpg"); + + let videos = get_images( + &conn, + None, + None, + Some("video"), + false, + 0, + false, + false, + None, + "name_asc", + 0, + 100, + ) + .unwrap(); + assert_eq!(videos.len(), 1); + + let favorites = get_images( + &conn, None, None, None, true, 0, false, false, None, "name_asc", 0, 100, + ) + .unwrap(); + assert_eq!(favorites.len(), 1); + assert_eq!(favorites[0].filename, "dog.jpg"); + + let rated = get_images( + &conn, None, None, None, false, 3, false, false, None, "name_asc", 0, 100, + ) + .unwrap(); + assert_eq!(rated.len(), 1); + + let failed = get_images( + &conn, None, None, None, false, 0, true, false, None, "name_asc", 0, 100, + ) + .unwrap(); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0].filename, "clip.mp4"); + + // Pagination: page size 2 then the remaining 1. + let page_one = get_images( + &conn, None, None, None, false, 0, false, false, None, "name_asc", 0, 2, + ) + .unwrap(); + let page_two = get_images( + &conn, None, None, None, false, 0, false, false, None, "name_asc", 2, 2, + ) + .unwrap(); + assert_eq!(page_one.len(), 2); + assert_eq!(page_two.len(), 1); + assert_eq!(page_one[0].filename, "cat.jpg"); + assert_eq!(page_two[0].filename, "dog.jpg"); + } + + #[test] + fn user_tags_upgrade_ai_tags_and_survive_tag_maintenance() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap(); + + conn.execute( + "INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at) + VALUES (?1, 'cat', 'ai', 'wd', 0.9, datetime('now'))", + [id], + ) + .unwrap(); + let upgraded = add_user_tag(&conn, id, "cat").unwrap(); + assert_eq!(upgraded.source, "user"); + assert_eq!(upgraded.ai_model, None); + assert_eq!(get_image_tags(&conn, id).unwrap().len(), 1); + + let tag = add_user_tag(&conn, id, "kitty").unwrap(); + remove_tag(&conn, tag.id).unwrap(); + assert_eq!(get_image_tags(&conn, id).unwrap().len(), 1); + } + + #[test] + fn rename_tag_merges_into_existing_tag() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let with_both = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap(); + let with_old = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap(); + + add_user_tag(&conn, with_both, "cat").unwrap(); + add_user_tag(&conn, with_both, "kitty").unwrap(); + add_user_tag(&conn, with_old, "kitty").unwrap(); + + rename_tag(&conn, "kitty", "cat").unwrap(); + + let both_tags = get_image_tags(&conn, with_both).unwrap(); + assert_eq!(both_tags.len(), 1); + assert_eq!(both_tags[0].tag, "cat"); + let old_tags = get_image_tags(&conn, with_old).unwrap(); + assert_eq!(old_tags.len(), 1); + assert_eq!(old_tags[0].tag, "cat"); + } + + #[test] + fn delete_tag_removes_it_everywhere_and_reports_count() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let first = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap(); + let second = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap(); + add_user_tag(&conn, first, "cat").unwrap(); + add_user_tag(&conn, second, "cat").unwrap(); + add_user_tag(&conn, second, "dog").unwrap(); + + assert_eq!(delete_tag(&conn, "cat").unwrap(), 2); + assert_eq!(get_image_tags(&conn, first).unwrap().len(), 0); + assert_eq!(get_image_tags(&conn, second).unwrap().len(), 1); + } + + #[test] + fn album_crud_and_membership() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let first = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap(); + let second = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap(); + + let album = create_album(&conn, "Holiday").unwrap(); + assert_eq!(album.name, "Holiday"); + assert_eq!(album.image_count, 0); + + // Adding is idempotent. + assert_eq!( + add_images_to_album(&conn, album.id, &[first, second]).unwrap(), + 2 + ); + assert_eq!(add_images_to_album(&conn, album.id, &[first]).unwrap(), 0); + assert_eq!(count_album_images(&conn, album.id).unwrap(), 2); + + remove_images_from_album(&conn, album.id, &[first]).unwrap(); + assert_eq!(count_album_images(&conn, album.id).unwrap(), 1); + + rename_album(&conn, album.id, "Trip").unwrap(); + assert_eq!(get_album(&conn, album.id).unwrap().name, "Trip"); + + delete_album(&conn, album.id).unwrap(); + assert!(list_albums(&conn).unwrap().is_empty()); + // Membership rows cascade away with the album. + let orphans: i64 = conn + .query_row("SELECT COUNT(*) FROM album_images", [], |row| row.get(0)) + .unwrap(); + assert_eq!(orphans, 0); + } + + #[test] + fn backfill_enqueues_only_unqueued_unready_images() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let pending = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap(); + let mut ready = test_image(folder_id, "C:/a/b.jpg"); + ready.embedding_status = "ready".into(); + upsert_image(&conn, &ready).unwrap(); + let queued = upsert_image(&conn, &test_image(folder_id, "C:/a/c.jpg")).unwrap(); + enqueue_embedding_job(&conn, queued).unwrap(); + + assert_eq!(backfill_embedding_jobs(&conn).unwrap(), 1); + let has_job: i64 = conn + .query_row( + "SELECT COUNT(*) FROM embedding_jobs WHERE image_id = ?1", + [pending], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(has_job, 1); + } + + #[test] + fn retry_failed_embeddings_skips_thumbnailless_videos() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let mut failed_image = test_image(folder_id, "C:/a/a.jpg"); + failed_image.embedding_status = "failed".into(); + let failed_image_id = upsert_image(&conn, &failed_image).unwrap(); + + let mut failed_video = test_image(folder_id, "C:/a/clip.mp4"); + failed_video.media_kind = "video".into(); + failed_video.embedding_status = "failed".into(); + failed_video.thumbnail_path = None; + upsert_image(&conn, &failed_video).unwrap(); + + assert_eq!(retry_failed_embedding_jobs(&conn, folder_id).unwrap(), 1); + let requeued = get_image_by_id(&conn, failed_image_id).unwrap(); + assert_eq!(requeued.embedding_status, "pending"); + } + + #[test] + fn repair_embedding_consistency_requeues_ready_images_without_vectors() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let mut ready = test_image(folder_id, "C:/a/a.jpg"); + ready.embedding_status = "ready".into(); + let ready_id = upsert_image(&conn, &ready).unwrap(); + + let (orphaned, requeued) = repair_embedding_consistency(&conn).unwrap(); + assert_eq!(orphaned, 0); + assert_eq!(requeued, 1); + let repaired = get_image_by_id(&conn, ready_id).unwrap(); + assert_eq!(repaired.embedding_status, "pending"); + } + + #[test] + fn search_images_by_tag_is_case_insensitive_and_paginates() { + let conn = test_conn(); + let folder_a = insert_folder(&conn, "C:/a", "a").unwrap(); + let folder_b = insert_folder(&conn, "C:/b", "b").unwrap(); + let first = upsert_image(&conn, &test_image(folder_a, "C:/a/a.jpg")).unwrap(); + let second = upsert_image(&conn, &test_image(folder_a, "C:/a/b.jpg")).unwrap(); + let third = upsert_image(&conn, &test_image(folder_b, "C:/b/c.jpg")).unwrap(); + add_user_tag(&conn, first, "Cat").unwrap(); + add_user_tag(&conn, second, "cat").unwrap(); + add_user_tag(&conn, third, "cat").unwrap(); + add_user_tag(&conn, third, "dog").unwrap(); + + // Query is trimmed and matched case-insensitively against stored tags. + let (all, total) = + search_images_by_tag(&conn, " CAT ", None, None, false, 0, None, 10, 0).unwrap(); + assert_eq!(total, 3); + assert_eq!(all.len(), 3); + + let (scoped, scoped_total) = + search_images_by_tag(&conn, "cat", Some(folder_a), None, false, 0, None, 10, 0) + .unwrap(); + assert_eq!(scoped_total, 2); + assert_eq!(scoped.len(), 2); + + // Pagination: total stays the full count while the page shrinks. + let (page, page_total) = + search_images_by_tag(&conn, "cat", None, None, false, 0, None, 2, 2).unwrap(); + assert_eq!(page_total, 3); + assert_eq!(page.len(), 1); + + // Blank queries return nothing instead of matching everything. + let (empty, empty_total) = + search_images_by_tag(&conn, " ", None, None, false, 0, None, 10, 0).unwrap(); + assert!(empty.is_empty()); + assert_eq!(empty_total, 0); + } + + #[test] + fn update_ai_tags_replaces_ai_state_without_downgrading_user_tags() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap(); + add_user_tag(&conn, id, "cat").unwrap(); + conn.execute( + "INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at) + VALUES (?1, 'stale', 'ai', 'wd', 0.5, datetime('now'))", + [id], + ) + .unwrap(); + enqueue_tagging_job(&conn, id).unwrap(); + + update_ai_tags( + &conn, + id, + &[("cat".into(), 0.9), ("outdoors".into(), 0.7)], + "general", + "wd", + ) + .unwrap(); + + let tags = get_image_tags(&conn, id).unwrap(); + assert_eq!(tags.len(), 2, "stale AI tag should be gone"); + let cat = tags.iter().find(|tag| tag.tag == "cat").unwrap(); + assert_eq!(cat.source, "user", "user tag must not be downgraded to ai"); + let outdoors = tags.iter().find(|tag| tag.tag == "outdoors").unwrap(); + assert_eq!(outdoors.source, "ai"); + assert_eq!(outdoors.confidence, Some(0.7)); + + let record = get_image_by_id(&conn, id).unwrap(); + assert_eq!(record.ai_rating.as_deref(), Some("general")); + assert_eq!(record.ai_tagger_model.as_deref(), Some("wd")); + assert_eq!(record.ai_tagger_error, None); + + let pending_jobs: i64 = conn + .query_row( + "SELECT COUNT(*) FROM tagging_jobs WHERE image_id = ?1", + [id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(pending_jobs, 0, "completed job should be dequeued"); + } + + #[test] + fn delete_folder_cascades_images_tags_and_vectors() { + let conn = test_conn(); + let folder_id = insert_folder(&conn, "C:/a", "a").unwrap(); + let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap(); + add_user_tag(&conn, id, "cat").unwrap(); + vector::upsert_embedding(&conn, id, &vec![0.5f32; vector::CLIP_VECTOR_DIM]).unwrap(); + + delete_folder(&conn, folder_id).unwrap(); + + assert!(get_folders(&conn).unwrap().is_empty()); + let remaining_images: i64 = conn + .query_row("SELECT COUNT(*) FROM images", [], |row| row.get(0)) + .unwrap(); + assert_eq!(remaining_images, 0); + let remaining_tags: i64 = conn + .query_row("SELECT COUNT(*) FROM image_tags", [], |row| row.get(0)) + .unwrap(); + assert_eq!(remaining_tags, 0); + assert!(!vector::has_image_vector(&conn, id).unwrap()); + } +} diff --git a/src-tauri/src/vector.rs b/src-tauri/src/vector.rs index 6130f51..f78feda 100644 --- a/src-tauri/src/vector.rs +++ b/src-tauri/src/vector.rs @@ -562,3 +562,81 @@ fn pack_f32(values: &[f32]) -> Vec { } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_support::{test_conn, test_image}; + + #[test] + fn pack_unpack_roundtrip() { + let values = vec![0.0f32, 1.5, -2.25, f32::MIN_POSITIVE, 1e10]; + assert_eq!(unpack_f32(&pack_f32(&values)), values); + assert!(unpack_f32(&pack_f32(&[])).is_empty()); + } + + #[test] + fn upsert_embedding_rejects_wrong_dimension() { + let conn = test_conn(); + let error = upsert_embedding(&conn, 1, &[0.5f32; 3]).unwrap_err(); + assert!(error.to_string().contains("dimension")); + } + + #[test] + fn upsert_and_delete_embedding_roundtrip() { + let conn = test_conn(); + let embedding = vec![0.25f32; CLIP_VECTOR_DIM]; + upsert_embedding(&conn, 42, &embedding).unwrap(); + assert!(has_image_vector(&conn, 42).unwrap()); + + // Upsert replaces rather than duplicates. + upsert_embedding(&conn, 42, &embedding).unwrap(); + let rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM image_vec WHERE image_id = 42", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(rows, 1); + + delete_embedding(&conn, 42).unwrap(); + assert!(!has_image_vector(&conn, 42).unwrap()); + } + + #[test] + fn find_similar_image_ids_ranks_by_cosine_distance() { + let conn = test_conn(); + let folder_id = crate::db::insert_folder(&conn, "C:/a", "a").unwrap(); + let base_id = + crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/base.jpg")).unwrap(); + let close_id = + crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/close.jpg")).unwrap(); + let far_id = + crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/far.jpg")).unwrap(); + + let mut base = vec![0.0f32; CLIP_VECTOR_DIM]; + base[0] = 1.0; + let mut close = vec![0.0f32; CLIP_VECTOR_DIM]; + close[0] = 1.0; + close[1] = 0.2; + let mut far = vec![0.0f32; CLIP_VECTOR_DIM]; + far[1] = 1.0; + upsert_embedding(&conn, base_id, &base).unwrap(); + upsert_embedding(&conn, close_id, &close).unwrap(); + upsert_embedding(&conn, far_id, &far).unwrap(); + + // Global KNN path: nearest first, query image excluded. + let global = find_similar_image_ids(&conn, base_id, 2, None).unwrap(); + assert_eq!(global, vec![close_id, far_id]); + + // Folder-scoped brute-force path returns the same ranking. + let scoped = find_similar_image_ids(&conn, base_id, 2, Some(folder_id)).unwrap(); + assert_eq!(scoped, vec![close_id, far_id]); + + // Images without an embedding yield no matches instead of an error. + assert!(find_similar_image_ids(&conn, 9999, 5, None) + .unwrap() + .is_empty()); + } +} From ca5c500e18124bcd3fb4bd11a0979d19cfd54b32 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 5 Jul 2026 21:19:50 +0100 Subject: [PATCH 3/4] test(backend): cover storage, indexer, thumbnail, and tag-filter logic storage.rs: thumbnail worker clamping across parallelism levels, the adaptive-profile EMA transitions, and UNC-path fallback detection. indexer.rs: supported-media extension matching plus media-kind and MIME mapping. thumbnail.rs: fit_dimensions aspect-ratio math with extreme ratios and is_jpeg extension checks. ai_tag_filter.rs: padding and mixed-separator normalization edge cases. --- src-tauri/src/ai_tag_filter.rs | 9 ++++++ src-tauri/src/indexer.rs | 39 ++++++++++++++++++++++++++ src-tauri/src/storage.rs | 50 ++++++++++++++++++++++++++++++++++ src-tauri/src/thumbnail.rs | 20 ++++++++++++++ 4 files changed, 118 insertions(+) diff --git a/src-tauri/src/ai_tag_filter.rs b/src-tauri/src/ai_tag_filter.rs index d683235..872996d 100644 --- a/src-tauri/src/ai_tag_filter.rs +++ b/src-tauri/src/ai_tag_filter.rs @@ -42,4 +42,13 @@ mod tests { assert!(!is_removed_ai_tag(tag), "{tag} should be kept"); } } + + #[test] + fn removed_ai_tags_tolerate_padding_and_mixed_separators() { + assert!(is_removed_ai_tag(" 1girl ")); + assert!(is_removed_ai_tag("1_-_girl")); + assert!(is_removed_ai_tag("No Humans")); + assert!(!is_removed_ai_tag("")); + assert!(!is_removed_ai_tag(" ")); + } } diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 8e3f1be..0fbd447 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -1995,3 +1995,42 @@ fn process_watcher_rename( Err(e) => log::error!("Watcher rename: post-update fetch error: {e}"), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supported_media_matches_known_extensions_case_insensitively() { + for path in ["a.jpg", "b.JPEG", "c.PNG", "d.avif", "e.mp4", "f.WEBM"] { + assert!( + is_supported_media(Path::new(path)), + "{path} should be supported" + ); + } + for path in ["notes.txt", "archive.zip", "no_extension", "clip.mkv"] { + assert!( + !is_supported_media(Path::new(path)), + "{path} should be skipped" + ); + } + } + + #[test] + fn media_kind_splits_video_from_image_extensions() { + for ext in ["mp4", "MOV", "m4v", "webm"] { + assert_eq!(media_kind_for_ext(ext), "video"); + } + for ext in ["jpg", "PNG", "webp", "avif"] { + assert_eq!(media_kind_for_ext(ext), "image"); + } + } + + #[test] + fn mime_types_map_per_extension() { + assert_eq!(mime_for_ext("JPG"), "image/jpeg"); + assert_eq!(mime_for_ext("png"), "image/png"); + assert_eq!(mime_for_ext("mov"), "video/quicktime"); + assert_eq!(mime_for_ext("m4v"), "video/mp4"); + } +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index bf7f29e..686719c 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -122,3 +122,53 @@ fn fallback_profile_for_path(path: &Path) -> StorageProfile { StorageProfile::Balanced } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn thumbnail_workers_scale_with_parallelism_within_clamps() { + assert_eq!(StorageProfile::Fast.thumbnail_workers(3), 2); + assert_eq!(StorageProfile::Fast.thumbnail_workers(12), 4); + assert_eq!(StorageProfile::Fast.thumbnail_workers(64), 4); + assert_eq!(StorageProfile::Balanced.thumbnail_workers(4), 2); + assert_eq!(StorageProfile::Balanced.thumbnail_workers(12), 3); + assert_eq!(StorageProfile::Balanced.thumbnail_workers(64), 3); + assert_eq!(StorageProfile::Conservative.thumbnail_workers(64), 1); + } + + #[test] + fn adaptive_profile_tracks_scan_speed() { + let mut adaptive = RuntimeAdaptiveProfile::new(StorageProfile::Balanced); + assert_eq!(adaptive.profile(), StorageProfile::Balanced); + + // 1 ms/item → fast storage. + adaptive.observe_scan_batch(10, Duration::from_millis(10)); + assert_eq!(adaptive.profile(), StorageProfile::Fast); + + // A very slow batch drags the EMA over the conservative threshold. + adaptive.observe_scan_batch(1, Duration::from_millis(100)); + assert_eq!(adaptive.profile(), StorageProfile::Conservative); + } + + #[test] + fn adaptive_profile_ignores_empty_batches() { + let mut adaptive = RuntimeAdaptiveProfile::new(StorageProfile::Fast); + adaptive.observe_scan_batch(0, Duration::from_secs(10)); + assert_eq!(adaptive.profile(), StorageProfile::Fast); + } + + #[test] + fn fallback_profile_treats_unc_paths_as_conservative() { + assert_eq!( + fallback_profile_for_path(Path::new("\\\\server\\share\\photos")), + StorageProfile::Conservative + ); + assert_eq!( + fallback_profile_for_path(Path::new("C:\\photos")), + StorageProfile::Balanced + ); + } +} diff --git a/src-tauri/src/thumbnail.rs b/src-tauri/src/thumbnail.rs index af29b9c..1e82858 100644 --- a/src-tauri/src/thumbnail.rs +++ b/src-tauri/src/thumbnail.rs @@ -383,6 +383,26 @@ fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) { mod tests { use super::*; + #[test] + fn fit_dimensions_preserves_aspect_ratio_within_max() { + // Already small enough: unchanged. + assert_eq!(fit_dimensions(100, 50, THUMB_SIZE), (100, 50)); + // Landscape and portrait scale to the max on their long edge. + assert_eq!(fit_dimensions(6400, 3200, THUMB_SIZE), (320, 160)); + assert_eq!(fit_dimensions(3200, 6400, THUMB_SIZE), (160, 320)); + // Extreme ratios never collapse to zero. + assert_eq!(fit_dimensions(1, 100_000, 320), (1, 320)); + assert_eq!(fit_dimensions(100_000, 1, 320), (320, 1)); + } + + #[test] + fn is_jpeg_checks_extension_only() { + assert!(is_jpeg(Path::new("photo.jpg"))); + assert!(is_jpeg(Path::new("photo.JPEG"))); + assert!(!is_jpeg(Path::new("photo.png"))); + assert!(!is_jpeg(Path::new("photo"))); + } + #[test] fn scale_numerator_picks_smallest_sufficient() { assert_eq!(scale_numerator(6000, THUMB_SIZE), 1); From 2c699a5aac441b9658871c27ffd3330641994051 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 5 Jul 2026 21:20:24 +0100 Subject: [PATCH 4/4] docs(claude): document the new unit-test layers and commands CLAUDE.md claimed the Playwright smoke tests were the only tests; add the Vitest and cargo-test commands and describe the three test layers, including the shared db::test_support fixture for DB-touching Rust tests. --- CLAUDE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b1ec28a..889e2f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,13 @@ pnpm build:app:cuda # Type-check frontend pnpm build:vite +# Frontend unit tests (Vitest; only picks up src/**/*.test.ts) +pnpm test:unit +pnpm test:unit:watch + +# Rust unit tests (in-memory SQLite; --no-default-features skips CUDA) +pnpm test:rust + # E2E tests (Playwright against the UI Lab; auto-starts the server) pnpm test:e2e pnpm exec playwright test tests/ui-lab.spec.ts # single file @@ -38,7 +45,10 @@ pnpm format:all Use **pnpm** — never npm. -The only tests are the Playwright e2e smoke tests in `tests/`, which run against the UI Lab (browser mocks), not the real Tauri app. There are no unit tests. +Three test layers, none of which exercise the real Tauri window: +- **Vitest unit tests** — co-located `*.test.ts` next to the pure logic they cover (`src/store/helpers.ts`, formatters, path utils); fixture factories in `src/test/factories.ts`. +- **Rust unit tests** — inline `#[cfg(test)]` modules; DB tests run against in-memory SQLite via the shared `db::test_support` fixture (`test_conn()` + `test_image()`), which registers sqlite-vec and applies both migrations. Reuse it for any new DB-touching tests. +- **Playwright e2e smoke tests** in `tests/` — run against the UI Lab (browser mocks). ## Architecture