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(" { 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,", "data:image/svg+xml,", "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(" { 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}'); }); });