Web de Senza Paura: catálogo, taller, blog y tienda
Sitio del letrista y compositor, con SvelteKit 2 y Svelte 5.
El catálogo es contenido, no base de datos: estilos, álbumes, canciones y
capítulos son Markdown en `src/content/`, validados al compilar y compilados
dentro del bundle, así que recorrer la música no hace ni una consulta. La base
de datos guarda solo lo que generan las personas: cuentas, carritos, pedidos,
compras, valoraciones y favoritos.
Lo que hay:
- Música agrupada por estilos, con página propia para cada estilo, álbum y
tema, buscador y tabla ordenable.
- Reproductor persistente que sobrevive a la navegación. Suena un fragmento;
el archivo completo solo se obtiene comprando.
- Taller de letras: los principios del oficio y una ficha de creación por
estilo hispanoamericano.
- Blog con feed RSS, y etiquetas que cruzan blog y catálogo.
- Tienda con cuentas por código de correo, carrito, Stripe y descargas
protegidas.
- Panel de contenido en `/admin` que escribe los mismos Markdown.
- Tema oscuro de marca y su inversión clara, aplicado antes de pintar.
Lo que falta y no es código: las claves de Stripe y de correo, el tratamiento
del IVA, las páginas de condiciones y privacidad, y el material real. Las
canciones de ejemplo son inventadas y están para sustituirse; el audio son
marcadores salvo un tema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
import { expect, test } from '@playwright/test';
|
|
|
|
|
|
|
|
|
|
test.describe('Fichas de contenido', () => {
|
|
|
|
|
test('la ficha de un tema publica la letra y sus créditos', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/como-si-nada');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Como si nada');
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Letra' })).toHaveAttribute('aria-selected', 'true');
|
|
|
|
|
await expect(page.getByText('Entras en la casa y no preguntas nada,')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('heading', { level: 2, name: 'Créditos' })).toBeVisible();
|
|
|
|
|
await expect(page.getByText('Marta Cifuentes')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('los versos conservan sus saltos de línea', async ({ page }) => {
|
|
|
|
|
// Sin remark-breaks, Markdown juntaría la estrofa en un único párrafo.
|
|
|
|
|
await page.goto('/canciones/como-si-nada');
|
|
|
|
|
|
|
|
|
|
const saltos = await page.locator('.texto br').count();
|
|
|
|
|
expect(saltos).toBeGreaterThan(5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('una pieza instrumental muestra notas en lugar de letra', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/preludio-i');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Sobre la pieza' })).toBeVisible();
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Letra' })).toHaveCount(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la bio se publica desde el Markdown de contenido', async ({ page }) => {
|
|
|
|
|
await page.goto('/bio');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Biografía');
|
|
|
|
|
await expect(page.getByRole('heading', { level: 2, name: 'Las baladas' })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Tarjetas de canción', () => {
|
|
|
|
|
test('la tarjeta sitúa el tema: disco, año y estilo', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica/baladas-romanticas');
|
|
|
|
|
|
|
|
|
|
// Por el enlace, no por el nombre: el disco se llama igual que el tema.
|
|
|
|
|
const tarjeta = page
|
|
|
|
|
.locator('article')
|
|
|
|
|
.filter({ has: page.locator('a[href="/canciones/como-si-nada"]') });
|
|
|
|
|
await expect(tarjeta.getByRole('heading', { name: 'Como si nada' })).toBeVisible();
|
|
|
|
|
await expect(tarjeta.getByText(/2024/)).toBeVisible();
|
|
|
|
|
await expect(tarjeta.getByText(/Baladas románticas/)).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la tarjeta no enseña la letra', async ({ page }) => {
|
|
|
|
|
// Identifica el tema; leer se lee en su ficha.
|
|
|
|
|
await page.goto('/musica/baladas-romanticas');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText('Entras en la casa y no preguntas nada,')).toHaveCount(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('desde la tarjeta se llega a la letra completa', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica/baladas-romanticas');
|
|
|
|
|
|
|
|
|
|
// Por el enlace, no por el nombre: el disco se llama igual que el tema.
|
|
|
|
|
const tarjeta = page
|
|
|
|
|
.locator('article')
|
|
|
|
|
.filter({ has: page.locator('a[href="/canciones/como-si-nada"]') });
|
|
|
|
|
await tarjeta.getByRole('link').first().click();
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(new RegExp('/canciones/'));
|
|
|
|
|
// La letra es la pestaña que se abre: es el contenido principal.
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Letra' })).toHaveAttribute('aria-selected', 'true');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Pestañas de la ficha', () => {
|
|
|
|
|
test('la letra se abre por defecto', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/como-si-nada');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Letra' })).toHaveAttribute('aria-selected', 'true');
|
|
|
|
|
await expect(page.getByRole('tabpanel', { name: 'Letra' })).toBeVisible();
|
|
|
|
|
await expect(page.getByRole('tabpanel', { name: 'De dónde sale' })).toBeHidden();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('al pulsar una pestaña se cambia de panel', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/como-si-nada');
|
|
|
|
|
await page.getByRole('tab', { name: 'De dónde sale' }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('tabpanel', { name: 'De dónde sale' })).toBeVisible();
|
|
|
|
|
await expect(page.getByRole('tabpanel', { name: 'Letra' })).toBeHidden();
|
|
|
|
|
await expect(page.getByText(/mi madre poniendo dos tazas/)).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('las flechas mueven entre pestañas', async ({ page }) => {
|
|
|
|
|
// Es el patrón de pestañas de WAI-ARIA: activación automática al moverse.
|
|
|
|
|
await page.goto('/canciones/como-si-nada');
|
|
|
|
|
await page.getByRole('tab', { name: 'Letra' }).focus();
|
|
|
|
|
await page.keyboard.press('ArrowRight');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'De dónde sale' })).toBeFocused();
|
|
|
|
|
await expect(page.getByRole('tabpanel', { name: 'De dónde sale' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Y da la vuelta al llegar al final. Se comprueba por posición y no por
|
|
|
|
|
// nombre: cuántas pestañas hay depende de lo que tenga el tema.
|
|
|
|
|
const pestanas = page.getByRole('tab');
|
|
|
|
|
await page.keyboard.press('End');
|
|
|
|
|
await expect(pestanas.last()).toBeFocused();
|
|
|
|
|
await page.keyboard.press('ArrowRight');
|
|
|
|
|
await expect(pestanas.first()).toBeFocused();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('solo hay pestañas para lo que el tema tiene', async ({ page }) => {
|
|
|
|
|
// "Media verdad" no tiene versiones alternativas.
|
|
|
|
|
await page.goto('/canciones/media-verdad');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Alternativas musicales' })).toHaveCount(0);
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Letra' })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('se puede enlazar una pestaña concreta', async ({ page }) => {
|
|
|
|
|
// Un panel cerrado está oculto, así que sin esto el navegador no tendría
|
|
|
|
|
// adónde ir: hay que abrir la pestaña antes de bajar hasta ella.
|
|
|
|
|
await page.goto('/canciones/como-si-nada#panel-videos');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Vídeos' })).toHaveAttribute(
|
|
|
|
|
'aria-selected',
|
|
|
|
|
'true'
|
|
|
|
|
);
|
|
|
|
|
await expect(page.getByRole('tabpanel', { name: 'Vídeos' })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('un ancla que no existe deja la pestaña de siempre', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/como-si-nada#panel-inventado');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('tab', { name: 'Letra' })).toHaveAttribute('aria-selected', 'true');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el contenido de las pestañas cerradas está en el HTML', async ({ page }) => {
|
|
|
|
|
// Aunque no se vea, tiene que estar publicado: es lo que indexan los
|
|
|
|
|
// buscadores y lo que se lee sin JavaScript.
|
|
|
|
|
await page.goto('/canciones/como-si-nada');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('tabpanel', { name: 'Alternativas musicales' })).toBeHidden();
|
|
|
|
|
await expect(page.getByText('Versión orquestal')).toBeAttached();
|
|
|
|
|
});
|
|
|
|
|
});
|
Carrusel en la portada y letras a dos columnas
La portada abre con tres láminas: el disco en el tocadiscos, una invitación al
taller y otra a la tienda. Un carrusel mal hecho es de las piezas más hostiles
que hay —cambia mientras lees, no se para, y para un lector de pantalla es un
cajón que aparece y desaparece—, así que este se para al pasar el puntero o al
entrar el foco, tiene botón de pausa, no gira solo con `prefers-reduced-motion`
y deja las láminas ocultas con `inert`, fuera del orden de tabulación.
Las letras que no están en español se enfrentan con su traducción en dos
columnas, verso a verso. Cada columna declara su `lang`, que es lo que impide
que un lector de pantalla lea el inglés con voz española, y la traducción se
pinta con `pre-line`: es verso, y perder los cortes la deja inservible para
seguir la original. El nombre del idioma sale de `Intl.DisplayNames`.
Sobre el reproductor: ya sobrevivía a la navegación, porque vive en el layout
raíz fuera de `main`. Lo que faltaba era comprobarlo de verdad; el test decía
que la barra seguía visible, no que siguiera sonando. Ahora mira que la posición
avance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
|
|
|
|
|
|
|
|
test.describe('Letra en otro idioma', () => {
|
|
|
|
|
test('se enfrenta con su traducción en dos columnas', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/the-long-way-home');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText('Letra original · inglés')).toBeVisible();
|
|
|
|
|
await expect(page.getByText('Traducción al español')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Cada columna declara su idioma: es lo que hace que un lector de pantalla
|
|
|
|
|
// no lea el inglés con voz española.
|
|
|
|
|
const columnas = page.locator('.letra.pareada > .texto');
|
|
|
|
|
await expect(columnas).toHaveCount(2);
|
|
|
|
|
await expect(columnas.first()).toHaveAttribute('lang', 'en');
|
|
|
|
|
await expect(columnas.last()).toHaveAttribute('lang', 'es');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la traducción conserva el corte de los versos', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/the-long-way-home');
|
|
|
|
|
|
|
|
|
|
// Verso a verso: si se perdieran los saltos, no se podría seguir la
|
|
|
|
|
// original en paralelo, que es para lo que está al lado.
|
|
|
|
|
const traduccion = await page.locator('.verso-a-verso').innerText();
|
|
|
|
|
expect(traduccion.split('\n').filter((l) => l.trim()).length).toBeGreaterThan(4);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('un tema en español se queda en una sola columna', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/como-si-nada');
|
|
|
|
|
|
|
|
|
|
await expect(page.locator('.letra.pareada')).toHaveCount(0);
|
|
|
|
|
await expect(page.getByText('Traducción al español')).toHaveCount(0);
|
|
|
|
|
});
|
|
|
|
|
});
|