chore: 初始化 BlockFlow Workbench 仓库
建立前端与 Tauri 桌面端的首个版本提交,包含核心编辑器、项目文件读写、测试与构建配置。 补充 Git 忽略规则和换行规范,排除依赖、构建产物、本地运行日志与临时验证文件,方便在其他电脑继续开发。
This commit is contained in:
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