import type { ComponentType } from 'react'; import { render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import type { CbarIconProps, IconProps } from './icon'; import * as generated from './generated'; import { iconManifest } from './generated'; import * as aliases from './icons'; import { CheckIcon, CrossIcon, MenuIcon, SearchIcon } from './generated'; import { CircleIcon } from './icons'; /** * `lucide-react` is a devDependency only — it is imported here to assert that * the three icons the kit still takes from upstream match it. Nothing in * `src/components/**` may import it (ESLint enforces that), and it never * reaches `dist/`. * * It is pinned to an **exact** version in `package.json` on purpose. With a * caret range a fresh install would pick up whatever upstream had redrawn since, * and a brand-new scaffold would greet its owner with a failing test it did * nothing to cause. Pinned, the comparison only speaks when someone * deliberately bumps the version — and then it is a decision to make, not a * break to paper over: copy the new geometry into `icons.tsx`, or keep ours and * say why here. */ import * as lucide from 'lucide-react'; /* Lucide's components are forwardRef exotics with their own prop type; all this comparison needs is "renders an SVG from these props". */ const upstreamIcons = lucide as unknown as Record>; /** * The icons whose geometry still comes from Lucide. Everything else is drawn * from the CBAR Design System file and deliberately diverges from upstream, so * comparing it would only assert that two unrelated sets disagree. */ const LUCIDE_SOURCED = ['CircleIcon', 'CircleCheckIcon', 'Loader2Icon'] as const; /** Serialises the drawn shapes so two SVGs can be compared ignoring chrome. */ const geometryOf = (container: HTMLElement) => Array.from(container.querySelectorAll('svg > *')).map((node) => ({ tag: node.tagName.toLowerCase(), /* `key` is Lucide's internal React key and is not rendered, so whatever is left here is the actual drawing instruction. */ attrs: Object.fromEntries(Array.from(node.attributes).map((attr) => [attr.name, attr.value])), })); /* The generated barrel also carries the manifest, which is data rather than a component; everything drawable is named `Icon`. */ const components = generated as unknown as Record>; const drawable = Object.keys(generated).filter((name) => name.endsWith('Icon')); describe('generated CBAR set', () => { it('exports exactly what the manifest describes', () => { expect(drawable.length).toBe(iconManifest.length); expect(new Set(drawable)).toEqual(new Set(iconManifest.map((entry) => entry.name))); }); it.each(iconManifest.map((entry) => [entry.name, entry] as const))('%s draws both variants', (name, entry) => { const KitIcon = components[name]; for (const variant of ['outline', 'solid'] as const) { const { container } = render(); const svg = container.querySelector('svg')!; expect(svg).toHaveAttribute('viewBox', '0 0 24 24'); expect(svg).toHaveAttribute('data-slot', 'icon'); /* An icon that renders an empty would pass every other assertion here, so make the geometry itself the thing being checked. */ expect(svg.children.length).toBeGreaterThan(0); } /* `shared` records that CBAR reused one drawing for both families — arrows and carets have no solid form. Anywhere else the two must differ, or the variant prop is quietly doing nothing. */ const outline = geometryOf(render().container); const solid = geometryOf(render().container); if (entry.shared) expect(solid).toEqual(outline); else expect(solid).not.toEqual(outline); }); it('defaults to the outline family', () => { const bare = geometryOf(render().container); const outline = geometryOf(render().container); expect(bare).toEqual(outline); }); it('ignores the variant on an icon CBAR drew once', () => { const entry = iconManifest.find((icon) => icon.name === 'MenuIcon'); expect(entry?.shared).toBe(true); expect(geometryOf(render().container)).toEqual( geometryOf(render().container) ); }); it('spreads every category across its own module', () => { const categories = new Set(iconManifest.map((entry) => entry.category)); expect(categories.size).toBe(9); }); }); describe('kit aliases and keepers', () => { const names = Object.keys(aliases) as (keyof typeof aliases)[]; it('covers every name the components import', () => { /* Eleven aliases onto the generated set plus the three Lucide keepers. The five names CBAR already agrees with (CheckIcon, SearchIcon, CalendarIcon, ArrowUpIcon, ArrowDownIcon) come straight from `./generated`. */ expect(names).toHaveLength(14); }); it.each(names)('%s draws something', (name) => { const KitIcon = aliases[name]; const { container } = render(); const svg = container.querySelector('svg')!; expect(svg).toHaveAttribute('viewBox', '0 0 24 24'); expect(svg).toHaveAttribute('data-slot', 'icon'); expect(svg.children.length).toBeGreaterThan(0); }); it('aliases point at the CBAR drawing, not a copy of it', () => { expect(aliases.ChevronDownIcon).toBe(generated.CaretDownIcon); expect(aliases.XIcon).toBe(generated.CrossIcon); expect(aliases.TriangleAlertIcon).toBe(generated.InfoTriangleIcon); }); it.each(LUCIDE_SOURCED)('%s still matches lucide-react', (name) => { const KitIcon = aliases[name]; const LucideIcon = upstreamIcons[name]; expect(LucideIcon, `lucide-react has no ${name}`).toBeDefined(); const kit = render(); const upstream = render(); expect(geometryOf(kit.container)).toEqual(geometryOf(upstream.container)); }); }); describe('icon chassis', () => { it('paints filled icons with currentColor and no stroke', () => { /* CBAR expanded most of its outline set into closed paths, so the common case is a fill. Painting both would ring a 1.5px halo around each shape. */ const { container } = render(); const svg = container.querySelector('svg')!; expect(svg).toHaveAttribute('fill', 'currentColor'); expect(svg).toHaveAttribute('stroke', 'none'); expect(svg).not.toHaveAttribute('stroke-width'); }); it("paints stroked icons at CBAR's 1.5 weight", () => { const { container } = render(); const svg = container.querySelector('svg')!; expect(svg).toHaveAttribute('stroke', 'currentColor'); expect(svg).toHaveAttribute('stroke-width', '1.5'); expect(svg).toHaveAttribute('fill', 'none'); }); it('lets a stroked icon override its weight', () => { const { container } = render(); expect(container.querySelector('svg')).toHaveAttribute('stroke-width', '2'); }); it('is hidden from assistive tech by default', () => { const { container } = render(); expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); }); it.each([ ['aria-label', { 'aria-label': 'Search' }], ['role', { role: 'img' }], ])('drops aria-hidden once %s marks it meaningful', (_label, props) => { const { container } = render(); expect(container.querySelector('svg')).not.toHaveAttribute('aria-hidden'); }); it('accepts a numeric size and lets className through', () => { const { container } = render(); const svg = container.querySelector('svg')!; expect(svg).toHaveAttribute('width', '16'); expect(svg).toHaveAttribute('height', '16'); expect(svg).toHaveClass('size-4', 'text-primary'); }); });