import { afterEach, expect, it, vi } from "vitest"; import type { DirectorProject } from "../schema/directorProject"; import { DIRECTOR_PROJECT_FILE_KIND, blobToDataUrl, buildProjectFileText, dataUrlToBlob, deriveProjectNameFromFileName, downloadTextFile, isPlausibleDirectorProject, parseProjectFile, sanitizeProjectFileName, } from "./projectFile"; afterEach(() => { vi.restoreAllMocks(); }); function createSampleProject(): DirectorProject { return { version: 2, scene: { backgroundColor: "#000000" }, assets: [], objects: [], cameras: [], } as unknown as DirectorProject; } it("wraps the project in an identifiable envelope", () => { const text = buildProjectFileText(createSampleProject(), "我的工程", new Date("2026-01-02T03:04:05.000Z")); const parsed = JSON.parse(text); expect(parsed.kind).toBe(DIRECTOR_PROJECT_FILE_KIND); expect(parsed.name).toBe("我的工程"); expect(parsed.savedAt).toBe("2026-01-02T03:04:05.000Z"); expect(parsed.project.scene.backgroundColor).toBe("#000000"); }); it("round-trips a project file", () => { const text = buildProjectFileText(createSampleProject(), "我的工程"); const parsed = parseProjectFile(text); expect(parsed?.name).toBe("我的工程"); expect(parsed?.project.scene.backgroundColor).toBe("#000000"); }); it("still accepts the bare project JSON exported by older versions", () => { const parsed = parseProjectFile(JSON.stringify(createSampleProject())); expect(parsed?.name).toBeNull(); expect(parsed?.project.version).toBe(2); }); it("rejects files that are not projects", () => { expect(parseProjectFile("not json at all")).toBeNull(); expect(parseProjectFile(JSON.stringify({ hello: "world" }))).toBeNull(); expect(parseProjectFile(JSON.stringify({ kind: DIRECTOR_PROJECT_FILE_KIND, project: { version: 2 } }))).toBeNull(); }); it("recognises plausible projects", () => { expect(isPlausibleDirectorProject(createSampleProject())).toBe(true); expect(isPlausibleDirectorProject(null)).toBe(false); expect(isPlausibleDirectorProject({ version: 9, assets: [], objects: [], cameras: [] })).toBe(false); }); it("sanitises project names for use as file names", () => { expect(sanitizeProjectFileName("我的/工程:第一幕?")).toBe("我的_工程_第一幕_"); expect(sanitizeProjectFileName(" ")).toBe("未命名工程"); expect(sanitizeProjectFileName("x".repeat(200)).length).toBe(80); }); it("derives a project name from the imported file name", () => { expect(deriveProjectNameFromFileName("我的工程.json")).toBe("我的工程"); expect(deriveProjectNameFromFileName("storyai-director-片段.json")).toBe("片段"); expect(deriveProjectNameFromFileName(".json")).toBe("未命名工程"); }); it("downloads text through a real anchor click", () => { const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); const createObjectUrlSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock"); downloadTextFile("工程.json", '{"a":1}'); expect(createObjectUrlSpy).toHaveBeenCalledTimes(1); expect(clickSpy).toHaveBeenCalledTimes(1); }); it("converts between data URLs and blobs", async () => { const original = new Blob([new Uint8Array([1, 2, 3, 250])], { type: "application/octet-stream" }); const dataUrl = await blobToDataUrl(original); expect(dataUrl?.startsWith("data:application/octet-stream;base64,")).toBe(true); const restored = dataUrlToBlob(dataUrl as string); expect(restored).not.toBeNull(); expect(new Uint8Array(await (restored as Blob).arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 250])); }); it("returns null for malformed data URLs", () => { expect(dataUrlToBlob("https://example.com/a.png")).toBeNull(); });