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.
senzapaura_es/e2e/responsive.e2e.ts

353 lines
13 KiB

import { expect, test } from '@playwright/test';
/** Anchos que representan un móvil pequeño, uno normal, una tableta y un panel. */
const ANCHOS = [320, 375, 768, 1440];
const RUTAS = [
'/',
'/musica',
'/musica/baladas-romanticas',
'/albumes/como-si-nada',
'/canciones/dorina',
'/taller/el-estribillo',
'/generos',
'/generos/bachata',
'/generos/glosario',
'/generos/letristas',
'/blog',
'/etiquetas',
'/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 }) => {
// 24x24 es el mínimo de la norma. Los enlaces dentro de un texto quedan
// fuera —los limita la línea—, pero un botón suelto no tiene excusa.
await page.setViewportSize({ width: 375, 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"]')) {
const c = e.getBoundingClientRect();
if (!c.width || !c.height) continue;
if (c.width < 24 || c.height < 24) {
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/como-si-nada');
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('/');
const nav = page.getByRole('navigation', { name: 'Principal' });
await expect(nav).toBeHidden();
await page.getByRole('button', { name: 'Menú' }).click();
await expect(nav).toBeVisible();
// 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', 'Taller de letras', 'Géneros', 'Blog', 'Bio']) {
await expect(nav.getByRole('link', { name: seccion })).toBeVisible();
}
// Contacto no: va solo en el pie, que es donde se busca escribir.
await expect(nav.getByRole('link', { name: 'Contacto' })).toHaveCount(0);
});
test('las barras laterales bajan en vez de desaparecer', async ({ page }) => {
// Son la navegación de su sección: esconderlas deja la página sin salidas.
await page.setViewportSize({ width: 375, height: 800 });
await page.goto('/musica/baladas-romanticas');
await expect(page.getByRole('navigation', { name: 'Estilos' })).toBeVisible();
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.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/como-si-nada', 'link', 'Desamor'],
['/albumes/como-si-nada', '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 del dorado', () => {
// 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 logo lleva el oro recortado sobre las letras, y ahí el degradado no
* puede usar las mismas paradas que un botón: en un botón el oro es el
* fondo y encima va marino, y en el logo el oro *es* el texto.
*
* Se comprueba parada por parada porque el fallo fue exactamente ese: con el
* degradado del botón, la parada de brillo daba 1,26 sobre el fondo claro y
* el centro de las letras desaparecía. A ojo, en la captura, se veía «algo
* más pálido»; en números, ilegible.
*/
for (const modo of ['oscuro', 'claro'] as const) {
test(`cada parada del oro del logo se lee en tema ${modo}`, async ({ page }) => {
await page.goto('/');
await page.evaluate((m) => {
document.documentElement.dataset.tema = m;
}, modo);
const peor = await page.evaluate(() => {
const marca = document.querySelector('header .marca')!;
const fondo = getComputedStyle(document.body).backgroundColor;
/*
* Las paradas se sacan recorriendo la cadena y contando paréntesis:
* `oklch(…)` y `color-mix(in oklab, …)` llevan comas dentro, así que
* partir por comas devuelve trozos que no son colores.
*/
const colores: string[] = [];
const gradiente = getComputedStyle(marca).backgroundImage;
/*
* Los nombres se buscan con un límite de palabra porque el navegador
* no devuelve lo que está escrito: en el tema claro resuelve los
* `color-mix()` a `oklab()`, y buscando «lab» sin límite se contaría
* dos veces cada uno.
*/
const empiezan = /\b(oklch|oklab|color-mix|rgba?|hsla?|lab|lch|color)\(/g;
for (let m = empiezan.exec(gradiente); m; m = empiezan.exec(gradiente)) {
let nivel = 0;
let i = m.index + m[1].length;
for (; i < gradiente.length; i++) {
if (gradiente[i] === '(') nivel++;
else if (gradiente[i] === ')' && --nivel === 0) break;
}
colores.push(gradiente.slice(m.index, i + 1));
empiezan.lastIndex = i + 1;
}
const pincel = document.createElement('canvas').getContext('2d')!;
const aRgb = (c: string) => {
pincel.fillStyle = c;
pincel.fillRect(0, 0, 1, 1);
const d = pincel.getImageData(0, 0, 1, 1).data;
return [d[0], d[1], d[2]];
};
const lum = (rgb: 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(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]);
};
const contraste = (c: string) => {
const [a, b] = [lum(aRgb(c)), lum(aRgb(fondo))].sort((m, n) => n - m);
return (a + 0.05) / (b + 0.05);
};
return {
cuantas: colores.length,
minimo: Math.min(...colores.map(contraste))
};
});
// Cinco paradas: si el degradado dejara de pintarse, esto lo dice.
expect(peor.cuantas).toBeGreaterThanOrEqual(3);
expect(peor.minimo).toBeGreaterThan(MINIMO);
});
}
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);
});
});

Powered by TurnKey Linux.