Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { JSDOM } from "jsdom";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildPlotParts,
|
|
CHART_CATALOG,
|
|
PLOT_TOOL_GUIDE,
|
|
type PlotSpec,
|
|
parsePlotData,
|
|
renderPlotSpecToSvg,
|
|
searchChartCatalog,
|
|
supportedPlotNames,
|
|
} from "./plot-tool.js";
|
|
|
|
const dom = () => new JSDOM("").window.document;
|
|
|
|
const penguinish = [
|
|
{ length: 39.1, depth: 18.7, species: "Adelie", island: "Torgersen" },
|
|
{ length: 46.5, depth: 17.9, species: "Chinstrap", island: "Dream" },
|
|
{ length: 50.0, depth: 15.2, species: "Gentoo", island: "Biscoe" },
|
|
{ length: 41.3, depth: 19.1, species: "Adelie", island: "Dream" },
|
|
{ length: 48.7, depth: 14.1, species: "Gentoo", island: "Biscoe" },
|
|
];
|
|
|
|
describe("render_plot", () => {
|
|
it("renders a titled scatterplot with a categorical legend into standalone SVG", () => {
|
|
const svg = renderPlotSpecToSvg(
|
|
{
|
|
title: "Culmen shape by species",
|
|
marks: [{ type: "dot", options: { x: "length", y: "depth", stroke: "species" } }],
|
|
},
|
|
penguinish,
|
|
dom(),
|
|
);
|
|
expect(svg.startsWith("<svg xmlns=")).toBe(true);
|
|
expect(svg).toContain("Culmen shape by species");
|
|
for (const species of ["Adelie", "Chinstrap", "Gentoo"]) expect(svg).toContain(species);
|
|
expect(svg).toContain("circle");
|
|
});
|
|
|
|
it("does not mutate caller-owned scale options", () => {
|
|
const color = Object.freeze({ legend: true });
|
|
const spec: PlotSpec = {
|
|
color,
|
|
marks: [{ type: "dot", options: { x: "length", y: "depth", stroke: "species" } }],
|
|
};
|
|
|
|
expect(() => renderPlotSpecToSvg(spec, penguinish, dom())).not.toThrow();
|
|
expect(color.legend).toBe(true);
|
|
});
|
|
|
|
const xlink = "http://www.w3.org/1999/xlink";
|
|
const linkedDot = { type: "dot", options: { x: "x", y: "y", href: "url" } };
|
|
|
|
function expectInertLinks(element: Element) {
|
|
const links = Array.from(element.querySelectorAll("a"));
|
|
expect(links.length).toBeGreaterThan(0);
|
|
for (const link of links) {
|
|
expect(link.hasAttribute("href")).toBe(false);
|
|
expect(link.hasAttributeNS(xlink, "href")).toBe(false);
|
|
expect(link.childElementCount).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
|
|
it.each([
|
|
"javascript:void(0)",
|
|
"JaVaScRiPt:void(0)",
|
|
" \u0000\u001fjavascript:void(0)",
|
|
"java\tscr\ript:\nvoid(0)",
|
|
"javascript:%76oid(0)",
|
|
"data:text/html,<script>void(0)</script>",
|
|
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
|
|
"vbscript:msgbox(1)",
|
|
"file:///example.txt",
|
|
"blob:https://example.test/chart",
|
|
"custom:launch",
|
|
"https://[invalid",
|
|
])("makes unsafe or malformed chart links inert: %j", (url) => {
|
|
const spec = { marks: [linkedDot] };
|
|
const data = [{ x: 1, y: 2, url }];
|
|
const { plotted } = buildPlotParts(spec, data, dom());
|
|
expectInertLinks(plotted);
|
|
expect(plotted.querySelectorAll("circle")).toHaveLength(1);
|
|
const svg = renderPlotSpecToSvg(spec, data, dom());
|
|
expectInertLinks(
|
|
new JSDOM(svg, { contentType: "image/svg+xml" }).window.document.documentElement,
|
|
);
|
|
expect(data).toEqual([{ x: 1, y: 2, url }]);
|
|
expect(linkedDot.options.href).toBe("url");
|
|
});
|
|
|
|
it.each([
|
|
"https://example.test/chart?a=1&b=2",
|
|
"HTTP://example.test/chart",
|
|
"//example.test/chart",
|
|
"/charts/1",
|
|
"../charts/1",
|
|
"?chart=1",
|
|
"#details",
|
|
"mailto:chart@example.test",
|
|
"tel:+15555550123",
|
|
// DOM attributes are not entity-decoded or percent-decoded into a scheme.
|
|
"javascript:void(0)",
|
|
"javascript%3Avoid(0)",
|
|
"java%73cript:void(0)",
|
|
])("preserves supported links without changing URL interpretation: %j", (url) => {
|
|
const spec = { marks: [linkedDot] };
|
|
const data = [{ x: 1, y: 2, url }];
|
|
const { plotted } = buildPlotParts(spec, data, dom());
|
|
expect(plotted.querySelector("a")?.getAttributeNS(xlink, "href")).toBe(url);
|
|
const svg = renderPlotSpecToSvg(spec, data, dom());
|
|
const parsed = new JSDOM(svg, { contentType: "image/svg+xml" }).window.document;
|
|
const link = parsed.querySelector("a");
|
|
expect(link?.getAttributeNS(xlink, "href") ?? link?.getAttribute("href")).toBe(url);
|
|
});
|
|
|
|
it("checks resolved links from nested, per-mark, array, scaled, and transformed channels", () => {
|
|
const url = "javascript:void(0)";
|
|
const data = [{ x: 1, y: 2, url }];
|
|
const specs: PlotSpec[] = [
|
|
{ data, marks: [linkedDot] },
|
|
{ marks: [{ ...linkedDot, data }] },
|
|
{ data, marks: [{ type: "dot", options: { x: "x", y: "y", href: [url] } }] },
|
|
{
|
|
data,
|
|
marks: [{ type: "dot", options: { x: "x", y: "y", href: { value: "url", scale: null } } }],
|
|
},
|
|
{
|
|
data,
|
|
color: { type: "ordinal", domain: [1], range: [url] },
|
|
marks: [{ type: "dot", options: { x: "x", y: "y", href: { value: "x", scale: "color" } } }],
|
|
},
|
|
{
|
|
data,
|
|
marks: [
|
|
{ ...linkedDot, transform: { name: "groupX", outputs: { y: "sum", href: "first" } } },
|
|
],
|
|
},
|
|
];
|
|
for (const spec of specs) {
|
|
expectInertLinks(buildPlotParts(spec, undefined, dom()).plotted);
|
|
}
|
|
});
|
|
|
|
it("supports transforms: binned histogram and grouped stacked bars", () => {
|
|
const histogram = renderPlotSpecToSvg(
|
|
{
|
|
marks: [
|
|
{
|
|
type: "rectY",
|
|
transform: { name: "binX", outputs: { y: "count" } },
|
|
options: { x: "length", fill: "species" },
|
|
},
|
|
{ type: "ruleY", data: [0] },
|
|
],
|
|
},
|
|
penguinish,
|
|
dom(),
|
|
);
|
|
expect(histogram).toContain("rect");
|
|
const grouped = renderPlotSpecToSvg(
|
|
{
|
|
marks: [
|
|
{
|
|
type: "barY",
|
|
transform: { name: "groupX", outputs: { y: "count" } },
|
|
options: { x: "island", fill: "species" },
|
|
},
|
|
],
|
|
},
|
|
penguinish,
|
|
dom(),
|
|
);
|
|
expect(grouped).toContain("rect");
|
|
});
|
|
|
|
it("supports facets", () => {
|
|
const svg = renderPlotSpecToSvg(
|
|
{
|
|
marks: [{ type: "dot", options: { x: "length", y: "depth", fx: "island" } }],
|
|
},
|
|
penguinish,
|
|
dom(),
|
|
);
|
|
for (const island of ["Torgersen", "Dream", "Biscoe"]) expect(svg).toContain(island);
|
|
});
|
|
|
|
it("rejects unknown marks and transforms with the supported lists", () => {
|
|
expect(() => renderPlotSpecToSvg({ marks: [{ type: "evilMark" }] }, penguinish, dom())).toThrow(
|
|
/Unsupported mark type "evilMark"/,
|
|
);
|
|
expect(() =>
|
|
renderPlotSpecToSvg(
|
|
{ marks: [{ type: "dot", transform: { name: "eval" }, options: {} }] },
|
|
penguinish,
|
|
dom(),
|
|
),
|
|
).toThrow(/Unsupported transform "eval"/);
|
|
expect(supportedPlotNames().marks).toContain("dot");
|
|
expect(supportedPlotNames().marks).not.toContain("image");
|
|
expect(supportedPlotNames().transforms).toContain("binX");
|
|
expect(() =>
|
|
renderPlotSpecToSvg(
|
|
{ marks: [{ type: "image", options: { src: "https://example.test/pixel.png" } }] },
|
|
[{ x: 1, y: 1 }],
|
|
dom(),
|
|
),
|
|
).toThrow(/Unsupported mark type "image"/);
|
|
});
|
|
|
|
it("rejects empty data and channel names that match no column", () => {
|
|
expect(() =>
|
|
renderPlotSpecToSvg({ marks: [{ type: "barY", options: { x: "q", y: "v" } }] }, [], dom()),
|
|
).toThrow(/empty data array/);
|
|
expect(() =>
|
|
renderPlotSpecToSvg(
|
|
{ marks: [{ type: "barY", options: { x: "Quarter", y: "Sales" } }] },
|
|
[{ quarter: "Q1", sales: 120 }],
|
|
dom(),
|
|
),
|
|
).toThrow(/x refers to "Quarter" but the data columns are: quarter, sales/);
|
|
expect(() =>
|
|
renderPlotSpecToSvg({ marks: [{ type: "barY" }] }, [{ quarter: "Q1", sales: 120 }], dom()),
|
|
).toThrow(/no position channels/);
|
|
});
|
|
|
|
it("coerces numeric strings so bar heights measure instead of stacking ordinally", () => {
|
|
const svg = renderPlotSpecToSvg(
|
|
{ marks: [{ type: "barY", options: { x: "quarter", y: "sales" } }] },
|
|
[
|
|
{ quarter: "Q1", sales: "120" },
|
|
{ quarter: "Q2", sales: "185" },
|
|
{ quarter: "Q3", sales: "143" },
|
|
{ quarter: "Q4", sales: "210" },
|
|
],
|
|
dom(),
|
|
);
|
|
// A quantitative y axis includes a 200 tick; the ordinal failure mode has none.
|
|
expect(svg).toContain("200");
|
|
expect(svg).toContain("Q3");
|
|
});
|
|
|
|
it("preserves numeric-looking category labels", () => {
|
|
const svg = renderPlotSpecToSvg(
|
|
{ marks: [{ type: "barY", options: { x: "code", y: "sales" } }] },
|
|
[
|
|
{ code: "007", sales: "120" },
|
|
{ code: "1e5", sales: "185" },
|
|
],
|
|
dom(),
|
|
);
|
|
|
|
expect(svg).toContain("007");
|
|
expect(svg).toContain("1e5");
|
|
});
|
|
|
|
it("parses data given as CSV text lines", () => {
|
|
const svg = renderPlotSpecToSvg(
|
|
{ marks: [{ type: "barY", options: { x: "quarter", y: "sales" } }] },
|
|
["quarter,sales", "Q1,120", "Q2,185", "Q3,143", "Q4,210"],
|
|
dom(),
|
|
);
|
|
expect(svg).toContain("200");
|
|
expect(svg).toContain("Q3");
|
|
});
|
|
|
|
it("accepts rows nested inside the spec as data", () => {
|
|
const svg = renderPlotSpecToSvg(
|
|
{
|
|
data: [
|
|
{ quarter: "Q1", sales: 120 },
|
|
{ quarter: "Q2", sales: 185 },
|
|
],
|
|
marks: [{ type: "barY", options: { x: "quarter", y: "sales" } }],
|
|
},
|
|
undefined,
|
|
dom(),
|
|
);
|
|
expect(svg).toContain("rect");
|
|
expect(svg).toContain("Q2");
|
|
});
|
|
|
|
it("rejects oversized data across top-level, spec, and per-mark arrays", () => {
|
|
const rows = Array.from({ length: 2_501 }, (_, x) => ({ x }));
|
|
|
|
expect(() =>
|
|
renderPlotSpecToSvg(
|
|
{
|
|
data: rows,
|
|
marks: [{ type: "dot", data: rows, options: { x: "x" } }],
|
|
},
|
|
undefined,
|
|
dom(),
|
|
),
|
|
).toThrow(/5,000-row limit/);
|
|
});
|
|
|
|
it("parses csv with automatic typing and json row arrays", () => {
|
|
const rows = parsePlotData("data.csv", "a,b\n1,2024-01-05\n2,2024-02-05\n") as {
|
|
a: number;
|
|
b: Date;
|
|
}[];
|
|
expect(rows[0]?.a).toBe(1);
|
|
expect(rows[0]?.b instanceof Date).toBe(true);
|
|
expect(parsePlotData("rows.json", '[{"x":1}]')).toEqual([{ x: 1 }]);
|
|
expect(() => parsePlotData("rows.json", '{"x":1}')).toThrow(/top-level array/);
|
|
});
|
|
|
|
it("renders every chart catalog example spec verbatim", () => {
|
|
for (const entry of CHART_CATALOG) {
|
|
const svg = renderPlotSpecToSvg(entry.spec, undefined, dom());
|
|
expect(svg.startsWith("<svg"), `catalog entry "${entry.name}" must render`).toBe(true);
|
|
}
|
|
expect(CHART_CATALOG.length).toBeGreaterThanOrEqual(18);
|
|
});
|
|
|
|
it("searches the chart catalog by keyword and falls back to the full list", () => {
|
|
const hist = searchChartCatalog("distribution");
|
|
expect(hist.map((entry) => entry.name)).toContain("histogram");
|
|
expect(hist.length).toBeLessThan(CHART_CATALOG.length);
|
|
expect(searchChartCatalog("qzx-no-match")).toHaveLength(CHART_CATALOG.length);
|
|
expect(searchChartCatalog(undefined)).toHaveLength(CHART_CATALOG.length);
|
|
});
|
|
|
|
it("rasterizes to a fully opaque PNG (no transparent background)", async () => {
|
|
const { plotSvgToPng } = await import("./plot-tool.js");
|
|
const svg = renderPlotSpecToSvg(
|
|
{ marks: [{ type: "barY", options: { x: "species", y: "length" } }] },
|
|
penguinish,
|
|
dom(),
|
|
);
|
|
const png = await plotSvgToPng(svg);
|
|
const sharp = (await import("sharp")).default;
|
|
const stats = await sharp(png).stats();
|
|
const meta = await sharp(png).metadata();
|
|
const alpha = stats.channels[3];
|
|
expect(meta.channels === 3 || (alpha && alpha.min === 255)).toBe(true);
|
|
});
|
|
|
|
it("ships a guide that teaches the agent how to call the tool", () => {
|
|
expect(PLOT_TOOL_GUIDE).toContain("How to use the tool");
|
|
expect(PLOT_TOOL_GUIDE).toContain("data_path");
|
|
expect(PLOT_TOOL_GUIDE).toContain("Chart catalog");
|
|
expect(PLOT_TOOL_GUIDE).toContain('{"help": true}');
|
|
});
|
|
});
|