128 lines
6.6 KiB
TypeScript
128 lines
6.6 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { describe, expect, it } from 'vitest';
|
||
import { DonutMetricCard } from '../../src/components/DonutMetricCard/DonutMetricCard';
|
||
import { donutArcPath } from '../../src/components/DonutMetricCard/donut-arc';
|
||
|
||
// Line endings depend on the checkout (CRLF on Windows working copies); the contract is about content.
|
||
const readStyle = (file: string) => readFileSync(new URL(`../../src/styles/${file}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
|
||
const styles = readStyle('components.css');
|
||
const tileContract = readStyle('tile-contract.css');
|
||
const tokens = readStyle('ui-tokens.css');
|
||
|
||
describe('DonutMetricCard binding design contract', () => {
|
||
it('renders the shared full-circle reference with a bold count above a regular percentage', () => {
|
||
const html = renderToStaticMarkup(
|
||
<DonutMetricCard label="Deviation" value={453} percentage={51} tone="deviation" />
|
||
);
|
||
|
||
expect(html).toContain('class="donut-metric-card donut-metric-card--deviation"');
|
||
expect(html).toMatch(/<h3 class="donut-metric-card__title">Deviation<\/h3><div class="donut-metric-card__plot">/);
|
||
expect(html).toContain('class="donut-metric-card__track" cx="60" cy="60" r="48" pathLength="100"');
|
||
expect(html).toContain('class="donut-metric-card__progress" d="M 114 60 A 54 54 0 1 1');
|
||
expect(html).toContain('<div class="donut-metric-card__primary"><strong>453</strong></div>');
|
||
expect(html).toContain('<small>51%</small>');
|
||
expect(styles).toContain('.donut-metric-card__progress {\n fill: currentColor;');
|
||
expect(styles).toContain('transform: rotate(-90deg);');
|
||
expect(styles).toMatch(/\.donut-metric-card__values\s*\{[^}]*inset:\s*calc\(var\(--donut-ring-stroke\) \+ var\(--space-2\)\);/s);
|
||
expect(styles).not.toContain('filter: var(--donut-ring-shadow);');
|
||
expect(styles).toMatch(/\.donut-metric-card__primary strong\s*\{[^}]*font-weight:\s*var\(--fw-bold\);/s);
|
||
expect(styles).toMatch(/\.donut-metric-card__values > small\s*\{[^}]*font-weight:\s*var\(--fw-regular\);/s);
|
||
expect(styles).not.toContain('stroke-linecap: round;');
|
||
expect(styles).toContain('@keyframes donut-progress-build');
|
||
expect(styles).not.toContain('.donut-metric-card::before');
|
||
expect(styles).not.toContain('@keyframes donut-accent-build');
|
||
expect(styles).toContain('animation: donut-progress-build var(--dur-data-build) var(--ease-data-build) both;');
|
||
expect(styles).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(auto-fill, var\(--tile-compact-width\)\);[^}]*justify-content:\s*start;/s);
|
||
expect(tileContract).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(auto-fill, var\(--tile-compact-width\)\);[^}]*justify-content:\s*start;/s);
|
||
expect(tokens).toContain('--dur-data-build: 700ms;');
|
||
expect(tokens).toContain('--donut-value-size: 26px;');
|
||
expect(tokens).not.toContain('--donut-ring-shadow:');
|
||
expect(tokens).toContain('--donut-percent-size: 14px;');
|
||
expect(tokens).toContain('--donut-compact-value-size: 22px;');
|
||
expect(tokens).toContain('--donut-compact-percent-size: 12px;');
|
||
expect(styles).not.toContain('.sidebar-severity-card');
|
||
});
|
||
|
||
it('renders an empty progress arc at zero and clamps percentages', () => {
|
||
const zero = renderToStaticMarkup(
|
||
<DonutMetricCard label="Error" value={2} percentage={0} tone="error" />
|
||
);
|
||
const clamped = renderToStaticMarkup(
|
||
<DonutMetricCard label="Results All" value={885} percentage={120} tone="all" />
|
||
);
|
||
|
||
expect(zero).not.toContain('donut-metric-card__progress');
|
||
expect(zero).toContain('<strong>2</strong>');
|
||
expect(zero).toContain('<small>0%</small>');
|
||
expect(clamped).toContain(`d="${donutArcPath(100)}"`);
|
||
expect(clamped).toContain('<strong>885</strong>');
|
||
expect(clamped).toContain('<small>100%</small>');
|
||
expect(zero).not.toContain('<animate');
|
||
expect(zero).not.toContain('donut-small-arc-marker');
|
||
});
|
||
|
||
it('keeps a tiny positive arc proportional and marks it legibly at compact sizes', () => {
|
||
const tiny = renderToStaticMarkup(
|
||
<DonutMetricCard label="Not Applicable" value={4} percentage={4 / 1833 * 100} tone="not-applicable" />
|
||
);
|
||
const onePercent = renderToStaticMarkup(
|
||
<DonutMetricCard label="Not Applicable" value={4} percentage={1} tone="not-applicable" />
|
||
);
|
||
expect(tiny).toContain(`d="${donutArcPath(4 / 1833 * 100)}"`);
|
||
expect(tiny).toContain('donut-small-arc-marker');
|
||
expect(tiny).toMatch(/class="donut-small-arc-marker" x="102" y="60"/);
|
||
expect(tiny).toContain('<small><1%</small>');
|
||
expect(onePercent).toContain(`d="${donutArcPath(1)}"`);
|
||
expect(onePercent).toContain('donut-small-arc-marker');
|
||
expect(onePercent).toContain('<small>1%</small>');
|
||
});
|
||
|
||
it('draws flat radial ends at 12 o’clock for small arcs and exact adjacent boundaries for segments', () => {
|
||
const tenPercent = donutArcPath(10);
|
||
expect(tenPercent).toMatch(/^M 114 60 A 54 54 0 0 1 /);
|
||
expect(tenPercent).toMatch(/A 42 42 0 0 0 102 60 Z$/);
|
||
expect(donutArcPath(0)).toBe('');
|
||
expect(donutArcPath(25, 10)).toContain('A 54 54 0 0 1');
|
||
expect(donutArcPath(100)).toContain('A 54 54 0 0 1 6 60');
|
||
});
|
||
|
||
it('distinguishes a pending metric from an observed zero', () => {
|
||
const pending = renderToStaticMarkup(
|
||
<DonutMetricCard label="Deviation" value={null} percentage={0} tone="deviation" pendingLabel="Loading…" />
|
||
);
|
||
expect(pending).toContain('aria-label="Deviation: Loading…"');
|
||
expect(pending).toContain('<strong>—</strong>');
|
||
expect(pending).toContain('<small>Loading…</small>');
|
||
expect(pending).not.toContain('donut-metric-card__progress');
|
||
expect(pending).not.toContain('<small>0%</small>');
|
||
});
|
||
|
||
it('supports a single suffixed score without a duplicate percentage label', () => {
|
||
const html = renderToStaticMarkup(
|
||
<DonutMetricCard label="Compliance Score" value={36} valueSuffix="%" showPercentage={false} percentage={36} tone="all" />
|
||
);
|
||
|
||
expect(html).toContain('<strong>36%</strong>');
|
||
expect(html).not.toContain('<small>36%</small>');
|
||
});
|
||
|
||
it('becomes an accessible active filter control when clickable', () => {
|
||
const html = renderToStaticMarkup(
|
||
<DonutMetricCard
|
||
label="High"
|
||
value={12}
|
||
percentage={24}
|
||
tone="high"
|
||
active
|
||
onClick={() => undefined}
|
||
className="test-donut-card"
|
||
/>
|
||
);
|
||
|
||
expect(html).toContain('type="button"');
|
||
expect(html).toContain('aria-pressed="true"');
|
||
expect(html).toContain('donut-metric-card--high test-donut-card');
|
||
});
|
||
});
|