From 310c472a01c096f9fa53b9a06eeed3c491be2512 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:19:36 +0100 Subject: [PATCH 01/17] feat: add genre constants module with tag matching for Last.fm --- src/lib/__tests__/genres.test.ts | 72 +++++++++++++++ src/lib/genres.ts | 147 +++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/lib/__tests__/genres.test.ts create mode 100644 src/lib/genres.ts diff --git a/src/lib/__tests__/genres.test.ts b/src/lib/__tests__/genres.test.ts new file mode 100644 index 0000000..8d7212f --- /dev/null +++ b/src/lib/__tests__/genres.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { GENRES, getGenreLabel, getGenreColor, matchGenre } from '../genres'; + +describe('genres', () => { + it('GENRES is a non-empty array of strings', () => { + expect(Array.isArray(GENRES)).toBe(true); + expect(GENRES.length).toBeGreaterThan(0); + GENRES.forEach((genre) => { + expect(typeof genre).toBe('string'); + }); + }); + + it('getGenreLabel returns a label for every genre', () => { + GENRES.forEach((genre) => { + const label = getGenreLabel(genre); + expect(typeof label).toBe('string'); + expect(label.length).toBeGreaterThan(0); + }); + }); + + it('getGenreColor returns Tailwind classes for every genre', () => { + GENRES.forEach((genre) => { + const color = getGenreColor(genre); + expect(color).toContain('bg-'); + expect(color).toContain('text-'); + }); + }); + + it('getGenreColor returns fallback for unknown genre', () => { + const color = getGenreColor('nonexistent' as never); + expect(color).toContain('bg-'); + expect(color).toContain('text-'); + }); +}); + +describe('matchGenre', () => { + it('matches exact genre slug', () => { + expect(matchGenre(['rock'])).toBe('rock'); + }); + + it('matches via alias (hip hop -> hip-hop)', () => { + expect(matchGenre(['hip hop'])).toBe('hip-hop'); + }); + + it('returns first matching genre by tag priority', () => { + expect(matchGenre(['indie rock', 'rock', 'alternative'])).toBe('indie'); + }); + + it('returns null when no tags match', () => { + expect(matchGenre(['seen live', 'favorites', 'my playlist'])).toBeNull(); + }); + + it('returns null for empty array', () => { + expect(matchGenre([])).toBeNull(); + }); + + it('is case-insensitive', () => { + expect(matchGenre(['ROCK'])).toBe('rock'); + expect(matchGenre(['Hip-Hop'])).toBe('hip-hop'); + }); + + it('matches r&b variants', () => { + expect(matchGenre(['rnb'])).toBe('r&b'); + expect(matchGenre(['rhythm and blues'])).toBe('r&b'); + }); + + it('matches electronic variants', () => { + expect(matchGenre(['edm'])).toBe('electronic'); + expect(matchGenre(['drum and bass'])).toBe('electronic'); + expect(matchGenre(['techno'])).toBe('electronic'); + }); +}); diff --git a/src/lib/genres.ts b/src/lib/genres.ts new file mode 100644 index 0000000..faaf28f --- /dev/null +++ b/src/lib/genres.ts @@ -0,0 +1,147 @@ +export const GENRES = [ + 'rock', + 'pop', + 'hip-hop', + 'electronic', + 'jazz', + 'classical', + 'r&b', + 'latin', + 'country', + 'metal', + 'indie', + 'folk', + 'punk', + 'reggae', + 'blues', + 'soul', + 'funk', + 'alternative', + 'other', +] as const; + +export type Genre = (typeof GENRES)[number]; + +const GENRE_LABELS: Record = { + rock: 'Rock', + pop: 'Pop', + 'hip-hop': 'Hip-Hop', + electronic: 'Electronic', + jazz: 'Jazz', + classical: 'Classical', + 'r&b': 'R&B', + latin: 'Latin', + country: 'Country', + metal: 'Metal', + indie: 'Indie', + folk: 'Folk', + punk: 'Punk', + reggae: 'Reggae', + blues: 'Blues', + soul: 'Soul', + funk: 'Funk', + alternative: 'Alternative', + other: 'Other', +}; + +const GENRE_COLORS: Record = { + rock: 'bg-red-500/15 text-red-400', + pop: 'bg-pink-500/15 text-pink-400', + 'hip-hop': 'bg-amber-500/15 text-amber-400', + electronic: 'bg-cyan-500/15 text-cyan-400', + jazz: 'bg-violet-500/15 text-violet-400', + classical: 'bg-slate-500/15 text-slate-400', + 'r&b': 'bg-rose-500/15 text-rose-400', + latin: 'bg-orange-500/15 text-orange-400', + country: 'bg-yellow-500/15 text-yellow-400', + metal: 'bg-zinc-500/15 text-zinc-400', + indie: 'bg-teal-500/15 text-teal-400', + folk: 'bg-lime-500/15 text-lime-400', + punk: 'bg-fuchsia-500/15 text-fuchsia-400', + reggae: 'bg-green-500/15 text-green-400', + blues: 'bg-blue-500/15 text-blue-400', + soul: 'bg-purple-500/15 text-purple-400', + funk: 'bg-emerald-500/15 text-emerald-400', + alternative: 'bg-indigo-500/15 text-indigo-400', + other: 'bg-neutral-500/15 text-neutral-400', +}; + +const FALLBACK_COLOR = 'bg-neutral-500/15 text-neutral-400'; + +const TAG_ALIASES: Record = { + 'hip hop': 'hip-hop', + 'hip-hop': 'hip-hop', + hiphop: 'hip-hop', + rap: 'hip-hop', + trap: 'hip-hop', + rnb: 'r&b', + 'r&b': 'r&b', + 'rhythm and blues': 'r&b', + edm: 'electronic', + house: 'electronic', + techno: 'electronic', + trance: 'electronic', + dnb: 'electronic', + 'drum and bass': 'electronic', + dubstep: 'electronic', + synthpop: 'electronic', + 'indie rock': 'indie', + 'indie pop': 'indie', + 'alt-country': 'country', + 'death metal': 'metal', + 'black metal': 'metal', + 'heavy metal': 'metal', + 'thrash metal': 'metal', + 'nu metal': 'metal', + 'punk rock': 'punk', + 'post-punk': 'punk', + 'pop punk': 'punk', + hardcore: 'punk', + 'alternative rock': 'alternative', + 'alt rock': 'alternative', + grunge: 'alternative', + 'neo soul': 'soul', + 'bossa nova': 'latin', + salsa: 'latin', + reggaeton: 'latin', + bachata: 'latin', + cumbia: 'latin', + 'classic rock': 'rock', + 'hard rock': 'rock', + 'progressive rock': 'rock', + 'psychedelic rock': 'rock', + 'blues rock': 'blues', + 'folk rock': 'folk', + acoustic: 'folk', + 'singer-songwriter': 'folk', +}; + +const genreSet = new Set(GENRES); + +export function getGenreLabel(genre: Genre): string { + return GENRE_LABELS[genre]; +} + +export function getGenreColor(genre: Genre): string { + return GENRE_COLORS[genre] ?? FALLBACK_COLOR; +} + +export function matchGenre(tags: string[]): Genre | null { + for (const tag of tags) { + const normalized = tag.toLowerCase().trim(); + + // 1. Check alias map + const aliased = TAG_ALIASES[normalized]; + if (aliased) return aliased; + + // 2. Check exact match against GENRES + if (genreSet.has(normalized)) return normalized as Genre; + + // 3. Check if tag contains a genre slug as substring + for (const genre of GENRES) { + if (normalized.includes(genre)) return genre; + } + } + + return null; +} From 3287e2bfb5c26cc2aef48b6e51dc02d84a0a519d Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:21:12 +0100 Subject: [PATCH 02/17] feat: add genre column to songs table and Song type --- src/lib/__tests__/scoring.test.ts | 4 ++-- src/lib/types.ts | 1 + supabase/add_genre_column.sql | 3 +++ supabase/schema.sql | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 supabase/add_genre_column.sql diff --git a/src/lib/__tests__/scoring.test.ts b/src/lib/__tests__/scoring.test.ts index 0d44b9a..c523561 100644 --- a/src/lib/__tests__/scoring.test.ts +++ b/src/lib/__tests__/scoring.test.ts @@ -37,9 +37,9 @@ function makeSong(overrides: Partial = {}): Song { artist: 'Test Artist', album: null, thumbnail_url: null, - spotify_url: null, - youtube_url: null, + platform_links: [], odesli_page_url: null, + genre: null, created_at: '2026-01-01T00:00:00Z', ...overrides, }; diff --git a/src/lib/types.ts b/src/lib/types.ts index fc47124..b5744be 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -36,6 +36,7 @@ export interface Song { thumbnail_url: string | null; platform_links: { platform: string; url: string }[]; odesli_page_url: string | null; + genre: string | null; created_at: string; } diff --git a/supabase/add_genre_column.sql b/supabase/add_genre_column.sql new file mode 100644 index 0000000..ffe65ae --- /dev/null +++ b/supabase/add_genre_column.sql @@ -0,0 +1,3 @@ +-- Add optional genre field to songs table +-- Run this in the Supabase SQL Editor +ALTER TABLE songs ADD COLUMN genre text CHECK (genre IS NULL OR char_length(genre) <= 30); diff --git a/supabase/schema.sql b/supabase/schema.sql index bec90a7..9b67bcc 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -43,6 +43,7 @@ create table songs ( thumbnail_url text check (thumbnail_url is null or thumbnail_url ~ '^https://'), platform_links jsonb not null default '[]', odesli_page_url text check (odesli_page_url is null or odesli_page_url ~ '^https://'), + genre text check (genre is null or char_length(genre) <= 30), created_at timestamptz default now() ); From 00e0cc6ec9fbdf95feae7faf45b516c66a643f5d Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:23:17 +0100 Subject: [PATCH 03/17] feat: add Last.fm genre detection with track/artist fallback --- src/lib/__tests__/lastfm.test.ts | 160 +++++++++++++++++++++++++++++++ src/lib/lastfm.ts | 40 ++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/lib/__tests__/lastfm.test.ts create mode 100644 src/lib/lastfm.ts diff --git a/src/lib/__tests__/lastfm.test.ts b/src/lib/__tests__/lastfm.test.ts new file mode 100644 index 0000000..f7743fc --- /dev/null +++ b/src/lib/__tests__/lastfm.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { detectGenre } from '../lastfm'; + +function makeTagResponse(tags: { name: string; count: number }[]) { + return { + ok: true, + status: 200, + json: () => Promise.resolve({ toptags: { tag: tags } }), + } as unknown as Response; +} + +function makeErrorResponse(status: number): Response { + return { + ok: false, + status, + json: () => Promise.resolve({}), + } as unknown as Response; +} + +describe('detectGenre', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + vi.stubEnv('LASTFM_API_KEY', 'test-key'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should return genre from track tags on first call', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'rock', count: 100 }, + { name: 'classic rock', count: 80 }, + ]), + ); + + const result = await detectGenre('Bohemian Rhapsody', 'Queen'); + + expect(result).toBe('rock'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + + it('should fallback to artist tags when track tags do not match', async () => { + const mockFetch = vi.mocked(fetch); + // Track tags: no genre match + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'seen live', count: 100 }, + { name: 'favorites', count: 90 }, + ]), + ); + // Artist tags: genre match + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'electronic', count: 100 }, + { name: 'ambient', count: 80 }, + ]), + ); + + const result = await detectGenre('Unknown Track', 'Boards of Canada'); + + expect(result).toBe('electronic'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should return null when neither track nor artist tags match', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'seen live', count: 100 }, + { name: 'favorites', count: 90 }, + ]), + ); + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'seen live', count: 100 }, + { name: 'british', count: 80 }, + ]), + ); + + const result = await detectGenre('Some Track', 'Some Artist'); + + expect(result).toBeNull(); + }); + + it('should return null when track request fails and artist tags do not match', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeErrorResponse(500)); + mockFetch.mockResolvedValueOnce( + makeTagResponse([ + { name: 'seen live', count: 100 }, + { name: 'favorites', count: 80 }, + ]), + ); + + const result = await detectGenre('Track', 'Artist'); + + expect(result).toBeNull(); + }); + + it('should return null when both requests fail with network error', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')); + mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')); + + const result = await detectGenre('Track', 'Artist'); + + expect(result).toBeNull(); + }); + + it('should pass track and artist as query parameters with autocorrect=1', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeTagResponse([{ name: 'rock', count: 100 }])); + + await detectGenre('Stairway to Heaven', 'Led Zeppelin'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const calledUrl = mockFetch.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get('method')).toBe('track.getTopTags'); + expect(url.searchParams.get('track')).toBe('Stairway to Heaven'); + expect(url.searchParams.get('artist')).toBe('Led Zeppelin'); + expect(url.searchParams.get('autocorrect')).toBe('1'); + expect(url.searchParams.get('api_key')).toBe('test-key'); + expect(url.searchParams.get('format')).toBe('json'); + }); + + it('should handle empty tag arrays gracefully', async () => { + const mockFetch = vi.mocked(fetch); + mockFetch.mockResolvedValueOnce(makeTagResponse([])); + mockFetch.mockResolvedValueOnce(makeTagResponse([])); + + const result = await detectGenre('Track', 'Artist'); + + expect(result).toBeNull(); + }); + + it('should match hip-hop variants from Last.fm tags', async () => { + const mockFetch = vi.mocked(fetch); + const variants = ['Hip-Hop', 'hip hop', 'rap', 'Rap', 'Hip Hop']; + + for (const variant of variants) { + mockFetch.mockResolvedValueOnce(makeTagResponse([{ name: variant, count: 100 }])); + + const result = await detectGenre('Track', 'Artist'); + expect(result).toBe('hip-hop'); + } + }); + + it('should return null when API key is not set', async () => { + vi.stubEnv('LASTFM_API_KEY', ''); + + const result = await detectGenre('Track', 'Artist'); + + expect(result).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/lastfm.ts b/src/lib/lastfm.ts new file mode 100644 index 0000000..11ffe9e --- /dev/null +++ b/src/lib/lastfm.ts @@ -0,0 +1,40 @@ +import { matchGenre } from './genres'; + +const LASTFM_API = 'https://ws.audioscrobbler.com/2.0/'; +const MAX_TAGS = 10; + +async function fetchTopTags( + apiKey: string, + method: string, + params: Record, +): Promise { + try { + const qs = new URLSearchParams({ + method, + api_key: apiKey, + format: 'json', + autocorrect: '1', + ...params, + }); + const res = await fetch(`${LASTFM_API}?${qs}`); + if (!res.ok) return []; + const data = (await res.json()) as { + toptags?: { tag?: { name: string; count: number }[] }; + }; + return (data.toptags?.tag ?? []).slice(0, MAX_TAGS).map((t) => t.name); + } catch { + return []; + } +} + +export async function detectGenre(title: string, artist: string): Promise { + const apiKey = import.meta.env.LASTFM_API_KEY ?? process.env.LASTFM_API_KEY ?? ''; + if (!apiKey) return null; + + const trackTags = await fetchTopTags(apiKey, 'track.getTopTags', { track: title, artist }); + const trackGenre = matchGenre(trackTags); + if (trackGenre) return trackGenre; + + const artistTags = await fetchTopTags(apiKey, 'artist.getTopTags', { artist }); + return matchGenre(artistTags); +} From 08817e496bbca487f8745c34efa053bbe3f2ae35 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:25:44 +0100 Subject: [PATCH 04/17] feat: integrate Last.fm genre detection into song resolution --- src/lib/__tests__/odesli.test.ts | 22 ++++++++++++++++++++++ src/lib/odesli.ts | 11 +++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/lib/__tests__/odesli.test.ts b/src/lib/__tests__/odesli.test.ts index f5425cb..d353677 100644 --- a/src/lib/__tests__/odesli.test.ts +++ b/src/lib/__tests__/odesli.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resolveSongLink } from '../odesli'; +vi.mock('../lastfm', () => ({ + detectGenre: vi.fn().mockResolvedValue('rock'), +})); + // Minimal valid Odesli API response for a Spotify track function makeOdesliResponse( overrides: Partial<{ @@ -208,4 +212,22 @@ describe('resolveSongLink', () => { expect(ytMusic).toBeDefined(); expect(ytMusic!.url).toBe('https://music.youtube.com/watch?v=fJ9rUzIMcZQ'); }); + + it('should include auto-detected genre from Last.fm', async () => { + const { detectGenre } = await import('../lastfm'); + vi.mocked(detectGenre).mockResolvedValueOnce('electronic'); + vi.mocked(fetch).mockResolvedValueOnce(makeOkResponse(makeOdesliResponse())); + + const result = await resolveSongLink('https://open.spotify.com/track/abc'); + expect(result.genre).toBe('electronic'); + }); + + it('should return null genre when Last.fm detection fails', async () => { + const { detectGenre } = await import('../lastfm'); + vi.mocked(detectGenre).mockRejectedValueOnce(new Error('API down')); + vi.mocked(fetch).mockResolvedValueOnce(makeOkResponse(makeOdesliResponse())); + + const result = await resolveSongLink('https://open.spotify.com/track/abc'); + expect(result.genre).toBeNull(); + }); }); diff --git a/src/lib/odesli.ts b/src/lib/odesli.ts index 7a52132..0f9e24c 100644 --- a/src/lib/odesli.ts +++ b/src/lib/odesli.ts @@ -1,5 +1,7 @@ /** Odesli (song.link) API wrapper — resolves music URLs to cross-platform data */ +import { detectGenre } from './lastfm'; + /** Use our own server-side proxy to avoid CORS issues with the Odesli API */ const RESOLVE_API = '/api/resolve'; @@ -57,6 +59,7 @@ export interface ResolvedSong { title: string; artist: string; album: string | null; + genre: string | null; thumbnailUrl: string | null; platformLinks: PlatformLink[]; pageUrl: string; @@ -173,10 +176,18 @@ export async function resolveSongLink(url: string): Promise { throw new Error('Invalid page URL returned by song resolution service.'); } + let genre: string | null = null; + try { + genre = await detectGenre(title, artist); + } catch { + // Genre detection is best-effort — never fail the full resolution + } + return { title, artist, album: null, + genre, thumbnailUrl, platformLinks, pageUrl, From a684afe3af7e255bcb8b984bd00c58c855713fc5 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:27:43 +0100 Subject: [PATCH 05/17] feat: add GenreBadge UI component --- src/components/ui/GenreBadge.tsx | 26 ++++++++++++++ .../ui/__tests__/GenreBadge.test.tsx | 35 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/components/ui/GenreBadge.tsx create mode 100644 src/components/ui/__tests__/GenreBadge.test.tsx diff --git a/src/components/ui/GenreBadge.tsx b/src/components/ui/GenreBadge.tsx new file mode 100644 index 0000000..ea017d5 --- /dev/null +++ b/src/components/ui/GenreBadge.tsx @@ -0,0 +1,26 @@ +import { GENRES, getGenreColor, getGenreLabel, type Genre } from '../../lib/genres'; + +interface GenreBadgeProps { + genre: string | null; +} + +const genreSet = new Set(GENRES); + +function isGenre(value: string): value is Genre { + return genreSet.has(value); +} + +/** Colored badge/chip for a music genre. Renders nothing if genre is null. */ +export function GenreBadge({ genre }: GenreBadgeProps) { + if (!genre) return null; + + const known = isGenre(genre); + const colorClasses = known ? getGenreColor(genre) : getGenreColor('other'); + const label = known ? getGenreLabel(genre) : genre; + + return ( + + {label} + + ); +} diff --git a/src/components/ui/__tests__/GenreBadge.test.tsx b/src/components/ui/__tests__/GenreBadge.test.tsx new file mode 100644 index 0000000..ff684f0 --- /dev/null +++ b/src/components/ui/__tests__/GenreBadge.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { GenreBadge } from '../GenreBadge'; + +describe('GenreBadge', () => { + it('should render the genre label', () => { + render(); + expect(screen.getByText('Rock')).toBeDefined(); + }); + + it('should apply genre-specific color classes', () => { + const { container } = render(); + const badge = container.firstElementChild!; + expect(badge.className).toContain('bg-red-500/15'); + expect(badge.className).toContain('text-red-400'); + }); + + it('should render nothing when genre is null', () => { + const { container } = render(); + expect(container.firstElementChild).toBeNull(); + }); + + it('should apply fallback color for unknown genre', () => { + const { container } = render(); + const badge = container.firstElementChild!; + expect(badge.className).toContain('bg-neutral-500/15'); + expect(badge.className).toContain('text-neutral-400'); + }); + + it('should capitalize unknown genre as label', () => { + render(); + expect(screen.getByText('unknown-genre')).toBeDefined(); + }); +}); From fa36e9a9ed876d4aa519b96cc66a20732a5832a5 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:32:14 +0100 Subject: [PATCH 06/17] feat: add genre picker to AddSong form --- package.json | 4 + pnpm-lock.yaml | 473 +++++++++++++++++- src/features/group/AddSong.tsx | 40 +- src/features/group/__tests__/AddSong.test.tsx | 113 +++++ 4 files changed, 619 insertions(+), 11 deletions(-) create mode 100644 src/features/group/__tests__/AddSong.test.tsx diff --git a/package.json b/package.json index 4cf8b3b..f5bea68 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,14 @@ }, "devDependencies": { "@astrojs/check": "^0.9.6", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd89fe5..25c9a2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,15 @@ importers: '@astrojs/check': specifier: ^0.9.6 version: 0.9.6(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@5.9.3) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@typescript-eslint/eslint-plugin': specifier: ^8.56.1 version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) @@ -57,6 +66,9 @@ importers: eslint-plugin-astro: specifier: ^1.6.0 version: 1.6.0(eslint@10.0.2(jiti@2.6.1)) + jsdom: + specifier: ^28.1.0 + version: 28.1.0 lefthook: specifier: ^2.1.1 version: 2.1.1 @@ -71,10 +83,26 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(yaml@2.8.2) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@astrojs/check@0.9.6': resolution: {integrity: sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA==} hasBin: true @@ -201,6 +229,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -213,6 +245,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -221,6 +257,37 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.29': + resolution: {integrity: sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -754,6 +821,15 @@ packages: resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.14.1': + resolution: {integrity: sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/accept-negotiator@2.0.1': resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} @@ -1564,6 +1640,38 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1871,6 +1979,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1894,6 +2006,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -1997,6 +2112,9 @@ packages: peerDependencies: ajv: 4.11.8 - 8 + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -2233,6 +2351,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2245,6 +2366,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2252,6 +2377,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2276,6 +2405,9 @@ packages: decache@4.6.2: resolution: {integrity: sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2379,6 +2511,12 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -2903,6 +3041,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -2912,6 +3054,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + http-shutdown@1.2.2: resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2957,6 +3103,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -3073,6 +3223,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3169,6 +3322,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3438,6 +3600,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3611,6 +3777,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.3: resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} engines: {node: 18 || 20 || >=22} @@ -3867,6 +4037,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -3962,6 +4135,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -4001,6 +4178,9 @@ packages: peerDependencies: react: ^19.2.4 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -4039,6 +4219,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4182,6 +4366,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4341,6 +4529,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + suf-log@2.5.3: resolution: {integrity: sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==} @@ -4357,6 +4549,9 @@ packages: engines: {node: '>=16'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4403,6 +4598,13 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.24: + resolution: {integrity: sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==} + + tldts@7.0.24: + resolution: {integrity: sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -4420,9 +4622,17 @@ packages: tomlify-j0.4@3.0.0: resolution: {integrity: sha512-2Ulkc8T7mXJ2l0W476YC/A209PR38Nw8PuaCNtk9uI3t1zzFdGQeWYGQvmj2PZkVvRC/Yoi4xQKMRnWc/N29tQ==} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4507,6 +4717,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} @@ -4833,6 +5047,10 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -4843,6 +5061,18 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -4923,6 +5153,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} @@ -5009,6 +5246,28 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@astrojs/check@0.9.6(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@5.9.3)': dependencies: '@astrojs/language-server': 2.16.3(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@5.9.3) @@ -5260,6 +5519,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -5283,12 +5544,38 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.1.0 + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.2 '@colors/colors@1.6.0': {} + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.0.29': {} + + '@csstools/css-tokenizer@4.0.0': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -5597,6 +5884,8 @@ snapshots: '@eslint/core': 1.1.0 levn: 0.4.1 + '@exodus/bytes@1.14.1': {} + '@fastify/accept-negotiator@2.0.1': {} '@fastify/busboy@3.2.0': {} @@ -6471,6 +6760,42 @@ snapshots: tailwindcss: 4.2.1 vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -6903,6 +7228,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@4.2.0: {} @@ -6937,6 +7264,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -7128,6 +7459,10 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -7353,6 +7688,8 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} cssfilter@0.0.10: {} @@ -7361,10 +7698,24 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.29 + css-tree: 3.1.0 + lru-cache: 11.2.6 + csstype@3.2.3: {} data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7391,6 +7742,8 @@ snapshots: dependencies: callsite: 1.0.0 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -7493,6 +7846,10 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -8218,12 +8575,25 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.14.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} http-cache-semantics@4.2.0: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-shutdown@1.2.2: {} https-proxy-agent@7.0.6: @@ -8258,6 +8628,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + indent-string@5.0.0: {} index-to-position@1.2.0: {} @@ -8396,6 +8768,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -8481,6 +8855,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.14.1 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.22.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -8714,6 +9115,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -9066,6 +9469,8 @@ snapshots: mimic-fn@4.0.0: {} + min-indent@1.0.1: {} + minimatch@10.2.3: dependencies: brace-expansion: 5.0.3 @@ -9319,6 +9724,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -9410,6 +9819,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.30.0: {} process-nextick-args@2.0.1: {} @@ -9441,6 +9856,8 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@19.2.4: {} @@ -9491,6 +9908,11 @@ snapshots: readdirp@5.0.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -9721,6 +10143,10 @@ snapshots: sax@1.4.4: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -9944,6 +10370,10 @@ snapshots: strip-final-newline@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + suf-log@2.5.3: dependencies: s.color: 0.0.15 @@ -9964,6 +10394,8 @@ snapshots: picocolors: 1.1.1 sax: 1.4.4 + symbol-tree@3.2.4: {} + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -10012,6 +10444,12 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@7.0.24: {} + + tldts@7.0.24: + dependencies: + tldts-core: 7.0.24 + tmp-promise@3.0.3: dependencies: tmp: 0.2.5 @@ -10026,8 +10464,16 @@ snapshots: tomlify-j0.4@3.0.0: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.24 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} triple-beam@1.4.1: {} @@ -10108,6 +10554,8 @@ snapshots: undici-types@7.18.2: {} + undici@7.22.0: {} + unicorn-magic@0.1.0: {} unified@11.0.5: @@ -10254,7 +10702,7 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2) - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.31.1)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(yaml@2.8.2)) @@ -10279,6 +10727,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 25.3.0 + jsdom: 28.1.0 transitivePeerDependencies: - jiti - less @@ -10389,12 +10838,28 @@ snapshots: vscode-uri@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.14.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -10505,6 +10970,10 @@ snapshots: ws@8.19.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xss@1.0.15: dependencies: commander: 2.20.3 diff --git a/src/features/group/AddSong.tsx b/src/features/group/AddSong.tsx index af6aa0d..c11ff6e 100644 --- a/src/features/group/AddSong.tsx +++ b/src/features/group/AddSong.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; import type { Locale } from '../../../site.config'; import { t } from '../../i18n'; +import type { TranslationKey } from '../../i18n'; +import { GENRES, getGenreLabel } from '../../lib/genres'; import { Button } from '../../components/ui/Button'; interface AddSongProps { - onAddSong: (url: string) => Promise; + onAddSong: (url: string, genre: string | null) => Promise; locale: Locale; } @@ -16,6 +18,7 @@ type AddSongState = 'idle' | 'resolving' | 'error'; */ export function AddSong({ onAddSong, locale }: AddSongProps) { const [url, setUrl] = useState(''); + const [genre, setGenre] = useState(''); const [state, setState] = useState('idle'); const [errorMessage, setErrorMessage] = useState(''); @@ -28,9 +31,10 @@ export function AddSong({ onAddSong, locale }: AddSongProps) { setErrorMessage(''); try { - await onAddSong(trimmed); - // Success: clear the input + await onAddSong(trimmed, genre || null); + // Success: clear the inputs setUrl(''); + setGenre(''); setState('idle'); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; @@ -41,7 +45,7 @@ export function AddSong({ onAddSong, locale }: AddSongProps) { return (
-
+
@@ -60,11 +64,29 @@ export function AddSong({ onAddSong, locale }: AddSongProps) { disabled={state === 'resolving'} className="min-w-0 flex-1 rounded-lg border border-border bg-bg-input px-3.5 py-2.5 text-sm text-text placeholder:text-text-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50" /> - +
+ + +
{state === 'error' && errorMessage && ( diff --git a/src/features/group/__tests__/AddSong.test.tsx b/src/features/group/__tests__/AddSong.test.tsx new file mode 100644 index 0000000..4abb359 --- /dev/null +++ b/src/features/group/__tests__/AddSong.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { AddSong } from '../AddSong'; + +const VALID_URL = 'https://open.spotify.com/track/123'; + +function renderAddSong(onAddSong = vi.fn<(url: string, genre: string | null) => Promise>()) { + const result = render(); + return { ...result, onAddSong }; +} + +describe('AddSong', () => { + it('should render URL input and submit button', () => { + renderAddSong(); + + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('should render a genre select dropdown', () => { + renderAddSong(); + + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + + // Should have genre options plus the empty placeholder + const options = screen.getAllByRole('option'); + // 19 genres + 1 placeholder = 20 + expect(options.length).toBe(20); + }); + + it('should disable submit when URL is empty', () => { + renderAddSong(); + + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('should call onAddSong with URL and null genre when none selected', async () => { + const user = userEvent.setup(); + const { onAddSong } = renderAddSong(vi.fn().mockResolvedValue(undefined)); + + await user.type(screen.getByRole('textbox'), VALID_URL); + await user.click(screen.getByRole('button')); + + expect(onAddSong).toHaveBeenCalledWith(VALID_URL, null); + }); + + it('should call onAddSong with URL and selected genre', async () => { + const user = userEvent.setup(); + const { onAddSong } = renderAddSong(vi.fn().mockResolvedValue(undefined)); + + await user.type(screen.getByRole('textbox'), VALID_URL); + await user.selectOptions(screen.getByRole('combobox'), 'rock'); + await user.click(screen.getByRole('button')); + + expect(onAddSong).toHaveBeenCalledWith(VALID_URL, 'rock'); + }); + + it('should reset genre after successful submission', async () => { + const user = userEvent.setup(); + renderAddSong(vi.fn().mockResolvedValue(undefined)); + + await user.type(screen.getByRole('textbox'), VALID_URL); + await user.selectOptions(screen.getByRole('combobox'), 'rock'); + await user.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('combobox')).toHaveValue(''); + }); + }); + + it('should clear URL input after successful submission', async () => { + const user = userEvent.setup(); + renderAddSong(vi.fn().mockResolvedValue(undefined)); + + await user.type(screen.getByRole('textbox'), VALID_URL); + await user.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + }); + + it('should show error message when onAddSong fails', async () => { + const user = userEvent.setup(); + renderAddSong(vi.fn().mockRejectedValue(new Error('Resolve failed'))); + + await user.type(screen.getByRole('textbox'), VALID_URL); + await user.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Resolve failed'); + }); + }); + + it('should disable inputs while resolving', async () => { + const user = userEvent.setup(); + // Never-resolving promise to keep the resolving state + const onAddSong = vi.fn().mockReturnValue(new Promise(() => {})); + render(); + + await user.type(screen.getByRole('textbox'), VALID_URL); + await user.click(screen.getByRole('button')); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('combobox')).toBeDisabled(); + }); + }); +}); From fe566bbdc45bec1bc19f3d73b4939498796645c0 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:33:43 +0100 Subject: [PATCH 07/17] feat: wire genre through addSong flow to Supabase --- src/features/group/useGroup.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/features/group/useGroup.ts b/src/features/group/useGroup.ts index 307ba01..04860ea 100644 --- a/src/features/group/useGroup.ts +++ b/src/features/group/useGroup.ts @@ -10,7 +10,7 @@ interface UseGroupResult { members: Member[]; isLoading: boolean; error: string | null; - addSong: (url: string) => Promise; + addSong: (url: string, genre?: string | null) => Promise; voteSong: (songId: string, rating: number) => Promise; refetch: () => Promise; } @@ -151,7 +151,7 @@ export function useGroup( /** Add a new song by resolving the URL via Odesli and inserting into Supabase */ const addSong = useCallback( - async (url: string) => { + async (url: string, genre: string | null = null) => { if (!round || !memberId) { throw new Error('Cannot add song: no active round or member'); } @@ -179,6 +179,7 @@ export function useGroup( thumbnail_url: resolved.thumbnailUrl, platform_links: resolved.platformLinks, odesli_page_url: resolved.pageUrl, + genre: genre ?? resolved.genre, }) .select() .single(); From 27405233328aed5c31713b8b7f4ce4e2b60aff1c Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:35:54 +0100 Subject: [PATCH 08/17] feat: display genre badge in SongCard Show GenreBadge between artist and "Added by" when song has a genre. Add SongCard component tests for genre badge rendering. --- src/features/group/SongCard.tsx | 7 +++ .../group/__tests__/SongCard.test.tsx | 60 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/features/group/__tests__/SongCard.test.tsx diff --git a/src/features/group/SongCard.tsx b/src/features/group/SongCard.tsx index 945e5e4..a200010 100644 --- a/src/features/group/SongCard.tsx +++ b/src/features/group/SongCard.tsx @@ -3,6 +3,7 @@ import type { Member, SongWithVotes } from '../../lib/types'; import { t } from '../../i18n'; import { StarRating } from '../../components/ui/StarRating'; import { PlatformLinks } from '../../components/ui/PlatformLinks'; +import { GenreBadge } from '../../components/ui/GenreBadge'; interface SongCardProps { song: SongWithVotes; @@ -72,6 +73,12 @@ export function SongCard({ song, memberId, members, onRate, locale }: SongCardPr

{song.title}

{song.artist}

+ {song.genre && ( +
+ +
+ )} + {/* Added by */}

{t('group.songCard.addedBy', locale)}{' '} diff --git a/src/features/group/__tests__/SongCard.test.tsx b/src/features/group/__tests__/SongCard.test.tsx new file mode 100644 index 0000000..36b6efc --- /dev/null +++ b/src/features/group/__tests__/SongCard.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { SongCard } from '../SongCard'; +import type { Member, SongWithVotes } from '../../../lib/types'; + +const defaultMembers: Member[] = [ + { + id: 'member-1', + group_id: 'group-1', + name: 'Alice', + avatar: 'A', + is_admin: false, + created_at: '2024-01-01T00:00:00Z', + }, +]; + +function makeSong(overrides: Partial = {}): SongWithVotes { + return { + id: 'song-1', + round_id: 'round-1', + member_id: 'member-1', + title: 'Test Song', + artist: 'Test Artist', + album: null, + thumbnail_url: null, + platform_links: [], + odesli_page_url: null, + genre: null, + created_at: '2024-01-01T00:00:00Z', + votes: [], + avgRating: 0, + totalVotes: 0, + ...overrides, + }; +} + +const defaultProps = { + memberId: 'member-2', + members: defaultMembers, + onRate: vi.fn(), + locale: 'en' as const, +}; + +describe('SongCard', () => { + it('should display genre badge when song has a genre', () => { + const song = makeSong({ genre: 'rock' }); + render(); + expect(screen.getByText('Rock')).toBeDefined(); + }); + + it('should not display genre badge when song genre is null', () => { + const song = makeSong({ genre: null }); + render(); + // Verify no genre label appears - check against known genre labels + expect(screen.queryByText('Rock')).toBeNull(); + expect(screen.queryByText('Pop')).toBeNull(); + expect(screen.queryByText('Jazz')).toBeNull(); + }); +}); From 2412df4d08ab357eb0ccdd01efcbf6df54e28211 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:38:01 +0100 Subject: [PATCH 09/17] feat(leaderboard): show genre badges in past rounds Add topGenre field to PastRound interface and render GenreBadge in past rounds cards below the song/artist info. --- src/features/leaderboard/PastRounds.tsx | 6 ++++++ src/features/leaderboard/useLeaderboard.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/features/leaderboard/PastRounds.tsx b/src/features/leaderboard/PastRounds.tsx index 9c4a478..8ebd188 100644 --- a/src/features/leaderboard/PastRounds.tsx +++ b/src/features/leaderboard/PastRounds.tsx @@ -1,6 +1,7 @@ import type { Locale } from '../../../site.config'; import type { PastRound } from './useLeaderboard'; import { t } from '../../i18n'; +import { GenreBadge } from '../../components/ui/GenreBadge'; interface PastRoundsProps { rounds: PastRound[]; @@ -35,6 +36,11 @@ export function PastRounds({ rounds, locale }: PastRoundsProps) {

{round.topSong} — {round.topArtist}

+ {round.topGenre && ( +
+ +
+ )}
{/* Right: winner + score */} diff --git a/src/features/leaderboard/useLeaderboard.ts b/src/features/leaderboard/useLeaderboard.ts index 87f3984..3100cd4 100644 --- a/src/features/leaderboard/useLeaderboard.ts +++ b/src/features/leaderboard/useLeaderboard.ts @@ -7,6 +7,7 @@ export interface PastRound { winner: string; topSong: string; topArtist: string; + topGenre: string | null; topScore: number; } @@ -175,6 +176,7 @@ export function useLeaderboard(groupId: string): UseLeaderboardResult { winner: winnerMember?.name ?? '?', topSong: winner.topSong.title, topArtist: winner.topSong.artist, + topGenre: winner.topSong.genre, topScore: winner.topScore, }); } From 5cad0172d96f66963863ea398f50f7254b763070 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:42:53 +0100 Subject: [PATCH 10/17] feat: add genre filter to leaderboard --- src/components/ui/GenreFilter.tsx | 27 ++++++++++ .../ui/__tests__/GenreFilter.test.tsx | 53 +++++++++++++++++++ src/features/leaderboard/LeaderboardView.tsx | 12 ++++- src/i18n/en.json | 2 + src/i18n/es.json | 2 + 5 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/GenreFilter.tsx create mode 100644 src/components/ui/__tests__/GenreFilter.test.tsx diff --git a/src/components/ui/GenreFilter.tsx b/src/components/ui/GenreFilter.tsx new file mode 100644 index 0000000..cdc9595 --- /dev/null +++ b/src/components/ui/GenreFilter.tsx @@ -0,0 +1,27 @@ +import type { Locale } from '../../../site.config'; +import { GENRES, getGenreLabel } from '../../lib/genres'; +import { t } from '../../i18n'; + +interface GenreFilterProps { + value: string | null; + onChange: (genre: string | null) => void; + locale: Locale; +} + +export function GenreFilter({ value, onChange, locale }: GenreFilterProps) { + return ( + + ); +} diff --git a/src/components/ui/__tests__/GenreFilter.test.tsx b/src/components/ui/__tests__/GenreFilter.test.tsx new file mode 100644 index 0000000..0003a7c --- /dev/null +++ b/src/components/ui/__tests__/GenreFilter.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GenreFilter } from '../GenreFilter'; + +describe('GenreFilter', () => { + it('should render a select with "All genres" as default option', () => { + render(); + const select = screen.getByRole('combobox'); + expect(select).toBeDefined(); + expect((select as HTMLSelectElement).value).toBe(''); + }); + + it('should list all genres as options plus the "All" option', () => { + render(); + const options = screen.getAllByRole('option'); + expect(options.length).toBe(20); // 19 genres + "All genres" + }); + + it('should call onChange with genre when selected', async () => { + const onChange = vi.fn(); + render(); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'rock'); + expect(onChange).toHaveBeenCalledWith('rock'); + }); + + it('should call onChange with null when "All" is selected', async () => { + const onChange = vi.fn(); + render(); + + await userEvent.selectOptions(screen.getByRole('combobox'), ''); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('should have accessible label', () => { + render(); + expect(screen.getByLabelText('Filter by genre')).toBeDefined(); + }); + + it('should show Spanish labels when locale is es', () => { + render(); + expect(screen.getByLabelText('Filtrar por genero')).toBeDefined(); + expect(screen.getByText('Todos los generos')).toBeDefined(); + }); + + it('should reflect the current value', () => { + render(); + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('jazz'); + }); +}); diff --git a/src/features/leaderboard/LeaderboardView.tsx b/src/features/leaderboard/LeaderboardView.tsx index 93b9671..5e30756 100644 --- a/src/features/leaderboard/LeaderboardView.tsx +++ b/src/features/leaderboard/LeaderboardView.tsx @@ -1,7 +1,9 @@ +import { useState } from 'react'; import type { Locale } from '../../../site.config'; import type { Group, MemberStats } from '../../lib/types'; import { t } from '../../i18n'; import { Button } from '../../components/ui/Button'; +import { GenreFilter } from '../../components/ui/GenreFilter'; import { useLeaderboard } from './useLeaderboard'; import { Podium } from './Podium'; import { PastRounds } from './PastRounds'; @@ -132,6 +134,11 @@ function ErrorState({ */ export function LeaderboardView({ group, locale }: LeaderboardViewProps) { const { members, pastRounds, isLoading, error } = useLeaderboard(group.id); + const [genreFilter, setGenreFilter] = useState(null); + + const filteredPastRounds = genreFilter + ? pastRounds.filter((r) => r.topGenre === genreFilter) + : pastRounds; return (
@@ -145,7 +152,10 @@ export function LeaderboardView({ group, locale }: LeaderboardViewProps) { <> - +
+ +
+ )}
diff --git a/src/i18n/en.json b/src/i18n/en.json index 43e578c..50422f0 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -76,6 +76,8 @@ "leaderboard.avg": "avg", "leaderboard.wins": "wins", "leaderboard.pts": "pts", + "leaderboard.genreFilter": "Filter by genre", + "leaderboard.allGenres": "All genres", "leaderboard.fullLink": "Full leaderboard", "footer.madeWith": "Made with", "footer.by": "by" diff --git a/src/i18n/es.json b/src/i18n/es.json index 9c348c2..81ce8f5 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -76,6 +76,8 @@ "leaderboard.avg": "media", "leaderboard.wins": "victorias", "leaderboard.pts": "pts", + "leaderboard.genreFilter": "Filtrar por genero", + "leaderboard.allGenres": "Todos los generos", "leaderboard.fullLink": "Clasificacion completa", "footer.madeWith": "Hecho con", "footer.by": "por" From b6fbad8e09f66c0901e6450520cdc9fafe71dc3e Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 14:43:59 +0100 Subject: [PATCH 11/17] feat: add i18n keys for genre feature and LASTFM env var --- .env.example | 1 + src/i18n/en.json | 2 ++ src/i18n/es.json | 2 ++ 3 files changed, 5 insertions(+) diff --git a/.env.example b/.env.example index 1162020..5c87b07 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ PUBLIC_SUPABASE_URL=https://your-project.supabase.co PUBLIC_SUPABASE_ANON_KEY=your-anon-key +LASTFM_API_KEY=your-lastfm-api-key diff --git a/src/i18n/en.json b/src/i18n/en.json index 50422f0..cfd9686 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -55,6 +55,8 @@ "group.addSong.placeholder": "Paste a Spotify or YouTube link...", "group.addSong.button": "Add", "group.addSong.resolving": "Resolving...", + "group.addSong.genreLabel": "Genre", + "group.addSong.genrePlaceholder": "Genre (optional)", "group.addSong.error": "Could not resolve that link. Try another.", "group.songCard.addedBy": "Added by", "group.songCard.yourTrack": "Your track", diff --git a/src/i18n/es.json b/src/i18n/es.json index 81ce8f5..f9466d4 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -55,6 +55,8 @@ "group.addSong.placeholder": "Pega un enlace de Spotify o YouTube...", "group.addSong.button": "Anadir", "group.addSong.resolving": "Resolviendo...", + "group.addSong.genreLabel": "Género", + "group.addSong.genrePlaceholder": "Género (opcional)", "group.addSong.error": "No se pudo resolver ese enlace. Prueba con otro.", "group.songCard.addedBy": "Anadida por", "group.songCard.yourTrack": "Tu cancion", From 1367879ffed7dd6476dc164b9813c43003114645 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 15:31:28 +0100 Subject: [PATCH 12/17] fix: add android to eslint ignores and allow console.error/warn Build artifacts in android/ were being linted unnecessarily. console.error in catch blocks is legitimate error logging, not debug noise. --- eslint.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 31fe768..638ec61 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; export default [ - { ignores: ['dist/**', '.astro/**', 'node_modules/**', 'prototype/**'] }, + { ignores: ['dist/**', '.astro/**', 'node_modules/**', 'prototype/**', 'android/**'] }, ...eslintPluginAstro.configs.recommended, { files: ['**/*.ts', '**/*.tsx'], @@ -19,7 +19,7 @@ export default [ }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - 'no-console': 'warn', + 'no-console': ['warn', { allow: ['warn', 'error'] }], }, }, ]; From ad138e696423a5a215b77fb22c5e1b674c5a7c45 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 15:37:47 +0100 Subject: [PATCH 13/17] fix: remove unsafe substring matching in matchGenre Substring matching caused false positives ("popular" matched "pop", "countryside" matched "country"). Replaced with explicit aliases for common Last.fm tag variants. Added tests for false-positive scenarios. --- src/lib/__tests__/genres.test.ts | 31 ++++++++++++++++++ src/lib/genres.ts | 55 +++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/lib/__tests__/genres.test.ts b/src/lib/__tests__/genres.test.ts index 8d7212f..dff81ab 100644 --- a/src/lib/__tests__/genres.test.ts +++ b/src/lib/__tests__/genres.test.ts @@ -31,6 +31,12 @@ describe('genres', () => { expect(color).toContain('bg-'); expect(color).toContain('text-'); }); + + it('returns correct labels for specific genres', () => { + expect(getGenreLabel('rock')).toBe('Rock'); + expect(getGenreLabel('hip-hop')).toBe('Hip-Hop'); + expect(getGenreLabel('r&b')).toBe('R&B'); + }); }); describe('matchGenre', () => { @@ -69,4 +75,29 @@ describe('matchGenre', () => { expect(matchGenre(['drum and bass'])).toBe('electronic'); expect(matchGenre(['techno'])).toBe('electronic'); }); + + it('does not false-positive "popular" as "pop"', () => { + expect(matchGenre(['popular'])).toBeNull(); + }); + + it('does not false-positive "countryside" as "country"', () => { + expect(matchGenre(['countryside'])).toBeNull(); + }); + + it('does not false-positive "funky" as "funk"', () => { + expect(matchGenre(['funky'])).toBeNull(); + }); + + it('does not false-positive "folktronica" as "folk"', () => { + expect(matchGenre(['folktronica'])).toBeNull(); + }); + + it('matches new explicit aliases', () => { + expect(matchGenre(['k-pop'])).toBe('pop'); + expect(matchGenre(['metalcore'])).toBe('metal'); + expect(matchGenre(['shoegaze'])).toBe('alternative'); + expect(matchGenre(['bluegrass'])).toBe('country'); + expect(matchGenre(['disco'])).toBe('funk'); + expect(matchGenre(['ska'])).toBe('reggae'); + }); }); diff --git a/src/lib/genres.ts b/src/lib/genres.ts index faaf28f..5bc0e04 100644 --- a/src/lib/genres.ts +++ b/src/lib/genres.ts @@ -110,10 +110,60 @@ const TAG_ALIASES: Record = { 'hard rock': 'rock', 'progressive rock': 'rock', 'psychedelic rock': 'rock', + 'garage rock': 'rock', 'blues rock': 'blues', 'folk rock': 'folk', acoustic: 'folk', 'singer-songwriter': 'folk', + 'indie folk': 'folk', + 'synth-pop': 'pop', + electropop: 'pop', + 'dream pop': 'pop', + 'power pop': 'pop', + 'art pop': 'pop', + 'k-pop': 'pop', + 'j-pop': 'pop', + 'latin pop': 'latin', + 'latin rock': 'latin', + flamenco: 'latin', + tango: 'latin', + 'smooth jazz': 'jazz', + 'acid jazz': 'jazz', + 'jazz fusion': 'jazz', + 'nu jazz': 'jazz', + 'delta blues': 'blues', + 'chicago blues': 'blues', + 'electric blues': 'blues', + 'neo-soul': 'soul', + 'southern soul': 'soul', + 'psychedelic soul': 'soul', + afrobeat: 'funk', + 'g-funk': 'funk', + disco: 'funk', + 'post-rock': 'rock', + shoegaze: 'alternative', + britpop: 'alternative', + 'new wave': 'alternative', + ambient: 'electronic', + industrial: 'electronic', + electronica: 'electronic', + downtempo: 'electronic', + metalcore: 'metal', + 'doom metal': 'metal', + 'power metal': 'metal', + 'folk metal': 'metal', + 'symphonic metal': 'metal', + 'ska punk': 'punk', + emo: 'punk', + dub: 'reggae', + dancehall: 'reggae', + ska: 'reggae', + 'roots reggae': 'reggae', + gospel: 'soul', + 'country rock': 'country', + bluegrass: 'country', + americana: 'country', + 'outlaw country': 'country', }; const genreSet = new Set(GENRES); @@ -136,11 +186,6 @@ export function matchGenre(tags: string[]): Genre | null { // 2. Check exact match against GENRES if (genreSet.has(normalized)) return normalized as Genre; - - // 3. Check if tag contains a genre slug as substring - for (const genre of GENRES) { - if (normalized.includes(genre)) return genre; - } } return null; From 98d95fa355b41fd07ecae077d511f1055afd6938 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 15:38:36 +0100 Subject: [PATCH 14/17] fix: move genre detection to server-side /api/resolve LASTFM_API_KEY is not exposed to client bundles (no PUBLIC_ prefix). Genre detection was dead code in production. Now the server endpoint detects genre and includes it as _detectedGenre in the response. --- src/lib/__tests__/odesli.test.ts | 22 ++++++++++------------ src/lib/lastfm.ts | 1 + src/lib/odesli.ts | 11 +++-------- src/pages/api/resolve.ts | 25 ++++++++++++++++++++++--- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/lib/__tests__/odesli.test.ts b/src/lib/__tests__/odesli.test.ts index d353677..317974b 100644 --- a/src/lib/__tests__/odesli.test.ts +++ b/src/lib/__tests__/odesli.test.ts @@ -1,10 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resolveSongLink } from '../odesli'; -vi.mock('../lastfm', () => ({ - detectGenre: vi.fn().mockResolvedValue('rock'), -})); - // Minimal valid Odesli API response for a Spotify track function makeOdesliResponse( overrides: Partial<{ @@ -12,12 +8,14 @@ function makeOdesliResponse( pageUrl: string; entitiesByUniqueId: Record; linksByPlatform: Record; + _detectedGenre: string | null; }> = {}, ) { return { entityUniqueId: 'SPOTIFY_SONG::abc123', userCountry: 'US', pageUrl: 'https://song.link/s/abc123', + _detectedGenre: null, entitiesByUniqueId: { 'SPOTIFY_SONG::abc123': { id: 'abc123', @@ -213,19 +211,19 @@ describe('resolveSongLink', () => { expect(ytMusic!.url).toBe('https://music.youtube.com/watch?v=fJ9rUzIMcZQ'); }); - it('should include auto-detected genre from Last.fm', async () => { - const { detectGenre } = await import('../lastfm'); - vi.mocked(detectGenre).mockResolvedValueOnce('electronic'); - vi.mocked(fetch).mockResolvedValueOnce(makeOkResponse(makeOdesliResponse())); + it('should include genre from server-side _detectedGenre field', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + makeOkResponse(makeOdesliResponse({ _detectedGenre: 'electronic' })), + ); const result = await resolveSongLink('https://open.spotify.com/track/abc'); expect(result.genre).toBe('electronic'); }); - it('should return null genre when Last.fm detection fails', async () => { - const { detectGenre } = await import('../lastfm'); - vi.mocked(detectGenre).mockRejectedValueOnce(new Error('API down')); - vi.mocked(fetch).mockResolvedValueOnce(makeOkResponse(makeOdesliResponse())); + it('should return null genre when _detectedGenre is absent', async () => { + const response = makeOdesliResponse(); + delete (response as Record)._detectedGenre; + vi.mocked(fetch).mockResolvedValueOnce(makeOkResponse(response)); const result = await resolveSongLink('https://open.spotify.com/track/abc'); expect(result.genre).toBeNull(); diff --git a/src/lib/lastfm.ts b/src/lib/lastfm.ts index 11ffe9e..680121a 100644 --- a/src/lib/lastfm.ts +++ b/src/lib/lastfm.ts @@ -28,6 +28,7 @@ async function fetchTopTags( } export async function detectGenre(title: string, artist: string): Promise { + // Server-side only: LASTFM_API_KEY is not exposed to the client (no PUBLIC_ prefix) const apiKey = import.meta.env.LASTFM_API_KEY ?? process.env.LASTFM_API_KEY ?? ''; if (!apiKey) return null; diff --git a/src/lib/odesli.ts b/src/lib/odesli.ts index 0f9e24c..b5499fb 100644 --- a/src/lib/odesli.ts +++ b/src/lib/odesli.ts @@ -1,7 +1,5 @@ /** Odesli (song.link) API wrapper — resolves music URLs to cross-platform data */ -import { detectGenre } from './lastfm'; - /** Use our own server-side proxy to avoid CORS issues with the Odesli API */ const RESOLVE_API = '/api/resolve'; @@ -176,12 +174,9 @@ export async function resolveSongLink(url: string): Promise { throw new Error('Invalid page URL returned by song resolution service.'); } - let genre: string | null = null; - try { - genre = await detectGenre(title, artist); - } catch { - // Genre detection is best-effort — never fail the full resolution - } + // Genre is detected server-side in /api/resolve and passed as _detectedGenre + const genre: string | null = + ((data as unknown as Record)._detectedGenre as string | null) ?? null; return { title, diff --git a/src/pages/api/resolve.ts b/src/pages/api/resolve.ts index 031f660..2b7a4c3 100644 --- a/src/pages/api/resolve.ts +++ b/src/pages/api/resolve.ts @@ -1,4 +1,5 @@ import type { APIRoute } from 'astro'; +import { detectGenre } from '../../lib/lastfm'; const ODESLI_API = 'https://api.song.link/v1-alpha.1/links'; @@ -60,10 +61,28 @@ export const GET: APIRoute = async ({ url }) => { const endpoint = `${ODESLI_API}?url=${encodeURIComponent(musicUrl)}`; const response = await fetch(endpoint); - const body = await response.text(); + if (!response.ok) { + return new Response(await response.text(), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const data = await response.json(); + + // Extract title and artist from the primary entity for genre detection + let genre: string | null = null; + try { + const primaryEntity = data.entitiesByUniqueId?.[data.entityUniqueId]; + if (primaryEntity?.title && primaryEntity?.artistName) { + genre = await detectGenre(primaryEntity.title, primaryEntity.artistName); + } + } catch { + // Genre detection is best-effort + } - return new Response(body, { - status: response.status, + return new Response(JSON.stringify({ ...data, _detectedGenre: genre }), { + status: 200, headers: { 'Content-Type': 'application/json' }, }); } catch { From 606d64640e8013218a3c0a0048fbe1dc7d399d2c Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 15:38:43 +0100 Subject: [PATCH 15/17] fix: add genre whitelist validation and env schema Validate genre against GENRES whitelist before Supabase insert to prevent arbitrary strings. Add LASTFM_API_KEY to env validation schema. --- src/features/group/useGroup.ts | 10 +++++++++- src/lib/env.ts | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/features/group/useGroup.ts b/src/features/group/useGroup.ts index 04860ea..76b147e 100644 --- a/src/features/group/useGroup.ts +++ b/src/features/group/useGroup.ts @@ -2,8 +2,16 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { supabase } from '../../lib/supabase'; import { ensureCurrentRound } from '../../lib/rounds'; import { resolveSongLink } from '../../lib/odesli'; +import { GENRES } from '../../lib/genres'; import type { Member, Round, Song, Vote, SongWithVotes } from '../../lib/types'; +const VALID_GENRES = new Set(GENRES); + +function validateGenre(genre: string | null): string | null { + if (!genre) return null; + return VALID_GENRES.has(genre) ? genre : null; +} + interface UseGroupResult { round: Round | null; songs: SongWithVotes[]; @@ -179,7 +187,7 @@ export function useGroup( thumbnail_url: resolved.thumbnailUrl, platform_links: resolved.platformLinks, odesli_page_url: resolved.pageUrl, - genre: genre ?? resolved.genre, + genre: validateGenre(genre ?? resolved.genre), }) .select() .single(); diff --git a/src/lib/env.ts b/src/lib/env.ts index 5193c32..6f33898 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -3,12 +3,14 @@ import * as z from 'zod'; const envSchema = z.object({ PUBLIC_SUPABASE_URL: z.url(), PUBLIC_SUPABASE_ANON_KEY: z.string().min(1, 'PUBLIC_SUPABASE_ANON_KEY is required'), + LASTFM_API_KEY: z.string().min(1).optional(), }); function validateEnv() { const result = envSchema.safeParse({ PUBLIC_SUPABASE_URL: import.meta.env.PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY: import.meta.env.PUBLIC_SUPABASE_ANON_KEY, + LASTFM_API_KEY: import.meta.env.LASTFM_API_KEY, }); if (!result.success) { From a59ebac3d4a7dc424bd818ec8d7e0e2c4b5887ed Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 15:38:53 +0100 Subject: [PATCH 16/17] test: improve test quality and coverage from review - Add PastRounds tests (genre badge, empty state, scores, locale) - Add SongCard tests (title/artist render, "Your track" state) - Fix test assertions: toBeDefined -> toBeInTheDocument - Fix test name: "capitalize" -> "render as-is" for unknown genres - Replace loop with it.each in lastfm hip-hop variants - Use GENRES.length instead of magic number 20 - Change odesli mock default to null for neutral behavior --- .../ui/__tests__/GenreBadge.test.tsx | 7 +- .../ui/__tests__/GenreFilter.test.tsx | 10 ++- src/features/group/__tests__/AddSong.test.tsx | 4 +- .../group/__tests__/SongCard.test.tsx | 16 +++- .../leaderboard/__tests__/PastRounds.test.tsx | 78 +++++++++++++++++++ src/lib/__tests__/lastfm.test.ts | 13 ++-- 6 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 src/features/leaderboard/__tests__/PastRounds.test.tsx diff --git a/src/components/ui/__tests__/GenreBadge.test.tsx b/src/components/ui/__tests__/GenreBadge.test.tsx index ff684f0..d2c650b 100644 --- a/src/components/ui/__tests__/GenreBadge.test.tsx +++ b/src/components/ui/__tests__/GenreBadge.test.tsx @@ -1,12 +1,13 @@ // @vitest-environment jsdom import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; import { GenreBadge } from '../GenreBadge'; describe('GenreBadge', () => { it('should render the genre label', () => { render(); - expect(screen.getByText('Rock')).toBeDefined(); + expect(screen.getByText('Rock')).toBeInTheDocument(); }); it('should apply genre-specific color classes', () => { @@ -28,8 +29,8 @@ describe('GenreBadge', () => { expect(badge.className).toContain('text-neutral-400'); }); - it('should capitalize unknown genre as label', () => { + it('should render unknown genre string as-is for label', () => { render(); - expect(screen.getByText('unknown-genre')).toBeDefined(); + expect(screen.getByText('unknown-genre')).toBeInTheDocument(); }); }); diff --git a/src/components/ui/__tests__/GenreFilter.test.tsx b/src/components/ui/__tests__/GenreFilter.test.tsx index 0003a7c..85d685e 100644 --- a/src/components/ui/__tests__/GenreFilter.test.tsx +++ b/src/components/ui/__tests__/GenreFilter.test.tsx @@ -1,21 +1,23 @@ // @vitest-environment jsdom import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; import userEvent from '@testing-library/user-event'; import { GenreFilter } from '../GenreFilter'; +import { GENRES } from '../../../lib/genres'; describe('GenreFilter', () => { it('should render a select with "All genres" as default option', () => { render(); const select = screen.getByRole('combobox'); - expect(select).toBeDefined(); + expect(select).toBeInTheDocument(); expect((select as HTMLSelectElement).value).toBe(''); }); it('should list all genres as options plus the "All" option', () => { render(); const options = screen.getAllByRole('option'); - expect(options.length).toBe(20); // 19 genres + "All genres" + expect(options.length).toBe(GENRES.length + 1); // genres + "All genres" }); it('should call onChange with genre when selected', async () => { @@ -36,12 +38,12 @@ describe('GenreFilter', () => { it('should have accessible label', () => { render(); - expect(screen.getByLabelText('Filter by genre')).toBeDefined(); + expect(screen.getByLabelText('Filter by genre')).toBeInTheDocument(); }); it('should show Spanish labels when locale is es', () => { render(); - expect(screen.getByLabelText('Filtrar por genero')).toBeDefined(); + expect(screen.getByLabelText('Filtrar por genero')).toBeInTheDocument(); expect(screen.getByText('Todos los generos')).toBeDefined(); }); diff --git a/src/features/group/__tests__/AddSong.test.tsx b/src/features/group/__tests__/AddSong.test.tsx index 4abb359..52fd208 100644 --- a/src/features/group/__tests__/AddSong.test.tsx +++ b/src/features/group/__tests__/AddSong.test.tsx @@ -4,6 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { AddSong } from '../AddSong'; +import { GENRES } from '../../../lib/genres'; const VALID_URL = 'https://open.spotify.com/track/123'; @@ -28,8 +29,7 @@ describe('AddSong', () => { // Should have genre options plus the empty placeholder const options = screen.getAllByRole('option'); - // 19 genres + 1 placeholder = 20 - expect(options.length).toBe(20); + expect(options.length).toBe(GENRES.length + 1); // genres + placeholder }); it('should disable submit when URL is empty', () => { diff --git a/src/features/group/__tests__/SongCard.test.tsx b/src/features/group/__tests__/SongCard.test.tsx index 36b6efc..2aceba3 100644 --- a/src/features/group/__tests__/SongCard.test.tsx +++ b/src/features/group/__tests__/SongCard.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; import { SongCard } from '../SongCard'; import type { Member, SongWithVotes } from '../../../lib/types'; @@ -43,10 +44,23 @@ const defaultProps = { }; describe('SongCard', () => { + it('should render song title and artist', () => { + const song = makeSong(); + render(); + expect(screen.getByText('Test Song')).toBeInTheDocument(); + expect(screen.getByText('Test Artist')).toBeInTheDocument(); + }); + + it('should show "Your track" when song belongs to current member', () => { + const song = makeSong({ member_id: 'member-2' }); + render(); + expect(screen.getByText('Your track')).toBeInTheDocument(); + }); + it('should display genre badge when song has a genre', () => { const song = makeSong({ genre: 'rock' }); render(); - expect(screen.getByText('Rock')).toBeDefined(); + expect(screen.getByText('Rock')).toBeInTheDocument(); }); it('should not display genre badge when song genre is null', () => { diff --git a/src/features/leaderboard/__tests__/PastRounds.test.tsx b/src/features/leaderboard/__tests__/PastRounds.test.tsx new file mode 100644 index 0000000..cd710dd --- /dev/null +++ b/src/features/leaderboard/__tests__/PastRounds.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { PastRounds } from '../PastRounds'; +import type { PastRound } from '../useLeaderboard'; + +const mockRounds: PastRound[] = [ + { + number: 1, + winner: 'Alice', + topSong: 'Bohemian Rhapsody', + topArtist: 'Queen', + topGenre: 'rock', + topScore: 4.5, + }, + { + number: 2, + winner: 'Bob', + topSong: 'So What', + topArtist: 'Miles Davis', + topGenre: 'jazz', + topScore: 3.8, + }, + { + number: 3, + winner: 'Carol', + topSong: 'No Genre Song', + topArtist: 'Unknown', + topGenre: null, + topScore: 4.0, + }, +]; + +describe('PastRounds', () => { + it('renders empty state when no rounds', () => { + render(); + expect(screen.getByText('No past rounds yet')).toBeInTheDocument(); + }); + + it('renders round numbers and song info', () => { + render(); + expect(screen.getByText('Bohemian Rhapsody — Queen')).toBeInTheDocument(); + expect(screen.getByText('So What — Miles Davis')).toBeInTheDocument(); + expect(screen.getByText('No Genre Song — Unknown')).toBeInTheDocument(); + }); + + it('shows GenreBadge for rounds with a genre', () => { + render(); + expect(screen.getByText('Rock')).toBeInTheDocument(); + expect(screen.getByText('Jazz')).toBeInTheDocument(); + }); + + it('does not show GenreBadge for rounds with null genre', () => { + render(); + expect(screen.queryByText('Rock')).not.toBeInTheDocument(); + expect(screen.queryByText('Jazz')).not.toBeInTheDocument(); + }); + + it('shows winner names', () => { + render(); + expect(screen.getByText(/Alice/)).toBeInTheDocument(); + expect(screen.getByText(/Bob/)).toBeInTheDocument(); + expect(screen.getByText(/Carol/)).toBeInTheDocument(); + }); + + it('displays scores formatted to one decimal', () => { + render(); + expect(screen.getByText('4.5')).toBeInTheDocument(); + expect(screen.getByText('3.8')).toBeInTheDocument(); + expect(screen.getByText('4.0')).toBeInTheDocument(); + }); + + it('renders in Spanish locale', () => { + render(); + expect(screen.getByText('Aun no hay rondas pasadas')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/__tests__/lastfm.test.ts b/src/lib/__tests__/lastfm.test.ts index f7743fc..7848a10 100644 --- a/src/lib/__tests__/lastfm.test.ts +++ b/src/lib/__tests__/lastfm.test.ts @@ -137,17 +137,16 @@ describe('detectGenre', () => { expect(result).toBeNull(); }); - it('should match hip-hop variants from Last.fm tags', async () => { - const mockFetch = vi.mocked(fetch); - const variants = ['Hip-Hop', 'hip hop', 'rap', 'Rap', 'Hip Hop']; - - for (const variant of variants) { + it.each(['Hip-Hop', 'hip hop', 'rap', 'Rap', 'Hip Hop'])( + 'should match "%s" as hip-hop', + async (variant) => { + const mockFetch = vi.mocked(fetch); mockFetch.mockResolvedValueOnce(makeTagResponse([{ name: variant, count: 100 }])); const result = await detectGenre('Track', 'Artist'); expect(result).toBe('hip-hop'); - } - }); + }, + ); it('should return null when API key is not set', async () => { vi.stubEnv('LASTFM_API_KEY', ''); From 9edae56e7971e97d54e1715d1a65d5935d52da99 Mon Sep 17 00:00:00 2001 From: Alvaro Torres Date: Tue, 3 Mar 2026 15:51:21 +0100 Subject: [PATCH 17/17] fix: correct genre select aria-label in AddSong tests --- src/features/group/__tests__/AddSong.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/group/__tests__/AddSong.test.tsx b/src/features/group/__tests__/AddSong.test.tsx index b4d1ce6..6556095 100644 --- a/src/features/group/__tests__/AddSong.test.tsx +++ b/src/features/group/__tests__/AddSong.test.tsx @@ -166,7 +166,7 @@ describe('AddSong', () => { it('should render genre select dropdown', () => { render(); - const select = screen.getByRole('combobox', { name: 'Genre (optional)' }); + const select = screen.getByRole('combobox', { name: 'Genre' }); expect(select).toBeDefined(); }); @@ -177,7 +177,7 @@ describe('AddSong', () => { const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); - const select = screen.getByRole('combobox', { name: 'Genre (optional)' }); + const select = screen.getByRole('combobox', { name: 'Genre' }); fireEvent.change(select, { target: { value: 'rock' } }); const form = input.closest('form')!; @@ -195,7 +195,7 @@ describe('AddSong', () => { const input = screen.getByPlaceholderText('Paste a Spotify or YouTube link...'); fireEvent.change(input, { target: { value: 'https://open.spotify.com/track/abc' } }); - const select = screen.getByRole('combobox', { name: 'Genre (optional)' }) as HTMLSelectElement; + const select = screen.getByRole('combobox', { name: 'Genre' }) as HTMLSelectElement; fireEvent.change(select, { target: { value: 'rock' } }); const form = input.closest('form')!;