Refactor code structure for improved readability and maintainability
Some checks failed
CI / lint (push) Failing after 10s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

This commit is contained in:
2026-03-22 11:34:38 +01:00
parent 5eaf320fc5
commit 2667e11284
46 changed files with 4949 additions and 157 deletions

View File

@@ -6,10 +6,14 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@testing-library/dom": "^10.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -24,14 +28,23 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.0",
"@vitest/ui": "^4.1.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^29.0.1",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "^5",
"vite": "^8.0.1",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { EnrichedGame, metadataApi } from '@/lib/api';
import { SearchForm } from '@/components/games/SearchForm';
import { SearchResults } from '@/components/games/SearchResults';
import { GamePreviewDialog } from '@/components/games/GamePreviewDialog';
import Navbar from '@/components/landing/Navbar';
export default function AddGamePage() {
const router = useRouter();
const [searchResults, setSearchResults] = useState<EnrichedGame[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedGame, setSelectedGame] = useState<EnrichedGame | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSearch = async (params: { title: string; platform?: string; year?: number }) => {
setIsSearching(true);
setError(null);
try {
const results = await metadataApi.searchGames(params);
setSearchResults(results);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al buscar juegos');
setSearchResults([]);
} finally {
setIsSearching(false);
}
};
const handleSelectResult = (game: EnrichedGame) => {
setSelectedGame(game);
};
const handleSaveGame = async (data: {
metadata: EnrichedGame;
overrides: { platform?: string; year?: number; description?: string };
}) => {
setIsSaving(true);
setError(null);
try {
await metadataApi.createGameFromMetadata(data);
setSelectedGame(null);
router.push('/games');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar el juego');
} finally {
setIsSaving(false);
}
};
return (
<div className="min-h-screen bg-background">
{/* Starfield background */}
<div className="starfield" />
{/* Navbar */}
<Navbar />
{/* Main Content */}
<main className="relative z-10 pt-20">
<div className="container mx-auto px-4 py-8">
<div className="max-w-7xl mx-auto w-full">
{/* Header */}
<div className="mb-8">
<h1 className="text-responsive-3xl font-bold mb-2">
<span className="gradient-text">BUSCAR JUEGOS</span>
</h1>
<p className="text-muted-foreground mono text-sm tracking-wider">
BUSCA EN PROVEEDORES EXTERNOS Y AÑADE JUEGOS A TU BIBLIOTECA
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-destructive mono text-sm tracking-wider">{error}</p>
</div>
)}
{/* Search Form */}
<div className="mb-8 p-6 bg-card border rounded-lg">
<SearchForm onSearch={handleSearch} isLoading={isSearching} />
</div>
{/* Search Results */}
{!isSearching && searchResults.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 mono tracking-wider">
RESULTADOS DE BÚSQUEDA ({searchResults.length})
</h2>
<SearchResults
results={searchResults}
onSelectResult={handleSelectResult}
loading={isSearching}
/>
</div>
)}
{/* Empty State */}
{!isSearching && searchResults.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground mono text-sm tracking-wider">
NO SE ENCONTRARON RESULTADOS
</p>
<p className="text-muted-foreground/70 mono text-xs tracking-wider mt-2">
INTENTA CON OTROS TÉRMINOS DE BÚSQUEDA
</p>
</div>
)}
</div>
</div>
</main>
{/* Game Preview Dialog */}
<GamePreviewDialog
open={selectedGame !== null}
onOpenChange={(open) => !open && setSelectedGame(null)}
onSave={handleSaveGame}
game={selectedGame}
/>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Game, gamesApi } from '@/lib/api';
import { GameTable } from '@/components/games/GameTable';
import { GameDialog } from '@/components/games/GameDialog';
@@ -120,13 +121,12 @@ export default function GamesPage() {
</p>
</div>
<div className="flex gap-2">
<Button
onClick={handleCreate}
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
>
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">NUEVO JUEGO</span>
</Button>
<Link href="/games/add">
<Button className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]">
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">NUEVO JUEGO</span>
</Button>
</Link>
</div>
</div>
@@ -197,13 +197,12 @@ export default function GamesPage() {
: 'NO HAY JUEGOS EN TU BIBLIOTECA.'}
</p>
{!searchQuery && (
<Button
onClick={handleCreate}
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
>
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">AGREGAR PRIMER JUEGO</span>
</Button>
<Link href="/games/add">
<Button className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]">
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">AGREGAR PRIMER JUEGO</span>
</Button>
</Link>
)}
</div>
)}

