Initial Commit
This commit is contained in:
327
packages/devtools/docs/compress-svg/src/parser/svg/svg.test.ts
Normal file
327
packages/devtools/docs/compress-svg/src/parser/svg/svg.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
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/>");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user