328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
SVG_COMPRESS_CODES,
|
|
SvgCompressError,
|
|
} from "../../error-codes/error-codes.js";
|
|
import { parseSvg, serializeSvg } from "./svg.js";
|
|
|
|
describe("parseSvg", () => {
|
|
it("parses a minimal SVG element", { timeout: 5000 }, () => {
|
|
const node = parseSvg('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
|
expect(node.type).toBe("element");
|
|
expect(node.name).toBe("svg");
|
|
expect(node.attributes).toEqual({
|
|
xmlns: "http://www.w3.org/2000/svg",
|
|
});
|
|
expect(node.children).toEqual([]);
|
|
});
|
|
|
|
it("parses self-closing elements", { timeout: 5000 }, () => {
|
|
const node = parseSvg('<svg><circle cx="50" cy="50" r="40"/></svg>');
|
|
expect(node.children).toHaveLength(1);
|
|
const circle = node.children![0]!;
|
|
expect(circle.type).toBe("element");
|
|
expect(circle.name).toBe("circle");
|
|
expect(circle.attributes).toEqual({ cx: "50", cy: "50", r: "40" });
|
|
expect(circle.children).toEqual([]);
|
|
});
|
|
|
|
it("parses nested elements", { timeout: 5000 }, () => {
|
|
const svg =
|
|
'<svg><g><rect x="0" y="0" width="100" height="100"/></g></svg>';
|
|
const node = parseSvg(svg);
|
|
const g = node.children![0]!;
|
|
expect(g.name).toBe("g");
|
|
const rect = g.children![0]!;
|
|
expect(rect.name).toBe("rect");
|
|
expect(rect.attributes?.x).toBe("0");
|
|
});
|
|
|
|
it("parses text nodes", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg><text>Hello world</text></svg>");
|
|
const text = node.children![0]!;
|
|
expect(text.name).toBe("text");
|
|
const textChild = text.children![0]!;
|
|
expect(textChild.type).toBe("text");
|
|
expect(textChild.value).toBe("Hello world");
|
|
});
|
|
|
|
it("parses XML comments", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg><!-- a comment --></svg>");
|
|
const comment = node.children![0]!;
|
|
expect(comment.type).toBe("comment");
|
|
expect(comment.value).toBe(" a comment ");
|
|
});
|
|
|
|
it("parses CDATA sections", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg><![CDATA[raw content]]></svg>");
|
|
const cdata = node.children![0]!;
|
|
expect(cdata.type).toBe("cdata");
|
|
expect(cdata.value).toBe("raw content");
|
|
});
|
|
|
|
it("parses doctype declarations", { timeout: 5000 }, () => {
|
|
const input =
|
|
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg></svg>';
|
|
const node = parseSvg(input);
|
|
expect(node.type).toBe("element");
|
|
expect(node.name).toBe("svg");
|
|
});
|
|
|
|
it("parses XML declaration", { timeout: 5000 }, () => {
|
|
const node = parseSvg(
|
|
'<?xml version="1.0" encoding="UTF-8"?><svg></svg>'
|
|
);
|
|
expect(node.name).toBe("svg");
|
|
});
|
|
|
|
it("parses attributes with single quotes", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg><rect x='10' y='20'/></svg>");
|
|
const rect = node.children![0]!;
|
|
expect(rect.attributes?.x).toBe("10");
|
|
expect(rect.attributes?.y).toBe("20");
|
|
});
|
|
|
|
it("parses attributes with no value (boolean)", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg><use disabled/></svg>");
|
|
const use = node.children![0]!;
|
|
expect(use.attributes?.disabled).toBe("");
|
|
});
|
|
|
|
it("parses multiple children", { timeout: 5000 }, () => {
|
|
const node = parseSvg(
|
|
"<svg><circle r='5'/><rect width='10'/><path d='M0 0'/></svg>"
|
|
);
|
|
expect(node.children).toHaveLength(3);
|
|
});
|
|
|
|
it("parses whitespace-only text nodes", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg>\n <circle r='5'/>\n</svg>");
|
|
const nonElement = node.children!.filter((c) => c.type !== "element");
|
|
expect(nonElement.every((n) => n.type === "text")).toBe(true);
|
|
});
|
|
|
|
it("handles deeply nested structure", { timeout: 5000 }, () => {
|
|
const svg = "<svg><g><g><g><rect/></g></g></g></svg>";
|
|
const node = parseSvg(svg);
|
|
const inner =
|
|
node.children![0]!.children![0]!.children![0]!.children![0]!;
|
|
expect(inner.name).toBe("rect");
|
|
});
|
|
|
|
it("throws PARSE_FAILED on unclosed tag", { timeout: 5000 }, () => {
|
|
expect(() => parseSvg("<svg><circle>")).toThrow(SvgCompressError);
|
|
try {
|
|
parseSvg("<svg><circle>");
|
|
} catch (e) {
|
|
expect((e as SvgCompressError).code).toBe(
|
|
SVG_COMPRESS_CODES.PARSE_FAILED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("throws PARSE_FAILED on mismatched closing tag", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => parseSvg("<svg><g></h></svg>")).toThrow(SvgCompressError);
|
|
});
|
|
|
|
it("throws PARSE_FAILED on closing tag with empty stack", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => parseSvg("</svg>")).toThrow(SvgCompressError);
|
|
try {
|
|
parseSvg("</svg>");
|
|
} catch (e) {
|
|
expect((e as SvgCompressError).code).toBe(
|
|
SVG_COMPRESS_CODES.PARSE_FAILED
|
|
);
|
|
expect((e as SvgCompressError).message).toContain("none");
|
|
}
|
|
});
|
|
|
|
it("throws PARSE_FAILED on empty input", { timeout: 5000 }, () => {
|
|
expect(() => parseSvg("")).toThrow(SvgCompressError);
|
|
});
|
|
|
|
it("throws PARSE_FAILED on non-SVG input (no root element)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => parseSvg("just text")).toThrow(SvgCompressError);
|
|
});
|
|
|
|
it("parses attributes with namespace prefixes", { timeout: 5000 }, () => {
|
|
const node = parseSvg(
|
|
'<svg xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="#id"/></svg>'
|
|
);
|
|
const use = node.children![0]!;
|
|
expect(use.attributes?.["xlink:href"]).toBe("#id");
|
|
});
|
|
|
|
it("parses attribute values with encoded entities", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const node = parseSvg('<svg title="a & b"></svg>');
|
|
expect(node.attributes?.title).toBe("a & b");
|
|
});
|
|
|
|
it("parses multiple attributes on same element", { timeout: 5000 }, () => {
|
|
const node = parseSvg(
|
|
'<svg width="100" height="200" viewBox="0 0 100 200" xmlns="http://www.w3.org/2000/svg"></svg>'
|
|
);
|
|
expect(node.attributes?.width).toBe("100");
|
|
expect(node.attributes?.height).toBe("200");
|
|
expect(node.attributes?.viewBox).toBe("0 0 100 200");
|
|
});
|
|
|
|
it("throws PARSE_FAILED on unclosed processing instruction", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => parseSvg("<?xml version='1.0'<svg/>")).toThrow(
|
|
SvgCompressError
|
|
);
|
|
});
|
|
|
|
it("throws PARSE_FAILED on unclosed comment", { timeout: 5000 }, () => {
|
|
expect(() => parseSvg("<!-- unclosed comment")).toThrow(
|
|
SvgCompressError
|
|
);
|
|
});
|
|
|
|
it("throws PARSE_FAILED on unclosed CDATA", { timeout: 5000 }, () => {
|
|
expect(() => parseSvg("<svg><![CDATA[unclosed")).toThrow(
|
|
SvgCompressError
|
|
);
|
|
});
|
|
|
|
it("throws PARSE_FAILED on unclosed DOCTYPE", { timeout: 5000 }, () => {
|
|
expect(() => parseSvg("<!DOCTYPE svg unclosed")).toThrow(
|
|
SvgCompressError
|
|
);
|
|
});
|
|
|
|
it("throws PARSE_FAILED on unclosed closing tag", { timeout: 5000 }, () => {
|
|
expect(() => parseSvg("<svg></svg")).toThrow(SvgCompressError);
|
|
try {
|
|
parseSvg("<svg></svg");
|
|
} catch (e) {
|
|
expect((e as SvgCompressError).code).toBe(
|
|
SVG_COMPRESS_CODES.PARSE_FAILED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("skips non-DOCTYPE declarations (e.g. <!ENTITY>)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const node = parseSvg("<!ENTITY foo bar><svg/>");
|
|
expect(node.name).toBe("svg");
|
|
});
|
|
|
|
it("decodes hex character entities (&#xHH;)", { timeout: 5000 }, () => {
|
|
const node = parseSvg('<svg title="AB"/>');
|
|
expect(node.attributes?.title).toBe("AB");
|
|
});
|
|
|
|
it("decodes decimal character entities (&#DD;)", { timeout: 5000 }, () => {
|
|
const node = parseSvg('<svg title="AB"/>');
|
|
expect(node.attributes?.title).toBe("AB");
|
|
});
|
|
});
|
|
|
|
describe("serializeSvg", () => {
|
|
it("serializes a minimal SVG element", { timeout: 5000 }, () => {
|
|
const node = parseSvg('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
|
const result = serializeSvg(node);
|
|
expect(result).toBe('<svg xmlns="http://www.w3.org/2000/svg"/>');
|
|
});
|
|
|
|
it("serializes self-closing elements with children empty", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const node = parseSvg('<svg><circle r="5"/></svg>');
|
|
const result = serializeSvg(node);
|
|
expect(result).toContain('<circle r="5"/>');
|
|
});
|
|
|
|
it("serializes text nodes", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg><text>Hello</text></svg>");
|
|
const result = serializeSvg(node);
|
|
expect(result).toContain("<text>Hello</text>");
|
|
});
|
|
|
|
it("serializes comment nodes", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg><!-- note --></svg>");
|
|
const result = serializeSvg(node);
|
|
expect(result).toContain("<!-- note -->");
|
|
});
|
|
|
|
it("serializes CDATA nodes", { timeout: 5000 }, () => {
|
|
const node = parseSvg("<svg><![CDATA[data]]></svg>");
|
|
const result = serializeSvg(node);
|
|
expect(result).toContain("<![CDATA[data]]>");
|
|
});
|
|
|
|
it("serializes elements with children (non-self-closing)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const node = parseSvg("<svg><g><rect/></g></svg>");
|
|
const result = serializeSvg(node);
|
|
expect(result).toContain("<g><rect/></g>");
|
|
});
|
|
|
|
it("produces compact output (no extra whitespace)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const _node = parseSvg("<svg>\n <rect/>\n</svg>");
|
|
const result = serializeSvg({
|
|
type: "element",
|
|
name: "svg",
|
|
attributes: {},
|
|
children: [
|
|
{ type: "element", name: "rect", attributes: {}, children: [] },
|
|
],
|
|
});
|
|
expect(result).toBe("<svg><rect/></svg>");
|
|
});
|
|
|
|
it("round-trips parse → serialize for complex SVG", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const input =
|
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="layer1"><circle cx="50" cy="50" r="40" fill="red"/><text x="10" y="90">label</text></g></svg>';
|
|
const node = parseSvg(input);
|
|
const output = serializeSvg(node);
|
|
// Re-parse the output and check structure
|
|
const reparsed = parseSvg(output);
|
|
expect(reparsed.name).toBe("svg");
|
|
expect(reparsed.children).toHaveLength(1);
|
|
const g = reparsed.children![0]!;
|
|
expect(g.name).toBe("g");
|
|
expect(g.children).toHaveLength(2);
|
|
});
|
|
|
|
it("serializes a text-type node directly", { timeout: 5000 }, () => {
|
|
const textNode = { type: "text" as const, value: "hello" };
|
|
expect(serializeSvg(textNode)).toBe("hello");
|
|
});
|
|
|
|
it("serializes a doctype node", { timeout: 5000 }, () => {
|
|
const doctype = {
|
|
type: "doctype" as const,
|
|
value: 'svg PUBLIC "-//W3C//DTD SVG 1.1//EN"',
|
|
};
|
|
expect(serializeSvg(doctype)).toBe(
|
|
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN">'
|
|
);
|
|
});
|
|
|
|
it("omits undefined attributes", { timeout: 5000 }, () => {
|
|
const node = serializeSvg({
|
|
type: "element",
|
|
name: "svg",
|
|
children: [],
|
|
});
|
|
expect(node).toBe("<svg/>");
|
|
});
|
|
});
|