/** * uat-report/render-svg.ts — PURE inline-SVG chart builders for the report. * * Hand-rolled strings, zero dependencies, zero CDN (the no-external-resource * golden test stays authoritative). Colors come from CSS custom properties * (`var(--pass)`, …) via per-segment classes, so light/dark theming is owned by * the report stylesheet. Deterministic: no Date, no randomness — same input ⇒ * same bytes (the report's idempotence test relies on it). */ const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); export interface DonutSegment { label: string; count: number; /** CSS class carrying the stroke color (seg-pass | seg-fail | seg-skip | seg-ind). */ cssClass: string; } const DONUT_SIZE = 120; const DONUT_RADIUS = 48; const DONUT_STROKE = 16; /** * Donut chart via stroke-dasharray ring segments. Zero-total renders an empty * muted ring (never NaN). Accessible: role="img" + + aria-label. */ export function donutSvg(segments: readonly DonutSegment[], centerLabel: string, title: string): string { const total = segments.reduce((a, s) => a + s.count, 0); const c = DONUT_SIZE / 2; const circumference = 2 * Math.PI * DONUT_RADIUS; const parts: string[] = []; parts.push( `<circle class="seg-track" cx="${c}" cy="${c}" r="${DONUT_RADIUS}" fill="none" stroke-width="${DONUT_STROKE}"/>`, ); if (total > 0) { let offset = 0; for (const seg of segments) { if (seg.count === 0) continue; const len = (seg.count / total) * circumference; // Rounded to 2 decimals — stable, and immune to float drift across platforms. const dash = `${len.toFixed(2)} ${(circumference - len).toFixed(2)}`; parts.push( `<circle class="${esc(seg.cssClass)}" cx="${c}" cy="${c}" r="${DONUT_RADIUS}" fill="none" ` + `stroke-width="${DONUT_STROKE}" stroke-dasharray="${dash}" stroke-dashoffset="${(-offset).toFixed(2)}" ` + `transform="rotate(-90 ${c} ${c})"><title>${esc(`${seg.label}: ${seg.count}`)}`, ); offset += len; } } parts.push( `${esc(centerLabel)}`, ); const legend = segments .map((s) => `${s.label} ${s.count}`) .join(' · '); return ( `${esc(title)}${parts.join('')}` ); } export interface BandBar { label: string; count: number; /** CSS class carrying the fill color (band-fast | band-ok | band-warn | band-slow). */ cssClass: string; } const BAR_WIDTH = 260; const BAR_HEIGHT = 16; const BAR_GAP = 6; const BAR_LABEL_W = 84; const BAR_COUNT_W = 34; /** * Horizontal band bars, widths proportional to the max count (integer math). * Zero-max renders hairline bars (never NaN). */ export function barsSvg(bands: readonly BandBar[], title: string): string { const max = bands.reduce((a, b) => Math.max(a, b.count), 0); const height = bands.length * (BAR_HEIGHT + BAR_GAP) - BAR_GAP; const width = BAR_LABEL_W + BAR_WIDTH + BAR_COUNT_W; const rows = bands .map((band, i) => { const y = i * (BAR_HEIGHT + BAR_GAP); const w = max > 0 ? Math.max(1, Math.round((band.count / max) * BAR_WIDTH)) : 1; return ( `${esc(band.label)}` + `${esc(`${band.label}: ${band.count}`)}` + `${band.count}` ); }) .join(''); const legend = bands.map((b) => `${b.label} ${b.count}`).join(' · '); return ( `${esc(title)}${rows}` ); }