feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user