chore: 初始化 BlockFlow Workbench 仓库
建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。 补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
This commit is contained in:
265
tests/app.test.tsx
Normal file
265
tests/app.test.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
||||
import { App } from "../src/app/App"
|
||||
import {
|
||||
defaultDatasetId,
|
||||
defaultDocumentId,
|
||||
defaultResourcePath,
|
||||
getCurrentDocument,
|
||||
getCurrentRenderResult,
|
||||
setProjectPersistenceForTests,
|
||||
useWorkbenchStore
|
||||
} from "../src/app/store/workbenchStore"
|
||||
|
||||
describe("workbench UI shell", () => {
|
||||
beforeEach(() => {
|
||||
setProjectPersistenceForTests(null)
|
||||
useWorkbenchStore.getState().resetDemo()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it("starts with the demo entry template and dataset selected", () => {
|
||||
const state = useWorkbenchStore.getState()
|
||||
|
||||
expect(state.selectedDocumentId).toBe(defaultDocumentId)
|
||||
expect(state.currentDatasetId).toBe(defaultDatasetId)
|
||||
expect(state.selectedResourcePath).toBe(defaultResourcePath)
|
||||
})
|
||||
|
||||
it("renders the resource tree from the project snapshot", () => {
|
||||
render(<App />)
|
||||
|
||||
expect(screen.getByTestId("resource-panel")).toHaveTextContent("main.json")
|
||||
expect(screen.getByTestId("resource-panel")).toHaveTextContent("common-footer.json")
|
||||
expect(screen.getByTestId("resource-panel")).toHaveTextContent("default.json")
|
||||
})
|
||||
|
||||
it("switches inspector tabs", () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "预览" }))
|
||||
expect(useWorkbenchStore.getState().rightPanelTab).toBe("preview")
|
||||
expect(screen.getByTestId("preview-tab")).toHaveTextContent("张三")
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "引用" }))
|
||||
expect(useWorkbenchStore.getState().rightPanelTab).toBe("references")
|
||||
expect(screen.getByTestId("references-tab")).toHaveTextContent("missing fragment_signature")
|
||||
})
|
||||
|
||||
it("generates preview output from the existing renderer", () => {
|
||||
const state = useWorkbenchStore.getState()
|
||||
const result = getCurrentRenderResult(state)
|
||||
|
||||
expect(result.output).toContain("张三")
|
||||
expect(result.output).toContain("- 产品 A: 99")
|
||||
expect(result.errors).toContainEqual(
|
||||
expect.objectContaining({ code: "MISSING_FRAGMENT", fragmentId: "fragment_signature" })
|
||||
)
|
||||
})
|
||||
|
||||
it("shows missing reference status", () => {
|
||||
render(<App />)
|
||||
|
||||
expect(screen.getByText("missing refs: 1")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("opens a project through the persistence adapter", async () => {
|
||||
const diskSnapshot = {
|
||||
...useWorkbenchStore.getState().snapshot,
|
||||
project: {
|
||||
...useWorkbenchStore.getState().snapshot.project,
|
||||
name: "Disk Project",
|
||||
paths: {
|
||||
...useWorkbenchStore.getState().snapshot.project.paths,
|
||||
root: "D:/DiskProject"
|
||||
}
|
||||
}
|
||||
}
|
||||
setProjectPersistenceForTests({
|
||||
isAvailable: () => true,
|
||||
openProjectFromDisk: async () => diskSnapshot,
|
||||
saveProjectChanges: async () => diskSnapshot
|
||||
})
|
||||
useWorkbenchStore.getState().resetDemo()
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open project" }))
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useWorkbenchStore.getState()
|
||||
expect(state.projectOpenStatus).toBe("open")
|
||||
expect(state.snapshot.project.name).toBe("Disk Project")
|
||||
expect(state.dirtyDocumentIds).toEqual([])
|
||||
})
|
||||
expect(screen.getByText("Disk Project")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("creates a fragment from the resource panel and selects it", () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
|
||||
|
||||
const state = useWorkbenchStore.getState()
|
||||
expect(state.snapshot.fragments.fragment_2).toMatchObject({
|
||||
kind: "fragment",
|
||||
name: "Fragment 2"
|
||||
})
|
||||
expect(state.selectedDocumentId).toBe("fragment_2")
|
||||
expect(state.selectedResourcePath).toBe("fragments/fragment-2.json")
|
||||
expect(state.saveStatus).toBe("dirty")
|
||||
expect(screen.getByTestId("resource-panel")).toHaveTextContent("fragment-2.json")
|
||||
})
|
||||
|
||||
it("saves dirty fragment changes through the persistence adapter", async () => {
|
||||
const savedDirtyIds: string[][] = []
|
||||
setProjectPersistenceForTests({
|
||||
isAvailable: () => true,
|
||||
openProjectFromDisk: async () => useWorkbenchStore.getState().snapshot,
|
||||
saveProjectChanges: async (snapshot, dirtyDocumentIds) => {
|
||||
savedDirtyIds.push(dirtyDocumentIds)
|
||||
return snapshot
|
||||
}
|
||||
})
|
||||
useWorkbenchStore.getState().resetDemo()
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save project" }))
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useWorkbenchStore.getState()
|
||||
expect(savedDirtyIds).toEqual([["fragment_2"]])
|
||||
expect(state.saveStatus).toBe("saved")
|
||||
expect(state.dirtyDocumentIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it("shows persistence save errors", async () => {
|
||||
setProjectPersistenceForTests({
|
||||
isAvailable: () => true,
|
||||
openProjectFromDisk: async () => useWorkbenchStore.getState().snapshot,
|
||||
saveProjectChanges: async () => {
|
||||
throw new Error("disk full")
|
||||
}
|
||||
})
|
||||
useWorkbenchStore.getState().resetDemo()
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create fragment" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save project" }))
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useWorkbenchStore.getState()
|
||||
expect(state.saveStatus).toBe("error")
|
||||
expect(state.lastSaveError).toBe("disk full")
|
||||
})
|
||||
expect(screen.getByText("disk full")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("opens a referenced fragment from the references panel", () => {
|
||||
useWorkbenchStore.getState().setRightPanelTab("references")
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open fragment_common_footer" }))
|
||||
|
||||
const state = useWorkbenchStore.getState()
|
||||
expect(state.selectedDocumentId).toBe("fragment_common_footer")
|
||||
expect(state.selectedResourcePath).toBe("fragments/common-footer.json")
|
||||
expect(getCurrentDocument(state).kind).toBe("fragment")
|
||||
})
|
||||
|
||||
it("expands the selected fragment reference into editable blocks", async () => {
|
||||
useWorkbenchStore.getState().setRightPanelTab("references")
|
||||
useWorkbenchStore.getState().setSelectedBlockId("block_footer")
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Expand ref" }))
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useWorkbenchStore.getState()
|
||||
const outgoing = state.snapshot.referenceIndex.outgoing.template_main?.map((item) => item.toFragmentId) ?? []
|
||||
expect(outgoing).not.toContain("fragment_common_footer")
|
||||
expect(JSON.stringify(state.snapshot.templates.template_main?.children)).not.toContain("block_footer")
|
||||
expect(state.saveStatus).toBe("dirty")
|
||||
expect(screen.getByTestId("blockflow-editor")).not.toHaveTextContent("fragment_common_footer")
|
||||
})
|
||||
})
|
||||
|
||||
it("updates the current dataset from valid JSON and refreshes preview data", async () => {
|
||||
useWorkbenchStore.getState().setRightPanelTab("data")
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Dataset JSON"), {
|
||||
target: {
|
||||
value: JSON.stringify(
|
||||
{
|
||||
user: { name: "Ada", isVip: false },
|
||||
items: []
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useWorkbenchStore.getState()
|
||||
expect(state.dataEditorStatus).toBe("valid")
|
||||
expect(getCurrentRenderResult(state).output).toContain("Ada")
|
||||
expect(state.saveStatus).toBe("dirty")
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps the last valid dataset when JSON editing is invalid", async () => {
|
||||
useWorkbenchStore.getState().setRightPanelTab("data")
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Dataset JSON"), {
|
||||
target: { value: "{" }
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useWorkbenchStore.getState()
|
||||
expect(state.dataEditorStatus).toBe("invalid")
|
||||
expect(state.snapshot.datasets[state.currentDatasetId]?.data.user).toEqual(
|
||||
expect.objectContaining({ name: "张三" })
|
||||
)
|
||||
})
|
||||
expect(screen.getByTestId("data-tab")).toHaveTextContent("Expected")
|
||||
})
|
||||
|
||||
it("renders the selected block preview", () => {
|
||||
useWorkbenchStore.getState().setRightPanelTab("preview")
|
||||
useWorkbenchStore.getState().setSelectedBlockId("block_hello")
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "当前块" }))
|
||||
|
||||
const expected = getCurrentRenderResult(useWorkbenchStore.getState(), { targetBlockId: "block_hello" }).output
|
||||
expect(screen.getByTestId("preview-tab")).toHaveTextContent("block_hello")
|
||||
expect(screen.getByTestId("preview-output").textContent).toBe(expected)
|
||||
})
|
||||
|
||||
it("shows missing variable details in preview", () => {
|
||||
useWorkbenchStore.getState().setCurrentDataset("dataset_empty")
|
||||
useWorkbenchStore.getState().setRightPanelTab("preview")
|
||||
render(<App />)
|
||||
|
||||
expect(screen.getByTestId("preview-tab")).toHaveTextContent("user.name")
|
||||
})
|
||||
|
||||
it("groups render errors, warnings, and logs in the debug tab", () => {
|
||||
useWorkbenchStore.getState().setRightPanelTab("debug")
|
||||
render(<App />)
|
||||
|
||||
expect(screen.getByTestId("debug-tab")).toHaveTextContent("Errors")
|
||||
expect(screen.getByTestId("debug-tab")).toHaveTextContent("Warnings")
|
||||
expect(screen.getByTestId("debug-tab")).toHaveTextContent("condition user.isVip")
|
||||
expect(screen.getByTestId("debug-tab")).toHaveTextContent("loop items")
|
||||
expect(screen.getByTestId("debug-tab")).toHaveTextContent("fragment fragment_common_footer")
|
||||
})
|
||||
})
|
||||
62
tests/editor.test.tsx
Normal file
62
tests/editor.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import "@testing-library/jest-dom/vitest"
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
||||
import { App } from "../src/app/App"
|
||||
import { getCurrentRenderResult, useWorkbenchStore } from "../src/app/store/workbenchStore"
|
||||
|
||||
describe("BlockFlowEditor", () => {
|
||||
beforeEach(() => {
|
||||
useWorkbenchStore.getState().resetDemo()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it("renders the demo template in the TipTap editor", () => {
|
||||
render(<App />)
|
||||
|
||||
expect(screen.getByTestId("blockflow-editor")).toHaveTextContent("user.name")
|
||||
expect(screen.getByTestId("blockflow-editor")).toHaveTextContent("fragment_common_footer")
|
||||
})
|
||||
|
||||
it("inserts a variable through the command menu and updates AST", async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "打开命令菜单" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "变量 user.email" }))
|
||||
|
||||
await waitFor(() => {
|
||||
const blocks = useWorkbenchStore.getState().snapshot.templates.template_main?.children ?? []
|
||||
expect(JSON.stringify(blocks)).toContain("user.email")
|
||||
expect(useWorkbenchStore.getState().saveStatus).toBe("dirty")
|
||||
})
|
||||
})
|
||||
|
||||
it("inserts condition and loop blocks through the command menu", async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "打开命令菜单" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "条件 if user.isVip" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "打开命令菜单" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "循环 for item in items" }))
|
||||
|
||||
await waitFor(() => {
|
||||
const blocks = useWorkbenchStore.getState().snapshot.templates.template_main?.children ?? []
|
||||
expect(JSON.stringify(blocks)).toContain("\"type\":\"condition\"")
|
||||
expect(JSON.stringify(blocks)).toContain("\"type\":\"loop\"")
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps render preview connected to the AST after editing", async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "打开命令菜单" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "变量 user.email" }))
|
||||
|
||||
await waitFor(() => {
|
||||
const result = getCurrentRenderResult(useWorkbenchStore.getState())
|
||||
expect(result.stats.missingVariables).toContain("user.email")
|
||||
})
|
||||
})
|
||||
})
|
||||
87
tests/editorAdapter.test.ts
Normal file
87
tests/editorAdapter.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { astToTiptap, tiptapToAst } from "../src/editor/adapter"
|
||||
import type { BlockNode } from "../src/core/types"
|
||||
|
||||
describe("BlockFlow TipTap adapter", () => {
|
||||
it("round-trips paragraphs, text, and variables", () => {
|
||||
const blocks: BlockNode[] = [
|
||||
{
|
||||
id: "p1",
|
||||
type: "paragraph",
|
||||
inlines: [
|
||||
{ type: "text", content: "你好," },
|
||||
{ type: "variable", path: "user.name", required: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(tiptapToAst(astToTiptap(blocks))).toEqual(blocks)
|
||||
})
|
||||
|
||||
it("round-trips structured blocks and atom blocks", () => {
|
||||
const blocks: BlockNode[] = [
|
||||
{
|
||||
id: "container1",
|
||||
type: "container",
|
||||
name: "用户通知",
|
||||
children: [
|
||||
{
|
||||
id: "if1",
|
||||
type: "condition",
|
||||
expression: "user.isVip",
|
||||
children: [
|
||||
{
|
||||
id: "loop1",
|
||||
type: "loop",
|
||||
source: "items",
|
||||
itemName: "item",
|
||||
indexName: "index",
|
||||
children: [
|
||||
{
|
||||
id: "p1",
|
||||
type: "paragraph",
|
||||
inlines: [{ type: "variable", path: "item.title" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "frag1",
|
||||
type: "fragmentRef",
|
||||
fragmentId: "fragment_common_footer"
|
||||
},
|
||||
{
|
||||
id: "comment1",
|
||||
type: "comment",
|
||||
content: "备注"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(tiptapToAst(astToTiptap(blocks))).toEqual(blocks)
|
||||
})
|
||||
|
||||
it("does not leak TipTap document wrapper into BlockFlow AST", () => {
|
||||
const ast = tiptapToAst({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { id: "p1" },
|
||||
content: [{ type: "text", text: "plain" }]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(ast).toEqual([
|
||||
{
|
||||
id: "p1",
|
||||
type: "paragraph",
|
||||
inlines: [{ type: "text", content: "plain" }]
|
||||
}
|
||||
])
|
||||
expect(JSON.stringify(ast)).not.toContain("\"doc\"")
|
||||
})
|
||||
})
|
||||
79
tests/expression.test.ts
Normal file
79
tests/expression.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { evaluateExpression, resolvePath } from "../src/core/expression"
|
||||
import type { ScopeFrame } from "../src/core/expression"
|
||||
|
||||
const scopes: ScopeFrame[] = [
|
||||
{
|
||||
type: "data",
|
||||
values: {
|
||||
user: {
|
||||
name: "张三",
|
||||
level: 4,
|
||||
type: "developer",
|
||||
isVip: true
|
||||
},
|
||||
tags: ["important", "draft"],
|
||||
items: [
|
||||
{ title: "A", price: 1 },
|
||||
{ title: "B", price: 2 }
|
||||
],
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
describe("expression evaluator", () => {
|
||||
it("resolves nested paths and array access", () => {
|
||||
expect(resolvePath("user.name", scopes)).toMatchObject({
|
||||
found: true,
|
||||
value: "张三"
|
||||
})
|
||||
expect(resolvePath("items[0].title", scopes)).toMatchObject({
|
||||
found: true,
|
||||
value: "A"
|
||||
})
|
||||
})
|
||||
|
||||
it("evaluates comparisons", () => {
|
||||
expect(evaluateExpression("user.level >= 3", scopes)).toMatchObject({
|
||||
ok: true,
|
||||
value: true
|
||||
})
|
||||
expect(evaluateExpression("user.type == \"developer\"", scopes)).toMatchObject({
|
||||
ok: true,
|
||||
value: true
|
||||
})
|
||||
})
|
||||
|
||||
it("evaluates logical operators", () => {
|
||||
expect(evaluateExpression("user.isVip && user.level >= 3", scopes)).toMatchObject({
|
||||
ok: true,
|
||||
value: true
|
||||
})
|
||||
expect(evaluateExpression("!disabled", scopes)).toMatchObject({
|
||||
ok: true,
|
||||
value: true
|
||||
})
|
||||
})
|
||||
|
||||
it("evaluates contains", () => {
|
||||
expect(evaluateExpression("tags contains \"important\"", scopes)).toMatchObject({
|
||||
ok: true,
|
||||
value: true
|
||||
})
|
||||
})
|
||||
|
||||
it("evaluates length access", () => {
|
||||
expect(evaluateExpression("items.length > 0", scopes)).toMatchObject({
|
||||
ok: true,
|
||||
value: true
|
||||
})
|
||||
})
|
||||
|
||||
it("evaluates nullish fallback", () => {
|
||||
expect(evaluateExpression("missing.name ?? \"未命名\"", scopes)).toMatchObject({
|
||||
ok: true,
|
||||
value: "未命名"
|
||||
})
|
||||
})
|
||||
})
|
||||
256
tests/project.test.ts
Normal file
256
tests/project.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
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
|
||||
}
|
||||
374
tests/renderTemplate.test.ts
Normal file
374
tests/renderTemplate.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { buildReferenceIndex, renderTemplate } from "../src/core"
|
||||
import type {
|
||||
BlockNode,
|
||||
FragmentDocument,
|
||||
InlineNode,
|
||||
TemplateDocument
|
||||
} from "../src/core/types"
|
||||
|
||||
function template(children: BlockNode[]): TemplateDocument {
|
||||
return {
|
||||
kind: "template",
|
||||
id: "template_main",
|
||||
name: "Main",
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
function fragment(id: string, children: BlockNode[]): FragmentDocument {
|
||||
return {
|
||||
kind: "fragment",
|
||||
id,
|
||||
name: id,
|
||||
children
|
||||
}
|
||||
}
|
||||
|
||||
function paragraph(inlines: InlineNode[], id = "p1"): BlockNode {
|
||||
return {
|
||||
id,
|
||||
type: "paragraph",
|
||||
inlines
|
||||
}
|
||||
}
|
||||
|
||||
describe("renderTemplate", () => {
|
||||
it("replaces variables", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
paragraph([
|
||||
{ type: "text", content: "你好," },
|
||||
{ type: "variable", path: "user.name" }
|
||||
])
|
||||
]),
|
||||
fragments: {},
|
||||
data: { user: { name: "张三" } }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("你好,张三")
|
||||
expect(result.stats.usedVariables).toEqual(["user.name"])
|
||||
})
|
||||
|
||||
it("keeps missing variables as placeholders in preview mode", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
paragraph([
|
||||
{ type: "text", content: "你好," },
|
||||
{ type: "variable", path: "user.name" }
|
||||
])
|
||||
]),
|
||||
fragments: {},
|
||||
data: { user: {} }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("你好,{user.name}")
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.objectContaining({ code: "MISSING_VARIABLE", path: "user.name" })
|
||||
)
|
||||
})
|
||||
|
||||
it("fails missing variables in export mode by default", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
paragraph([
|
||||
{ type: "text", content: "你好," },
|
||||
{ type: "variable", path: "user.name" }
|
||||
])
|
||||
]),
|
||||
fragments: {},
|
||||
data: { user: {} },
|
||||
options: { mode: "export" }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.errors).toContainEqual(
|
||||
expect.objectContaining({ code: "MISSING_VARIABLE_EXPORT", path: "user.name" })
|
||||
)
|
||||
})
|
||||
|
||||
it("uses expression fallback with nullish coalescing", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
paragraph([
|
||||
{ type: "text", content: "你好," },
|
||||
{ type: "expression", expression: "user.name ?? \"未命名\"" }
|
||||
])
|
||||
]),
|
||||
fragments: {},
|
||||
data: { user: {} }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("你好,未命名")
|
||||
})
|
||||
|
||||
it("renders matching conditions", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
{
|
||||
id: "if1",
|
||||
type: "condition",
|
||||
expression: "user.isVip",
|
||||
children: [paragraph([{ type: "text", content: "VIP 用户" }], "p_vip")]
|
||||
}
|
||||
]),
|
||||
fragments: {},
|
||||
data: { user: { isVip: true } }
|
||||
})
|
||||
|
||||
expect(result.output).toBe("VIP 用户")
|
||||
expect(result.logs).toContainEqual(
|
||||
expect.objectContaining({ type: "condition", blockId: "if1", result: true })
|
||||
)
|
||||
})
|
||||
|
||||
it("omits non-matching conditions", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
{
|
||||
id: "if1",
|
||||
type: "condition",
|
||||
expression: "user.isVip",
|
||||
children: [paragraph([{ type: "text", content: "VIP 用户" }], "p_vip")]
|
||||
}
|
||||
]),
|
||||
fragments: {},
|
||||
data: { user: { isVip: false } }
|
||||
})
|
||||
|
||||
expect(result.output).toBe("")
|
||||
})
|
||||
|
||||
it("expands loops with item and index scopes", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
{
|
||||
id: "loop1",
|
||||
type: "loop",
|
||||
source: "items",
|
||||
itemName: "item",
|
||||
indexName: "index",
|
||||
children: [
|
||||
paragraph([
|
||||
{ type: "text", content: "- " },
|
||||
{ type: "variable", path: "item.title" },
|
||||
{ type: "text", content: ": " },
|
||||
{ type: "variable", path: "item.price" }
|
||||
])
|
||||
]
|
||||
}
|
||||
]),
|
||||
fragments: {},
|
||||
data: {
|
||||
items: [
|
||||
{ title: "A", price: 1 },
|
||||
{ title: "B", price: 2 }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("- A: 1\n- B: 2")
|
||||
})
|
||||
|
||||
it("errors when loop source is not an array", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
{
|
||||
id: "loop1",
|
||||
type: "loop",
|
||||
source: "items",
|
||||
itemName: "item",
|
||||
children: [paragraph([{ type: "variable", path: "item.title" }])]
|
||||
}
|
||||
]),
|
||||
fragments: {},
|
||||
data: { items: "not-array" },
|
||||
options: { mode: "export" }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.errors).toContainEqual(
|
||||
expect.objectContaining({ code: "LOOP_SOURCE_NOT_ARRAY", path: "items" })
|
||||
)
|
||||
})
|
||||
|
||||
it("renders fragment references", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
paragraph([{ type: "text", content: "正文" }], "body"),
|
||||
{ id: "ref1", type: "fragmentRef", fragmentId: "fragment_footer" }
|
||||
]),
|
||||
fragments: {
|
||||
fragment_footer: fragment("fragment_footer", [
|
||||
paragraph([{ type: "text", content: "以上。" }], "footer")
|
||||
])
|
||||
},
|
||||
data: {}
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("正文\n以上。")
|
||||
expect(result.stats.usedFragments).toEqual(["fragment_footer"])
|
||||
})
|
||||
|
||||
it("renders a target paragraph block only", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
paragraph([{ type: "text", content: "First" }], "p1"),
|
||||
paragraph([{ type: "text", content: "Second" }], "p2")
|
||||
]),
|
||||
fragments: {},
|
||||
data: {},
|
||||
options: { targetBlockId: "p2" }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("Second")
|
||||
expect(result.stats.renderedBlockCount).toBe(1)
|
||||
})
|
||||
|
||||
it("renders a target condition block", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
{
|
||||
id: "if1",
|
||||
type: "condition",
|
||||
expression: "user.isVip",
|
||||
children: [paragraph([{ type: "text", content: "VIP" }], "vip_text")]
|
||||
},
|
||||
paragraph([{ type: "text", content: "Tail" }], "tail")
|
||||
]),
|
||||
fragments: {},
|
||||
data: { user: { isVip: true } },
|
||||
options: { targetBlockId: "if1" }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("VIP")
|
||||
expect(result.logs).toContainEqual(expect.objectContaining({ type: "condition", blockId: "if1" }))
|
||||
})
|
||||
|
||||
it("renders a target loop block", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
{
|
||||
id: "loop1",
|
||||
type: "loop",
|
||||
source: "items",
|
||||
itemName: "item",
|
||||
children: [paragraph([{ type: "variable", path: "item.title" }], "row")]
|
||||
}
|
||||
]),
|
||||
fragments: {},
|
||||
data: { items: [{ title: "A" }, { title: "B" }] },
|
||||
options: { targetBlockId: "loop1" }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("A\nB")
|
||||
})
|
||||
|
||||
it("keeps loop scope when rendering a nested target block", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([
|
||||
{
|
||||
id: "loop1",
|
||||
type: "loop",
|
||||
source: "items",
|
||||
itemName: "item",
|
||||
children: [
|
||||
paragraph([
|
||||
{ type: "text", content: "- " },
|
||||
{ type: "variable", path: "item.title" }
|
||||
], "row")
|
||||
]
|
||||
}
|
||||
]),
|
||||
fragments: {},
|
||||
data: { items: [{ title: "A" }, { title: "B" }] },
|
||||
options: { targetBlockId: "row" }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("- A\n- B")
|
||||
})
|
||||
|
||||
it("renders a target fragment reference block", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([{ id: "ref1", type: "fragmentRef", fragmentId: "fragment_footer" }]),
|
||||
fragments: {
|
||||
fragment_footer: fragment("fragment_footer", [paragraph([{ type: "text", content: "Footer" }], "footer")])
|
||||
},
|
||||
data: {},
|
||||
options: { targetBlockId: "ref1" }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("Footer")
|
||||
expect(result.stats.usedFragments).toEqual(["fragment_footer"])
|
||||
})
|
||||
|
||||
it("warns when a target block is missing", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([paragraph([{ type: "text", content: "Only" }], "p1")]),
|
||||
fragments: {},
|
||||
data: {},
|
||||
options: { targetBlockId: "missing_block" }
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.output).toBe("")
|
||||
expect(result.warnings).toContainEqual(
|
||||
expect.objectContaining({ code: "TARGET_BLOCK_NOT_FOUND", blockId: "missing_block" })
|
||||
)
|
||||
})
|
||||
|
||||
it("errors on missing fragment references", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([{ id: "ref1", type: "fragmentRef", fragmentId: "missing" }]),
|
||||
fragments: {},
|
||||
data: {}
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.errors).toContainEqual(
|
||||
expect.objectContaining({ code: "MISSING_FRAGMENT", fragmentId: "missing" })
|
||||
)
|
||||
})
|
||||
|
||||
it("detects fragment cycles during render", () => {
|
||||
const result = renderTemplate({
|
||||
template: template([{ id: "ref_a", type: "fragmentRef", fragmentId: "A" }]),
|
||||
fragments: {
|
||||
A: fragment("A", [{ id: "ref_b", type: "fragmentRef", fragmentId: "B" }]),
|
||||
B: fragment("B", [{ id: "ref_a2", type: "fragmentRef", fragmentId: "A" }])
|
||||
},
|
||||
data: {}
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.errors).toContainEqual(expect.objectContaining({ code: "FRAGMENT_CYCLE" }))
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildReferenceIndex", () => {
|
||||
it("collects missing fragments and cycles", () => {
|
||||
const main = template([
|
||||
{ id: "ref_missing", type: "fragmentRef", fragmentId: "missing" },
|
||||
{ id: "ref_a", type: "fragmentRef", fragmentId: "A" }
|
||||
])
|
||||
const fragmentA = fragment("A", [{ id: "ref_b", type: "fragmentRef", fragmentId: "B" }])
|
||||
const fragmentB = fragment("B", [{ id: "ref_a2", type: "fragmentRef", fragmentId: "A" }])
|
||||
|
||||
const index = buildReferenceIndex([main, fragmentA, fragmentB])
|
||||
|
||||
expect(index.missingFragments).toContainEqual(
|
||||
expect.objectContaining({ fromId: "template_main", fragmentId: "missing" })
|
||||
)
|
||||
expect(index.cycles).toContainEqual(expect.objectContaining({ chain: ["A", "B", "A"] }))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user