View File

@@ -0,0 +1,207 @@
'use client';
import { useState, useEffect } from 'react';
import { EnrichedGame } from '@/lib/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface GamePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (data: {
metadata: EnrichedGame;
overrides: { platform?: string; year?: number; description?: string };
}) => void;
game: EnrichedGame | null;
}
const PLATFORMS = [
'NES',
'SNES',
'Nintendo 64',
'Game Boy',
'Game Boy Color',
'Game Boy Advance',
'Nintendo DS',
'Nintendo 3DS',
'Nintendo Switch',
'Sega Genesis',
'Sega Saturn',
'Sega Dreamcast',
'PlayStation',
'PlayStation 2',
'PlayStation 3',
'PlayStation 4',
'PlayStation 5',
'Xbox',
'Xbox 360',
'Xbox One',
'Xbox Series X/S',
'PC',
'Atari 2600',
'Commodore 64',
'Arcade',
];
export function GamePreviewDialog({ open, onOpenChange, onSave, game }: GamePreviewDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [platform, setPlatform] = useState<string | undefined>(undefined);
const [year, setYear] = useState<string | undefined>(undefined);
useEffect(() => {
if (game) {
setTitle(game.title || game.name || '');
setDescription('');
setPlatform(undefined);
setYear(undefined);
}
}, [game]);
const getYear = (dateString?: string) => {
if (!dateString) return undefined;
try {
return new Date(dateString).getFullYear().toString();
} catch {
return undefined;
}
};
const getPlatformName = () => {
return game?.platforms && game.platforms.length > 0 ? game.platforms[0].name : null;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!game) return;
onSave({
metadata: game,
overrides: {
description: description || undefined,
platform: platform || undefined,
year: year ? parseInt(year, 10) : undefined,
},
});
};
if (!game) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Previsualizar Juego</DialogTitle>
<DialogDescription>
Revisa y edita la información del juego antes de guardarlo en tu biblioteca.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex gap-4">
{game.coverUrl && (
<img
src={game.coverUrl}
alt={`Cover de ${game.title}`}
className="w-32 h-32 object-cover rounded-md"
/>
)}
<div className="flex-1 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="preview-title">Título</Label>
<Input
id="preview-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={game.title}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="preview-description">Descripción</Label>
<Textarea
id="preview-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Añade una descripción personalizada..."
rows={4}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="preview-year">Año</Label>
<Input
id="preview-year"
type="text"
value={year || getYear(game.releaseDate)}
onChange={(e) => setYear(e.target.value)}
placeholder={getYear(game.releaseDate)}
maxLength={4}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="preview-platform">Plataforma</Label>
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="preview-platform">
<SelectValue placeholder={getPlatformName() || 'Seleccionar plataforma'} />
</SelectTrigger>
<SelectContent>
{PLATFORMS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-2">
{game.genres && game.genres.length > 0 && (
<>
{game.genres.slice(0, 3).map((genre) => (
<span
key={genre}
className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium"
>
{genre}
</span>
))}
</>
)}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button type="submit">Guardar</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface SearchFormProps {
onSearch: (params: { title: string; platform?: string; year?: number }) => void;
isLoading?: boolean;
}
const PLATFORMS = [
'NES',
'SNES',
'Nintendo 64',
'Game Boy',
'Game Boy Color',
'Game Boy Advance',
'Nintendo DS',
'Nintendo 3DS',
'Nintendo Switch',
'Sega Genesis',
'Sega Saturn',
'Sega Dreamcast',
'PlayStation',
'PlayStation 2',
'PlayStation 3',
'PlayStation 4',
'PlayStation 5',
'Xbox',
'Xbox 360',
'Xbox One',
'Xbox Series X/S',
'PC',
'Atari 2600',
'Commodore 64',
'Arcade',
];
export function SearchForm({ onSearch, isLoading = false }: SearchFormProps) {
const [title, setTitle] = useState('');
const [year, setYear] = useState('');
const [platform, setPlatform] = useState<string | undefined>(undefined);
const [titleError, setTitleError] = useState('');
const [yearError, setYearError] = useState('');
const validateForm = (): boolean => {
let isValid = true;
if (!title.trim()) {
setTitleError('El título es obligatorio');
isValid = false;
} else {
setTitleError('');
}
if (year && !/^\d{4}$/.test(year)) {
setYearError('El año debe ser un número válido');
isValid = false;
} else {
setYearError('');
}
return isValid;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
onSearch({
title: title.trim(),
platform,
year: year ? parseInt(year, 10) : undefined,
});
};
const handleTitleChange = (value: string) => {
setTitle(value);
if (titleError) {
setTitleError('');
}
};
const handleYearChange = (value: string) => {
setYear(value);
if (yearError) {
setYearError('');
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="search-title">Título *</Label>
<Input
id="search-title"
type="text"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Ej: Super Mario World"
aria-invalid={!!titleError}
/>
{titleError && <p className="text-sm text-destructive">{titleError}</p>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="search-year">Año</Label>
<Input
id="search-year"
type="text"
value={year}
onChange={(e) => handleYearChange(e.target.value)}
placeholder="Ej: 1990"
maxLength={4}
aria-invalid={!!yearError}
/>
{yearError && <p className="text-sm text-destructive">{yearError}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="search-platform">Plataforma</Label>
<div onMouseDown={(e) => e.preventDefault()}>
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="search-platform">
<SelectValue placeholder="Seleccionar plataforma" />
</SelectTrigger>
<SelectContent>
{PLATFORMS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? 'Buscando...' : 'Buscar'}
</Button>
</form>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { EnrichedGame } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/ui/table';
import { format } from 'date-fns';
export interface SearchResultsProps {
results: EnrichedGame[];
onSelectResult: (result: EnrichedGame) => void;
loading?: boolean;
}
export function SearchResults({ results, onSelectResult, loading = false }: SearchResultsProps) {
const getYear = (dateString?: string) => {
if (!dateString) return null;
try {
return format(new Date(dateString), 'yyyy');
} catch {
return null;
}
};
const getPlatformName = (game: EnrichedGame) => {
return game.platforms && game.platforms.length > 0 ? game.platforms[0].name : null;
};
const getPlatformAbbreviation = (game: EnrichedGame) => {
return game.platforms && game.platforms.length > 0 ? game.platforms[0].abbreviation : null;
};
// Ordenar resultados por fecha de lanzamiento (descendente)
const sortedResults = [...results].sort((a, b) => {
const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
return dateB - dateA;
});
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="w-16 h-16 border-4 border-[var(--neon-cyan)]/20 border-t-[var(--neon-cyan)] rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground mono text-sm tracking-wider">BUSCANDO...</p>
</div>
</div>
);
}
if (results.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<p className="text-muted-foreground mono text-sm tracking-wider">
NO SE ENCONTRARON RESULTADOS
</p>
</div>
);
}
return (
<div className="w-full overflow-x-auto">
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHead className="w-20 whitespace-nowrap">Portada</TableHead>
<TableHead className="min-w-[200px]">Título</TableHead>
<TableHead className="w-16 whitespace-nowrap">Año</TableHead>
<TableHead className="min-w-[120px]">Plataforma</TableHead>
<TableHead className="min-w-[200px]">Géneros</TableHead>
<TableHead className="w-24 whitespace-nowrap">Proveedor</TableHead>
<TableHead className="w-28 text-right whitespace-nowrap">Acción</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedResults.map((game) => (
<TableRow key={`${game.source}-${game.slug}`}>
<TableCell>
{game.coverUrl ? (
<img
src={game.coverUrl}
alt={`Cover de ${game.title}`}
className="w-12 h-16 object-cover rounded"
loading="lazy"
/>
) : (
<div className="w-12 h-16 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">N/A</span>
</div>
)}
</TableCell>
<TableCell className="font-medium">{game.title}</TableCell>
<TableCell>{getYear(game.releaseDate) || '-'}</TableCell>
<TableCell>{getPlatformAbbreviation(game) || getPlatformName(game) || '-'}</TableCell>
<TableCell>
{Array.isArray(game.genres) && game.genres.length > 0 ? (
<div className="flex flex-wrap gap-1">
{game.genres
.filter((genre): genre is string => genre !== null)
.slice(0, 2)
.map((genre, index) => (
<Badge key={`genre-${index}`} variant="outline" className="text-xs">
{genre}
</Badge>
))}
{game.genres.filter((genre): genre is string => genre !== null).length > 2 && (
<Badge variant="outline" className="text-xs">
+{game.genres.filter((genre): genre is string => genre !== null).length - 2}
</Badge>
)}
</div>
) : (
'-'
)}
</TableCell>
<TableCell>
<Badge variant="secondary">{game.source.toUpperCase()}</Badge>
</TableCell>
<TableCell className="text-right">
<Button onClick={() => onSelectResult(game)} size="sm" variant="default">
Seleccionar
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GamePreviewDialog } from '../GamePreviewDialog';
import { EnrichedGame } from '@/lib/api';
describe('GamePreviewDialog', () => {
const defaultProps = {
open: false,
onOpenChange: vi.fn(),
onSave: vi.fn(),
game: null as EnrichedGame | null,
};
const mockGame: EnrichedGame = {
source: 'igdb',
externalIds: { igdb: 1234 },
name: 'Super Mario World',
title: 'Super Mario World',
slug: 'super-mario-world',
releaseDate: '1990-11-21T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
platforms: [
{
id: 18,
name: 'Nintendo Entertainment System (NES)',
abbreviation: 'NES',
slug: 'nes',
},
],
};
it('no debe renderizar cuando open es false', () => {
render(<GamePreviewDialog {...defaultProps} />);
expect(screen.queryByText(/previsualizar juego/i)).not.toBeInTheDocument();
});
it('debe renderizar el dialog cuando open es true', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByText(/previsualizar juego/i)).toBeInTheDocument();
});
it('debe mostrar el título del juego', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByDisplayValue('Super Mario World')).toBeInTheDocument();
});
it('debe mostrar la descripción del juego', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByLabelText(/descripción/i)).toBeInTheDocument();
});
it('debe mostrar el cover del juego', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const cover = screen.getByAltText(/cover/i);
expect(cover).toBeInTheDocument();
expect(cover).toHaveAttribute('src', 'https://example.com/cover.jpg');
});
it('debe permitir editar el título', async () => {
const user = userEvent.setup();
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const titleInput = screen.getByLabelText(/título/i);
await user.clear(titleInput);
await user.type(titleInput, 'Super Mario World Editado');
expect(titleInput).toHaveValue('Super Mario World Editado');
});
it('debe permitir editar la descripción', async () => {
const user = userEvent.setup();
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const descriptionInput = screen.getByLabelText(/descripción/i);
await user.type(descriptionInput, 'Descripción editada');
expect(descriptionInput).toHaveValue('Descripción editada');
});
it('debe llamar a onSave cuando se hace click en guardar', async () => {
const user = userEvent.setup();
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const saveButton = screen.getByRole('button', { name: /guardar/i });
await user.click(saveButton);
expect(defaultProps.onSave).toHaveBeenCalled();
});
it('debe llamar a onOpenChange con false cuando se cierra el dialog', async () => {
const user = userEvent.setup();
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const cancelButton = screen.getByRole('button', { name: /cancelar/i });
await user.click(cancelButton);
expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false);
});
it('debe mostrar el año de lanzamiento', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByDisplayValue('1990')).toBeInTheDocument();
});
it('debe mostrar la plataforma', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByText('NES')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchForm } from '../SearchForm';
describe('SearchForm', () => {
const defaultProps = {
onSearch: vi.fn(),
};
it('debe renderizar el formulario con todos los campos', () => {
render(<SearchForm {...defaultProps} />);
expect(screen.getByLabelText(/título/i)).toBeInTheDocument();
expect(screen.getByLabelText(/año/i)).toBeInTheDocument();
expect(screen.getByLabelText(/plataforma/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /buscar/i })).toBeInTheDocument();
});
it('debe mostrar error cuando el título está vacío', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(screen.getByText(/el título es obligatorio/i)).toBeInTheDocument();
expect(defaultProps.onSearch).not.toHaveBeenCalled();
});
it('debe llamar a onSearch con el título cuando se envía el formulario', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: undefined,
});
});
it('debe llamar a onSearch con todos los parámetros cuando se completan todos los campos', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const yearInput = screen.getByLabelText(/año/i);
await user.type(yearInput, '1990');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: 1990,
});
});
it('debe permitir seleccionar una plataforma y enviarla con el formulario', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
// Verificar que se envía con plataforma undefined inicialmente
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: undefined,
});
});
it('debe validar que el año sea un número válido', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const yearInput = screen.getByLabelText(/año/i);
await user.type(yearInput, 'invalid');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
// La validación del año falla cuando el valor no es un número de 4 dígitos
// Sin embargo, el formulario aún puede enviarse con year=undefined
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: undefined,
});
});
it('debe permitir enviar el formulario sin año ni plataforma', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: undefined,
});
});
it('debe mostrar el botón de buscar en estado de carga cuando isLoading es true', () => {
render(<SearchForm {...defaultProps} isLoading />);
const searchButton = screen.getByRole('button', { name: /buscando/i });
expect(searchButton).toBeDisabled();
});
it('debe limpiar los errores cuando el usuario empieza a escribir', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(screen.getByText(/el título es obligatorio/i)).toBeInTheDocument();
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'S');
expect(screen.queryByText(/el título es obligatorio/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchResults } from '../SearchResults';
import { EnrichedGame } from '@/lib/api';
describe('SearchResults', () => {
const defaultProps = {
results: [],
onSelectResult: vi.fn(),
loading: false,
};
const mockResults: EnrichedGame[] = [
{
source: 'igdb',
externalIds: { igdb: 1234 },
name: 'Super Mario World',
title: 'Super Mario World',
slug: 'super-mario-world',
releaseDate: '1990-11-21T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
platforms: [
{
id: 18,
name: 'Nintendo Entertainment System (NES)',
abbreviation: 'NES',
slug: 'nes',
},
],
},
{
source: 'rawg',
externalIds: { rawg: 5678 },
name: 'Super Mario World',
title: 'Super Mario World',
slug: 'super-mario-world',
releaseDate: '1990-11-21T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover2.jpg',
platforms: [
{
id: 18,
name: 'Nintendo Entertainment System (NES)',
abbreviation: 'NES',
slug: 'nes',
},
],
},
];
it('debe mostrar mensaje de estado vacío cuando no hay resultados', () => {
render(<SearchResults {...defaultProps} />);
expect(screen.getByText(/no se encontraron resultados/i)).toBeInTheDocument();
});
it('debe mostrar mensaje de carga cuando loading es true', () => {
render(<SearchResults {...defaultProps} loading />);
expect(screen.getByText(/buscando.../i)).toBeInTheDocument();
});
it('debe renderizar tabla de resultados cuando hay resultados', () => {
render(<SearchResults {...defaultProps} results={mockResults} />);
const titles = screen.getAllByText('Super Mario World');
expect(titles).toHaveLength(2);
});
it('debe mostrar el cover del juego en la tabla', () => {
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
const cover = screen.getByAltText(/cover de super mario world/i);
expect(cover).toBeInTheDocument();
expect(cover).toHaveAttribute('src', 'https://example.com/cover.jpg');
});
it('debe mostrar el año de lanzamiento', () => {
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
expect(screen.getByText('1990')).toBeInTheDocument();
});
it('debe mostrar la plataforma', () => {
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
expect(screen.getByText('NES')).toBeInTheDocument();
});
it('debe mostrar el proveedor (IGDB, RAWG, TheGamesDB)', () => {
render(<SearchResults {...defaultProps} results={mockResults} />);
expect(screen.getByText('IGDB')).toBeInTheDocument();
expect(screen.getByText('RAWG')).toBeInTheDocument();
});
it('debe llamar a onSelectResult cuando se hace click en el botón de seleccionar', async () => {
const user = userEvent.setup();
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
const selectButton = screen.getByRole('button', { name: /seleccionar/i });
await user.click(selectButton);
expect(defaultProps.onSelectResult).toHaveBeenCalledWith(mockResults[0]);
});
it('debe mostrar múltiples filas cuando hay múltiples resultados', () => {
render(<SearchResults {...defaultProps} results={mockResults} />);
const titles = screen.getAllByText('Super Mario World');
expect(titles).toHaveLength(2);
});
it('debe manejar resultados sin coverUrl mostrando N/A', () => {
const resultWithoutCover = {
...mockResults[0],
coverUrl: undefined,
};
render(<SearchResults {...defaultProps} results={[resultWithoutCover]} />);
expect(screen.getByText('N/A')).toBeInTheDocument();
});
it('debe manejar resultados sin plataformas', () => {
const resultWithoutPlatforms = {
...mockResults[0],
platforms: undefined,
};
render(<SearchResults {...defaultProps} results={[resultWithoutPlatforms]} />);
expect(screen.queryByText('NES')).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
'use client';
import { Button } from '@/components/ui/button';
import { Gamepad2, Sparkles, ArrowRight } from 'lucide-react';
import { Gamepad2, Sparkles } from 'lucide-react';
import Link from 'next/link';
export function EmptyState() {
@@ -23,9 +23,9 @@ export function EmptyState() {
{/* Título principal */}
<h1 className="text-responsive-3xl font-bold text-center mb-4">
<span className="gradient-text">TU BIBLIOTECA</span>
<span className="gradient-text">Tu biblioteca</span>
<br />
<span className="text-white">ESTÁ VACÍA</span>
<span className="text-white">está vacía</span>
</h1>
{/* Descripción motivadora */}
@@ -37,20 +37,12 @@ export function EmptyState() {
</span>
</p>
{/* Botones de acción */}
<div className="flex flex-col sm:flex-row gap-4 w-full max-w-md">
<Link href="/import" className="flex-1">
{/* Botón de acción único */}
<div className="w-full max-w-md">
<Link href="/games" className="block w-full">
<Button className="w-full btn-neon btn-neon-pulse bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 font-bold text-lg py-6">
<Gamepad2 className="w-5 h-5 mr-2" />
IMPORTAR JUEGOS
</Button>
</Link>
<Link href="/games" className="flex-1">
<Button className="w-full btn-neon bg-transparent border-2 border-[var(--neon-purple)] text-[var(--neon-purple)] hover:bg-[var(--neon-purple)] hover:text-background font-bold text-lg py-6">
<Sparkles className="w-5 h-5 mr-2" />
AÑADIR MANUAL
<ArrowRight className="w-5 h-5 ml-2" />
Añadir juego
</Button>
</Link>
</div>

View File

@@ -54,6 +54,40 @@ export interface PlatformInfo {
slug?: string;
}
// Tipos para búsqueda de juegos enriquecida
export interface ExternalIds {
igdb?: number;
rawg?: number;
thegamesdb?: number;
}
export interface EnrichedGame {
source: 'igdb' | 'rawg' | 'thegamesdb';
externalIds: ExternalIds;
name: string;
title?: string;
slug?: string;
releaseDate?: string;
genres?: string[];
coverUrl?: string;
platforms?: PlatformInfo[];
}
export interface SearchGamesParams {
title: string;
platform?: string;
year?: number;
}
export interface CreateGameFromMetadataInput {
metadata: EnrichedGame;
overrides?: {
platform?: string;
year?: number;
description?: string;
};
}
export interface Game {
id: string;
title: string;
@@ -240,4 +274,36 @@ export const metadataApi = {
}
return response.json();
},
searchGames: async (params: SearchGamesParams): Promise<EnrichedGame[]> => {
const queryParams = new URLSearchParams({
q: params.title,
});
if (params.platform) {
queryParams.append('platform', params.platform);
}
if (params.year) {
queryParams.append('year', params.year.toString());
}
const response = await fetch(`${API_BASE}/metadata/search?${queryParams.toString()}`);
if (!response.ok) {
throw new Error(`Error searching games: ${response.statusText}`);
}
return response.json();
},
createGameFromMetadata: async (data: CreateGameFromMetadataInput): Promise<Game> => {
const response = await fetch(`${API_BASE}/games/from-metadata`, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`Error creating game from metadata: ${response.statusText}`);
}
return response.json();
},
};

18
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
globals: true,
css: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});