diff --git a/package.json b/package.json index 115ce15..42c9ad9 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,10 @@ "format:rust:check": "cd src-tauri && cargo fmt --check", "preview": "vite preview", "tauri": "tauri", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:rust": "cd src-tauri && cargo test --no-default-features", + "test:unit": "vitest run", + "test:unit:watch": "vitest" }, "dependencies": { "@tanstack/react-virtual": "^3.13.23", @@ -53,6 +56,7 @@ "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.2.2", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^4.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 099476c..a0d2e1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: vite: specifier: ^7.0.4 version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@26.0.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) website: dependencies: @@ -705,6 +708,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -917,9 +923,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-force@3.0.10': resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -940,6 +952,39 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + baseline-browser-mapping@2.10.14: resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==} engines: {node: '>=6.0.0'} @@ -953,6 +998,10 @@ packages: caniuse-lite@1.0.30001785: resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -995,6 +1044,9 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1007,6 +1059,13 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1165,6 +1224,13 @@ packages: node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1280,10 +1346,19 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} @@ -1291,10 +1366,21 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1358,6 +1444,52 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1784,6 +1916,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -1954,8 +2088,15 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-force@3.0.10': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/node@26.0.1': @@ -1982,6 +2123,49 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + baseline-browser-mapping@2.10.14: {} browserslist@4.28.2: @@ -1994,6 +2178,8 @@ snapshots: caniuse-lite@1.0.30001785: {} + chai@6.2.2: {} + convert-source-map@2.0.0: {} csstype@3.2.3: {} @@ -2023,6 +2209,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + es-module-lexer@2.3.0: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -2056,6 +2244,12 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2158,6 +2352,10 @@ snapshots: node-releases@2.0.37: {} + obug@2.1.3: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -2259,17 +2457,29 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + tailwindcss@4.2.2: {} tapable@2.3.2: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tslib@2.8.1: {} typescript@5.8.3: {} @@ -2305,6 +2515,38 @@ snapshots: jiti: 2.6.1 lightningcss: 1.32.0 + vitest@4.1.9(@types/node@26.0.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.0.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yallist@3.1.1: {} zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): diff --git a/src/components/duplicateFinder/format.test.ts b/src/components/duplicateFinder/format.test.ts new file mode 100644 index 0000000..2c3713d --- /dev/null +++ b/src/components/duplicateFinder/format.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { DuplicateScanProgress } from '../../store' +import { + duplicateProgressLabel, + duplicateProgressPercent, + formatBytes, + formatRelativeTime, +} from './format' + +function progress(overrides: Partial = {}): DuplicateScanProgress { + return { phase: 'checking', processed: 0, total: 0, skipped: 0, ...overrides } +} + +describe('formatBytes', () => { + it('formats each size tier', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2 KB') + expect(formatBytes(1_048_576)).toBe('1.0 MB') + expect(formatBytes(1_610_612_736)).toBe('1.5 GB') + }) +}) + +describe('formatRelativeTime', () => { + const now = () => Math.floor(Date.now() / 1000) + + it('formats each time bucket', () => { + expect(formatRelativeTime(now())).toBe('just now') + expect(formatRelativeTime(now() - 120)).toBe('2m ago') + expect(formatRelativeTime(now() - 7200)).toBe('2h ago') + expect(formatRelativeTime(now() - 172_800)).toBe('2d ago') + }) +}) + +describe('duplicateProgressLabel', () => { + it('returns null without progress', () => { + expect(duplicateProgressLabel(null)).toBeNull() + }) + + it('labels each phase', () => { + expect(duplicateProgressLabel(progress({ phase: 'checking' }))).toBe('Checking file sizes') + expect(duplicateProgressLabel(progress({ phase: 'hashing' }))).toBe( + 'Hashing duplicate candidates' + ) + expect(duplicateProgressLabel(progress({ phase: 'confirming' }))).toBe( + 'Confirming exact matches' + ) + }) +}) + +describe('duplicateProgressPercent', () => { + it('returns 0 for missing progress or zero totals', () => { + expect(duplicateProgressPercent(null)).toBe(0) + expect(duplicateProgressPercent(progress({ processed: 5, total: 0 }))).toBe(0) + }) + + it('rounds to whole percent', () => { + expect(duplicateProgressPercent(progress({ processed: 50, total: 200 }))).toBe(25) + expect(duplicateProgressPercent(progress({ processed: 1, total: 3 }))).toBe(33) + }) +}) diff --git a/src/components/folderPicker/pathUtils.test.ts b/src/components/folderPicker/pathUtils.test.ts new file mode 100644 index 0000000..7e4a757 --- /dev/null +++ b/src/components/folderPicker/pathUtils.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { + buildBreadcrumbs, + cleanAddressInput, + folderName, + friendlyDirectoryError, + normalizePath, +} from './pathUtils' + +describe('normalizePath', () => { + it('converts backslashes, strips trailing slashes, and lowercases', () => { + expect(normalizePath('C:\\Users\\Me\\Pictures\\')).toBe('c:/users/me/pictures') + expect(normalizePath('/home/User/photos///')).toBe('/home/user/photos') + expect(normalizePath('relative/path')).toBe('relative/path') + }) +}) + +describe('cleanAddressInput', () => { + it('strips matching surrounding quotes', () => { + expect(cleanAddressInput('"C:\\Photos"')).toBe('C:\\Photos') + expect(cleanAddressInput("'C:\\Photos'")).toBe('C:\\Photos') + }) + + it('leaves mismatched quotes alone', () => { + expect(cleanAddressInput('"C:\\Photos\'')).toBe('"C:\\Photos\'') + }) + + it('trims whitespace inside and outside quotes', () => { + expect(cleanAddressInput(' " C:\\Photos " ')).toBe('C:\\Photos') + expect(cleanAddressInput(' C:\\Photos ')).toBe('C:\\Photos') + }) +}) + +describe('friendlyDirectoryError', () => { + it('maps not-found style errors to a friendly message', () => { + expect(friendlyDirectoryError(new Error('The system cannot find the path specified.'))).toBe( + 'Folder not found. Check the path and try again.' + ) + expect(friendlyDirectoryError(new Error('os error 3'))).toBe( + 'Folder not found. Check the path and try again.' + ) + expect(friendlyDirectoryError('No such file or directory')).toBe( + 'Folder not found. Check the path and try again.' + ) + }) + + it('passes other messages through', () => { + expect(friendlyDirectoryError(new Error('Access denied'))).toBe('Access denied') + expect(friendlyDirectoryError(42)).toBe('42') + }) +}) + +describe('folderName', () => { + it('returns the final path component', () => { + expect(folderName('C:\\Users\\me\\Pictures')).toBe('Pictures') + expect(folderName('/home/user/photos/')).toBe('photos') + }) + + it('handles roots', () => { + expect(folderName('C:\\')).toBe('C:') + expect(folderName('/')).toBe('/') + }) +}) + +describe('buildBreadcrumbs', () => { + it('returns the home crumb for null paths', () => { + expect(buildBreadcrumbs(null)).toEqual([{ label: 'This PC / Home', path: null }]) + }) + + it('builds Windows drive breadcrumbs', () => { + expect(buildBreadcrumbs('C:\\Users\\me')).toEqual([ + { label: 'This PC', path: null }, + { label: 'C:', path: 'C:' }, + { label: 'Users', path: 'C:\\Users' }, + { label: 'me', path: 'C:\\Users\\me' }, + ]) + }) + + it('builds Unix breadcrumbs', () => { + expect(buildBreadcrumbs('/home/user')).toEqual([ + { label: '/', path: null }, + { label: 'home', path: '/home' }, + { label: 'user', path: '/home/user' }, + ]) + }) + + it('ignores trailing separators', () => { + const crumbs = buildBreadcrumbs('C:\\Users\\') + expect(crumbs.map((c) => c.label)).toEqual(['This PC', 'C:', 'Users']) + }) +}) diff --git a/src/components/gallery/format.test.ts b/src/components/gallery/format.test.ts new file mode 100644 index 0000000..9e1acf1 --- /dev/null +++ b/src/components/gallery/format.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { formatDuration } from './format' + +describe('formatDuration', () => { + it('returns null for missing or non-positive durations', () => { + expect(formatDuration(null)).toBeNull() + expect(formatDuration(0)).toBeNull() + expect(formatDuration(-100)).toBeNull() + }) + + it('formats sub-hour durations as M:SS', () => { + expect(formatDuration(1000)).toBe('0:01') + expect(formatDuration(59_999)).toBe('0:59') + expect(formatDuration(65_000)).toBe('1:05') + }) + + it('formats hour-plus durations as H:MM:SS', () => { + expect(formatDuration(3_600_000)).toBe('1:00:00') + expect(formatDuration(3_661_000)).toBe('1:01:01') + }) +}) diff --git a/src/components/lightbox/format.test.ts b/src/components/lightbox/format.test.ts new file mode 100644 index 0000000..8cde57b --- /dev/null +++ b/src/components/lightbox/format.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from './format' + +describe('formatBytes', () => { + it('formats each size tier', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB') + }) +}) + +describe('formatDate', () => { + it('returns Unknown for null', () => { + expect(formatDate(null)).toBe('Unknown') + }) + + it('formats ISO dates with a full year', () => { + // Exact output is locale-dependent; assert the stable parts. + const formatted = formatDate('2024-03-15T12:00:00Z') + expect(formatted).toContain('2024') + expect(formatted).not.toBe('Unknown') + }) +}) + +describe('formatDuration', () => { + it('reports pending for missing or zero durations', () => { + expect(formatDuration(null)).toBe('Pending / unavailable') + expect(formatDuration(0)).toBe('Pending / unavailable') + expect(formatDuration(-5)).toBe('Pending / unavailable') + }) + + it('formats minutes and hours', () => { + expect(formatDuration(65_000)).toBe('1:05') + expect(formatDuration(3_661_000)).toBe('1:01:01') + }) +}) + +describe('embeddingLabel', () => { + it('labels each status', () => { + expect(embeddingLabel('ready', 'clip-vit')).toBe('Ready (clip-vit)') + expect(embeddingLabel('ready', null)).toBe('Ready') + expect(embeddingLabel('failed', null)).toBe('Failed') + expect(embeddingLabel('processing', null)).toBe('Processing') + expect(embeddingLabel('pending', null)).toBe('Queued') + }) +}) + +describe('ratingPill', () => { + it('maps each AI rating to a label and tone', () => { + expect(ratingPill('general').label).toBe('General') + expect(ratingPill('general').className).toContain('emerald') + expect(ratingPill('sensitive').label).toBe('Sensitive') + expect(ratingPill('sensitive').className).toContain('sky') + expect(ratingPill('questionable').label).toBe('Questionable') + expect(ratingPill('questionable').className).toContain('amber') + expect(ratingPill('explicit').label).toBe('Explicit') + expect(ratingPill('explicit').className).toContain('red') + }) +}) diff --git a/src/components/videoPlayer/format.test.ts b/src/components/videoPlayer/format.test.ts new file mode 100644 index 0000000..6960088 --- /dev/null +++ b/src/components/videoPlayer/format.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { formatTime } from './format' + +describe('formatTime', () => { + it('returns 0:00 for invalid input', () => { + expect(formatTime(Number.NaN)).toBe('0:00') + expect(formatTime(Number.POSITIVE_INFINITY)).toBe('0:00') + expect(formatTime(-5)).toBe('0:00') + }) + + it('formats sub-hour times as M:SS', () => { + expect(formatTime(0)).toBe('0:00') + expect(formatTime(59)).toBe('0:59') + expect(formatTime(61.7)).toBe('1:01') + }) + + it('formats hour-plus times as H:MM:SS', () => { + expect(formatTime(3600)).toBe('1:00:00') + expect(formatTime(3661)).toBe('1:01:01') + }) +}) diff --git a/src/store/helpers.test.ts b/src/store/helpers.test.ts new file mode 100644 index 0000000..8809b52 --- /dev/null +++ b/src/store/helpers.test.ts @@ -0,0 +1,349 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeFolderProgress, makeImage } from '../test/factories' +import { + countNewImages, + imagesAffectScope, + initialAiCaptionsEnabled, + initialBoolSetting, + initialNumberSetting, + isCurrentGalleryRequest, + matchesFilters, + matchesSearch, + mergeImages, + mergeIntoVisibleWindow, + nextGalleryRequestToken, + parseSearchValue, + replaceExistingImages, + replaceImage, + scopeHasTaggingPending, + searchModeLabel, + taggingProgressAffectsScope, + tileSizeForZoom, +} from './helpers' + +describe('parseSearchValue', () => { + it('returns empty filename search for blank input', () => { + expect(parseSearchValue('')).toEqual({ mode: 'filename', query: '', prefix: null }) + expect(parseSearchValue(' ')).toEqual({ mode: 'filename', query: '', prefix: null }) + }) + + it('treats plain text as filename search', () => { + expect(parseSearchValue('sunset')).toEqual({ mode: 'filename', query: 'sunset', prefix: null }) + }) + + it('parses slash prefixes', () => { + expect(parseSearchValue('/s beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: '/s' }) + expect(parseSearchValue('/t cat')).toEqual({ mode: 'tag', query: 'cat', prefix: '/t' }) + expect(parseSearchValue('/f holiday')).toEqual({ + mode: 'filename', + query: 'holiday', + prefix: '/f', + }) + }) + + it('parses a bare slash prefix with no query yet', () => { + expect(parseSearchValue('/s')).toEqual({ mode: 'semantic', query: '', prefix: '/s' }) + expect(parseSearchValue('/t')).toEqual({ mode: 'tag', query: '', prefix: '/t' }) + }) + + it('is case-insensitive on slash prefixes', () => { + expect(parseSearchValue('/S beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: '/s' }) + }) + + it('keeps multi-word queries intact', () => { + expect(parseSearchValue('/s beach at sunset').query).toBe('beach at sunset') + }) + + it('falls back to filename for unknown slash prefixes', () => { + expect(parseSearchValue('/x foo')).toEqual({ mode: 'filename', query: 'foo', prefix: null }) + }) + + it('parses colon prefixes', () => { + expect(parseSearchValue('s: beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: 's:' }) + expect(parseSearchValue('t:cat')).toEqual({ mode: 'tag', query: 'cat', prefix: 't:' }) + expect(parseSearchValue('f: holiday')).toEqual({ + mode: 'filename', + query: 'holiday', + prefix: 'f:', + }) + expect(parseSearchValue('S: beach')).toEqual({ mode: 'semantic', query: 'beach', prefix: 's:' }) + }) + + it('falls back to filename for unknown colon prefixes', () => { + expect(parseSearchValue('x: foo')).toEqual({ mode: 'filename', query: 'foo', prefix: null }) + }) + + it('does not treat multi-letter colon words as prefixes', () => { + expect(parseSearchValue('note: hello')).toEqual({ + mode: 'filename', + query: 'note: hello', + prefix: null, + }) + }) +}) + +describe('searchModeLabel / tileSizeForZoom', () => { + it('labels every search mode', () => { + expect(searchModeLabel('semantic')).toBe('Semantic Search') + expect(searchModeLabel('tag')).toBe('Tag Search') + expect(searchModeLabel('filename')).toBe('Filename Search') + }) + + it('maps zoom presets to tile sizes', () => { + expect(tileSizeForZoom('compact')).toBe(160) + expect(tileSizeForZoom('comfortable')).toBe(220) + expect(tileSizeForZoom('detail')).toBe(280) + }) +}) + +describe('matchesSearch', () => { + it('matches any image when search is empty', () => { + expect(matchesSearch(makeImage(), '')).toBe(true) + }) + + it('matches filename substrings case-insensitively', () => { + const image = makeImage({ filename: 'Beach_Sunset.JPG' }) + expect(matchesSearch(image, 'sunset')).toBe(true) + expect(matchesSearch(image, 'SUNSET')).toBe(true) + expect(matchesSearch(image, 'mountain')).toBe(false) + }) +}) + +describe('matchesFilters', () => { + const pass = (image = makeImage()) => + matchesFilters(image, null, 'all', false, 0, false, false, '') + + it('passes with no filters active', () => { + expect(pass()).toBe(true) + }) + + it('filters by folder', () => { + const image = makeImage({ folder_id: 2 }) + expect(matchesFilters(image, 2, 'all', false, 0, false, false, '')).toBe(true) + expect(matchesFilters(image, 3, 'all', false, 0, false, false, '')).toBe(false) + }) + + it('filters by media kind', () => { + const video = makeImage({ media_kind: 'video' }) + expect(matchesFilters(video, null, 'video', false, 0, false, false, '')).toBe(true) + expect(matchesFilters(video, null, 'image', false, 0, false, false, '')).toBe(false) + }) + + it('filters favorites and minimum rating', () => { + const image = makeImage({ favorite: false, rating: 2 }) + expect(matchesFilters(image, null, 'all', true, 0, false, false, '')).toBe(false) + expect(matchesFilters(image, null, 'all', false, 3, false, false, '')).toBe(false) + expect(matchesFilters(image, null, 'all', false, 2, false, false, '')).toBe(true) + }) + + it('filters failed embeddings and failed tagging', () => { + const healthy = makeImage({ embedding_status: 'ready', ai_tagger_error: null }) + const broken = makeImage({ embedding_status: 'failed', ai_tagger_error: 'boom' }) + expect(matchesFilters(healthy, null, 'all', false, 0, true, false, '')).toBe(false) + expect(matchesFilters(broken, null, 'all', false, 0, true, false, '')).toBe(true) + expect(matchesFilters(healthy, null, 'all', false, 0, false, true, '')).toBe(false) + expect(matchesFilters(broken, null, 'all', false, 0, false, true, '')).toBe(true) + }) + + it('applies the search term', () => { + const image = makeImage({ filename: 'cat.jpg' }) + expect(matchesFilters(image, null, 'all', false, 0, false, false, 'cat')).toBe(true) + expect(matchesFilters(image, null, 'all', false, 0, false, false, 'dog')).toBe(false) + }) +}) + +describe('mergeImages', () => { + it('deduplicates by path, letting new records win', () => { + const stale = makeImage({ path: 'C:/media/a.jpg', filename: 'a.jpg', rating: 0 }) + const fresh = makeImage({ path: 'C:/media/a.jpg', filename: 'a.jpg', rating: 5 }) + const merged = mergeImages([stale], [fresh], 'name_asc') + expect(merged).toHaveLength(1) + expect(merged[0].rating).toBe(5) + }) + + it('sorts by name in both directions', () => { + const a = makeImage({ path: 'a', filename: 'apple.jpg' }) + const b = makeImage({ path: 'b', filename: 'banana.jpg' }) + expect(mergeImages([b], [a], 'name_asc').map((i) => i.filename)).toEqual([ + 'apple.jpg', + 'banana.jpg', + ]) + expect(mergeImages([b], [a], 'name_desc').map((i) => i.filename)).toEqual([ + 'banana.jpg', + 'apple.jpg', + ]) + }) + + it('sorts by modified date, treating null as the epoch', () => { + const older = makeImage({ path: 'a', modified_at: '2025-01-01T00:00:00Z' }) + const newer = makeImage({ path: 'b', modified_at: '2026-01-01T00:00:00Z' }) + const undated = makeImage({ path: 'c', modified_at: null }) + const asc = mergeImages([newer, undated], [older], 'date_asc') + expect(asc.map((i) => i.path)).toEqual(['c', 'a', 'b']) + const desc = mergeImages([newer, undated], [older], 'date_desc') + expect(desc.map((i) => i.path)).toEqual(['b', 'a', 'c']) + }) + + it('sorts by size, rating, and duration', () => { + const small = makeImage({ path: 'a', file_size: 10, rating: 1, duration_ms: 100 }) + const large = makeImage({ path: 'b', file_size: 20, rating: 3, duration_ms: null }) + expect(mergeImages([large], [small], 'size_asc')[0].path).toBe('a') + expect(mergeImages([large], [small], 'size_desc')[0].path).toBe('b') + expect(mergeImages([large], [small], 'rating_asc')[0].path).toBe('a') + expect(mergeImages([large], [small], 'rating_desc')[0].path).toBe('b') + // null duration sorts as 0 + expect(mergeImages([large], [small], 'duration_asc')[0].path).toBe('b') + expect(mergeImages([large], [small], 'duration_desc')[0].path).toBe('a') + }) + + it('falls back to modified_at when taken_at is missing', () => { + const taken = makeImage({ + path: 'a', + taken_at: '2024-01-01T00:00:00Z', + modified_at: '2026-01-01T00:00:00Z', + }) + const untaken = makeImage({ + path: 'b', + taken_at: null, + modified_at: '2025-01-01T00:00:00Z', + }) + expect(mergeImages([taken], [untaken], 'taken_asc').map((i) => i.path)).toEqual(['a', 'b']) + expect(mergeImages([taken], [untaken], 'taken_desc').map((i) => i.path)).toEqual(['b', 'a']) + }) +}) + +describe('mergeIntoVisibleWindow', () => { + it('limits the merged list to the window size', () => { + const images = [1, 2, 3, 4].map((n) => makeImage({ path: `p${n}`, filename: `${n}.jpg` })) + const windowed = mergeIntoVisibleWindow(images.slice(0, 2), images.slice(2), 'name_asc', 3) + expect(windowed).toHaveLength(3) + expect(windowed.map((i) => i.filename)).toEqual(['1.jpg', '2.jpg', '3.jpg']) + }) + + it('clamps negative window sizes to zero', () => { + expect(mergeIntoVisibleWindow([makeImage()], [], 'name_asc', -1)).toEqual([]) + }) +}) + +describe('countNewImages', () => { + it('counts only paths not already present', () => { + const current = [makeImage({ path: 'a' })] + const incoming = [makeImage({ path: 'a' }), makeImage({ path: 'b' }), makeImage({ path: 'c' })] + expect(countNewImages(current, incoming)).toBe(2) + }) + + it('counts duplicate new paths once', () => { + const incoming = [makeImage({ path: 'b' }), makeImage({ path: 'b' })] + expect(countNewImages([], incoming)).toBe(1) + }) +}) + +describe('replaceImage / replaceExistingImages', () => { + it('replaceImage swaps the matching record and re-sorts', () => { + const a = makeImage({ path: 'a', filename: 'a.jpg' }) + const b = makeImage({ path: 'b', filename: 'b.jpg' }) + const renamed = makeImage({ path: 'a', filename: 'z.jpg' }) + const result = replaceImage([a, b], renamed, 'name_asc') + expect(result.map((i) => i.filename)).toEqual(['b.jpg', 'z.jpg']) + }) + + it('replaceExistingImages swaps in place without re-sorting', () => { + const a = makeImage({ path: 'a', rating: 0 }) + const b = makeImage({ path: 'b', rating: 0 }) + const updated = makeImage({ path: 'b', rating: 5 }) + const result = replaceExistingImages([b, a], [updated]) + expect(result.map((i) => i.path)).toEqual(['b', 'a']) + expect(result[0].rating).toBe(5) + }) + + it('replaceExistingImages ignores updates for unknown paths and returns the same array', () => { + const current = [makeImage({ path: 'a' })] + const result = replaceExistingImages(current, [makeImage({ path: 'zzz' })]) + expect(result).toBe(current) + }) +}) + +describe('localStorage-backed initial settings', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + function stubLocalStorage(entries: Record) { + const store = new Map(Object.entries(entries)) + vi.stubGlobal('window', { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + }, + }) + } + + it('returns the fallback when window is undefined', () => { + expect(initialBoolSetting('missing', true)).toBe(true) + expect(initialNumberSetting('missing', 7, 0, 10)).toBe(7) + expect(initialAiCaptionsEnabled('missing')).toBe(false) + }) + + it('initialAiCaptionsEnabled only enables on the literal string "true"', () => { + stubLocalStorage({ on: 'true', off: 'yes' }) + expect(initialAiCaptionsEnabled('on')).toBe(true) + expect(initialAiCaptionsEnabled('off')).toBe(false) + expect(initialAiCaptionsEnabled('absent')).toBe(false) + }) + + it('reads booleans from storage', () => { + stubLocalStorage({ on: 'true', off: 'false' }) + expect(initialBoolSetting('on', false)).toBe(true) + expect(initialBoolSetting('off', true)).toBe(false) + expect(initialBoolSetting('absent', true)).toBe(true) + }) + + it('clamps stored numbers to the allowed range', () => { + stubLocalStorage({ low: '-5', high: '999', ok: '4', junk: 'abc' }) + expect(initialNumberSetting('low', 5, 0, 10)).toBe(0) + expect(initialNumberSetting('high', 5, 0, 10)).toBe(10) + expect(initialNumberSetting('ok', 5, 0, 10)).toBe(4) + expect(initialNumberSetting('junk', 5, 0, 10)).toBe(5) + expect(initialNumberSetting('absent', 5, 0, 10)).toBe(5) + }) +}) + +describe('gallery request tokens', () => { + it('only the most recent token is current', () => { + const first = nextGalleryRequestToken() + expect(isCurrentGalleryRequest(first)).toBe(true) + const second = nextGalleryRequestToken() + expect(second).toBeGreaterThan(first) + expect(isCurrentGalleryRequest(first)).toBe(false) + expect(isCurrentGalleryRequest(second)).toBe(true) + }) +}) + +describe('tagging scope helpers', () => { + it('scopeHasTaggingPending checks a single folder scope', () => { + const progress = { 1: makeFolderProgress({ folder_id: 1, tagging_pending: 3 }) } + expect(scopeHasTaggingPending(progress, 1)).toBe(true) + expect(scopeHasTaggingPending(progress, 2)).toBe(false) + }) + + it('scopeHasTaggingPending checks all folders when scope is null', () => { + const progress = { + 1: makeFolderProgress({ folder_id: 1, tagging_pending: 0 }), + 2: makeFolderProgress({ folder_id: 2, tagging_pending: 1 }), + } + expect(scopeHasTaggingPending(progress, null)).toBe(true) + expect(scopeHasTaggingPending({}, null)).toBe(false) + }) + + it('taggingProgressAffectsScope matches null or same-folder scopes', () => { + expect(taggingProgressAffectsScope(1, null)).toBe(true) + expect(taggingProgressAffectsScope(1, 1)).toBe(true) + expect(taggingProgressAffectsScope(1, 2)).toBe(false) + }) + + it('imagesAffectScope checks folder membership', () => { + const images = [makeImage({ folder_id: 1 }), makeImage({ folder_id: 2 })] + expect(imagesAffectScope(images, null)).toBe(true) + expect(imagesAffectScope(images, 2)).toBe(true) + expect(imagesAffectScope(images, 3)).toBe(false) + }) +}) diff --git a/src/test/factories.ts b/src/test/factories.ts new file mode 100644 index 0000000..b4a7b9a --- /dev/null +++ b/src/test/factories.ts @@ -0,0 +1,73 @@ +import type { Folder, FolderJobProgress, ImageRecord } from '../store/types' + +let nextImageId = 1 + +export function makeImage(overrides: Partial = {}): ImageRecord { + const id = overrides.id ?? nextImageId++ + return { + id, + folder_id: 1, + path: `C:/media/image-${id}.jpg`, + filename: `image-${id}.jpg`, + thumbnail_path: null, + width: 1920, + height: 1080, + file_size: 1024, + created_at: null, + modified_at: '2026-01-01T00:00:00Z', + taken_at: null, + mime_type: 'image/jpeg', + media_kind: 'image', + duration_ms: null, + video_codec: null, + audio_codec: null, + metadata_updated_at: null, + metadata_error: null, + favorite: false, + rating: 0, + embedding_status: 'pending', + embedding_model: null, + embedding_updated_at: null, + embedding_error: null, + generated_caption: null, + caption_model: null, + caption_updated_at: null, + caption_error: null, + ai_rating: null, + ai_tagger_model: null, + ai_tagged_at: null, + ai_tagger_error: null, + ...overrides, + } +} + +export function makeFolder(overrides: Partial = {}): Folder { + return { + id: 1, + path: 'C:/media', + name: 'media', + image_count: 0, + indexed_at: null, + scan_error: null, + sort_order: 0, + ...overrides, + } +} + +export function makeFolderProgress(overrides: Partial = {}): FolderJobProgress { + return { + folder_id: 1, + thumbnail_pending: 0, + metadata_pending: 0, + embedding_pending: 0, + embedding_ready: 0, + embedding_failed: 0, + caption_pending: 0, + caption_ready: 0, + caption_failed: 0, + tagging_pending: 0, + tagging_ready: 0, + tagging_failed: 0, + ...overrides, + } +} diff --git a/vite.config.ts b/vite.config.ts index 9534a9f..bb13b6b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' @@ -9,6 +10,11 @@ const host = process.env.TAURI_DEV_HOST export default defineConfig(async () => ({ plugins: [react(), tailwindcss()], + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, + clearScreen: false, server: { port: 1420,