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('', () => { const matchMediaOriginal = window.matchMedia; afterEach(() => { window.matchMedia = matchMediaOriginal; }); it('is decorative: hidden from assistive tech', () => { const { container } = render(); 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(); expect(container.querySelectorAll('path')).toHaveLength(1); }); it('travels with the document instead of sticking to the viewport', () => { const { container } = render(); // `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(); expect(container.firstElementChild?.className).toContain('pointer-events-none'); }); it('runs down the centre of the column, not in a side gutter', () => { const { container } = render(); 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(); 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(); 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(); // 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(); const cls = container.querySelector('svg')?.getAttribute('class') ?? ''; // An 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(); const path = container.querySelector('path'); // 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. The attribute is the // desktop weight, the classes narrow it on phones where 12px is 3% of the // screen — CSS stroke-width beats the presentation attribute. expect(Number(path?.getAttribute('stroke-width'))).toBeGreaterThanOrEqual(10); expect(path?.getAttribute('class') ?? '').toMatch(/md:\[stroke-width:/); }); it('sweeps wide enough across the column to read as a route', () => { const { container } = render(); 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(); 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(); 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(); 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 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'); }); });