Files
svg-tools/tests/project/project-format.test.ts

166 lines
5.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
PROJECT_FORMAT,
PROJECT_SCHEMA_VERSION,
ProjectFormatError,
createProject,
parseProject,
readProject,
serializeProject,
validateProject,
} from "../../src/project/project-format";
function project(
source = '<svg xmlns="http://www.w3.org/2000/svg">\r\n <path d="M0 0"/>\r\n</svg>',
) {
return createProject({
appVersion: "0.1.0",
document: { source },
ui: {
selectedNodeKey: "id:path",
expandedNodeKeys: ["id:root"],
activePanel: "path",
zoom: 1.25,
pan: { x: 12, y: -4 },
showGrid: true,
sourceSelection: { anchor: 1, head: 4 },
},
animations: [
{
id: "fade",
name: "Fade",
targetNodeKey: "id:path",
property: "opacity",
kind: "style",
enabled: true,
keyframes: [
{ offset: 0, value: "0" },
{ offset: 1, value: "1", easing: "linear" },
],
timing: {
durationMs: 750,
delayMs: 0,
iterations: 1,
direction: "normal",
fillMode: "both",
easing: "ease-in-out",
},
},
],
metadata: {
title: "Exact source",
originalFileName: "drawing.svg",
createdAt: "2026-07-31T00:00:00.000Z",
updatedAt: "2026-07-31T00:00:00.000Z",
},
});
}
describe("SVG Tools project format", () => {
it("round-trips canonical source byte-for-byte, including temporarily invalid XML", () => {
const source =
'<svg xmlns="http://www.w3.org/2000/svg">\r\n <g data-x="&amp;">\r\n';
const original = project(source);
const restored = parseProject(serializeProject(original));
expect(restored.document.source).toBe(source);
expect(restored).toEqual(original);
});
it("serializes deterministically with the versioned identity", () => {
const value = project();
const first = serializeProject(value);
expect(serializeProject(value)).toBe(first);
expect(first.endsWith("\n")).toBe(true);
expect(JSON.parse(first)).toMatchObject({
format: PROJECT_FORMAT,
schemaVersion: PROJECT_SCHEMA_VERSION,
appVersion: "0.1.0",
});
});
it("accepts a UTF-8 BOM and rejects incompatible schemas", () => {
const serialized = serializeProject(project());
expect(parseProject(`\ufeff${serialized}`).document.source).toContain(
"<svg",
);
const unsupported = JSON.parse(serialized) as Record<string, unknown>;
unsupported.schemaVersion = 999;
expect(() => parseProject(JSON.stringify(unsupported))).toThrowError(
expect.objectContaining({ code: "UNSUPPORTED_SCHEMA_VERSION" }),
);
});
it("rejects invalid projects instead of silently repairing them", () => {
const invalid = JSON.parse(serializeProject(project())) as {
animations: Array<{ keyframes: Array<{ offset: number }> }>;
ui: { expandedNodeKeys: string[] };
};
invalid.animations[0]!.keyframes[0]!.offset = 1;
invalid.animations[0]!.keyframes[1]!.offset = 0;
expect(() => parseProject(JSON.stringify(invalid))).toThrowError(
ProjectFormatError,
);
invalid.animations[0]!.keyframes[0]!.offset = 0;
invalid.animations[0]!.keyframes[1]!.offset = 1;
invalid.ui.expandedNodeKeys = ["same", "same"];
expect(() => parseProject(JSON.stringify(invalid))).toThrow(
/Duplicate values/u,
);
expect(() => parseProject("not json")).toThrowError(
expect.objectContaining({ code: "INVALID_JSON" }),
);
});
it("applies the animation CSS policy to imported project definitions", () => {
type ImportedProject = {
animations: Array<{
property: string;
keyframes: Array<{ offset: number; value: string; easing?: string }>;
timing: {
durationMs: number;
delayMs: number;
iterations: number | "infinite";
easing: string;
};
}>;
};
const original = JSON.parse(serializeProject(project())) as ImportedProject;
const expectInvalid = (mutate: (input: ImportedProject) => void) => {
const input = structuredClone(original);
mutate(input);
expect(() => validateProject(input)).toThrowError(
expect.objectContaining({ code: "INVALID_PROJECT" }),
);
};
expectInvalid((input) => {
input.animations[0]!.property = "opacity; stroke: red";
});
expectInvalid((input) => {
input.animations[0]!.keyframes[0]!.value =
"0</style><script>alert(1)</script>";
});
expectInvalid((input) => {
input.animations[0]!.timing.easing = "linear; stroke: red";
});
expectInvalid((input) => {
input.animations[0]!.keyframes[0]!.offset = Number.NaN;
});
expectInvalid((input) => {
input.animations[0]!.timing.durationMs = Number.POSITIVE_INFINITY;
});
expectInvalid((input) => {
input.animations[0]!.timing.iterations = Number.POSITIVE_INFINITY;
});
});
it("rejects invalid UTF-8 project bytes", async () => {
await expect(
readProject(new Blob([Uint8Array.of(0xc3, 0x28)])),
).rejects.toMatchObject({
code: "INVALID_UTF8",
});
});
});