217 lines
5.4 KiB
TypeScript
217 lines
5.4 KiB
TypeScript
import {
|
||
cleanup,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
} from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { WaveformEditor } from '../../../src/components/WaveformEditor';
|
||
import { aggregatePcmPeaks } from '../../../src/waveform';
|
||
|
||
describe('WaveformEditor', () => {
|
||
afterEach(cleanup);
|
||
|
||
beforeEach(() => {
|
||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||
createCanvasContext()
|
||
);
|
||
});
|
||
|
||
it('renders a bounded canvas with accessible trim and marker controls', async () => {
|
||
render(
|
||
<WaveformEditor
|
||
peaks={createPeaks()}
|
||
inSeconds={1}
|
||
outSeconds={9}
|
||
markers={[3]}
|
||
currentTimeSeconds={5}
|
||
onChange={vi.fn()}
|
||
/>
|
||
);
|
||
|
||
const canvas = document.querySelector('canvas');
|
||
if (!(canvas instanceof HTMLCanvasElement)) {
|
||
throw new Error('Missing waveform canvas');
|
||
}
|
||
await waitFor(() =>
|
||
expect(canvas).toHaveAttribute(
|
||
'aria-label',
|
||
expect.stringContaining('Selected 00:00:01.000–00:00:09.000')
|
||
)
|
||
);
|
||
expect(
|
||
screen.getByRole('slider', { name: 'In trim handle' })
|
||
).toHaveAttribute('aria-valuenow', '1');
|
||
expect(
|
||
screen.getByRole('slider', { name: 'Out trim handle' })
|
||
).toHaveAttribute('aria-valuenow', '9');
|
||
expect(
|
||
screen.getByRole('slider', { name: 'Split marker 1' })
|
||
).toHaveAttribute('aria-valuenow', '3');
|
||
expect(screen.getByText('Single sequential audio track')).toBeVisible();
|
||
});
|
||
|
||
it('emits validated keyboard edits and current-time actions', async () => {
|
||
const user = userEvent.setup();
|
||
const onChange = vi.fn();
|
||
render(
|
||
<WaveformEditor
|
||
peaks={createPeaks()}
|
||
inSeconds={1}
|
||
outSeconds={9}
|
||
markers={[3]}
|
||
currentTimeSeconds={4}
|
||
onChange={onChange}
|
||
/>
|
||
);
|
||
|
||
fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), {
|
||
key: 'ArrowRight',
|
||
});
|
||
expect(onChange).toHaveBeenLastCalledWith({
|
||
inSeconds: 1.1,
|
||
outSeconds: 9,
|
||
markers: [3],
|
||
});
|
||
|
||
await user.click(
|
||
screen.getByRole('button', { name: 'Add split at current time' })
|
||
);
|
||
expect(onChange).toHaveBeenLastCalledWith({
|
||
inSeconds: 1,
|
||
outSeconds: 9,
|
||
markers: [3, 4],
|
||
});
|
||
|
||
fireEvent.keyDown(screen.getByRole('slider', { name: 'Split marker 1' }), {
|
||
key: 'Delete',
|
||
});
|
||
expect(onChange).toHaveBeenLastCalledWith({
|
||
inSeconds: 1,
|
||
outSeconds: 9,
|
||
markers: [],
|
||
});
|
||
});
|
||
|
||
it('drags split markers and seeks using the visible zoomed range', async () => {
|
||
const onChange = vi.fn();
|
||
const onSeek = vi.fn();
|
||
const { container } = render(
|
||
<WaveformEditor
|
||
peaks={createPeaks()}
|
||
inSeconds={0}
|
||
outSeconds={10}
|
||
markers={[2]}
|
||
onChange={onChange}
|
||
onSeek={onSeek}
|
||
/>
|
||
);
|
||
const stage = container.querySelector('.waveform-editor__stage');
|
||
if (!(stage instanceof HTMLElement)) {
|
||
throw new Error('Missing waveform stage');
|
||
}
|
||
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
|
||
x: 0,
|
||
y: 0,
|
||
left: 0,
|
||
top: 0,
|
||
right: 100,
|
||
bottom: 168,
|
||
width: 100,
|
||
height: 168,
|
||
toJSON: () => ({}),
|
||
});
|
||
const marker = screen.getByRole('slider', { name: 'Split marker 1' });
|
||
|
||
fireEvent.pointerDown(marker, {
|
||
pointerId: 11,
|
||
clientX: 20,
|
||
clientY: 20,
|
||
});
|
||
fireEvent.pointerMove(marker, {
|
||
pointerId: 11,
|
||
clientX: 50,
|
||
clientY: 20,
|
||
});
|
||
fireEvent.pointerUp(marker, { pointerId: 11 });
|
||
expect(onChange).toHaveBeenLastCalledWith({
|
||
inSeconds: 0,
|
||
outSeconds: 10,
|
||
markers: [5],
|
||
});
|
||
|
||
fireEvent.pointerDown(stage, {
|
||
button: 0,
|
||
clientX: 75,
|
||
clientY: 30,
|
||
});
|
||
expect(onSeek).toHaveBeenLastCalledWith(7.5);
|
||
|
||
fireEvent.change(screen.getByRole('slider', { name: 'Zoom waveform' }), {
|
||
target: { value: '2' },
|
||
});
|
||
expect(
|
||
screen.getByRole('slider', { name: 'Scroll waveform' })
|
||
).toBeEnabled();
|
||
});
|
||
|
||
it('normalizes untrusted selection values and caps marker count', () => {
|
||
render(
|
||
<WaveformEditor
|
||
peaks={createPeaks()}
|
||
inSeconds={Number.NaN}
|
||
outSeconds={999}
|
||
markers={[
|
||
Number.NaN,
|
||
-1,
|
||
2,
|
||
2.0004,
|
||
...Array.from({ length: 510 }, (_, index) => 2.01 + index * 0.01),
|
||
]}
|
||
onChange={vi.fn()}
|
||
/>
|
||
);
|
||
|
||
expect(
|
||
screen.getByText(/Selected 00:00:00.000–00:00:10.000/u)
|
||
).toBeVisible();
|
||
expect(screen.getByText('254/254')).toBeVisible();
|
||
expect(
|
||
screen.getAllByRole('slider', { name: /Split marker/u })
|
||
).toHaveLength(254);
|
||
});
|
||
});
|
||
|
||
function createPeaks() {
|
||
return aggregatePcmPeaks(
|
||
{
|
||
container: 'raw',
|
||
data: new Int16Array(
|
||
Array.from({ length: 100 }, (_, index) =>
|
||
Math.round(Math.sin(index / 5) * 20_000)
|
||
)
|
||
),
|
||
encoding: 's16le',
|
||
sampleRate: 10,
|
||
},
|
||
{ bucketCount: 100 }
|
||
);
|
||
}
|
||
|
||
function createCanvasContext(): CanvasRenderingContext2D {
|
||
return {
|
||
setTransform: vi.fn(),
|
||
clearRect: vi.fn(),
|
||
fillRect: vi.fn(),
|
||
beginPath: vi.fn(),
|
||
moveTo: vi.fn(),
|
||
lineTo: vi.fn(),
|
||
stroke: vi.fn(),
|
||
fillStyle: '',
|
||
strokeStyle: '',
|
||
lineWidth: 1,
|
||
} as unknown as CanvasRenderingContext2D;
|
||
}
|