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

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