建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。 补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
257 lines
9.1 KiB
TypeScript
257 lines
9.1 KiB
TypeScript
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<string> {
|
|
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<string> {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "blockflow-project-"))
|
|
tempRoots.push(root)
|
|
return root
|
|
}
|
|
|
|
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
|
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<void> {
|
|
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
|
|
}
|