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
+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,
};
}