mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-20 15:20:39 -07:00
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:
@@ -29,6 +29,9 @@ jobs:
|
|||||||
- name: Typecheck app + web
|
- name: Typecheck app + web
|
||||||
run: bun run typecheck
|
run: bun run typecheck
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: bun run test
|
||||||
|
|
||||||
- name: Build web smoke test
|
- name: Build web smoke test
|
||||||
run: bun run build:web
|
run: bun run build:web
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "bun test",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "biome lint src",
|
"lint": "biome lint src",
|
||||||
"lint:fix": "biome lint --write src",
|
"lint:fix": "biome lint --write src",
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/bun": "^1.3.4",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
useUpdateStoryItemVolume,
|
useUpdateStoryItemVolume,
|
||||||
} from '@/lib/hooks/useStories';
|
} from '@/lib/hooks/useStories';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { computeTrimValues } from '@/lib/utils/trim';
|
||||||
import { useGenerationStore } from '@/stores/generationStore';
|
import { useGenerationStore } from '@/stores/generationStore';
|
||||||
import { useStoryStore } from '@/stores/storyStore';
|
import { useStoryStore } from '@/stores/storyStore';
|
||||||
|
|
||||||
@@ -600,41 +601,17 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
|||||||
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
|
const deltaMs = pixelsToMs(deltaX); // Signed delta in milliseconds
|
||||||
|
|
||||||
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
const { item, initialTrimStart, initialTrimEnd } = trimStartItemRef.current;
|
||||||
const originalDurationMs = item.duration * 1000;
|
const newTrimValues = computeTrimValues(
|
||||||
|
trimSide,
|
||||||
let newTrimStart = initialTrimStart;
|
deltaMs,
|
||||||
let newTrimEnd = initialTrimEnd;
|
initialTrimStart,
|
||||||
|
initialTrimEnd,
|
||||||
if (trimSide === 'start') {
|
item.duration * 1000,
|
||||||
// Moving right increases trim_start (trims more from start)
|
);
|
||||||
// Moving left decreases trim_start (restores from start)
|
if (!newTrimValues) return;
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update temporary trim values for visual feedback
|
// Update temporary trim values for visual feedback
|
||||||
setTempTrimValues({
|
setTempTrimValues(newTrimValues);
|
||||||
trim_start_ms: newTrimStart,
|
|
||||||
trim_end_ms: newTrimEnd,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[trimmingItem, trimSide, trimStartX, pixelsToMs],
|
[trimmingItem, trimSide, trimStartX, pixelsToMs],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LanguageCode } from '@/lib/constants/languages';
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
|
import { formatErrorDetail } from '@/lib/api/errors';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import type {
|
import type {
|
||||||
ActiveTasksResponse,
|
ActiveTasksResponse,
|
||||||
@@ -55,21 +56,6 @@ import type {
|
|||||||
CloudStatus,
|
CloudStatus,
|
||||||
} from './types';
|
} 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 {
|
class ApiClient {
|
||||||
private getBaseUrl(): string {
|
private getBaseUrl(): string {
|
||||||
const serverUrl = useServerStore.getState().serverUrl;
|
const serverUrl = useServerStore.getState().serverUrl;
|
||||||
|
|||||||
@@ -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('模型未加载 🎙️');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -25,7 +25,7 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/bun": "^1.3.4",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.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/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/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="],
|
"@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=="],
|
"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=="],
|
"callsites": ["[email protected]", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
"camelcase-css": ["[email protected]", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"update:icons": "./scripts/update-icons.sh",
|
"update:icons": "./scripts/update-icons.sh",
|
||||||
"convert:assets": "./scripts/convert-assets.sh",
|
"convert:assets": "./scripts/convert-assets.sh",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
|
"test": "cd app && bun test",
|
||||||
"typecheck": "bunx tsc -p app/tsconfig.json --noEmit && cd web && bunx tsc --noEmit",
|
"typecheck": "bunx tsc -p app/tsconfig.json --noEmit && cd web && bunx tsc --noEmit",
|
||||||
"lint:fix": "biome lint --write .",
|
"lint:fix": "biome lint --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
Reference in New Issue
Block a user