/** * Deterministic tests for the site-native chart library. * * Tests cover: * - Scale functions (linear, band, niceTicks, extent) * - Geometry path builders (line, smooth, area, rounded rect, orthogonal) * - SVG escaping * - Empty / error / degenerate cases * * Run with: npx tsx --test src/charts/charts.test.ts */ import assert from "node:assert/strict"; import test from "node:test"; import { linearScale, bandScale, bandScaleWidth, niceTicks, extent, } from "./scale.ts"; import { buildLinePath, buildSmoothPath, buildAreaPath, buildRoundedRect, buildOrthogonalPath, fmt, } from "./geometry.ts"; import { escapeSvg, escapeCdata } from "./escape.ts"; import { chartId, finiteDomain, formatValue } from "./format.ts"; import { layoutLineage } from "./lineage.tsx"; // ─── linearScale ────────────────────────────────────────────── test("linearScale maps domain to range", () => { const s = linearScale([0, 10], [0, 100]); assert.equal(s(0), 0); assert.equal(s(5), 50); assert.equal(s(10), 100); }); test("linearScale handles negative range", () => { const s = linearScale([0, 10], [100, 0]); assert.equal(s(0), 100); assert.equal(s(5), 50); assert.equal(s(10), 0); }); test("linearScale handles negative domain", () => { const s = linearScale([-5, 5], [0, 200]); assert.equal(s(-5), 0); assert.equal(s(0), 100); assert.equal(s(5), 200); }); test("linearScale handles inverted domain", () => { const s = linearScale([10, 0], [0, 100]); assert.equal(s(10), 0); assert.equal(s(5), 50); assert.equal(s(0), 100); }); test("linearScale degenerate domain returns mid-range", () => { const s = linearScale([5, 5], [0, 100]); assert.equal(s(5), 50); assert.equal(s(999), 50); }); test("linearScale clamps values outside domain", () => { const s = linearScale([0, 10], [0, 100]); // Extrapolates — callers should clamp if needed assert.equal(s(-5), -50); assert.equal(s(15), 150); }); // ─── bandScale ─────────────────────────────────────────────── test("bandScale distributes bands evenly", () => { const s = bandScale(["a", "b", "c"], [0, 300]); // step = 100, padding 0.2 → band = 80, offset = 10 assert.equal(s("a"), 10); assert.equal(s("b"), 110); assert.equal(s("c"), 210); }); test("bandScale with custom padding", () => { const s = bandScale(["x", "y"], [0, 200], 0.5); // step = 100, band = 50, offset = 25 assert.equal(s("x"), 25); assert.equal(s("y"), 125); }); test("bandScale unknown category returns origin", () => { const s = bandScale(["a", "b"], [0, 100]); assert.equal(s("zzz"), 0); }); test("bandScale empty domain returns origin", () => { const s = bandScale([], [0, 100]); assert.equal(s("anything"), 0); }); test("bandScaleWidth returns correct width", () => { assert.equal(bandScaleWidth(["a", "b", "c"], [0, 300]), 80); assert.equal(bandScaleWidth(["a", "b", "c"], [0, 300], 0.5), 50); assert.equal(bandScaleWidth([], [0, 300]), 0); }); // ─── niceTicks ─────────────────────────────────────────────── test("niceTicks produces round numbers", () => { const ticks = niceTicks(0, 100, 5); assert.deepEqual(ticks, [0, 20, 40, 60, 80, 100]); }); test("niceTicks handles non-zero start", () => { const ticks = niceTicks(3, 97, 5); assert.deepEqual(ticks, [20, 40, 60, 80]); }); test("niceTicks handles small ranges", () => { const ticks = niceTicks(0, 1, 5); assert.deepEqual(ticks, [0, 0.2, 0.4, 0.6, 0.8, 1]); }); test("niceTicks handles negative ranges", () => { const ticks = niceTicks(-50, 50, 5); assert.deepEqual(ticks, [-40, -20, 0, 20, 40]); }); test("niceTicks degenerate range returns single value", () => { const ticks = niceTicks(7, 7, 5); assert.deepEqual(ticks, [7]); }); test("niceTicks non-finite returns empty", () => { assert.deepEqual(niceTicks(NaN, 100, 5), []); assert.deepEqual(niceTicks(0, Infinity, 5), []); }); // ─── extent ────────────────────────────────────────────────── test("extent finds min and max", () => { assert.deepEqual(extent([3, 1, 4, 1, 5, 9, 2, 6]), [1, 9]); }); test("extent ignores null and NaN", () => { assert.deepEqual(extent([3, null, 1, NaN, 5, undefined, 2]), [1, 5]); }); test("extent empty array returns [0, 0]", () => { assert.deepEqual(extent([]), [0, 0]); }); test("extent all-null array returns [0, 0]", () => { assert.deepEqual(extent([null, NaN, undefined]), [0, 0]); }); test("extent single value", () => { assert.deepEqual(extent([42]), [42, 42]); }); test("extent negative values", () => { assert.deepEqual(extent([-5, -1, -10]), [-10, -1]); }); // ─── buildLinePath ──────────────────────────────────────────── test("buildLinePath with multiple points", () => { const path = buildLinePath([ { x: 0, y: 10 }, { x: 50, y: 20 }, { x: 100, y: 0 }, ]); assert.equal(path, "M 0 10 L 50 20 L 100 0"); }); test("buildLinePath with single point", () => { assert.equal(buildLinePath([{ x: 5, y: 10 }]), "M 5 10"); }); test("buildLinePath empty returns empty string", () => { assert.equal(buildLinePath([]), ""); }); // ─── buildSmoothPath ───────────────────────────────────────── test("buildSmoothPath falls back to line for < 3 points", () => { const two = buildSmoothPath([ { x: 0, y: 0 }, { x: 10, y: 10 }, ]); assert.equal(two, "M 0 0 L 10 10"); const one = buildSmoothPath([{ x: 5, y: 5 }]); assert.equal(one, "M 5 5"); assert.equal(buildSmoothPath([]), ""); }); test("buildSmoothPath starts with M and uses C commands", () => { const path = buildSmoothPath([ { x: 0, y: 0 }, { x: 50, y: 50 }, { x: 100, y: 0 }, ]); assert.match(path, /^M 0 0 C /); // One cubic segment connects each adjacent pair. assert.equal((path.match(/C/g) ?? []).length, 2); }); // ─── buildAreaPath ──────────────────────────────────────────── test("buildAreaPath closes to baseline", () => { const path = buildAreaPath( [ { x: 0, y: 10 }, { x: 50, y: 20 }, { x: 100, y: 10 }, ], 100, ); assert.equal(path, "M 0 100 L 0 10 L 50 20 L 100 10 L 100 100 Z"); }); test("buildAreaPath single point", () => { const path = buildAreaPath([{ x: 50, y: 10 }], 100); assert.equal(path, "M 50 10 L 50 100 Z"); }); test("buildAreaPath empty returns empty string", () => { assert.equal(buildAreaPath([], 100), ""); }); // ─── buildRoundedRect ───────────────────────────────────────── test("buildRoundedRect with radius", () => { const path = buildRoundedRect(0, 0, 100, 50, 5); assert.match(path, /^M 5 0/); assert.match(path, /Z$/); // Contains arc commands assert.match(path, /a 5 5/); }); test("buildRoundedRect with zero radius is plain rect", () => { const path = buildRoundedRect(0, 0, 100, 50, 0); assert.equal(path, "M 0 0 h 100 v 50 h -100 Z"); }); test("buildRoundedRect clamps radius to half the smaller dimension", () => { // width=20, height=20, radius=100 → radius clamped to 10 const path = buildRoundedRect(0, 0, 20, 20, 100); assert.match(path, /a 10 10/); }); // ─── buildOrthogonalPath ───────────────────────────────────── test("buildOrthogonalPath with default midpoint", () => { const path = buildOrthogonalPath( { x: 0, y: 10 }, { x: 100, y: 50 }, ); assert.equal(path, "M 0 10 L 50 10 L 50 50 L 100 50"); }); test("buildOrthogonalPath with explicit midpoint", () => { const path = buildOrthogonalPath( { x: 0, y: 10 }, { x: 100, y: 50 }, 75, ); assert.equal(path, "M 0 10 L 75 10 L 75 50 L 100 50"); }); // ─── fmt ───────────────────────────────────────────────────── test("fmt rounds to 4 decimal places", () => { assert.equal(fmt(1.23456), "1.2346"); assert.equal(fmt(1.1), "1.1"); assert.equal(fmt(100), "100"); assert.equal(fmt(0.00001), "0"); }); test("fmt handles non-finite", () => { assert.equal(fmt(NaN), "0"); assert.equal(fmt(Infinity), "0"); assert.equal(fmt(-Infinity), "0"); }); // ─── escapeSvg ─────────────────────────────────────────────── test("escapeSvg escapes all special characters", () => { assert.equal(escapeSvg("a&b"), "a&b"); assert.equal(escapeSvg("ab"), "a>b"); assert.equal(escapeSvg('a"b'), "a"b"); assert.equal(escapeSvg("a'b"), "a'b"); }); test("escapeSvg handles combined special characters", () => { assert.equal( escapeSvg(``), "<script>alert("x")</script>", ); }); test("escapeSvg returns empty for null/undefined", () => { assert.equal(escapeSvg(null), ""); assert.equal(escapeSvg(undefined), ""); }); test("escapeSvg converts non-strings", () => { assert.equal(escapeSvg(42), "42"); assert.equal(escapeSvg(true), "true"); }); test("escapeSvg leaves safe text unchanged", () => { assert.equal(escapeSvg("Hello World 123"), "Hello World 123"); }); // ─── escapeCdata ───────────────────────────────────────────── test("escapeCdata splits on ]]> sequence", () => { assert.equal( escapeCdata("before]]>after"), "before]]]]>after", ); }); test("escapeCdata leaves normal text unchanged", () => { assert.equal(escapeCdata("normal text"), "normal text"); }); test("escapeCdata returns empty for null/undefined", () => { assert.equal(escapeCdata(null), ""); assert.equal(escapeCdata(undefined), ""); }); // ─── chart formatting ────────────────────────────────────────── test("chartId is deterministic, bounded, and collision-resistant after slug truncation", () => { const markupId = chartId({ title: `` }, "title"); assert.match(markupId, /^chart-script-training-validation-script-[a-f0-9]{16}-title$/); assert.equal(markupId, chartId({ title: `` }, "title")); const prefix = "same-prefix-".repeat(8); const left = chartId({ id: `${prefix}left`, title: "Ignored" }, "description"); const right = chartId({ id: `${prefix}right`, title: "Ignored" }, "description"); assert.notEqual(left, right); assert.ok(left.length <= 80); assert.ok(right.length <= 80); }); test("formatValue applies stable number and percent formatting", () => { assert.equal(formatValue(0.8123, "percent", 1), "81.2%"); assert.equal(formatValue(1234.8, "integer"), "1,235"); assert.equal(formatValue(Number.NaN), "No data"); }); test("finiteDomain normalizes reverse and degenerate domains", () => { assert.deepEqual(finiteDomain([10, 0], [2, 3]), [0, 10]); assert.deepEqual(finiteDomain([0, 0], [2, 3]), [-1, 1]); assert.deepEqual(finiteDomain([Number.NaN, 2], [2, 3]), [2, 3]); }); // ─── lineage layout ──────────────────────────────────────────── test("layoutLineage assigns deterministic stages from edges", () => { const layout = layoutLineage( [ { id: "data", label: "Data" }, { id: "train", label: "Train" }, { id: "eval", label: "Eval" }, ], [ { from: "data", to: "train" }, { from: "train", to: "eval" }, ], ); assert.deepEqual(layout.nodes.map((node) => [node.id, node.stageIndex]), [ ["data", 0], ["train", 1], ["eval", 2], ]); assert.equal(layout.omittedEdges, 0); assert.equal(layout.cyclicNodes, 0); }); test("layoutLineage omits unknown edges and contains cycles", () => { const layout = layoutLineage( [ { id: "a", label: "A" }, { id: "b", label: "B" }, { id: "b", label: "Duplicate B" }, ], [ { from: "a", to: "b" }, { from: "b", to: "a" }, { from: "missing", to: "a" }, ], ); assert.equal(layout.nodes.length, 2); assert.equal(layout.edges.length, 2); assert.equal(layout.duplicateNodes, 1); assert.equal(layout.omittedEdges, 1); assert.equal(layout.cyclicNodes, 2); assert.equal(new Set(layout.nodes.map((node) => node.stageIndex)).size, 1); });