166 lines
5.1 KiB
TypeScript
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="&">\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",
|
|
});
|
|
});
|
|
});
|