import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, describe, expect, it } from "vitest" import type { BlockNode, DataSetDocument, FragmentDocument, InlineNode, TemplateDocument } from "../src/core/types" import { nodeProjectFileSystem } from "../src/project/nodeFileSystem" import { getProjectPaths, joinPath, normalizePath, toRelativePath } from "../src/project/paths" import { createProject, openProject, readDocument, scanProject, writeDocument } from "../src/project" const tempRoots: string[] = [] afterEach(async () => { await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))) tempRoots.length = 0 }) describe("project folder system", () => { it("normalizes project paths without Node path helpers", () => { expect(normalizePath("D:\\Projects\\BlockFlow\\templates\\..\\fragments")).toBe("D:/Projects/BlockFlow/fragments") expect(joinPath("D:\\Projects\\BlockFlow", "templates", "main.json")).toBe("D:/Projects/BlockFlow/templates/main.json") expect(toRelativePath("D:\\Projects\\BlockFlow", "D:\\Projects\\BlockFlow\\templates\\main.json")).toBe("templates/main.json") expect(getProjectPaths("D:\\Projects\\BlockFlow").fragmentsDir).toBe("D:/Projects/BlockFlow/fragments") }) it("creates a minimal project folder", async () => { const root = await makeTempRoot() const result = await createProject(root, { id: "project_test", name: "Test Project", entryTemplateId: "template_main", includeExportsDir: true, fs: nodeProjectFileSystem, now: () => new Date("2026-05-24T00:00:00.000Z") }) await expectPathExists(path.join(root, "project.json")) await expectPathExists(path.join(root, "templates")) await expectPathExists(path.join(root, "fragments")) await expectPathExists(path.join(root, "datasets")) await expectPathExists(path.join(root, "exports")) expect(result.project.id).toBe("project_test") const projectJson = JSON.parse(await fs.readFile(path.join(root, "project.json"), "utf8")) as { paths?: unknown entryTemplateId?: string } expect(projectJson.paths).toBeUndefined() expect(projectJson.entryTemplateId).toBe("template_main") }) it("opens templates, fragments, datasets, and resource tree entries", async () => { const root = await makeProjectWithResources() const snapshot = await openProject(root, { fs: nodeProjectFileSystem }) expect(snapshot.project.entryTemplateId).toBe("template_main") expect(Object.keys(snapshot.templates)).toEqual(["template_main"]) expect(Object.keys(snapshot.fragments)).toEqual(["fragment_footer"]) expect(Object.keys(snapshot.datasets)).toEqual(["dataset_default"]) expect(snapshot.templates.template_main?.filePath).toContain("templates/main.json") expect(snapshot.datasets.dataset_default?.data).toEqual({ user: { name: "张三" } }) const templateFile = findResource(snapshot.resourceTree.children, "templates/main.json") expect(templateFile).toMatchObject({ type: "file", resourceKind: "template", documentId: "template_main" }) }) it("detects missing fragment references", async () => { const root = await makeProjectWithResources({ templateChildren: [{ id: "missing_ref", type: "fragmentRef", fragmentId: "missing" }] }) const snapshot = await openProject(root, { fs: nodeProjectFileSystem }) expect(snapshot.referenceIndex.missingFragments).toContainEqual( expect.objectContaining({ fromId: "template_main", fragmentId: "missing" }) ) expect(snapshot.diagnostics).toContainEqual( expect.objectContaining({ code: "MISSING_FRAGMENT", fragmentId: "missing" }) ) }) it("skips invalid resource JSON and returns diagnostics", async () => { const root = await makeProjectWithResources() await fs.writeFile(path.join(root, "templates", "broken.json"), "{ nope", "utf8") const snapshot = await openProject(root, { fs: nodeProjectFileSystem }) expect(snapshot.templates.template_main).toBeDefined() expect(snapshot.templates.broken).toBeUndefined() expect(snapshot.diagnostics).toContainEqual( expect.objectContaining({ code: "INVALID_JSON" }) ) }) it("detects duplicate document ids across scanned files", async () => { const root = await makeProjectWithResources() await writeJson(path.join(root, "fragments", "duplicate.json"), { kind: "fragment", id: "template_main", name: "Duplicate", children: [] }) const snapshot = await openProject(root, { fs: nodeProjectFileSystem }) expect(snapshot.diagnostics).toContainEqual( expect.objectContaining({ code: "DUPLICATE_DOCUMENT_ID", documentId: "template_main" }) ) }) it("writes documents without persisting runtime filePath", async () => { const root = await makeProjectWithResources() const filePath = path.join(root, "templates", "main.json") const document = await readDocument(filePath, { fs: nodeProjectFileSystem }) as TemplateDocument document.children = [paragraph([{ type: "text", content: "更新" }])] const writtenPath = await writeDocument(root, document, { fs: nodeProjectFileSystem }) expect(writtenPath).toBe(filePath) const saved = JSON.parse(await fs.readFile(filePath, "utf8")) as { filePath?: unknown } expect(saved.filePath).toBeUndefined() const reopened = await openProject(root, { fs: nodeProjectFileSystem }) expect(reopened.templates.template_main?.children).toEqual(document.children) }) it("writes new fragments to the fragments directory", async () => { const root = await makeProjectWithResources() const fragment: FragmentDocument = { kind: "fragment", id: "fragment_new", name: "New Fragment", children: [paragraph([{ type: "text", content: "New body" }])] } const writtenPath = await writeDocument(root, fragment, { fs: nodeProjectFileSystem }) expect(writtenPath).toContain("fragments/fragment_new.json") const saved = JSON.parse(await fs.readFile(writtenPath, "utf8")) as { filePath?: unknown } expect(saved.filePath).toBeUndefined() const reopened = await openProject(root, { fs: nodeProjectFileSystem }) expect(reopened.fragments.fragment_new?.children).toEqual(fragment.children) }) it("scans nested json resource files", async () => { const root = await makeProjectWithResources() await fs.mkdir(path.join(root, "templates", "nested"), { recursive: true }) await writeJson(path.join(root, "templates", "nested", "extra.json"), { kind: "template", id: "template_extra", name: "Extra", children: [] }) const scanned = await scanProject(root, { fs: nodeProjectFileSystem }) expect(scanned.resourceFiles).toContainEqual( expect.objectContaining({ relativePath: "templates/nested/extra.json", resourceKind: "template" }) ) }) }) async function makeProjectWithResources(options: { templateChildren?: BlockNode[] } = {}): Promise { const root = await makeTempRoot() await createProject(root, { entryTemplateId: "template_main", fs: nodeProjectFileSystem, now: () => new Date("2026-05-24T00:00:00.000Z") }) await writeJson(path.join(root, "templates", "main.json"), { kind: "template", id: "template_main", name: "Main", children: options.templateChildren ?? [ paragraph([{ type: "text", content: "正文" }]), { id: "ref_footer", type: "fragmentRef", fragmentId: "fragment_footer" } ] }) await writeJson(path.join(root, "fragments", "footer.json"), { kind: "fragment", id: "fragment_footer", name: "Footer", children: [paragraph([{ type: "text", content: "以上。" }])] }) await writeJson(path.join(root, "datasets", "default.json"), { kind: "dataset", id: "dataset_default", name: "Default", data: { user: { name: "张三" } } } satisfies DataSetDocument) return root } function paragraph(inlines: InlineNode[]): BlockNode { return { id: `p_${Math.random().toString(36).slice(2)}`, type: "paragraph", inlines } } async function makeTempRoot(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "blockflow-project-")) tempRoots.push(root) return root } async function writeJson(filePath: string, value: unknown): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8") } async function expectPathExists(filePath: string): Promise { await expect(fs.stat(filePath)).resolves.toBeDefined() } function findResource( nodes: Array<{ type: string; relativePath: string; children?: unknown[] }>, relativePath: string ): unknown { for (const node of nodes) { if (node.relativePath === relativePath) { return node } if (Array.isArray(node.children)) { const found = findResource( node.children as Array<{ type: string; relativePath: string; children?: unknown[] }>, relativePath ) if (found !== undefined) { return found } } } return undefined }