chore: 初始化 BlockFlow Workbench 仓库

建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。

补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
This commit is contained in:
2026-05-29 17:23:43 +08:00
commit 589ff15213
88 changed files with 31656 additions and 0 deletions

265
tests/app.test.tsx Normal file
View 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
View 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")
})
})
})

View 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
View 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
View 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
}

View 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"] }))
})
})