Compare commits

..

6 Commits

Author SHA1 Message Date
LyAhn dcc1612802 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.
2026-07-05 21:26:32 +01:00
LyAhn 2c699a5aac docs(claude): document the new unit-test layers and commands
CLAUDE.md claimed the Playwright smoke tests were the only tests; add
the Vitest and cargo-test commands and describe the three test layers,
including the shared db::test_support fixture for DB-touching Rust
tests.
2026-07-05 21:20:24 +01:00
LyAhn ca5c500e18 test(backend): cover storage, indexer, thumbnail, and tag-filter logic
storage.rs: thumbnail worker clamping across parallelism levels, the
adaptive-profile EMA transitions, and UNC-path fallback detection.
indexer.rs: supported-media extension matching plus media-kind and MIME
mapping. thumbnail.rs: fit_dimensions aspect-ratio math with extreme
ratios and is_jpeg extension checks. ai_tag_filter.rs: padding and
mixed-separator normalization edge cases.
2026-07-05 21:19:50 +01:00
LyAhn 782cf0ea08 test(backend): add in-memory SQLite test harness with db and vector tests
A shared test_support module in db.rs provides the fixture: sqlite-vec
registered via auto-extension, an in-memory connection with foreign keys
on, and both migrations applied - no refactoring of production code
needed since every query function already takes &Connection.

db.rs coverage: folder idempotency, upsert_image update semantics
(favorite/rating preserved, AI tag state invalidated), the get_images
filter matrix with pagination and count_images agreement, tag
merge/rename/delete, user-tag precedence over AI tags in update_ai_tags,
album CRUD with FK cascade, the embedding job queue (backfill, retry,
consistency repair), tag search, and delete_folder cascades.

vector.rs coverage: pack/unpack round-trip, embedding upsert/delete with
dimension validation, and find_similar_image_ids ranking on both the
global KNN and folder-scoped brute-force paths.
2026-07-05 21:19:42 +01:00
LyAhn 5004a2d01a 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.
2026-07-05 21:19:31 +01:00
LyAhn 9a282dda86 docs(claude): document UI Lab, Playwright e2e tests, and tooling scripts
CLAUDE.md still claimed no test suites existed. Update it for the
current repo state:

- add pnpm test:e2e commands (full run, single file, single test) and
  note the suite runs against UI Lab mocks, not the Tauri app
- new UI Lab section: src/dev mock layer, ?scenario=/?changelog= params,
  mediaSrc rule, and the mockBackend requirement for new commands
- add dev:ui and format:all to the command list
- note website/ as a separate Vite project and document the
  changelog:add script syntax
2026-07-05 20:48:16 +01:00
17 changed files with 1639 additions and 3 deletions
+34 -1
View File
@@ -15,6 +15,9 @@ pnpm dev:app
# Frontend only (no Tauri window)
pnpm dev:vite
# UI Lab — browser-only frontend with mocked Tauri backend (http://127.0.0.1:1422)
pnpm dev:ui
# Production build (CPU)
pnpm build:app:cpu
@@ -23,11 +26,29 @@ 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
pnpm exec playwright test -g "filename search" # single test by name
# Formatting (Prettier + prettier-plugin-tailwindcss; cargo fmt for Rust)
pnpm format:all
```
Use **pnpm** — never npm.
There are no test suites configured.
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
@@ -42,6 +63,16 @@ There are no test suites configured.
- Virtualized gallery grid: `@tanstack/react-virtual`.
- Animation: `framer-motion`.
### UI Lab (`src/dev/`)
`pnpm dev:ui` runs the real frontend (same `App.tsx`, store, components, CSS) in a plain browser with Tauri fully mocked — no Rust backend or Tauri window. `src/main.tsx` imports `src/dev/setupMockTauri.ts` before `App` in `ui` mode; `mockBackend.ts` implements an in-memory command backend, with fixtures in `mockFixtures.ts`/`mockScenarios.ts`. Pick a seeded state via `?scenario=` (`rich` default; also `empty`, `new-user`, `just-updated`, `busy`, `duplicates`, `album`, `errors`, `huge`) and a What's New entry via `?changelog=`. `Ctrl+Shift+D` opens the demo panel. Full guide: `docs/ui-lab.md`.
Rules that keep UI Lab working:
- Components that render thumbnails/covers/posters must use `mediaSrc(...)` from `src/lib/mediaSrc.ts`, never `convertFileSrc(...)` directly.
- New Tauri commands invoked from the frontend need a mock in `src/dev/mockBackend.ts` (unmocked commands log console errors, which fail the e2e tests).
UI Lab is for visual/layout work and agent browser inspection. Native behavior (file pickers, real thumbnails, window controls, updater) must still be validated in `pnpm dev:app`.
### Search modes
The search bar supports prefix syntax parsed by `parseSearchValue` in `src/store/helpers.ts`:
@@ -98,3 +129,5 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle
- ML inference crates (`candle-*`, `ort`, `image`, `rayon`, `tokenizers`, `xxhash-rust`, `rusqlite`) use `opt-level = 3` in dev profile to keep inference performance acceptable.
- The caption worker is intentionally disabled (`lib.rs:73`) — the backend code is intact for future re-enabling.
- **Never use `any` type** in TypeScript — look up correct types.
- `website/` is a separate Vite project for the marketing site (phokus.jezz.wtf): `pnpm dev:web` / `pnpm build:web`. It is not part of the app build.
- `pnpm changelog:add -- --type fixed --message "..."` appends an entry to the `[Unreleased]` section of `CHANGELOG.md`, which also feeds the in-app What's New modal (types: added, changed, deprecated, removed, fixed, security).
+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,