test: add unit-test harness (tsx + node:test) + first suite for utils/common
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<number>[] = [
|
||||
{ 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);
|
||||
});
|
||||
@@ -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'];
|
||||
|
||||
+1
-1
@@ -13,6 +13,6 @@
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2020", "DOM"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"],
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user