import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { renderGenerativeUI } from "../renderGenerativeUI";
import { dataVocabulary } from "./data";
const render = (node: unknown) =>
renderToStaticMarkup(<>{renderGenerativeUI(node, dataVocabulary)}>);
describe("dataVocabulary", () => {
it("Table renders columns and rows", () => {
const html = render({
$type: "Table",
columns: [{ label: "Name" }, { label: "Age" }],
rows: [
["Ada", 36],
["Bob", 24],
],
});
expect(html).toContain('data-aui="table"');
expect(html).toContain('
Name ');
expect(html).toContain('Age ');
expect(html).toContain("Ada ");
expect(html).toContain("36 ");
expect(html).toContain("Bob ");
expect(html).toContain("24 ");
});
it("Table renders with only rows (no header)", () => {
const html = render({ $type: "Table", rows: [["only"]] });
expect(html).toContain('data-aui="table"');
expect(html).not.toContain("");
expect(html).toContain("only ");
});
it("Table ignores malformed collections instead of throwing", () => {
expect(
render({
$type: "Table",
columns: "not-an-array",
rows: [["kept", null, {}, false], null],
}),
).toBe(
'',
);
expect(render({ $type: "Table", columns: [null], rows: [null] })).toBe(
'',
);
expect(
render({
$type: "Table",
columns: [null, { label: "Name" }],
rows: "not-an-array",
}),
).toBe(
'',
);
});
it("Markdown renders the value in a div", () => {
expect(render({ $type: "Markdown", value: "# hi" })).toBe(
'# hi
',
);
});
it("Markdown renders partial value while streaming", () => {
expect(
renderToStaticMarkup(
<>
{renderGenerativeUI(
{ $type: "Markdown", value: "partial" },
dataVocabulary,
{ status: "streaming" },
)}
>,
),
).toBe('partial
');
});
it("Markdown ignores malformed streaming values", () => {
expect(
renderToStaticMarkup(
<>
{renderGenerativeUI(
{ $type: "Markdown", value: { unexpected: true } },
dataVocabulary,
{ status: "streaming" },
)}
>,
),
).toBe('
');
});
it("Chart bar variant renders one rect per data point", () => {
const html = render({
$type: "Chart",
variant: "bar",
data: [{ value: 1 }, { value: 2 }, { value: 3 }],
color: "#f00",
});
expect(html).toContain('data-aui="chart"');
expect(html).toContain('data-aui-variant="bar"');
expect(html).toContain('data-aui-color="#f00"');
expect((html.match(/ {
const html = render({
$type: "Chart",
variant: "line",
data: [{ value: 0 }, { value: 20 }, { value: 40 }],
});
expect((html.match(/ {
const html = render({
$type: "Chart",
variant: "line",
data: [{ value: 20 }],
});
expect(html).not.toContain(" {
const html = render({
$type: "Chart",
variant: "sparkline",
data: [{ value: 0 }, { value: 20 }, { value: 40 }],
});
expect((html.match(/ {
const html = render({
$type: "Chart",
variant: "bar",
data: [{ value: 1 }, { value: 2 }],
});
expect(html).toContain('aria-label="bar chart with 2 data points"');
});
it("Chart with empty data renders an empty svg without throwing", () => {
expect(() =>
render({ $type: "Chart", variant: "bar", data: [] }),
).not.toThrow();
const html = render({ $type: "Chart", variant: "line", data: [] });
expect(html).toContain('data-aui="chart"');
expect(html).not.toContain(" {
expect(() => render({ $type: "Chart", variant: "bar" })).not.toThrow();
const html = render({ $type: "Chart", variant: "bar" });
expect(html).toContain('data-aui="chart"');
expect(html).not.toContain(" {
const html = render({
$type: "Chart",
variant: "bar",
data: [{ value: -5 }, { value: Number.NaN }, { value: 10 }],
});
expect((html.match(/ {
const barHtml = render({
$type: "Chart",
variant: "bar",
data: [{ value: 0 }, { value: 0 }],
});
expect((barHtml.match(/height="0"/g) ?? []).length).toBe(2);
const lineHtml = render({
$type: "Chart",
variant: "line",
data: [{ value: 0 }, { value: 0 }, { value: 0 }],
});
expect(lineHtml).toContain('points="0,40 50,40 100,40"');
});
});
describe("dataVocabulary Chart multi-series", () => {
it("wraps each series' marks in its own data-aui=chart-series group, indexed", () => {
const html = render({
$type: "Chart",
variant: "line",
series: [
{ label: "A", data: [{ value: 1 }, { value: 2 }] },
{ label: "B", data: [{ value: 3 }, { value: 4 }] },
{ label: "C", data: [{ value: 5 }, { value: 6 }] },
],
});
expect((html.match(/data-aui="chart-series"/g) ?? []).length).toBe(3);
expect(html).toContain('data-aui-series="0"');
expect(html).toContain('data-aui-series="1"');
expect(html).toContain('data-aui-series="2"');
expect((html.match(/ {
const withEmptySeries = render({
$type: "Chart",
variant: "bar",
series: [],
data: [{ value: 1 }, { value: 2 }],
});
const withDataOnly = render({
$type: "Chart",
variant: "bar",
data: [{ value: 1 }, { value: 2 }],
});
expect((withEmptySeries.match(/ {
const html = render({
$type: "Chart",
variant: "bar",
data: [{ value: 999 }],
series: [{ data: [{ value: 1 }, { value: 2 }] }],
});
expect((html.match(/ {
expect(() =>
render({
$type: "Chart",
variant: "bar",
series: [
{ data: [{ value: 10 }, { value: 20 }, { value: 30 }] },
{ data: [{ value: 5 }] },
],
}),
).not.toThrow();
const html = render({
$type: "Chart",
variant: "bar",
series: [
{ data: [{ value: 10 }, { value: 20 }, { value: 30 }] },
{ data: [{ value: 5 }] },
],
});
expect((html.match(/ {
expect(() =>
render({
$type: "Chart",
variant: "bar",
series: [{ label: "A" }, { data: [{ value: 1 }] }] as never,
}),
).not.toThrow();
});
});
describe("dataVocabulary Chart stacking", () => {
it("stacked bars accumulate per x across series (known input)", () => {
const html = render({
$type: "Chart",
variant: "bar",
stacked: true,
series: [
{ data: [{ value: 10 }] },
{ data: [{ value: 20 }] },
{ data: [{ value: 30 }] },
],
});
// total at the single x is 60 -> scaleMax (unstacked axis, plain max) = 60.
// series 0: height (10/60)*40 ≈ 6.667 at y = 40 - 0 - 6.667 ≈ 33.333
// series 1: height (20/60)*40 ≈ 13.333 at y = 40 - 6.667 - 13.333 ≈ 20
// series 2: height (30/60)*40 = 20 at y = 40 - 20 - 20 = 0
expect(html).toContain('y="33.333333333333336"');
expect(html).toContain('height="6.666666666666666"');
expect(html).toContain('y="20.000000000000004"');
expect(html).toContain('height="13.333333333333332"');
expect(html).toContain('y="0" width="80" height="20"');
});
it("unstacked (overlaid) bars all start from the baseline regardless of series order", () => {
const html = render({
$type: "Chart",
variant: "bar",
series: [{ data: [{ value: 10 }] }, { data: [{ value: 20 }] }],
});
expect((html.match(/y="0"/g) ?? []).length).toBe(1); // only the tallest bar touches y=0
});
it("stacked areas build cumulative top/bottom polygons per series", () => {
const html = render({
$type: "Chart",
variant: "area",
stacked: true,
series: [
{ data: [{ value: 10 }, { value: 10 }] },
{ data: [{ value: 10 }, { value: 10 }] },
],
});
expect((html.match(/ y=20, bottom=0 -> y=40
expect(html).toContain('points="0,20 100,20 100,40 0,40"');
// series 1 (top band): top=20/20*40=40 -> y=0, bottom=10/20*40=20 -> y=20
expect(html).toContain('points="0,0 100,0 100,20 0,20"');
});
it("stacked bars with showAxis keep the bottom segment on the baseline, abut exactly, and share one denominator with the ticks (totals crossing a tick boundary)", () => {
const html = render({
$type: "Chart",
variant: "bar",
stacked: true,
showAxis: true,
series: [
{ data: [{ value: 50 }] },
{ data: [{ value: 30 }] },
{ data: [{ value: 25 }] },
],
});
// total 105 crosses the 50*10^n boundary, so niceMax must fall back to the
// next power of ten (200) instead of resolving to an under-sized 100.
expect(html).toContain("200
150
");
expect(html).toContain("100
50
0
");
// series 0 (bottom): height=(50/200)*40=10 at y=40-0-10=30; bottom edge
// y+height=40, the chart baseline.
expect(html).toContain('y="30" width="80" height="10"');
// series 1: height=(30/200)*40=6 at y=40-10-6=24; abuts series 0's y=30
// exactly (24+6=30, no gap or overlap).
expect(html).toContain('y="24" width="80" height="6"');
// series 2 (top): height=(25/200)*40=5 at y=40-16-5=19; abuts series 1's
// y=24 exactly (19+5=24). The stack top (y=19) equals
// 40-(105/200)*40=19, the same denominator (200) the ticks above use.
expect(html).toContain('y="19" width="80" height="5"');
});
it("stacked areas keep the bottom band's baseline at the chart bottom regardless of scale", () => {
const html = render({
$type: "Chart",
variant: "area",
stacked: true,
showAxis: true,
series: [
{ data: [{ value: 60 }, { value: 60 }] },
{ data: [{ value: 60 }, { value: 60 }] },
],
});
// total 120 crosses the same tick boundary (niceMax -> 200); the bottom
// band's own bottom edge must still sit at the chart baseline (y=40)
// regardless of scale, since it is built from an all-zero `bottoms` array.
expect(html).toContain("200
");
expect(html).toContain('points="0,28 100,28 100,40 0,40"');
});
});
describe("dataVocabulary Chart area variant", () => {
it("renders an unstroked filled polygon plus a top-edge polyline", () => {
const html = render({
$type: "Chart",
variant: "area",
data: [{ value: 0 }, { value: 40 }],
});
expect(html).toContain("]*stroke=/);
// fill polygon: top line 0,40 -> 100,0 then closed back down to the baseline in reverse.
expect(html).toContain('points="0,40 100,0 100,40 0,40"');
// the visible edge is a separate polyline over the top points only.
expect(html).toMatch(
/]*points="0,40 100,0"[^>]*stroke="currentColor"/,
);
expect(html).toMatch(/]*vector-effect="non-scaling-stroke"/);
});
it("renders a circle instead of a degenerate polygon for a single point", () => {
const html = render({
$type: "Chart",
variant: "area",
data: [{ value: 20 }],
});
expect(html).not.toContain(" {
expect(() =>
render({ $type: "Chart", variant: "area", data: [] }),
).not.toThrow();
const html = render({ $type: "Chart", variant: "area", data: [] });
expect(html).not.toContain(" {
it("derives a round niceMax and renders 5 ticks from it down to 0", () => {
const html = render({
$type: "Chart",
variant: "bar",
showAxis: true,
data: [{ value: 42 }],
});
// niceMax(42): exponent=1, candidates 10,20,50 -> 50 is the first >= 42.
expect(html).toContain('data-aui="chart-frame"');
expect(html).toContain('data-aui="chart-ticks"');
const ticksMatch = html.match(
/(.*?)<\/div>
/g) ?? []).length).toBe(5);
expect(ticksHtml).toContain("50
");
expect(ticksHtml).toContain("37.5
");
expect(ticksHtml).toContain("25
");
expect(ticksHtml).toContain("12.5
");
expect(ticksHtml).toContain("0
");
});
it("scales marks against niceMax (not the raw max) when the axis is shown", () => {
const html = render({
$type: "Chart",
variant: "bar",
showAxis: true,
data: [{ value: 42 }],
});
// niceMax = 50, so height = (42/50)*40 = 33.6, not (42/42)*40 = 40.
expect(html).toContain('height="33.6"');
});
it("stays a bare svg (no frame) when neither showAxis nor showLegend is set, even with new props", () => {
const html = render({
$type: "Chart",
variant: "bar",
stacked: true,
series: [{ data: [{ value: 1 }] }],
});
expect(html).not.toContain("chart-frame");
expect(html.startsWith(" {
it("renders an x-label row from the first series' point labels", () => {
const html = render({
$type: "Chart",
variant: "line",
showAxis: true,
data: [
{ label: "Mon", value: 1 },
{ label: "Tue", value: 2 },
],
});
expect(html).toContain('data-aui="chart-xlabels">');
expect(html).toContain("Mon
");
expect(html).toContain("Tue
");
});
it("omits the legend row when showLegend is not set", () => {
const html = render({
$type: "Chart",
variant: "line",
showAxis: true,
series: [{ label: "A", data: [{ value: 1 }] }],
});
expect(html).not.toContain("chart-legend");
});
it("renders a legend item per series with an indexed swatch when showLegend is set", () => {
const html = render({
$type: "Chart",
variant: "line",
showLegend: true,
series: [
{ label: "Revenue", data: [{ value: 1 }] },
{ label: "Cost", data: [{ value: 2 }] },
],
});
expect((html.match(/data-aui="chart-legend-item"/g) ?? []).length).toBe(2);
expect(html).toContain(
'',
);
expect(html).toContain(
'',
);
expect(html).toContain("Revenue");
expect(html).toContain("Cost");
expect((html.match(/data-aui="chart-legend-swatch"/g) ?? []).length).toBe(
2,
);
});
it("falls back to a positional label when a series has no label", () => {
const html = render({
$type: "Chart",
variant: "line",
showLegend: true,
series: [{ data: [{ value: 1 }] }],
});
expect(html).toContain("Series 1");
});
it("area series overlay from the baseline when not stacked", () => {
const html = render({
$type: "Chart",
variant: "area",
series: [
{ data: [{ value: 10 }, { value: 20 }] },
{ data: [{ value: 30 }, { value: 40 }] },
],
});
const points = [...html.matchAll(/]*points="([^"]+)"/g)].map(
(m) => m[1]!,
);
expect(points).toHaveLength(2);
for (const polygon of points) {
expect(polygon).toContain("100,40");
expect(polygon).not.toContain("-");
}
expect(points[1]).toContain("100,0");
});
it("area series accumulate when stacked", () => {
const html = render({
$type: "Chart",
variant: "area",
stacked: true,
series: [
{ data: [{ value: 10 }, { value: 20 }] },
{ data: [{ value: 30 }, { value: 40 }] },
],
});
const points = [...html.matchAll(/]*points="([^"]+)"/g)].map(
(m) => m[1]!,
);
expect(points).toHaveLength(2);
expect(points[1]).toContain("100,0");
expect(points[1]).not.toContain("100,40");
});
it("line charts ignore stacked for the scale", () => {
const html = render({
$type: "Chart",
variant: "line",
stacked: true,
series: [{ data: [{ value: 100 }] }, { data: [{ value: 100 }] }],
});
expect((html.match(/cy="0"/g) ?? []).length).toBe(2);
});
it("cycles series colors past the palette size", () => {
const html = render({
$type: "Chart",
variant: "bar",
showLegend: true,
series: Array.from({ length: 6 }, (_, i) => ({
label: `S${i + 1}`,
data: [{ value: i + 1 }],
})),
});
expect(html).toContain('');
expect(
(html.match(//g) ?? [])
.length,
).toBe(2);
expect(
(
html.match(
//g,
) ?? []
).length,
).toBe(2);
});
it("legend without axis renders no ticks or x labels", () => {
const html = render({
$type: "Chart",
variant: "bar",
showLegend: true,
series: [{ label: "A", data: [{ value: 1, label: "Jan" }] }],
});
expect(html).toContain('data-aui="chart-legend"');
expect(html).not.toContain('data-aui="chart-ticks"');
expect(html).not.toContain('data-aui="chart-xlabels"');
});
it("axis renders ticks and x labels", () => {
const html = render({
$type: "Chart",
variant: "bar",
showAxis: true,
series: [{ label: "A", data: [{ value: 1, label: "Jan" }] }],
});
expect(html).toContain('data-aui="chart-ticks"');
expect(html).toContain('data-aui="chart-xlabels"');
});
});