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 = '\r\n \r\n', ) { 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 = '\r\n \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( "; 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"; }); 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", }); }); });