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

Cierra una redirección abierta, quita código muerto y afina el responsive El campo `volverA` de los formularios se comprobaba en tres sitios con tres copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el navegador convierte la barra invertida en barra normal al resolver, así que eso es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera. Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz. Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si acaso" y una API que no se usa no es una API: es código que leer y mantener sin que nada dependa de ella. Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían 8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la barra se mide sola y publica su alto. Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro anchos. Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un problema inexistente. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
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',
Cierra una redirección abierta, quita código muerto y afina el responsive El campo `volverA` de los formularios se comprobaba en tres sitios con tres copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el navegador convierte la barra invertida en barra normal al resolver, así que eso es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera. Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz. Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si acaso" y una API que no se usa no es una API: es código que leer y mantener sin que nada dependa de ella. Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían 8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la barra se mide sola y publica su alto. Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro anchos. Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un problema inexistente. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
'/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.
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
for (const seccion of ['Música', 'Taller de letras', 'Géneros', 'Blog', 'Bio']) {
await expect(nav.getByRole('link', { name: seccion })).toBeVisible();
}
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
// Contacto no: va solo en el pie, que es donde se busca escribir.
await expect(nav.getByRole('link', { name: 'Contacto' })).toHaveCount(0);
Cierra una redirección abierta, quita código muerto y afina el responsive El campo `volverA` de los formularios se comprobaba en tres sitios con tres copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el navegador convierte la barra invertida en barra normal al resolver, así que eso es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera. Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz. Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si acaso" y una API que no se usa no es una API: es código que leer y mantener sin que nada dependa de ella. Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían 8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la barra se mide sola y publica su alto. Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro anchos. Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un problema inexistente. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
});
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();
Las fichas de género salen del taller y tienen su propia sección Con lo que pediste: portada con mapa de Iberoamérica, buscador y un aside que empieza por Inicio, Los principios, Glosario y Letristas antes de la lista de géneros. El mapa es interactivo de verdad: cada país se pinta según lo que tenga —marcado si es cuna de algún género, tenue si solo lo adoptó, apagado si no hay nada—, y al pulsarlo la lista de abajo se parte en «nació aquí» y «también se canta ahí». Esa diferencia es lo que un mapa cuenta mejor que una lista. Los contornos son de `BlankMap-Americas.svg` de Wikimedia, en dominio público, recortados a los veinte países de Iberoamérica en América. De 640 KB a 36 KB comprimidos, y solo en esta ruta: coordenadas a un decimal y fuera los 134 islotes que a este tamaño miden menos de un píxel. Va por script y no pegado a mano, para poder rehacerlo. Cada país lleva además un punto de agarre calculado —el promedio de los vértices de su costa, no el centro de su rectángulo, que en Cuba cae en el mar—: sin él, Puerto Rico mide cuatro píxeles y una isla alargada no hay quien la acierte. «Letristas» es nueva: las mismas personas que salen repartidas en nueve fichas, aquí juntas, con su papel en cada género y sus obras. La página del capítulo pasa a `$lib/components/Capitulo.svelte` y la usan las dos secciones: son setecientas líneas, y copiarlas habría sido dos sitios donde arreglar cada cosa. Las direcciones viejas redirigen con 301 —`/taller/bolero` a `/generos/bolero`— resolviendo contra el catálogo de capítulos, no contra una lista escrita a mano que alguien tenga que ampliar. Un enlace compartido no puede empezar a dar 404 porque hayamos reordenado el menú. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
await page.goto('/generos/bachata');
await expect(page.getByRole('navigation', { name: 'Contenido de géneros' })).toBeVisible();
Cierra una redirección abierta, quita código muerto y afina el responsive El campo `volverA` de los formularios se comprobaba en tres sitios con tres copias del mismo filtro, y las tres dejaban pasar `/\otro-dominio.com`: el navegador convierte la barra invertida en barra normal al resolver, así que eso es `//otro-dominio.com` con otra ropa. En la pantalla de entrar eso es phishing servido: enlace a nuestro propio acceso, y quien escribe su correo acaba fuera. Ahora hay un solo `destinoSeguro`, que no compara prefijos sino que resuelve la URL y solo acepta lo que sigue en el origen. Con pruebas de cada disfraz. Fuera siete funciones exportadas que no llamaba nadie. Las escribí "por si acaso" y una API que no se usa no es una API: es código que leer y mantener sin que nada dependa de ella. Del repaso responsive salen dos fallos reales. Los puntos del carrusel medían 8×8 píxeles, la tercera parte del mínimo para acertarles con el dedo; ahora el punto se ve igual y el botón mide 24×24. Y el hueco reservado bajo el contenido para la barra de reproducción estaba escrito a mano en 88 píxeles cuando la barra mide 97 en escritorio y 145 apilada en un móvil: se comía el pie. Ahora la barra se mide sola y publica su alto. Todo ello fijado en `responsive.e2e.ts`, que recorre diez páginas a cuatro anchos. Sobre rendimiento, nada que hacer: parecía que las páginas tardaban 215 ms, pero eran de la resolución de `localhost` por IPv6 en la medición. Por IPv4 se renderizan en 5-9 ms, así que memorizar el catálogo habría sido optimizar un problema inexistente. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 month ago
});
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 nombre de la cabecera, en oro de verdad y más grande Las mismas cinco paradas y el mismo ángulo que el botón dorado, recortadas sobre las letras, y la banda de luz recorre el nombre al pasar el puntero igual que recorre el botón. De 24 a 34 píxeles, que es el siguiente peldaño de la escala y no un número a ojo. El recorte va dentro de un `@supports` y el color plano se queda fuera: sin el recorte, `color: transparent` dejaría la marca invisible, y eso es peor que dejarla dorada y lisa. Es el mismo cuidado que ya tenía el botón, que guarda un color de reserva por si el degradado no llega a pintar. Y hace falta un segundo degradado, `--oro-escrito`. Sobre marino es literalmente el mismo que el del botón —la peor de sus paradas da 5,4 de contraste—, pero sobre papel no vale: medido contra el fondo claro, la parada de brillo da **1,26**, la de superficie 2,05 y la de sombra 3,79, las tres por debajo del mínimo de 4,5. Con el degradado del botón, el centro de las letras desaparecía. En claro la banda va al revés: el punto más claro es el propio `--c-accent` —5,29— y los extremos se oscurecen. Sigue pareciendo metal porque lo que hace el efecto es la banda que cruza, no que llegue a brillar. Es la regla que el proyecto ya tenía escrita y que yo no había aplicado: el dorado hace dos trabajos, y de relleno tiene que ser claro mientras que escrito tiene que ser hondo. La prueba comprueba **cada parada** del degradado contra el fondo, en los dos temas. A ojo, en la captura, el fallo se veía como «algo más pálido»; en números, como ilegible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
/*
* 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('/');
Textos legales, fuentes propias y ajustes de botones Cinco páginas legales en /legal, enlazadas desde el pie de todas las páginas: información legal, condiciones de uso, privacidad, cookies y condiciones de venta. Son borradores con los datos del titular marcados entre corchetes y un aviso de que hace falta revisión de un abogado antes de vender: cubren lo que pide la ley española, no son un documento validado. La política de cookies y la de privacidad están escritas mirando el código, no copiadas de una plantilla: dos cookies, las dos necesarias, y dos terceros —Stripe y Resend— que reciben lo imprescindible. Y eran tres, porque las tipografías venían de Google: cada visita mandaba la IP del visitante a un tercero sin pedirlo y sin que aportara nada. Ahora se sirven desde el propio dominio. Cargar la web ya no contacta con ningún dominio ajeno, y hay una prueba que lo comprueba. Aparte: el símbolo de marca registrada en el aviso de derechos, los botones de reproducir de las tarjetas pasan de 48 a 36 píxeles —eran casi una quinta parte de la portada, y cambiaban de proporción según la tarjeta— y el de ver el carrito solo aparece cuando hay algo dentro. De paso se arregla un fallo de tipos que estaba latente: `resolve()` tiene una sobrecarga por ruta y no acepta una unión, así que la navegación dejaba de compilar en cuanto se añadían rutas. Ahora se resuelve en `site.ts`, una a una. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
// 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')
Textos legales, fuentes propias y ajustes de botones Cinco páginas legales en /legal, enlazadas desde el pie de todas las páginas: información legal, condiciones de uso, privacidad, cookies y condiciones de venta. Son borradores con los datos del titular marcados entre corchetes y un aviso de que hace falta revisión de un abogado antes de vender: cubren lo que pide la ley española, no son un documento validado. La política de cookies y la de privacidad están escritas mirando el código, no copiadas de una plantilla: dos cookies, las dos necesarias, y dos terceros —Stripe y Resend— que reciben lo imprescindible. Y eran tres, porque las tipografías venían de Google: cada visita mandaba la IP del visitante a un tercero sin pedirlo y sin que aportara nada. Ahora se sirven desde el propio dominio. Cargar la web ya no contacta con ningún dominio ajeno, y hay una prueba que lo comprueba. Aparte: el símbolo de marca registrada en el aviso de derechos, los botones de reproducir de las tarjetas pasan de 48 a 36 píxeles —eran casi una quinta parte de la portada, y cambiaban de proporción según la tarjeta— y el de ver el carrito solo aparece cuando hay algo dentro. De paso se arregla un fallo de tipos que estaba latente: `resolve()` tiene una sobrecarga por ruta y no acepta una unión, así que la navegación dejaba de compilar en cuanto se añadían rutas. Ahora se resuelve en `site.ts`, una a una. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 weeks ago
.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.