From e80ebd35cb6ef7e7ed38e159873a9985cb6858cf Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 30 Jun 2026 08:45:22 -0400 Subject: [PATCH] test: add unit-test harness (tsx + node:test) + first suite for utils/common MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the "no automated test suite" gap. Chose Node's built-in test runner via tsx rather than vitest: the project is on Vite 8.0.14, ahead of vitest's supported Vite range, so vitest would fight peer deps. tsx is build-independent. - `npm test` → `node --import tsx --test $(find src -name '*.test.ts')` (works on Node 20 local + 24 CI without relying on --test glob support). - src/app/utils/common.test.ts: 15 tests covering the pure helpers (bytesToSize, time formatters, binarySearch, parseGeoUri, slash trimmers, nameInitials, randomStr, suffixRename, splitWithSpace, promise-settled helpers, etc.) — asserts actual behavior, traced from source. - common.ts: folds import made `import type` (it's types only) so the module is pure and testable without loading folds/CSS. - tsconfig excludes *.test.ts (tsx transpiles tests; eslint isn't type-aware so it still lints them); added an informational CI "Unit tests" step (promote to a hard gate by dropping continue-on-error). Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/ci.yml | 7 ++ package-lock.json | 20 +++++ package.json | 2 + src/app/utils/common.test.ts | 146 +++++++++++++++++++++++++++++++++++ src/app/utils/common.ts | 2 +- tsconfig.json | 2 +- 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/app/utils/common.test.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 18ea85265..c312178df 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -48,6 +48,13 @@ jobs: VITE_APP_VERSION: ${{ github.sha }} # ── Quality checks (informational — pre-existing issues exist) ─────── + # Unit tests run on Node's built-in runner via tsx (no vitest — Vite 8 is + # ahead of vitest's supported range). Informational for now to match the + # section's convention; promote to a hard gate by dropping continue-on-error. + - name: Unit tests + run: npm test + continue-on-error: true + - name: TypeScript run: npm run typecheck continue-on-error: true diff --git a/package-lock.json b/package-lock.json index b3ceb70c6..23bac371b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,7 @@ "husky": "9.1.7", "lint-staged": "17.0.5", "prettier": "3.8.3", + "tsx": "4.22.4", "typescript": "6.0.3", "vite": "8.0.14", "vite-plugin-pwa": "1.3.0", @@ -12357,6 +12358,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 9115ba991..ca9d5726a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", + "test": "node --import tsx --test $(find src -name '*.test.ts')", "prepare": "husky", "commit": "git-cz", "postinstall": "node scripts/patch-folds.mjs", @@ -132,6 +133,7 @@ "husky": "9.1.7", "lint-staged": "17.0.5", "prettier": "3.8.3", + "tsx": "4.22.4", "typescript": "6.0.3", "vite": "8.0.14", "vite-plugin-pwa": "1.3.0", diff --git a/src/app/utils/common.test.ts b/src/app/utils/common.test.ts new file mode 100644 index 000000000..0119f2342 --- /dev/null +++ b/src/app/utils/common.test.ts @@ -0,0 +1,146 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + bytesToSize, + millisecondsToMinutesAndSeconds, + millisecondsToMinutes, + secondsToMinutesAndSeconds, + binarySearch, + randomNumberBetween, + scaleYDimension, + parseGeoUri, + trimLeadingSlash, + trimTrailingSlash, + trimSlash, + nameInitials, + randomStr, + suffixRename, + replaceSpaceWithDash, + splitWithSpace, + fulfilledPromiseSettledResult, + promiseFulfilledResult, + promiseRejectedResult, +} from './common'; + +test('bytesToSize', () => { + assert.equal(bytesToSize(0), '0KB'); + assert.equal(bytesToSize(500), '0.5 KB'); // sub-KB is floored up to the KB unit + assert.equal(bytesToSize(1500), '1.5 KB'); + assert.equal(bytesToSize(1_500_000), '1.5 MB'); + assert.equal(bytesToSize(1_500_000_000), '1.5 GB'); +}); + +test('millisecondsToMinutesAndSeconds zero-pads seconds', () => { + assert.equal(millisecondsToMinutesAndSeconds(0), '0:00'); + assert.equal(millisecondsToMinutesAndSeconds(5000), '0:05'); + assert.equal(millisecondsToMinutesAndSeconds(65_000), '1:05'); + assert.equal(millisecondsToMinutesAndSeconds(125_000), '2:05'); +}); + +test('millisecondsToMinutes floors to whole minutes', () => { + assert.equal(millisecondsToMinutes(0), '0'); + assert.equal(millisecondsToMinutes(59_000), '0'); + assert.equal(millisecondsToMinutes(125_000), '2'); +}); + +test('secondsToMinutesAndSeconds', () => { + assert.equal(secondsToMinutesAndSeconds(5), '0:05'); + assert.equal(secondsToMinutesAndSeconds(65), '1:05'); + assert.equal(secondsToMinutesAndSeconds(90), '1:30'); +}); + +test('binarySearch finds and misses on an ascending array', () => { + const arr = [1, 3, 5, 7, 9]; + const matcher = (target: number) => (item: number) => + item === target ? 0 : item > target ? 1 : -1; + assert.equal(binarySearch(arr, matcher(7)), 7); + assert.equal(binarySearch(arr, matcher(1)), 1); + assert.equal(binarySearch(arr, matcher(9)), 9); + assert.equal(binarySearch(arr, matcher(4)), undefined); + assert.equal(binarySearch([], matcher(1)), undefined); +}); + +test('randomNumberBetween stays within inclusive bounds', () => { + for (let i = 0; i < 1000; i += 1) { + const n = randomNumberBetween(3, 7); + assert.ok(n >= 3 && n <= 7, `${n} out of [3,7]`); + assert.equal(Number.isInteger(n), true); + } + assert.equal(randomNumberBetween(5, 5), 5); +}); + +test('scaleYDimension preserves aspect ratio', () => { + assert.equal(scaleYDimension(100, 50, 200), 100); + assert.equal(scaleYDimension(200, 200, 80), 80); +}); + +test('parseGeoUri', () => { + assert.deepEqual(parseGeoUri('geo:37.7,-122.4'), { latitude: '37.7', longitude: '-122.4' }); + assert.deepEqual(parseGeoUri('geo:37.7,-122.4;u=10'), { + latitude: '37.7', + longitude: '-122.4', + }); + assert.equal(parseGeoUri('not-a-geo-uri'), undefined); + assert.equal(parseGeoUri(''), undefined); +}); + +test('slash trimmers', () => { + assert.equal(trimLeadingSlash('///a/b'), 'a/b'); + assert.equal(trimTrailingSlash('a/b///'), 'a/b'); + assert.equal(trimSlash('//a/b//'), 'a/b'); + assert.equal(trimSlash('a/b'), 'a/b'); +}); + +test('nameInitials', () => { + assert.equal(nameInitials('Alice'), 'A'); + assert.equal(nameInitials('alice'), 'a'); + assert.equal(nameInitials('Alice Bob', 2), 'Al'); // takes characters, not words + // empty/nullish all collapse to the same single-char fallback + const fallback = nameInitials(''); + assert.equal(fallback.length, 1); + assert.equal(nameInitials(null), fallback); + assert.equal(nameInitials(undefined), fallback); +}); + +test('randomStr length and charset', () => { + const allowed = /^[A-Za-z0-9]+$/; + assert.equal(randomStr(12).length, 12); + assert.equal(randomStr(1).length, 1); + assert.match(randomStr(32), allowed); +}); + +test('suffixRename increments until the validator rejects', () => { + // validator returns true while the candidate is still "taken" + assert.equal( + suffixRename('file', (n) => n === 'file1'), + 'file2', + ); + assert.equal( + suffixRename('doc', () => false), + 'doc1', + ); +}); + +test('replaceSpaceWithDash', () => { + assert.equal(replaceSpaceWithDash('a b c'), 'a-b-c'); + assert.equal(replaceSpaceWithDash('nospaces'), 'nospaces'); +}); + +test('splitWithSpace trims and drops empties', () => { + assert.deepEqual(splitWithSpace(' a b '), ['a', 'b']); + assert.deepEqual(splitWithSpace(' '), []); + assert.deepEqual(splitWithSpace('hello'), ['hello']); +}); + +test('promise settled-result helpers', () => { + const settled: PromiseSettledResult[] = [ + { status: 'fulfilled', value: 1 }, + { status: 'rejected', reason: 'boom' }, + { status: 'fulfilled', value: 2 }, + ]; + assert.deepEqual(fulfilledPromiseSettledResult(settled), [1, 2]); + assert.equal(promiseFulfilledResult(settled[0]), 1); + assert.equal(promiseFulfilledResult(settled[1]), undefined); + assert.equal(promiseRejectedResult(settled[1]), 'boom'); + assert.equal(promiseRejectedResult(settled[0]), undefined); +}); diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index 264305a67..c36d58ccf 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -1,4 +1,4 @@ -import { IconName, IconSrc } from 'folds'; +import type { IconName, IconSrc } from 'folds'; export const bytesToSize = (bytes: number): string => { const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; diff --git a/tsconfig.json b/tsconfig.json index 3582e9fa4..810b93984 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,6 @@ "skipLibCheck": true, "lib": ["ES2020", "DOM"] }, - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], "include": ["src"] }