test(app): add bun test infra and first unit tests

bun test as the runner — already the toolchain, zero new deps beyond
@types/bun. 54 tests across 5 files covering the already-pure logic:
FastAPI error normalization (extracted verbatim to lib/api/errors.ts),
clip trim clamping (extracted from StoryTrackEditor to lib/utils/trim.ts,
magic 100 now MIN_CLIP_DURATION_MS), duration/size/engine formatters,
engine-language map consistency, and changelog parsing. Wired into CI
after typecheck.

Known issues surfaced by the tests, left as-is for now: parseChangelog's
heading regex uses \s* which matches newlines, so a dateless heading
followed directly by a bullet swallows that bullet; formatFileSize has
no TB unit; ENGINE_DISPLAY_NAMES lacks tada/kokoro.
This commit is contained in:
Jamie Pine
2026-07-26 23:17:58 -07:00
parent 000c13b6b9
commit cb5a800445
14 changed files with 475 additions and 49 deletions
+3
View File
@@ -29,6 +29,9 @@ jobs:
- name: Typecheck app + web
run: bun run typecheck
- name: Unit tests
run: bun run test
- name: Build web smoke test
run: bun run build:web
+2
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "bun test",
"preview": "vite preview",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
@@ -60,6 +61,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/bun": "^1.3.4",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -38,6 +38,7 @@ import {
useUpdateStoryItemVolume,
} from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { computeTrimValues } from '@/lib/utils/trim';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
@@ -600,41 +601,17 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
const originalDurationMs = item.duration * 1000;
let newTrimStart = initialTrimStart;
let newTrimEnd = initialTrimEnd;
if (trimSide === 'start') {
// Moving right increases trim_start (trims more from start)
// Moving left decreases trim_start (restores from start)
newTrimStart = Math.round(
Math.max(
0,
Math.min(initialTrimStart + deltaMs, originalDurationMs - initialTrimEnd - 100),
),
);
} else {
// Moving right decreases trim_end (restores from end)
// Moving left increases trim_end (trims more from end)
newTrimEnd = Math.round(
Math.max(
0,
Math.min(initialTrimEnd - deltaMs, originalDurationMs - initialTrimStart - 100),
),
);
}
// Validate that we don't exceed duration
if (newTrimStart + newTrimEnd >= originalDurationMs - 100) {
return; // Don't allow trimming to less than 100ms
}
const newTrimValues = computeTrimValues(
trimSide,
deltaMs,
initialTrimStart,
initialTrimEnd,
item.duration * 1000,
);
if (!newTrimValues) return;
// Update temporary trim values for visual feedback
setTempTrimValues({
trim_start_ms: newTrimStart,
trim_end_ms: newTrimEnd,
});
setTempTrimValues(newTrimValues);
},
[trimmingItem, trimSide, trimStartX, pixelsToMs],
);
+1 -15
View File
@@ -1,4 +1,5 @@
import type { LanguageCode } from '@/lib/constants/languages';
import { formatErrorDetail } from '@/lib/api/errors';
import { useServerStore } from '@/stores/serverStore';
import type {
ActiveTasksResponse,
@@ -55,21 +56,6 @@ import type {
CloudStatus,
} from './types';
function formatErrorDetail(detail: unknown, fallback: string): string {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail
.map((e: Record<string, unknown>) => e.msg || e.message || JSON.stringify(e))
.join('; ');
}
if (detail && typeof detail === 'object') {
const obj = detail as Record<string, unknown>;
if (typeof obj.message === 'string') return obj.message;
return JSON.stringify(detail);
}
return fallback;
}
class ApiClient {
private getBaseUrl(): string {
const serverUrl = useServerStore.getState().serverUrl;
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { formatErrorDetail } from './errors';
const FALLBACK = 'HTTP error! status: 500';
describe('formatErrorDetail', () => {
test('returns string details as-is', () => {
expect(formatErrorDetail('Profile not found', FALLBACK)).toBe('Profile not found');
});
test('returns empty string details as-is (not the fallback)', () => {
expect(formatErrorDetail('', FALLBACK)).toBe('');
});
test('joins FastAPI validation error arrays on msg', () => {
const detail = [
{ loc: ['body', 'text'], msg: 'field required', type: 'value_error.missing' },
{ loc: ['body', 'seed'], msg: 'value is not a valid integer', type: 'type_error.integer' },
];
expect(formatErrorDetail(detail, FALLBACK)).toBe(
'field required; value is not a valid integer',
);
});
test('falls back to message key within array entries', () => {
expect(formatErrorDetail([{ message: 'boom' }], FALLBACK)).toBe('boom');
});
test('stringifies array entries with neither msg nor message', () => {
expect(formatErrorDetail([{ code: 42 }], FALLBACK)).toBe('{"code":42}');
});
test('returns empty string for an empty array', () => {
expect(formatErrorDetail([], FALLBACK)).toBe('');
});
test('uses message property of object details', () => {
expect(formatErrorDetail({ message: 'engine offline' }, FALLBACK)).toBe('engine offline');
});
test('stringifies objects without a string message', () => {
expect(formatErrorDetail({ message: 42, hint: 'x' }, FALLBACK)).toBe(
'{"message":42,"hint":"x"}',
);
expect(formatErrorDetail({ error: 'nested' }, FALLBACK)).toBe('{"error":"nested"}');
});
test('falls back for null, undefined, and primitives', () => {
expect(formatErrorDetail(null, FALLBACK)).toBe(FALLBACK);
expect(formatErrorDetail(undefined, FALLBACK)).toBe(FALLBACK);
expect(formatErrorDetail(404, FALLBACK)).toBe(FALLBACK);
expect(formatErrorDetail(true, FALLBACK)).toBe(FALLBACK);
});
test('preserves unicode in messages', () => {
expect(formatErrorDetail('模型未加载 🎙️', FALLBACK)).toBe('模型未加载 🎙️');
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Normalizes a FastAPI error `detail` payload into a human-readable message.
*
* FastAPI returns `detail` as a plain string for HTTPException, an array of
* validation error objects for 422 responses, or an arbitrary object for
* custom handlers. Anything unrecognized falls back to the provided default.
*/
export function formatErrorDetail(detail: unknown, fallback: string): string {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail
.map((e: Record<string, unknown>) => e.msg || e.message || JSON.stringify(e))
.join('; ');
}
if (detail && typeof detail === 'object') {
const obj = detail as Record<string, unknown>;
if (typeof obj.message === 'string') return obj.message;
return JSON.stringify(detail);
}
return fallback;
}
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import {
ALL_LANGUAGES,
ENGINE_LANGUAGES,
LANGUAGE_CODES,
LANGUAGE_OPTIONS,
getLanguageOptionsForEngine,
} from './languages';
describe('ENGINE_LANGUAGES', () => {
test('every engine maps only to codes defined in ALL_LANGUAGES', () => {
for (const [engine, codes] of Object.entries(ENGINE_LANGUAGES)) {
for (const code of codes) {
expect(ALL_LANGUAGES[code], `${engine} references unknown code "${code}"`).toBeDefined();
}
}
});
test('no engine lists a language twice', () => {
for (const [engine, codes] of Object.entries(ENGINE_LANGUAGES)) {
expect(new Set(codes).size, `${engine} has duplicate codes`).toBe(codes.length);
}
});
test('every engine supports at least English', () => {
for (const codes of Object.values(ENGINE_LANGUAGES)) {
expect(codes).toContain('en');
}
});
test('English-only engines list exactly one language', () => {
expect(ENGINE_LANGUAGES.luxtts).toEqual(['en']);
expect(ENGINE_LANGUAGES.chatterbox_turbo).toEqual(['en']);
});
test('qwen and qwen_custom_voice support the same languages', () => {
expect(ENGINE_LANGUAGES.qwen_custom_voice).toEqual(ENGINE_LANGUAGES.qwen);
});
});
describe('getLanguageOptionsForEngine', () => {
test('builds value/label pairs from ALL_LANGUAGES', () => {
expect(getLanguageOptionsForEngine('luxtts')).toEqual([{ value: 'en', label: 'English' }]);
});
test('preserves the engine declaration order', () => {
const values = getLanguageOptionsForEngine('qwen').map((o) => o.value);
expect(values).toEqual([...ENGINE_LANGUAGES.qwen]);
});
test('falls back to qwen languages for unknown engines', () => {
expect(getLanguageOptionsForEngine('does-not-exist')).toEqual(
getLanguageOptionsForEngine('qwen'),
);
});
});
describe('language option exports', () => {
test('LANGUAGE_CODES covers every ALL_LANGUAGES key exactly once', () => {
const codes: string[] = [...LANGUAGE_CODES].sort();
expect(codes).toEqual(Object.keys(ALL_LANGUAGES).sort());
expect(new Set(LANGUAGE_CODES).size).toBe(LANGUAGE_CODES.length);
});
test('LANGUAGE_OPTIONS labels match ALL_LANGUAGES', () => {
for (const option of LANGUAGE_OPTIONS) {
expect(option.label).toBe(ALL_LANGUAGES[option.value]);
}
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test';
import { formatDuration, formatEngineName, formatFileSize } from './format';
describe('formatDuration', () => {
test('formats zero', () => {
expect(formatDuration(0)).toBe('0:00');
});
test('pads single-digit seconds', () => {
expect(formatDuration(65)).toBe('1:05');
});
test('handles the minute boundary', () => {
expect(formatDuration(59)).toBe('0:59');
expect(formatDuration(60)).toBe('1:00');
});
test('floors fractional seconds', () => {
expect(formatDuration(89.9)).toBe('1:29');
});
test('does not roll minutes into hours', () => {
expect(formatDuration(3661)).toBe('61:01');
});
});
describe('formatFileSize', () => {
test('special-cases zero', () => {
expect(formatFileSize(0)).toBe('0 Bytes');
});
test('formats bytes below 1 KB', () => {
expect(formatFileSize(512)).toBe('512 Bytes');
expect(formatFileSize(1023)).toBe('1023 Bytes');
});
test('formats KB, MB, and GB boundaries', () => {
expect(formatFileSize(1024)).toBe('1 KB');
expect(formatFileSize(1024 ** 2)).toBe('1 MB');
expect(formatFileSize(1024 ** 3)).toBe('1 GB');
});
test('rounds to two decimal places', () => {
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(2_684_354_560)).toBe('2.5 GB');
expect(formatFileSize(1_234_567)).toBe('1.18 MB');
});
});
describe('formatEngineName', () => {
test('maps known engines to display names', () => {
expect(formatEngineName('luxtts')).toBe('LuxTTS');
expect(formatEngineName('chatterbox')).toBe('Chatterbox');
expect(formatEngineName('chatterbox_turbo')).toBe('Chatterbox Turbo');
});
test('defaults to Qwen when engine is undefined', () => {
expect(formatEngineName()).toBe('Qwen');
expect(formatEngineName(undefined, '1.7B')).toBe('Qwen');
});
test('appends the model size for qwen only', () => {
expect(formatEngineName('qwen', '1.7B')).toBe('Qwen 1.7B');
expect(formatEngineName('qwen')).toBe('Qwen');
expect(formatEngineName('luxtts', '1.7B')).toBe('LuxTTS');
});
test('passes unknown engines through verbatim', () => {
expect(formatEngineName('kokoro')).toBe('kokoro');
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, test } from 'bun:test';
import { parseChangelog } from './parseChangelog';
const SAMPLE = `# Changelog
All notable changes to this project will be documented in this file.
## [0.5.0] - 2026-06-01
### Added
- Story track editor
- Cloud login
## [0.4.1]
### Fixed
- Trim clamping
## [0.4.0] - 2026-04-15
Initial public release.
[0.5.0]: https://example.com/compare/v0.4.1...v0.5.0
[0.4.1]: https://example.com/compare/v0.4.0...v0.4.1
`;
describe('parseChangelog', () => {
test('splits entries on version headings', () => {
const entries = parseChangelog(SAMPLE);
expect(entries.map((e) => e.version)).toEqual(['0.5.0', '0.4.1', '0.4.0']);
});
test('extracts the date when present and null otherwise', () => {
const entries = parseChangelog(SAMPLE);
expect(entries[0].date).toBe('2026-06-01');
expect(entries[1].date).toBeNull();
});
test('keeps the markdown body between headings', () => {
const entries = parseChangelog(SAMPLE);
expect(entries[0].body).toBe('### Added\n\n- Story track editor\n- Cloud login');
expect(entries[2].body).toBe('Initial public release.');
});
test('strips trailing link reference definitions from the last body', () => {
const entries = parseChangelog(SAMPLE);
expect(entries[2].body).toBe('Initial public release.');
expect(entries[2].body).not.toContain('example.com');
});
test('returns an empty array when no headings match', () => {
expect(parseChangelog('')).toEqual([]);
expect(parseChangelog('# Changelog\n\nNothing yet.')).toEqual([]);
});
test('handles a heading with an empty body', () => {
const entries = parseChangelog('## [1.0.0] - 2026-01-01\n');
expect(entries).toEqual([{ version: '1.0.0', date: '2026-01-01', body: '' }]);
});
test('accepts non-semver headings like Unreleased', () => {
const entries = parseChangelog('## [Unreleased]\n\n### Added\n\n- WIP\n');
expect(entries[0].version).toBe('Unreleased');
expect(entries[0].date).toBeNull();
expect(entries[0].body).toBe('### Added\n\n- WIP');
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, test } from 'bun:test';
import { MIN_CLIP_DURATION_MS, computeTrimValues } from './trim';
// A 10-second clip with no existing trims unless stated otherwise.
const DURATION = 10_000;
describe('computeTrimValues', () => {
describe('start handle', () => {
test('dragging right trims from the start', () => {
expect(computeTrimValues('start', 500, 0, 0, DURATION)).toEqual({
trim_start_ms: 500,
trim_end_ms: 0,
});
});
test('dragging left restores previously trimmed audio', () => {
expect(computeTrimValues('start', -300, 1000, 0, DURATION)).toEqual({
trim_start_ms: 700,
trim_end_ms: 0,
});
});
test('clamps at zero when restoring past the clip start', () => {
expect(computeTrimValues('start', -5000, 1000, 0, DURATION)).toEqual({
trim_start_ms: 0,
trim_end_ms: 0,
});
});
test('never trims below the minimum clip duration', () => {
const result = computeTrimValues('start', 99_999, 0, 2000, DURATION);
// Clamp lands exactly on the minimum, which the guard rejects.
expect(result).toBeNull();
});
test('rounds fractional millisecond deltas', () => {
expect(computeTrimValues('start', 100.6, 0, 0, DURATION)).toEqual({
trim_start_ms: 101,
trim_end_ms: 0,
});
});
test('preserves the untouched end trim', () => {
expect(computeTrimValues('start', 250, 0, 400, DURATION)).toEqual({
trim_start_ms: 250,
trim_end_ms: 400,
});
});
});
describe('end handle', () => {
test('dragging left trims from the end', () => {
expect(computeTrimValues('end', -500, 0, 0, DURATION)).toEqual({
trim_start_ms: 0,
trim_end_ms: 500,
});
});
test('dragging right restores previously trimmed audio', () => {
expect(computeTrimValues('end', 300, 0, 1000, DURATION)).toEqual({
trim_start_ms: 0,
trim_end_ms: 700,
});
});
test('clamps at zero when restoring past the clip end', () => {
expect(computeTrimValues('end', 5000, 0, 1000, DURATION)).toEqual({
trim_start_ms: 0,
trim_end_ms: 0,
});
});
test('never trims below the minimum clip duration', () => {
expect(computeTrimValues('end', -99_999, 3000, 0, DURATION)).toBeNull();
});
});
describe('minimum duration guard', () => {
test('rejects drags that leave less than the minimum audible clip', () => {
// 9.5s already trimmed; taking 450ms more leaves only 50ms.
expect(computeTrimValues('start', 450, 5000, 4500, DURATION)).toBeNull();
});
test('allows a drag that leaves just over the minimum', () => {
expect(computeTrimValues('start', 399, 5000, 4500, DURATION)).toEqual({
trim_start_ms: 5399,
trim_end_ms: 4500,
});
});
test('boundary: exactly the minimum remaining is rejected', () => {
// trim_start + trim_end === duration - MIN_CLIP_DURATION_MS
expect(
computeTrimValues('start', 400, 5000, 4500, DURATION)?.trim_start_ms ?? null,
).toBeNull();
expect(MIN_CLIP_DURATION_MS).toBe(100);
});
});
test('zero delta is a no-op that returns the initial trims', () => {
expect(computeTrimValues('start', 0, 1200, 800, DURATION)).toEqual({
trim_start_ms: 1200,
trim_end_ms: 800,
});
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { StoryItemTrim } from '@/lib/api/types';
/** Clips are never allowed to shrink below this effective duration. */
export const MIN_CLIP_DURATION_MS = 100;
/**
* Computes new trim values for a clip while a trim handle is being dragged.
*
* `deltaMs` is the signed drag distance converted to milliseconds. Dragging
* the start handle right increases `trim_start_ms` (trims more from the
* start); dragging it left restores. The end handle mirrors this for
* `trim_end_ms`. Both values are clamped so the clip keeps at least
* MIN_CLIP_DURATION_MS of audible content.
*
* Returns null when the drag would leave less than the minimum duration.
*/
export function computeTrimValues(
side: 'start' | 'end',
deltaMs: number,
initialTrimStart: number,
initialTrimEnd: number,
originalDurationMs: number,
): StoryItemTrim | null {
let newTrimStart = initialTrimStart;
let newTrimEnd = initialTrimEnd;
if (side === 'start') {
newTrimStart = Math.round(
Math.max(
0,
Math.min(
initialTrimStart + deltaMs,
originalDurationMs - initialTrimEnd - MIN_CLIP_DURATION_MS,
),
),
);
} else {
newTrimEnd = Math.round(
Math.max(
0,
Math.min(
initialTrimEnd - deltaMs,
originalDurationMs - initialTrimStart - MIN_CLIP_DURATION_MS,
),
),
);
}
if (newTrimStart + newTrimEnd >= originalDurationMs - MIN_CLIP_DURATION_MS) {
return null;
}
return {
trim_start_ms: newTrimStart,
trim_end_ms: newTrimEnd,
};
}
+1 -1
View File
@@ -25,7 +25,7 @@
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
"types": ["vite/client", "bun"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
+5
View File
@@ -65,6 +65,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/bun": "^1.3.4",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
@@ -611,6 +612,8 @@
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="],
@@ -685,6 +688,8 @@
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
+1
View File
@@ -23,6 +23,7 @@
"update:icons": "./scripts/update-icons.sh",
"convert:assets": "./scripts/convert-assets.sh",
"lint": "biome lint .",
"test": "cd app && bun test",
"typecheck": "bunx tsc -p app/tsconfig.json --noEmit && cd web && bunx tsc --noEmit",
"lint:fix": "biome lint --write .",
"format": "biome format --write .",