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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@
"@astrojs/check": "^0.9.6",
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.1.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"eslint": "^10.0.2",
"eslint-plugin-astro": "^1.6.0",
"jsdom": "^28.1.0",
"lefthook": "^2.1.1",
"prettier": "^3.8.1",
"prettier-plugin-astro": "^0.14.1",
Expand Down
433 changes: 431 additions & 2 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

113 changes: 113 additions & 0 deletions src/components/ui/__tests__/StarRating.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { StarRating } from '../StarRating';

describe('StarRating', () => {
// --- Read-only display ---

it('should render with img role when not interactive', () => {
render(<StarRating rating={3} />);

const container = screen.getByRole('img');
expect(container).toBeDefined();
});

it('should display rating in aria-label', () => {
render(<StarRating rating={3.5} />);

const container = screen.getByRole('img');
expect(container.getAttribute('aria-label')).toBe('Rating: 3.5 out of 5 stars');
});

it('should display 0 rating when rating is null', () => {
render(<StarRating rating={null} />);

const container = screen.getByRole('img');
expect(container.getAttribute('aria-label')).toBe('Rating: 0 out of 5 stars');
});

it('should not render interactive buttons when onRate is not provided', () => {
render(<StarRating rating={3} />);

const buttons = screen.queryAllByRole('button');
expect(buttons).toHaveLength(0);
});

// --- Interactive mode ---

it('should render with radiogroup role when interactive', () => {
render(<StarRating rating={3} onRate={() => {}} />);

const container = screen.getByRole('radiogroup');
expect(container).toBeDefined();
});

it('should render 10 interactive buttons when onRate is provided', () => {
render(<StarRating rating={3} onRate={() => {}} />);

// 5 stars x 2 halves = 10 buttons
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(10);
});

it('should call onRate with full star value when right half is clicked', () => {
const onRate = vi.fn();
render(<StarRating rating={null} onRate={onRate} />);

// "3 stars" button is the right half of the 3rd star
const button = screen.getByLabelText('3 stars');
fireEvent.click(button);

expect(onRate).toHaveBeenCalledWith(3);
});

it('should call onRate with half star value when left half is clicked', () => {
const onRate = vi.fn();
render(<StarRating rating={null} onRate={onRate} />);

// "2.5 stars" button is the left half of the 3rd star
const button = screen.getByLabelText('2.5 stars');
fireEvent.click(button);

expect(onRate).toHaveBeenCalledWith(2.5);
});

it('should call onRate with 0.5 when left half of first star is clicked', () => {
const onRate = vi.fn();
render(<StarRating rating={null} onRate={onRate} />);

const button = screen.getByLabelText('0.5 stars');
fireEvent.click(button);

expect(onRate).toHaveBeenCalledWith(0.5);
});

it('should call onRate with 5 when right half of last star is clicked', () => {
const onRate = vi.fn();
render(<StarRating rating={null} onRate={onRate} />);

const button = screen.getByLabelText('5 stars');
fireEvent.click(button);

expect(onRate).toHaveBeenCalledWith(5);
});

it('should have accessible labels for all half-star increments', () => {
render(<StarRating rating={null} onRate={() => {}} />);

const expectedLabels = [0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5];
for (const label of expectedLabels) {
expect(screen.getByLabelText(`${label} stars`)).toBeDefined();
}
});

// --- SVG rendering ---

it('should render exactly 5 star SVGs', () => {
const { container } = render(<StarRating rating={3} />);

const svgs = container.querySelectorAll('svg');
expect(svgs).toHaveLength(5);
});
});
165 changes: 165 additions & 0 deletions src/features/group/__tests__/AddSong.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AddSong } from '../AddSong';

describe('AddSong', () => {
let mockOnAddSong: ReturnType<typeof vi.fn<(url: string) => Promise<void>>>;

beforeEach(() => {
mockOnAddSong = vi.fn<(url: string) => Promise<void>>();
});

it('should render input and submit button', () => {
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

expect(screen.getByPlaceholderText('Paste a Spotify or YouTube link...')).toBeDefined();
expect(screen.getByRole('button', { name: 'Add' })).toBeDefined();
});

it('should disable submit button when input is empty', () => {
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const button = screen.getByRole('button', { name: 'Add' });
expect(button.hasAttribute('disabled')).toBe(true);
});

it('should enable submit button when input has value', () => {
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });

const button = screen.getByRole('button', { name: 'Add' });
expect(button.hasAttribute('disabled')).toBe(false);
});

it('should call onAddSong with trimmed URL on submit', async () => {
mockOnAddSong.mockResolvedValue(undefined);
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
fireEvent.change(input, { target: { value: ' https://open.spotify.com/track/abc ' } });

const form = input.closest('form')!;
fireEvent.submit(form);

await waitFor(() => {
expect(mockOnAddSong).toHaveBeenCalledWith('https://open.spotify.com/track/abc');
});
});

it('should clear input on successful submission', async () => {
mockOnAddSong.mockResolvedValue(undefined);
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText(
'Paste a Spotify or YouTube link...',
) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });

const form = input.closest('form')!;
fireEvent.submit(form);

await waitFor(() => {
expect(input.value).toBe('');
});
});

it('should show error message when onAddSong rejects', async () => {
mockOnAddSong.mockRejectedValue(new Error('Song not found'));
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/notfound' } });

const form = input.closest('form')!;
fireEvent.submit(form);

await waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert.textContent).toBe('Song not found');
});
});

it('should show "Unknown error" when onAddSong rejects with non-Error', async () => {
mockOnAddSong.mockRejectedValue('string error');
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });

const form = input.closest('form')!;
fireEvent.submit(form);

await waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert.textContent).toBe('Unknown error');
});
});

it('should show "Resolving..." button text while submitting', async () => {
// Never resolve so we can see the loading state
mockOnAddSong.mockReturnValue(new Promise(() => {}));
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });

const form = input.closest('form')!;
fireEvent.submit(form);

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Resolving...' })).toBeDefined();
});
});

it('should disable input while resolving', async () => {
mockOnAddSong.mockReturnValue(new Promise(() => {}));
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText(
'Paste a Spotify or YouTube link...',
) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });

const form = input.closest('form')!;
fireEvent.submit(form);

await waitFor(() => {
expect(input.disabled).toBe(true);
});
});

it('should clear error when input value changes after error', async () => {
mockOnAddSong.mockRejectedValue(new Error('fail'));
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } });

const form = input.closest('form')!;
fireEvent.submit(form);

await waitFor(() => {
expect(screen.getByRole('alert')).toBeDefined();
});

// Change input to clear error
fireEvent.change(input, { target: { value: 'new value' } });

expect(screen.queryByRole('alert')).toBeNull();
});

it('should not call onAddSong when input is only whitespace', () => {
render(<AddSong onAddSong={mockOnAddSong} locale="en" />);

const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...');
fireEvent.change(input, { target: { value: ' ' } });

const form = input.closest('form')!;
fireEvent.submit(form);

expect(mockOnAddSong).not.toHaveBeenCalled();
});
});
Loading
Loading