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.
This commit is contained in:
2026-07-05 21:19:31 +01:00
parent 9a282dda86
commit 5004a2d01a
10 changed files with 929 additions and 2 deletions
+6 -2
View File
@@ -23,7 +23,10 @@
"format:rust:check": "cd src-tauri && cargo fmt --check", "format:rust:check": "cd src-tauri && cargo fmt --check",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "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": { "dependencies": {
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
@@ -53,6 +56,7 @@
"prettier-plugin-tailwindcss": "^0.8.0", "prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.0.4",
"vitest": "^4.1.9"
} }
} }
+242
View File
@@ -87,6 +87,9 @@ importers:
vite: vite:
specifier: ^7.0.4 specifier: ^7.0.4
version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) 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: website:
dependencies: dependencies:
@@ -705,6 +708,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tailwindcss/node@4.2.2': '@tailwindcss/node@4.2.2':
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
@@ -917,9 +923,15 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} 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': '@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -940,6 +952,39 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 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: baseline-browser-mapping@2.10.14:
resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==} resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -953,6 +998,10 @@ packages:
caniuse-lite@1.0.30001785: caniuse-lite@1.0.30001785:
resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==} 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: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -995,6 +1044,9 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
es-module-lexer@2.3.0:
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
esbuild@0.27.7: esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1007,6 +1059,13 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} 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: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1165,6 +1224,13 @@ packages:
node-releases@2.0.37: node-releases@2.0.37:
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} 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: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1280,10 +1346,19 @@ packages:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 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: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} 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: tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
@@ -1291,10 +1366,21 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'} 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: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} 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: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1358,6 +1444,52 @@ packages:
yaml: yaml:
optional: true 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: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1784,6 +1916,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.1': '@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true optional: true
'@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.2.2': '@tailwindcss/node@4.2.2':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@@ -1954,8 +2088,15 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@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/d3-force@3.0.10': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/node@26.0.1': '@types/node@26.0.1':
@@ -1982,6 +2123,49 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: {} baseline-browser-mapping@2.10.14: {}
browserslist@4.28.2: browserslist@4.28.2:
@@ -1994,6 +2178,8 @@ snapshots:
caniuse-lite@1.0.30001785: {} caniuse-lite@1.0.30001785: {}
chai@6.2.2: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@@ -2023,6 +2209,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.2 tapable: 2.3.2
es-module-lexer@2.3.0: {}
esbuild@0.27.7: esbuild@0.27.7:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7 '@esbuild/aix-ppc64': 0.27.7
@@ -2056,6 +2244,12 @@ snapshots:
estree-walker@2.0.2: {} 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): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -2158,6 +2352,10 @@ snapshots:
node-releases@2.0.37: {} node-releases@2.0.37: {}
obug@2.1.3: {}
pathe@2.0.3: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.4: {} picomatch@4.0.4: {}
@@ -2259,17 +2457,29 @@ snapshots:
'@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5 '@img/sharp-win32-x64': 0.34.5
siginfo@2.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
tailwindcss@4.2.2: {} tailwindcss@4.2.2: {}
tapable@2.3.2: {} tapable@2.3.2: {}
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinyrainbow@3.1.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
typescript@5.8.3: {} typescript@5.8.3: {}
@@ -2305,6 +2515,38 @@ snapshots:
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.32.0 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: {} yallist@3.1.1: {}
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): zustand@5.0.12(@types/react@19.2.14)(react@19.2.4):
@@ -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> = {}): 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)
})
})
@@ -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'])
})
})
+21
View File
@@ -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')
})
})
+59
View File
@@ -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')
})
})
+21
View File
@@ -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')
})
})
+349
View File
@@ -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<string, string>) {
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)
})
})
+73
View File
@@ -0,0 +1,73 @@
import type { Folder, FolderJobProgress, ImageRecord } from '../store/types'
let nextImageId = 1
export function makeImage(overrides: Partial<ImageRecord> = {}): 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> = {}): 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> = {}): 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,
}
}
+6
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@@ -9,6 +10,11 @@ const host = process.env.TAURI_DEV_HOST
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
clearScreen: false, clearScreen: false,
server: { server: {
port: 1420, port: 1420,