merge: add automated test coverage
github/actions/ci GitHub Actions CI finished: success

Merge the Tauri test feature branch into main.

Adds Vitest coverage for frontend formatting, path, and store helper behavior, plus Rust unit coverage for database, vector, storage, indexer, thumbnail, and AI tag filtering logic. Also wires the new unit test scripts into the package commands and documents the test layers.
This commit is contained in:
2026-07-05 21:26:32 +01:00
17 changed files with 1616 additions and 3 deletions
+11 -1
View File
@@ -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
+6 -2
View File
@@ -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"
}
}
+242
View File
@@ -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):
+9
View File
@@ -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(" "));
}
}
+480
View File
@@ -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());
}
}
+39
View File
@@ -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");
}
}
+50
View File
@@ -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
);
}
}
+20
View File
@@ -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);
+78
View File
@@ -562,3 +562,81 @@ fn pack_f32(values: &[f32]) -> Vec<u8> {
}
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());
}
}
@@ -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 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,