/**
* 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(
``,
);
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(
`${esc(`${seg.label}: ${seg.count}`)}`,
);
offset += len;
}
}
parts.push(
`${esc(centerLabel)}`,
);
const legend = segments
.map((s) => `${s.label} ${s.count}`)
.join(' · ');
return (
``
);
}
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 (
``
);
}