import { expect, test } from '@playwright/test'; /** Matriz común: móvil, tableta, escritorio compacto y escritorio amplio. */ const ANCHOS = [390, 768, 1024, 1440]; const RUTAS = [ '/', '/musica', '/musica/baladas-romanticas', '/albumes/cupido-sin-flechas', '/canciones/dorina', '/taller/el-estribillo', '/generos', '/generos/bachata', '/generos/glosario', '/generos/letristas', '/blog', '/interpretes', '/carrito', '/entrar' ]; test.describe('Responsive', () => { for (const ancho of ANCHOS) { test(`ninguna página desborda a lo ancho a ${ancho}px`, async ({ page }) => { // Es el fallo clásico: un elemento se pasa de ancho, aparece una barra // horizontal y toda la página se puede arrastrar de lado. await page.setViewportSize({ width: ancho, height: 800 }); for (const ruta of RUTAS) { await page.goto(ruta); const desborde = await page.evaluate( () => document.documentElement.scrollWidth - window.innerWidth ); expect(desborde, `${ruta} a ${ancho}px`).toBeLessThanOrEqual(1); } }); } test('los controles se pueden pulsar con el dedo', async ({ page }) => { // El contrato del sitio fija 44x44 como mínimo para controles accionables. // Los enlaces dentro de texto no forman parte de esta comprobación. await page.setViewportSize({ width: 390, height: 800 }); for (const ruta of ['/', '/musica', '/carrito', '/canciones/dorina']) { await page.goto(ruta); const pequenos = await page.evaluate(() => { const fuera: string[] = []; for (const e of document.querySelectorAll('button, input, select, [role="button"]')) { if (!(e instanceof HTMLElement)) continue; const ancho = e.offsetWidth; const alto = e.offsetHeight; if (!ancho || !alto) continue; if (ancho < 44 || alto < 44) { fuera.push(`${e.getAttribute('aria-label') ?? e.textContent?.trim().slice(0, 20)}`); } } return [...new Set(fuera)]; }); expect(pequenos, `controles pequeños en ${ruta}`).toEqual([]); } }); test('la barra de reproducción no tapa el final de la página', async ({ page }) => { // La barra se apila en un móvil y crece de 97 a 145 píxeles. El hueco que // se le reserva sale de medirla, no de un número escrito a mano: con uno // fijo, en móvil se comía el pie. await page.setViewportSize({ width: 375, height: 800 }); await page.goto('/albumes/cupido-sin-flechas'); await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click(); await expect(page.getByRole('region', { name: 'Reproductor' })).toBeVisible(); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); const solapa = await page.evaluate(() => { const barra = document.querySelector('[aria-label="Reproductor"]')!.getBoundingClientRect(); const pie = document.querySelector('footer')!.getBoundingClientRect(); return Math.round(pie.bottom - barra.top); }); expect(solapa).toBeLessThanOrEqual(1); }); test('en móvil la navegación se despliega y sigue completa', async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); await page.goto('/'); await expect(page.locator('html')).toHaveAttribute('data-hidratado', ''); const nav = page.getByRole('navigation', { name: 'Principal' }); await expect(nav).toBeHidden(); const antes = await page.evaluate(() => ({ altoCabecera: document.querySelector('header')!.getBoundingClientRect().height, inicioContenido: document.querySelector('main')!.getBoundingClientRect().top })); await page.getByRole('button', { name: 'Menú' }).click(); await expect(nav).toBeVisible(); const aspecto = await nav.getByRole('link', { name: 'Música' }).evaluate((enlace) => { const caja = enlace.getBoundingClientRect(); const cabecera = enlace.closest('header'); if (!cabecera) throw new Error('Cabecera ausente'); return { fontSize: getComputedStyle(enlace).fontSize, centro: caja.left + caja.width / 2, fondo: getComputedStyle(cabecera).backgroundColor }; }); expect(Number.parseFloat(aspecto.fontSize)).toBeGreaterThanOrEqual(18); expect(Math.abs(aspecto.centro - 375 / 2)).toBeLessThan(2); expect(aspecto.fondo).not.toBe('rgba(0, 0, 0, 0)'); const despues = await page.evaluate(() => ({ altoCabecera: document.querySelector('header')!.getBoundingClientRect().height, inicioContenido: document.querySelector('main')!.getBoundingClientRect().top })); expect(despues.altoCabecera).toBeCloseTo(antes.altoCabecera, 0); expect(despues.inicioContenido).toBeCloseTo(antes.inicioContenido, 0); // Por nombre y no por cuenta: en móvil no se recorta el menú, y así la // prueba dice cuál falta en vez de que el número no cuadra. for (const seccion of ['Música', 'Músicos y grupos', 'Taller de letra', 'Blog']) { await expect(nav.getByRole('link', { name: seccion })).toBeVisible(); } await expect(nav.getByRole('link', { name: 'Bio', exact: true })).toHaveCount(0); await expect(nav.getByRole('link', { name: 'Géneros', exact: true })).toHaveCount(0); // Contacto no: va solo en el pie, que es donde se busca escribir. await expect(nav.getByRole('link', { name: 'Contacto' })).toHaveCount(0); }); test('el burger separa el acceso, las utilidades y el tema', async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); await page.goto('/'); await expect(page.locator('html')).toHaveAttribute('data-hidratado', ''); await page.getByRole('button', { name: 'Menú' }).click(); const entrar = page.locator('.acciones-movil').getByRole('link', { name: 'Entrar' }); const buscar = page .locator('.utilidades-movil') .getByRole('link', { name: 'Buscar en Senza Paura' }); const carrito = page.locator('.utilidades-movil').getByRole('link', { name: /^Carrito/ }); const tema = page.locator('.tema-movil').getByRole('button', { name: /tema/ }); await expect(entrar).toBeVisible(); await expect(buscar).toBeVisible(); await expect(carrito).toBeVisible(); await expect(tema).toBeVisible(); const posiciones = await page.evaluate(() => { const caja = (selector: string) => document.querySelector(selector)!.getBoundingClientRect(); return { utilidades: caja('.utilidades-movil'), buscar: caja('.utilidades-movil a:first-child'), carrito: caja('.utilidades-movil a:last-child'), tema: caja('.tema-movil'), botonTema: caja('.tema-movil button'), fuenteNavegacion: getComputedStyle(document.querySelector('.nav a')!).fontFamily, fuenteAccion: getComputedStyle(document.querySelector('.opcion-menu-movil')!).fontFamily, tamanoNavegacion: getComputedStyle(document.querySelector('.nav a')!).fontSize, tamanoAccion: getComputedStyle(document.querySelector('.opcion-menu-movil')!).fontSize }; }); expect(posiciones.carrito.top).toBeGreaterThanOrEqual(posiciones.buscar.bottom); expect(posiciones.tema.top).toBeGreaterThan(posiciones.utilidades.bottom); expect(posiciones.botonTema.width).toBe(56); expect(posiciones.botonTema.height).toBe(56); expect(posiciones.fuenteNavegacion).toBe(posiciones.fuenteAccion); expect(posiciones.tamanoNavegacion).toBe(posiciones.tamanoAccion); }); test('en tema claro el menú móvil cubre el contenido con el fondo del tema', async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); await page.goto('/favoritos'); await expect(page.locator('html')).toHaveAttribute('data-hidratado', ''); await page.getByRole('button', { name: 'Menú' }).click(); await page.getByRole('button', { name: 'Cambiar al tema claro' }).click(); const colores = await page.locator('#menu-cabecera').evaluate((menu) => ({ menu: getComputedStyle(menu).backgroundColor, pagina: getComputedStyle(document.body).backgroundColor })); expect(colores.menu).toBe(colores.pagina); await expect(page.getByRole('navigation', { name: 'Principal' })).toBeVisible(); }); test('los filtros de música pasan a un panel táctil sin empujar el contenido', async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); await page.goto('/musica/baladas-romanticas'); const botonFiltros = page.getByRole('button', { name: /Filtros/ }); await expect(botonFiltros).toBeVisible(); await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); await expect(page.getByRole('navigation', { name: 'Estilos' })).toBeHidden(); await botonFiltros.click(); const dialogo = page.getByRole('dialog', { name: 'Filtrar música' }); await expect(dialogo).toBeVisible(); await expect(dialogo.getByRole('navigation', { name: 'Estilos' })).toBeVisible(); await expect(dialogo.getByRole('link', { name: /Baladas románticas/ })).toHaveAttribute( 'aria-current', 'page' ); const tactil = await dialogo .getByRole('link', { name: /Baladas románticas/ }) .evaluate((enlace) => enlace.getBoundingClientRect().height); expect(tactil).toBeGreaterThanOrEqual(48); await page.keyboard.press('Escape'); // El índice de géneros aún cabe como parte de su propia plantilla. await page.goto('/generos/bachata'); await expect(page.getByRole('navigation', { name: 'Contenido de géneros' })).toBeVisible(); }); test('la tabla se desplaza dentro de su marco, no arrastra la página', async ({ page }) => { await page.setViewportSize({ width: 375, height: 800 }); await page.goto('/musica'); await page.getByRole('button', { name: 'Tabla' }).click(); const dentro = await page.evaluate(() => { const marco = document.querySelector('.marco')!; return marco.scrollWidth > marco.clientWidth; }); expect(dentro).toBe(true); }); test('el encabezado de la tabla no tapa la primera canción en anchura intermedia', async ({ page }) => { await page.setViewportSize({ width: 900, height: 650 }); await page.goto('/albumes/cupido-sin-flechas'); const posiciones = await page.locator('.marco table').evaluate((tabla) => { const cabecera = tabla.querySelector('thead'); const celda = tabla.querySelector('thead th'); const primera = tabla.querySelector('tbody tr'); if (!cabecera || !celda || !primera) throw new Error('Tabla incompleta'); return { finalCabecera: Math.round(cabecera.getBoundingClientRect().bottom), inicioPrimera: Math.round(primera.getBoundingClientRect().top), posicionCelda: getComputedStyle(celda).position }; }); expect(posiciones.posicionCelda).toBe('static'); expect(posiciones.inicioPrimera).toBeGreaterThanOrEqual(posiciones.finalCabecera); }); }); test.describe('Respuesta al puntero', () => { // Un control que no acusa el puntero parece deshabilitado. Se comprueba en // pruebas porque no se ve en una captura en reposo, que es justo como se // coló el botón dorado quedándose transparente al pasar por encima. const CASOS: [string, 'link' | 'button', string][] = [ ['/', 'link', 'Ver el disco'], ['/musica', 'button', 'Tabla'], ['/blog', 'link', 'Seguirlo por RSS'], ['/canciones/prometiste', 'link', 'Desamor'], ['/albumes/cupido-sin-flechas', 'button', 'Añadir'] ]; for (const [ruta, rol, nombre] of CASOS) { test(`«${nombre}» acusa el puntero`, async ({ page }) => { await page.goto(ruta); const el = page.getByRole(rol, { name: nombre }).first(); const estilo = () => el.evaluate((e) => { const s = getComputedStyle(e); return [s.backgroundColor, s.color, s.borderColor, s.textDecorationLine].join('|'); }); const antes = await estilo(); await el.hover(); await expect.poll(estilo).not.toBe(antes); }); } test('el botón dorado sigue siendo dorado al pasar por encima', async ({ page }) => { // Pasó: una regla anterior usaba el atajo `background`, que borra // `background-image`, y el botón se quedaba en un rectángulo oscuro con el // borde de oro. await page.goto('/'); const boton = page.getByRole('button', { name: 'Poner el disco' }); await boton.hover(); const fondo = await boton.evaluate((e) => getComputedStyle(e).backgroundImage); expect(fondo).toContain('linear-gradient'); }); }); test.describe('Contraste y color de marca', () => { // El dorado hace dos trabajos y no puede ser el mismo valor en los dos: de // relleno tiene que ser claro para que encima se lea el marino, y escrito // tiene que ser hondo para leerse sobre el fondo. Esto lo fija. const MINIMO = 4.5; async function contraste(page: import('@playwright/test').Page, selector: string) { return page .locator(selector) .first() .evaluate((e) => { const aRgb = (c: string) => { const x = document.createElement('canvas').getContext('2d')!; x.fillStyle = c; x.fillRect(0, 0, 1, 1); const d = x.getImageData(0, 0, 1, 1).data; return [d[0], d[1], d[2]]; }; const lum = ([r, g, b]: number[]) => { const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; const s = getComputedStyle(e); const [a, b] = [lum(aRgb(s.color)), lum(aRgb(s.backgroundColor))].sort((m, n) => n - m); return (a + 0.05) / (b + 0.05); }); } /* * Se pone `data-tema` a mano en vez de emular `prefers-color-scheme`: el * sitio ya no sigue al sistema —el marino es el de la marca y se aplica * siempre—, así que emular el esquema claro dejaba esta prueba comprobando * dos veces lo mismo sin que nadie lo notara. */ for (const modo of ['oscuro', 'claro'] as const) { test(`el botón dorado se lee en tema ${modo}`, async ({ page }) => { await page.goto('/'); await page.evaluate((m) => { document.documentElement.dataset.tema = m; }, modo); expect(await contraste(page, '.btn--primary')).toBeGreaterThan(MINIMO); // Y también con el puntero encima, que es cuando la banda de luz se // mueve por debajo del texto. await page.locator('.btn--primary').first().hover(); expect(await contraste(page, '.btn--primary')).toBeGreaterThan(MINIMO); }); } test('sin elegir nada, el sitio sale en marino aunque el sistema pida claro', async ({ page }) => { // Es una decisión, no un descuido: el color forma parte de la marca. Si // algún día vuelve a seguir al sistema, esta prueba lo dice. await page.emulateMedia({ colorScheme: 'light' }); await page.goto('/'); const { fondo, claridad } = await page.evaluate(() => { const color = getComputedStyle(document.body).backgroundColor; /* * El color viene en `oklch()`, que no se puede desmontar a mano. Se * pinta y se lee el píxel: es la única conversión que no se inventa * nada, y en su día unas cuentas sobre la cadena dieron números que no * significaban nada. */ const lienzo = document.createElement('canvas'); lienzo.width = lienzo.height = 1; const d = lienzo.getContext('2d')!; d.fillStyle = color; d.fillRect(0, 0, 1, 1); const [r, g, b] = d.getImageData(0, 0, 1, 1).data; return { fondo: color, claridad: (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 }; }); expect(claridad, `el fondo era ${fondo}`).toBeLessThan(0.2); }); test('el botón de la cabecera sigue llevando al claro', async ({ page }) => { // El marino manda por defecto, pero el claro no desaparece: está a un clic // y la elección se recuerda. await page.goto('/'); await page.getByRole('button', { name: /tema|claro|oscuro/i }).click(); await expect(page.locator('html')).toHaveAttribute('data-tema', 'claro'); }); /* El identificador de cabecera es monocromo y no depende de degradados. */ for (const modo of ['oscuro', 'claro'] as const) { test(`el logo plano conserva el contraste en tema ${modo}`, async ({ page }) => { await page.goto('/'); await page.evaluate((m) => { document.documentElement.dataset.tema = m; }, modo); const resultado = await page.evaluate(() => { const logo = document.querySelector('header [data-logo-senza-paura]')!; const fondo = document.querySelector('header')!; const color = getComputedStyle(logo).color; const base = getComputedStyle(fondo).backgroundColor; return { color, base, etiqueta: logo.tagName, relleno: getComputedStyle(logo).fill }; }); expect(resultado.etiqueta).toBe('svg'); expect(resultado.relleno).toBe(resultado.color); expect(resultado.color).not.toBe(resultado.base); }); } test('todos los botones llevan la misma letra', async ({ page }) => { // Dos botones juntos con distinto cuerpo se ven como un descuido, y así // estaban: el dorado a 16px y el de al lado a 14. await page.goto('/'); // Los botones de página. El de la esquina de una tarjeta queda fuera a // propósito: es un adorno accionable sobre una portada y tiene su propio // tamaño, más pequeño y fijo. const tamanos = await page .locator('a.btn, button.solido') .evaluateAll((es) => es .filter((e) => !e.closest('.tarjeta')) .map((e) => `${getComputedStyle(e).fontSize} ${getComputedStyle(e).padding}`) ); expect([...new Set(tamanos)]).toHaveLength(1); }); });