Files
ttstd-3d-director/src/editor/io/projectFile.test.ts

103 lines
3.7 KiB
TypeScript

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();
});