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