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('Reproductor', () => {
|
|
|
|
|
/** El título del tema dentro de la barra, por su enlace exacto. */
|
|
|
|
|
const temaEnBarra = (page: import('@playwright/test').Page, slug: string) =>
|
|
|
|
|
page.getByRole('group', { name: 'Sonando ahora' }).locator(`a[href="/canciones/${slug}"]`);
|
|
|
|
|
|
|
|
|
|
test('la barra aparece al reproducir un álbum y anuncia el tema', async ({ page }) => {
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
|
|
|
|
|
const barra = page.getByRole('region', { name: 'Reproductor' });
|
|
|
|
|
await expect(barra).toBeHidden();
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click();
|
|
|
|
|
|
|
|
|
|
await expect(barra).toBeVisible();
|
|
|
|
|
await expect(temaEnBarra(page, 'como-si-nada')).toBeVisible();
|
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
|
|
|
|
|
|
|
|
// Y sigue sonando de verdad, no solo pintada: la posición avanza. Sin esto
|
|
|
|
|
// el test pasaría igual con una barra congelada.
|
|
|
|
|
const posicion = page.getByLabel('Posición de reproducción');
|
|
|
|
|
const antes = Number(await posicion.inputValue());
|
|
|
|
|
await page.waitForTimeout(1500);
|
|
|
|
|
expect(Number(await posicion.inputValue())).toBeGreaterThan(antes);
|
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
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el paso al tema siguiente carga el orden del disco', async ({ page }) => {
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click();
|
|
|
|
|
|
|
|
|
|
const barra = page.getByRole('region', { name: 'Reproductor' });
|
|
|
|
|
await barra.getByRole('button', { name: 'Tema siguiente' }).click();
|
|
|
|
|
|
|
|
|
|
await expect(temaEnBarra(page, 'media-verdad')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la reproducción sobrevive al cambiar de página', async ({ page }) => {
|
|
|
|
|
// Es la razón de que la barra viva en el layout raíz: navegar por la web
|
|
|
|
|
// no puede cortar lo que se está escuchando.
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click();
|
|
|
|
|
|
|
|
|
|
const barra = page.getByRole('region', { name: 'Reproductor' });
|
|
|
|
|
await expect(temaEnBarra(page, 'como-si-nada')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page
|
|
|
|
|
.getByRole('navigation', { name: 'Principal' })
|
|
|
|
|
.getByRole('link', { name: 'Bio' })
|
|
|
|
|
.click();
|
|
|
|
|
await expect(page).toHaveURL('/bio');
|
|
|
|
|
|
|
|
|
|
await expect(barra).toBeVisible();
|
|
|
|
|
await expect(temaEnBarra(page, 'como-si-nada')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la cola muestra los temas pendientes', async ({ page }) => {
|
|
|
|
|
await page.goto('/albumes/como-si-nada');
|
|
|
|
|
await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click();
|
|
|
|
|
|
|
|
|
|
const barra = page.getByRole('region', { name: 'Reproductor' });
|
|
|
|
|
await barra.getByRole('button', { name: 'Ver la cola de reproducción' }).click();
|
|
|
|
|
|
|
|
|
|
const cola = page.getByRole('complementary', { name: 'Cola de reproducción' });
|
|
|
|
|
await expect(cola).toBeVisible();
|
|
|
|
|
await expect(cola.getByRole('button', { name: /El último tren/ })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Saltar desde la cola cambia lo que suena.
|
|
|
|
|
await cola.getByRole('button', { name: /El último tren/ }).click();
|
|
|
|
|
await expect(temaEnBarra(page, 'el-ultimo-tren')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|