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('Página de música', () => {
|
|
|
|
|
test('los estilos están siempre a la vista, con su recuento', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
|
|
|
|
|
const lateral = page.getByRole('navigation', { name: 'Estilos' });
|
|
|
|
|
await expect(lateral).toBeVisible();
|
|
|
|
|
await expect(lateral.getByRole('link', { name: /Baladas románticas/ })).toBeVisible();
|
|
|
|
|
await expect(lateral.getByRole('link', { name: /Clásica/ })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('buscar y elegir vista son una sola pieza', async ({ page }) => {
|
|
|
|
|
/*
|
|
|
|
|
* Eran dos cajas con un hueco entre ellas y parecían dos controles sin
|
|
|
|
|
* relación. El borde es de la barra: dentro, el campo y los botones no
|
|
|
|
|
* tienen canto propio y llegan al mismo alto.
|
|
|
|
|
*/
|
|
|
|
|
await page.setViewportSize({ width: 1100, height: 800 });
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
|
|
|
|
|
const medidas = await page.evaluate(() => {
|
|
|
|
|
const campo = document.querySelector('input[type="search"]')!;
|
|
|
|
|
const activo = document.querySelector('.vista.oro')!;
|
|
|
|
|
const barra = document.querySelector('.barra')!.getBoundingClientRect();
|
|
|
|
|
return {
|
|
|
|
|
bordeDelCampo: getComputedStyle(campo).borderTopWidth,
|
|
|
|
|
altoCampo: Math.round(campo.getBoundingClientRect().height),
|
|
|
|
|
altoBoton: Math.round(activo.getBoundingClientRect().height),
|
|
|
|
|
altoBarra: Math.round(barra.height),
|
|
|
|
|
dentro: activo.getBoundingClientRect().right <= barra.right + 1
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(medidas.bordeDelCampo).toBe('0px');
|
|
|
|
|
expect(medidas.altoCampo).toBe(medidas.altoBoton);
|
|
|
|
|
// Sin hueco: la barra mide lo que miden sus piezas, más su propio canto.
|
|
|
|
|
expect(medidas.altoBarra).toBeLessThanOrEqual(medidas.altoCampo + 2);
|
|
|
|
|
expect(medidas.dentro).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la vista activa lleva el oro de los botones, no un color plano', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
|
|
|
|
|
const oro = page.locator('.vista.oro');
|
|
|
|
|
await expect(oro).toHaveText('Tarjetas');
|
|
|
|
|
|
|
|
|
|
// El degradado es lo que se pedía: al lado hay un botón dorado de verdad
|
|
|
|
|
// y se notaba que el conmutador no era el mismo oro.
|
|
|
|
|
const pintura = await oro.evaluate((e) => {
|
|
|
|
|
const s = getComputedStyle(e);
|
|
|
|
|
return { imagen: s.backgroundImage, filo: s.boxShadow, color: s.color };
|
|
|
|
|
});
|
|
|
|
|
expect(pintura.imagen).toContain('linear-gradient');
|
|
|
|
|
expect(pintura.filo).toContain('inset');
|
|
|
|
|
|
|
|
|
|
// Y la letra es la del botón dorado, no el gris del inactivo: el `.vista`
|
|
|
|
|
// de la página le ganaba por especificidad al `.oro` general.
|
|
|
|
|
const inactivo = await page
|
|
|
|
|
.getByRole('button', { name: 'Tabla' })
|
|
|
|
|
.evaluate((e) => getComputedStyle(e).color);
|
|
|
|
|
expect(pintura.color).not.toBe(inactivo);
|
|
|
|
|
|
|
|
|
|
// Y cambia con la vista.
|
|
|
|
|
await page.getByRole('button', { name: 'Tabla' }).click();
|
|
|
|
|
await expect(page.locator('.vista.oro')).toHaveText('Tabla');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('en un móvil la barra se apila sin estirar el campo', async ({ page }) => {
|
|
|
|
|
// En columna, `flex-basis` mide el alto: las doce rem que dan ancho al
|
|
|
|
|
// campo lo convertían en un cajón de doscientos píxeles.
|
|
|
|
|
await page.setViewportSize({ width: 360, height: 800 });
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
|
|
|
|
|
const alto = await page
|
|
|
|
|
.locator('input[type="search"]')
|
|
|
|
|
.evaluate((e) => Math.round(e.getBoundingClientRect().height));
|
|
|
|
|
expect(alto).toBeLessThan(60);
|
|
|
|
|
});
|
|
|
|
|
|
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('la tabla ordena por la columna que se pulsa', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
await page.getByRole('button', { name: 'Tabla' }).click();
|
|
|
|
|
|
|
|
|
|
const columna = page.getByRole('columnheader', { name: 'Duración' });
|
|
|
|
|
await expect(columna).toHaveAttribute('aria-sort', 'none');
|
|
|
|
|
|
|
|
|
|
// Las cifras empiezan de mayor a menor: lo más largo arriba.
|
|
|
|
|
await columna.getByRole('button').click();
|
|
|
|
|
await expect(columna).toHaveAttribute('aria-sort', 'descending');
|
|
|
|
|
|
Una sola tabla de canciones, y la cola se puede llenar
Había dos listados: una tabla ordenable para el catálogo y una lista
aparte para los discos. Cada arreglo había que hacerlo dos veces y el
resultado era que la portada estaba en una y el año en la otra. Ahora es
un solo componente, y lo que cambia son las columnas de contexto, que se
encienden por props: dentro de un disco, la columna «Álbum» diría diez
veces lo mismo.
Solo recibe canciones. El estilo, el disco y la portada los resuelve
contra el catálogo que ya baja en `page.data`, así que quien la usa no
tiene que armar filas a mano, que era lo que obligaba a repetir código.
Cada fila lleva su portada —con la ampliada al pasar el ratón—, y debajo
del título van los géneros y las etiquetas en vez del primer verso: la
letra está entera en la ficha, y aquí lo que sirve para elegir es de qué
está hecho el tema. Las etiquetas se cortan por número, con un «+2» al
final: recortarlas con un degradado dejaba «Autoengaño» en «Autoeng», que
no parece una elección sino algo roto.
La cola ya se puede llenar. Hasta ahora solo se podía sustituir —cada
play la vaciaba y la llenaba con su contexto—, así que no había forma de
armar una escucha cogiendo temas de discos distintos. `encolar()` es la
otra mitad, y con la cola vacía arranca, porque un botón que no hace nada
es peor que no tenerlo.
Los géneros de una canción entran en el modelo: `cancion_genero` existía
en la base y no llegaba a la web. Son cosa distinta del estilo —el estilo
es cómo se ordena la obra aquí y es uno solo; los géneros son de qué está
hecha la canción y pueden ser varios— y llevan al glosario.
De paso: las letras largas van a dos columnas de 37ch, que es el mínimo
que deja caber dos y a la vez el verso más largo del catálogo —importa
afinarlo, porque en una letra dónde acaba cada verso es parte del texto—;
la página baja de 2808 a 2023 píxeles. Contacto sale de la barra de menú
y se queda solo en el pie. Y el titular pasa a «Letrista y creador
musical».
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
|
|
|
// Por su clase y no por su posición: la tabla lleva portada, y el número
|
|
|
|
|
// de columna cambia con lo que se enseñe en cada página.
|
|
|
|
|
const duraciones = await page.locator('tbody td.duracion').allInnerTexts();
|
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
|
|
|
const enSegundos = duraciones.map((d) => {
|
|
|
|
|
const [m, s] = d.trim().split(':').map(Number);
|
|
|
|
|
return m * 60 + s;
|
|
|
|
|
});
|
|
|
|
|
expect(enSegundos).toEqual([...enSegundos].sort((a, b) => b - a));
|
|
|
|
|
|
|
|
|
|
// Y volver a pulsarla le da la vuelta.
|
|
|
|
|
await columna.getByRole('button').click();
|
|
|
|
|
await expect(columna).toHaveAttribute('aria-sort', 'ascending');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('la búsqueda encuentra por etiqueta, no solo por título', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
await page.getByLabel('Buscar un tema').fill('desamor');
|
|
|
|
|
|
|
|
|
|
// Ningún tema se llama «desamor»: si aparecen, es por sus etiquetas.
|
|
|
|
|
const estado = page.getByRole('status');
|
|
|
|
|
await expect(estado).toContainText('para «desamor»');
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Media verdad' })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('una búsqueda sin resultados lo dice', async ({ page }) => {
|
|
|
|
|
await page.goto('/musica');
|
|
|
|
|
await page.getByLabel('Buscar un tema').fill('zzzzz');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('status')).toContainText('0 temas');
|
|
|
|
|
await expect(page.getByText('Ningún tema coincide.')).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('en la página de un estilo se ven todos sus temas', async ({ page }) => {
|
|
|
|
|
// No solo los destacados: si no, un tema recién añadido no se ve.
|
|
|
|
|
await page.goto('/musica/baladas-romanticas');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Todos los temas' })).toBeVisible();
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Dorina' })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.describe('Etiquetas', () => {
|
|
|
|
|
test('la ficha de un tema enlaza sus etiquetas', async ({ page }) => {
|
|
|
|
|
await page.goto('/canciones/como-si-nada');
|
|
|
|
|
|
|
|
|
|
await page.getByRole('link', { name: 'Desamor', exact: true }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL('/etiquetas/desamor');
|
|
|
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Desamor');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('una etiqueta reúne temas de discos distintos', async ({ page }) => {
|
|
|
|
|
await page.goto('/etiquetas/desamor');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Como si nada' })).toBeVisible();
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'La última carta' })).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('el índice ordena de más usada a menos', async ({ page }) => {
|
|
|
|
|
await page.goto('/etiquetas');
|
|
|
|
|
|
|
|
|
|
const nombres = await page.locator('.nube a').allInnerTexts();
|
|
|
|
|
const cuentas = nombres.map((t) => Number(t.trim().split(/\s+/).pop()));
|
|
|
|
|
expect(cuentas).toEqual([...cuentas].sort((a, b) => b - a));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('una etiqueta que no existe da 404', async ({ page }) => {
|
|
|
|
|
const respuesta = await page.goto('/etiquetas/no-existe');
|
|
|
|
|
expect(respuesta?.status()).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
});
|