You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
423 lines
17 KiB
423 lines
17 KiB
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<SVGElement>('header [data-logo-senza-paura]')!;
|
|
const fondo = document.querySelector<HTMLElement>('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);
|
|
});
|
|
});
|