evOo-local/packages/adapters/src/plot-tool.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
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.
2026-09-20 17:15:16 +02:00

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.
"java&#x73;cript: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}');
});
});