Files
Creeer-Web/components/primitives/__tests__/HiloConductor.test.tsx
T
erikghandClaude Opus 5 174fcb855a fix(hilo): la banda alargaba el documento 5.800px de vacío
Un <svg> con viewBox es un elemento reemplazado con proporción intrínseca
(100:1200). Al dimensionarlo con top-0 + bottom-20 y dejar la altura en
auto, el navegador la resolvía desde el ancho — 1152 × 12 = 13.824px — y
esa altura ganaba silenciosamente a `bottom`.

Consecuencias, todas visibles en el navegador y ninguna en los tests:
- 5.840px de blanco vacío scrolleable por debajo del footer.
- scrollYProgress repartido sobre 13.901px en vez de 8.061, así que el
  trazo nunca terminaba sobre contenido real.
- la onda estirada 1,8x de más, otra vez casi recta.

Altura explícita con h-[calc(100%-5rem)], y test que lo fija: jsdom no
hace layout, así que el guardián es la clase.

El trazo pasa a completarse a mitad de scroll (FIN 0.85 → 0.5). Con 0.85
la punta se quedaba a media pantalla durante el 42% de la página, que es
justo lo que se leía como "sin terminar".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:11:41 -06:00

166 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { render } from '@testing-library/react';
import { describe, it, expect, afterEach } from 'vitest';
import { HiloConductor } from '../HiloConductor';
import { BARRAS_SIMBOLO, VIEWBOX_SIMBOLO } from '@/lib/simbolo';
const preferirMenosMovimiento = () => {
window.matchMedia = (query: string) =>
({
matches: true,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
};
const simboloFinal = (container: HTMLElement) =>
Array.from(container.querySelectorAll('svg')).find(
(svg) => svg.getAttribute('viewBox') === VIEWBOX_SIMBOLO,
);
describe('<HiloConductor />', () => {
const matchMediaOriginal = window.matchMedia;
afterEach(() => {
window.matchMedia = matchMediaOriginal;
});
it('is decorative: hidden from assistive tech', () => {
const { container } = render(<HiloConductor />);
const svg = container.querySelector('svg');
expect(svg).toHaveAttribute('aria-hidden', 'true');
expect(svg).toHaveAttribute('focusable', 'false');
});
it('draws a single continuous thread', () => {
const { container } = render(<HiloConductor />);
expect(container.querySelectorAll('path')).toHaveLength(1);
});
it('travels with the document instead of sticking to the viewport', () => {
const { container } = render(<HiloConductor />);
// `fixed` would read as an ornament pinned to the window; the thread
// belongs to the page and must scroll with it.
expect(container.firstElementChild?.className).toContain('absolute');
expect(container.firstElementChild?.className).not.toContain('fixed');
});
it('stays out of the way of pointer input', () => {
const { container } = render(<HiloConductor />);
expect(container.firstElementChild?.className).toContain('pointer-events-none');
});
it('runs down the centre of the column, not in a side gutter', () => {
const { container } = render(<HiloConductor />);
const cls = container.querySelector('svg')?.getAttribute('class') ?? '';
// `left-1/2 -translate-x-1/2` anchors the thread to the column's centre;
// the old `right-full` banished it to the left gutter, which is why the
// thread read as off to the side instead of as the page's spine.
expect(cls).toContain('left-1/2');
expect(cls).toContain('-translate-x-1/2');
expect(cls).not.toContain('right-full');
});
it('declares a stacking level that beats section roots but stays under the header', () => {
const { container } = render(<HiloConductor />);
const wrapperClass = container.firstElementChild?.className ?? '';
// Section roots never set z-index (auto), so any explicit positive value
// paints above them regardless of whether the section itself happens to
// be `position: relative` or `static` — that inconsistency was the bug.
// The sticky header is z-50 and must still win.
expect(wrapperClass).toMatch(/\bz-\d+\b/);
expect(wrapperClass).not.toContain('z-50');
});
it('renders on every breakpoint, not only on very wide screens', () => {
const { container } = render(<HiloConductor />);
const wrapperClass = container.firstElementChild?.className ?? '';
// The thread is the brand's through-line; it belongs in the middle on a
// phone too. Legibility is handled by layering it behind the text, not by
// hiding it below a breakpoint.
expect(wrapperClass).not.toContain('hidden');
expect(wrapperClass).not.toContain('2xl:block');
});
it('keeps its declared weight on screen despite the non-uniform viewBox scale', () => {
const { container } = render(<HiloConductor />);
// The band is stretched with preserveAspectRatio="none": y over the whole
// document, x over a couple hundred pixels. That squeezes the stroke of a
// near-vertical line by the x factor alone, so a declared 1.5 rendered as
// well under 1 real pixel — a ghost hairline. non-scaling-stroke pins the
// width to the value we actually chose.
expect(container.querySelector('path')).toHaveAttribute('vector-effect', 'non-scaling-stroke');
});
it('sizes the band with an explicit height, never a top+bottom pair', () => {
const { container } = render(<HiloConductor />);
const cls = container.querySelector('svg')?.getAttribute('class') ?? '';
// An <svg> carrying a viewBox is a replaced element with an intrinsic
// ratio, so `height: auto` resolves from the width and silently beats
// `bottom` — which stretched this one to 13824px and hung ~6000px of blank
// scroll below the footer. jsdom has no layout, so the class is the guard.
expect(cls).toMatch(/\bh-\[/);
expect(cls).not.toMatch(/\bbottom-/);
});
it('reads as a band, not as a hairline', () => {
const { container } = render(<HiloConductor />);
const grosor = Number(container.querySelector('path')?.getAttribute('stroke-width'));
// Twice regressed into a thread nobody could see. The band is the page's
// spine and has to carry weight; the text stays legible because it sits a
// stacking level above, not because the band is faint.
expect(grosor).toBeGreaterThanOrEqual(10);
});
it('sweeps wide enough across the column to read as a route', () => {
const { container } = render(<HiloConductor />);
const d = container.querySelector('path')?.getAttribute('d') ?? '';
// Coordinates come in x/y pairs, so the even ones are the horizontal
// excursion, in a 0–100 viewBox stretched across the content column.
// Too little travel and the swell collapses into a straight rule.
const numeros = (d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number);
const xs = numeros.filter((_, i) => i % 2 === 0);
expect(Math.max(...xs) - Math.min(...xs)).toBeGreaterThanOrEqual(30);
expect((d.match(/C/g) ?? []).length).toBeGreaterThanOrEqual(3);
});
it('resolves into the ≡ of the wordmark instead of stopping in mid-air', () => {
const { container } = render(<HiloConductor />);
const simbolo = simboloFinal(container);
// Per the design: the thread "resuelve en el ≡ del wordmark en el cierre".
// It reuses the brand geometry rather than drawing a lookalike.
expect(simbolo).toBeDefined();
expect(simbolo?.querySelectorAll('rect')).toHaveLength(BARRAS_SIMBOLO.length);
});
it('shows the resolved ≡ from the start under reduced motion', () => {
preferirMenosMovimiento();
const { container } = render(<HiloConductor />);
const barras = Array.from(simboloFinal(container)?.querySelectorAll('rect') ?? []);
// These readers never get the scroll draw that reveals the mark, so the
// ending has to be there already — otherwise the thread ends in nothing.
expect(barras).toHaveLength(BARRAS_SIMBOLO.length);
barras.forEach((barra) => {
expect(barra.getAttribute('style') ?? '').not.toContain('opacity: 0');
});
});
it('renders a genuine plain path under reduced motion, not a neutralised motion.path', () => {
preferirMenosMovimiento();
const { container } = render(<HiloConductor />);
const path = container.querySelector('path');
// motion.path stamps stroke-dasharray/stroke-dashoffset onto the DOM even
// without initial/animate — the dash-scaffolding trap that leaves the
// thread invisible for reduced-motion users. A real plain <path> never
// gets them, so their absence proves the branches weren't collapsed back
// into one.
expect(path).not.toHaveAttribute('stroke-dasharray');
expect(path).not.toHaveAttribute('stroke-dashoffset');
});
});