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:
+6
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+242
@@ -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):
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user