Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 131 additions & 38 deletions __tests__/components/youtube-import/TrimRangeSlider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useState, type ComponentProps } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
TrimRangeSlider,
applyTrimHandleSeconds,
nudgeTrimHandleValue,
pickClosestKeyframe,
} from '@/components/youtube-import/TrimRangeSlider';
Expand Down Expand Up @@ -90,6 +91,24 @@ describe('nudgeTrimHandleValue', () => {
});
});

describe('applyTrimHandleSeconds', () => {
it('clamps the start handle within the trim range', () => {
expect(
applyTrimHandleSeconds({ startSeconds: 10, endSeconds: 100 }, 'start', 150, 300)
).toEqual({
startSeconds: 100,
endSeconds: 100,
});
});

it('clamps the end handle within the trim range', () => {
expect(applyTrimHandleSeconds({ startSeconds: 10, endSeconds: 100 }, 'end', 5, 300)).toEqual({
startSeconds: 10,
endSeconds: 10,
});
});
});

describe('TrimRangeSlider', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
Expand Down Expand Up @@ -122,7 +141,7 @@ describe('TrimRangeSlider', () => {
vi.restoreAllMocks();
});

it('requests keyframes when the handle is released via keyboard', async () => {
it('requests keyframes when the slider handle is released via keyboard and snapping is enabled', async () => {
const onChange = vi.fn();
vi.mocked(global.fetch).mockResolvedValue(
new Response(JSON.stringify({ data: { keyframeSeconds: [12] } }), { status: 200 })
Expand All @@ -139,10 +158,10 @@ describe('TrimRangeSlider', () => {

const [url] = vi.mocked(global.fetch).mock.calls[0] ?? [];
expect(String(url)).toContain('/api/youtube-import/keyframes');
expect(String(url)).toContain(`youtubeVideoId=${VIDEO_ID}`);
expect(onChange).toHaveBeenCalled();
});

it('does not request keyframes when snapping is disabled', async () => {
it('does not request keyframes when the slider handle is released and snapping is disabled', async () => {
const onChange = vi.fn();
renderSlider({ onChange, enableKeyframeSnap: false });

Expand All @@ -154,50 +173,137 @@ describe('TrimRangeSlider', () => {
expect(onChange).toHaveBeenCalled();
});

it('snaps the moved handle to the closest returned keyframe', async () => {
it('requests keyframes when a frame nudge button is clicked', async () => {
const onChange = vi.fn();
vi.mocked(global.fetch).mockResolvedValue(
new Response(JSON.stringify({ data: { keyframeSeconds: [12] } }), { status: 200 })
);
renderSlider({ onChange });

fireEvent.click(screen.getByTestId('trim-start-frame-later'));

await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
});

const [url] = vi.mocked(global.fetch).mock.calls[0] ?? [];
expect(String(url)).toContain('/api/youtube-import/keyframes');
expect(String(url)).toContain(`youtubeVideoId=${VIDEO_ID}`);
});

it('does not request keyframes when snapping is disabled', async () => {
const onChange = vi.fn();
renderSlider({ onChange, enableKeyframeSnap: false });

fireEvent.click(screen.getByTestId('trim-start-frame-later'));

expect(global.fetch).not.toHaveBeenCalled();
expect(onChange).toHaveBeenCalled();
});

it('snaps the moved handle to the closest returned keyframe after a frame nudge', async () => {
const onChange = vi.fn();
renderSlider({ onChange, value: { startSeconds: 10, endSeconds: 100 } });

vi.mocked(global.fetch).mockResolvedValue(
new Response(JSON.stringify({ data: { keyframeSeconds: [5, 12, 20] } }), { status: 200 })
);

const [startThumb] = screen.getAllByRole('slider');
fireEvent.keyDown(startThumb, { key: 'ArrowRight' });
fireEvent.keyUp(startThumb);
fireEvent.click(screen.getByTestId('trim-start-frame-later'));

await waitFor(() => {
const lastCall = onChange.mock.calls.at(-1)?.[0];
expect(lastCall?.startSeconds).toBe(12);
});
});

it('leaves the handle at the raw dragged value when keyframes are empty', async () => {
it('nudges the start handle by the selected jump amount', () => {
const onChange = vi.fn();
renderSlider({ onChange, value: { startSeconds: 10, endSeconds: 100 } });
renderSlider({ onChange, enableKeyframeSnap: false });

vi.mocked(global.fetch).mockResolvedValue(
new Response(JSON.stringify({ data: { keyframeSeconds: [] } }), { status: 200 })
);
fireEvent.click(screen.getByTestId('trim-start-jump-later'));

const [startThumb] = screen.getAllByRole('slider');
fireEvent.keyDown(startThumb, { key: 'ArrowRight' });
expect(onChange).toHaveBeenCalledWith({
startSeconds: 15,
endSeconds: 100,
});
});

const rawValue = onChange.mock.calls.at(-1)?.[0];
expect(rawValue).toBeDefined();
it('uses the selected jump step amount', () => {
const onChange = vi.fn();
renderSlider({ onChange, enableKeyframeSnap: false });

fireEvent.keyUp(startThumb);
fireEvent.click(screen.getByTestId('trim-jump-step-10'));
fireEvent.click(screen.getByTestId('trim-start-jump-later'));

await waitFor(() => {
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({
startSeconds: 20,
endSeconds: 100,
});
});

await waitFor(() => {
expect(screen.queryByTestId('trim-start-loading')).not.toBeInTheDocument();
it('nudges the start handle from on-screen frame buttons', () => {
const onChange = vi.fn();
renderSlider({ onChange, enableKeyframeSnap: false });

fireEvent.click(screen.getByTestId('trim-start-frame-later'));

expect(onChange).toHaveBeenCalledWith({
startSeconds: 10 + 1 / 30,
endSeconds: 100,
});
});

it('commits a typed start timestamp', async () => {
const onChange = vi.fn();
renderSlider({
onChange,
enableKeyframeSnap: false,
value: { startSeconds: 10, endSeconds: 100 },
});

const finalValue = onChange.mock.calls.at(-1)?.[0];
expect(finalValue?.startSeconds).toBe(rawValue?.startSeconds);
fireEvent.click(screen.getByTestId('trim-start-time-display'));
const input = screen.getByTestId('trim-start-time-input');
fireEvent.change(input, { target: { value: '1:30' } });
fireEvent.keyDown(input, { key: 'Enter' });

expect(onChange).toHaveBeenCalledWith({
startSeconds: 90,
endSeconds: 100,
});
});

it('commits a typed start timestamp only once when Enter is pressed', () => {
const onChange = vi.fn();
renderSlider({
onChange,
enableKeyframeSnap: false,
value: { startSeconds: 10, endSeconds: 100 },
});

fireEvent.click(screen.getByTestId('trim-start-time-display'));
const input = screen.getByTestId('trim-start-time-input');
fireEvent.change(input, { target: { value: '1:30' } });
fireEvent.keyDown(input, { key: 'Enter' });

expect(onChange).toHaveBeenCalledTimes(1);
});

it('discards a typed start timestamp when Escape is pressed', () => {
const onChange = vi.fn();
renderSlider({
onChange,
enableKeyframeSnap: false,
value: { startSeconds: 10, endSeconds: 100 },
});

fireEvent.click(screen.getByTestId('trim-start-time-display'));
const input = screen.getByTestId('trim-start-time-input');
fireEvent.change(input, { target: { value: '1:30' } });
fireEvent.keyDown(input, { key: 'Escape' });

expect(onChange).not.toHaveBeenCalled();
expect(screen.getByTestId('trim-start-time-display')).toHaveTextContent('0:10');
});

it('seeks the preview player while dragging when a player handle is provided', async () => {
Expand All @@ -218,7 +324,7 @@ describe('TrimRangeSlider', () => {
expect(onChange).toHaveBeenCalled();
});

it('seeks immediately when the handle is released', async () => {
it('seeks immediately when the slider handle is released', async () => {
const playerHandle = {
previewAt: vi.fn(),
getCurrentTime: vi.fn().mockReturnValue(0),
Expand All @@ -233,18 +339,6 @@ describe('TrimRangeSlider', () => {
expect(playerHandle.previewAt).toHaveBeenCalled();
});

it('nudges the start handle from on-screen arrow buttons', () => {
const onChange = vi.fn();
renderSlider({ onChange, enableKeyframeSnap: false });

fireEvent.click(screen.getByTestId('trim-start-nudge-later'));

expect(onChange).toHaveBeenCalledWith({
startSeconds: 10 + 1 / 30,
endSeconds: 100,
});
});

it('shows a loading indicator on the handle being snapped', async () => {
let resolveFetch: (value: Response) => void = () => {};
const fetchPromise = new Promise<Response>((resolve) => {
Expand All @@ -254,8 +348,7 @@ describe('TrimRangeSlider', () => {

renderSlider({ value: { startSeconds: 10, endSeconds: 100 } });

const [startThumb] = screen.getAllByRole('slider');
fireEvent.keyDown(startThumb, { key: 'ArrowRight' });
fireEvent.click(screen.getByTestId('trim-start-frame-later'));

await waitFor(() => {
expect(screen.getByTestId('trim-start-loading')).toBeInTheDocument();
Expand Down
47 changes: 47 additions & 0 deletions __tests__/lib/livestreams/delete-livestream-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { toast } from 'sonner';
import { deleteLivestreamViaApi } from '@/lib/livestreams/delete-livestream-client';

vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));

describe('deleteLivestreamViaApi', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
} as Response)
);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it('returns true and shows success toast when the API delete succeeds', async () => {
await expect(deleteLivestreamViaApi('livestream-1')).resolves.toBe(true);
expect(global.fetch).toHaveBeenCalledWith('/api/livestreams/livestream-1', {
method: 'DELETE',
});
expect(toast.success).toHaveBeenCalledWith('Livestream deleted');
expect(toast.error).not.toHaveBeenCalled();
});

it('returns false and shows an error toast when the API delete fails', async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Cannot delete scheduled livestream' }),
} as Response);

await expect(deleteLivestreamViaApi('livestream-2')).resolves.toBe(false);
expect(toast.error).toHaveBeenCalledWith('Cannot delete scheduled livestream');
expect(toast.success).not.toHaveBeenCalled();
});
});
40 changes: 40 additions & 0 deletions __tests__/lib/parse-trim-time-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { formatTrimTimeInputValue, parseTrimTimeInput } from '@/lib/parse-trim-time-input';

describe('parseTrimTimeInput', () => {
it('parses plain second values', () => {
expect(parseTrimTimeInput('90')).toBe(90);
expect(parseTrimTimeInput('90.5')).toBe(90.5);
});

it('parses minute:second values', () => {
expect(parseTrimTimeInput('1:30')).toBe(90);
expect(parseTrimTimeInput('12:05')).toBe(725);
expect(parseTrimTimeInput('1:30.5')).toBe(90.5);
});

it('parses hour:minute:second values', () => {
expect(parseTrimTimeInput('1:02:03')).toBe(3723);
expect(parseTrimTimeInput('1:02:03.25')).toBe(3723.25);
});

it('rejects invalid values', () => {
expect(parseTrimTimeInput('')).toBeNull();
expect(parseTrimTimeInput('abc')).toBeNull();
expect(parseTrimTimeInput('1:75')).toBeNull();
expect(parseTrimTimeInput('1:02:75')).toBeNull();
expect(parseTrimTimeInput('1:2:3:4')).toBeNull();
});
});

describe('formatTrimTimeInputValue', () => {
it('formats whole seconds using YouTube-style labels', () => {
expect(formatTrimTimeInputValue(90)).toBe('1:30');
expect(formatTrimTimeInputValue(3723)).toBe('1:02:03');
});

it('formats fractional seconds as decimals', () => {
expect(formatTrimTimeInputValue(90.5)).toBe('90.5');
expect(formatTrimTimeInputValue(10 + 1 / 30)).toBe('10.033');
});
});
Loading
Loading