Skip to content

Commit 90d23ab

Browse files
authored
Merge pull request #117 from Two-Weeks-Team/test/issue-109-frontend-test-infra
test: add frontend test infrastructure with Vitest + Testing Library (#109)
2 parents 5e2dfbd + 033e9b5 commit 90d23ab

14 files changed

Lines changed: 3756 additions & 595 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,8 @@ jobs:
119119
- name: Lint
120120
run: npm run lint
121121

122+
- name: Unit tests
123+
run: npm test
124+
122125
- name: Build (static export)
123126
run: npm run build
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import HighlightCard, { type Highlight } from '../../components/HighlightCard';
4+
5+
describe('HighlightCard', () => {
6+
it('returns null for empty highlights', () => {
7+
const { container } = render(<HighlightCard highlights={[]} />);
8+
expect(container.innerHTML).toBe('');
9+
});
10+
11+
it('renders all highlight items', () => {
12+
const highlights: Highlight[] = [
13+
{ timestamp: '0:10', description: 'First moment', expression: 'happy' },
14+
{ timestamp: '1:30', description: 'Second moment', expression: 'excited' },
15+
{ timestamp: '3:00', description: 'Third moment', expression: 'nostalgic' },
16+
];
17+
render(<HighlightCard highlights={highlights} />);
18+
19+
expect(screen.getByText('First moment')).toBeInTheDocument();
20+
expect(screen.getByText('Second moment')).toBeInTheDocument();
21+
expect(screen.getByText('Third moment')).toBeInTheDocument();
22+
});
23+
24+
it('shows timestamp, description, and expression for each item', () => {
25+
const highlights: Highlight[] = [
26+
{ timestamp: '2:45', description: 'A key scene', expression: 'surprised' },
27+
];
28+
render(<HighlightCard highlights={highlights} />);
29+
30+
expect(screen.getByText('2:45')).toBeInTheDocument();
31+
expect(screen.getByText('A key scene')).toBeInTheDocument();
32+
expect(screen.getByText('surprised')).toBeInTheDocument();
33+
});
34+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import OnboardingFlow from '../../components/OnboardingFlow';
4+
import type { YouTubeVideo } from '../../components/YouTubeGrid';
5+
import type { Highlight } from '../../components/HighlightCard';
6+
7+
const sampleVideos: YouTubeVideo[] = [
8+
{ id: 'v1', title: 'Video One', thumbnail: 'https://example.com/1.jpg', privacyStatus: 'public' },
9+
{ id: 'v2', title: 'Video Two', thumbnail: 'https://example.com/2.jpg', privacyStatus: 'public' },
10+
];
11+
12+
const sampleHighlights: Highlight[] = [
13+
{ timestamp: '0:15', description: 'Laughing together', expression: 'joyful' },
14+
];
15+
16+
const sampleCrops = [
17+
'https://example.com/crop1.jpg',
18+
'https://example.com/crop2.jpg',
19+
];
20+
21+
const makeDefaultProps = () => ({
22+
stage: 'welcome' as const,
23+
videos: sampleVideos,
24+
personCrops: sampleCrops,
25+
highlights: sampleHighlights,
26+
analysisStep: 'Extracting features...',
27+
analysisPercent: 30,
28+
onSelectVideo: vi.fn(),
29+
onSelectPerson: vi.fn(),
30+
});
31+
32+
let defaultProps: ReturnType<typeof makeDefaultProps>;
33+
34+
beforeEach(() => {
35+
defaultProps = makeDefaultProps();
36+
});
37+
38+
describe('OnboardingFlow', () => {
39+
it('renders YouTubeGrid when stage is youtube_grid', () => {
40+
render(<OnboardingFlow {...defaultProps} stage="youtube_grid" />);
41+
expect(screen.getByText('Select a video')).toBeInTheDocument();
42+
expect(screen.getByText('Video One')).toBeInTheDocument();
43+
expect(screen.getByText('Video Two')).toBeInTheDocument();
44+
});
45+
46+
it('renders person select UI when stage is person_select with crops', () => {
47+
render(<OnboardingFlow {...defaultProps} stage="person_select" />);
48+
expect(screen.getByText('Select the person to analyze')).toBeInTheDocument();
49+
expect(screen.getByAltText('Person 1')).toBeInTheDocument();
50+
expect(screen.getByAltText('Person 2')).toBeInTheDocument();
51+
});
52+
53+
it('does not render person select when personCrops is empty', () => {
54+
render(<OnboardingFlow {...defaultProps} stage="person_select" personCrops={[]} />);
55+
expect(screen.queryByText('Select the person to analyze')).not.toBeInTheDocument();
56+
});
57+
58+
it('renders ProgressBar when stage is analyzing', () => {
59+
render(<OnboardingFlow {...defaultProps} stage="analyzing" />);
60+
expect(screen.getByText('Extracting features...')).toBeInTheDocument();
61+
expect(screen.getByText('30%')).toBeInTheDocument();
62+
});
63+
64+
it('calls onSelectPerson when person button clicked', () => {
65+
const onSelectPerson = vi.fn();
66+
render(
67+
<OnboardingFlow {...defaultProps} stage="person_select" onSelectPerson={onSelectPerson} />
68+
);
69+
fireEvent.click(screen.getByAltText('Person 2'));
70+
expect(onSelectPerson).toHaveBeenCalledWith(1);
71+
});
72+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import ProgressBar from '../../components/ProgressBar';
4+
5+
describe('ProgressBar', () => {
6+
it('renders step text', () => {
7+
render(<ProgressBar step="Analyzing video..." percent={50} />);
8+
expect(screen.getByText('Analyzing video...')).toBeInTheDocument();
9+
});
10+
11+
it('renders percent text', () => {
12+
render(<ProgressBar step="Loading" percent={42} />);
13+
expect(screen.getByText('42%')).toBeInTheDocument();
14+
});
15+
16+
it('clamps width to 0% for negative values', () => {
17+
render(<ProgressBar step="Step" percent={-20} />);
18+
const fillBar = screen.getByTestId('progress-fill');
19+
expect(fillBar).toBeInTheDocument();
20+
expect(fillBar).toHaveStyle({ width: '0%' });
21+
});
22+
23+
it('clamps width to 100% for values greater than 100', () => {
24+
render(<ProgressBar step="Step" percent={150} />);
25+
const fillBar = screen.getByTestId('progress-fill');
26+
expect(fillBar).toHaveStyle({ width: '100%' });
27+
});
28+
29+
it('shows correct width for normal values', () => {
30+
render(<ProgressBar step="Step" percent={65} />);
31+
const fillBar = screen.getByTestId('progress-fill');
32+
expect(fillBar).toHaveStyle({ width: '65%' });
33+
});
34+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import SceneDisplay from '../../components/SceneDisplay';
4+
5+
describe('SceneDisplay', () => {
6+
it('returns null when both previewSrc and finalSrc are null', () => {
7+
const { container } = render(<SceneDisplay previewSrc={null} finalSrc={null} />);
8+
expect(container.innerHTML).toBe('');
9+
});
10+
11+
it('renders preview image when previewSrc provided', () => {
12+
render(<SceneDisplay previewSrc="data:image/png;base64,preview" finalSrc={null} />);
13+
const img = screen.getByAltText('Scene preview');
14+
expect(img).toBeInTheDocument();
15+
expect(img).toHaveAttribute('src', 'data:image/png;base64,preview');
16+
});
17+
18+
it('renders final image element when finalSrc provided', () => {
19+
render(<SceneDisplay previewSrc={null} finalSrc="data:image/png;base64,final" />);
20+
const img = screen.getByAltText('Scene final');
21+
expect(img).toBeInTheDocument();
22+
});
23+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import SessionTransition from '../../components/SessionTransition';
4+
5+
describe('SessionTransition', () => {
6+
it('returns null when phase is idle', () => {
7+
const { container } = render(<SessionTransition phase="idle" />);
8+
expect(container.innerHTML).toBe('');
9+
});
10+
11+
it('shows "Please wait..." when transitioning', () => {
12+
render(<SessionTransition phase="transitioning" />);
13+
expect(screen.getByText('Please wait...')).toBeInTheDocument();
14+
});
15+
16+
it('shows "Ready" when ready', () => {
17+
render(<SessionTransition phase="ready" />);
18+
expect(screen.getByText('Ready')).toBeInTheDocument();
19+
});
20+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import YouTubeGrid, { type YouTubeVideo } from '../../components/YouTubeGrid';
4+
5+
const publicVideo: YouTubeVideo = {
6+
id: 'vid-1',
7+
title: 'Public Video Title',
8+
thumbnail: 'https://example.com/thumb1.jpg',
9+
privacyStatus: 'public',
10+
};
11+
12+
const privateVideo: YouTubeVideo = {
13+
id: 'vid-2',
14+
title: 'Private Video Title',
15+
thumbnail: 'https://example.com/thumb2.jpg',
16+
privacyStatus: 'private',
17+
};
18+
19+
const unlistedVideo: YouTubeVideo = {
20+
id: 'vid-3',
21+
title: 'Unlisted Video Title',
22+
thumbnail: 'https://example.com/thumb3.jpg',
23+
privacyStatus: 'unlisted',
24+
};
25+
26+
describe('YouTubeGrid', () => {
27+
it('returns null for empty videos', () => {
28+
const { container } = render(
29+
<YouTubeGrid videos={[]} onSelect={vi.fn()} onPrivateClick={vi.fn()} />
30+
);
31+
expect(container.innerHTML).toBe('');
32+
});
33+
34+
it('renders all video items with titles', () => {
35+
const videos = [publicVideo, privateVideo, unlistedVideo];
36+
render(
37+
<YouTubeGrid videos={videos} onSelect={vi.fn()} onPrivateClick={vi.fn()} />
38+
);
39+
40+
expect(screen.getByText('Public Video Title')).toBeInTheDocument();
41+
expect(screen.getByText('Private Video Title')).toBeInTheDocument();
42+
expect(screen.getByText('Unlisted Video Title')).toBeInTheDocument();
43+
});
44+
45+
it('clicking public video calls onSelect with video id', () => {
46+
const onSelect = vi.fn();
47+
const onPrivateClick = vi.fn();
48+
render(
49+
<YouTubeGrid videos={[publicVideo]} onSelect={onSelect} onPrivateClick={onPrivateClick} />
50+
);
51+
52+
fireEvent.click(screen.getByText('Public Video Title'));
53+
expect(onSelect).toHaveBeenCalledWith('vid-1');
54+
expect(onPrivateClick).not.toHaveBeenCalled();
55+
});
56+
57+
it('clicking private video calls onPrivateClick', () => {
58+
const onSelect = vi.fn();
59+
const onPrivateClick = vi.fn();
60+
render(
61+
<YouTubeGrid videos={[privateVideo]} onSelect={onSelect} onPrivateClick={onPrivateClick} />
62+
);
63+
64+
fireEvent.click(screen.getByText('Private Video Title'));
65+
expect(onPrivateClick).toHaveBeenCalledTimes(1);
66+
expect(onSelect).not.toHaveBeenCalled();
67+
});
68+
69+
it('shows "Private" badge for non-public videos', () => {
70+
const videos = [publicVideo, privateVideo, unlistedVideo];
71+
render(
72+
<YouTubeGrid videos={videos} onSelect={vi.fn()} onPrivateClick={vi.fn()} />
73+
);
74+
75+
const privateBadges = screen.getAllByText('Private');
76+
// privateVideo and unlistedVideo both get the badge
77+
expect(privateBadges).toHaveLength(2);
78+
});
79+
});

0 commit comments

Comments
 (0)