/** * Renderer 一致性测试(对应设计文档 §15 的 * "HTML、SVG、PDF Renderer 的公共布局结果一致性测试")。 * * 核心断言:同一份 LayoutDocument 经 HTML / SVG 渲染后, * 分页结构一致(页数相同、节点都能找到)。 */ import { describe, expect, it } from "vitest" import { createDocument, createTableElement, type ReportDocument } from "@report/core" import { createEstimateMeasurer, createPassthroughFontResolver } from "@report/layout" import { layoutDocument } from "@report/layout" import { renderHtml, renderSvg, createPdfRenderer } from "../src/index.js" import type { RenderContext } from "@report/layout" const measurer = createEstimateMeasurer() const fontResolver = createPassthroughFontResolver() async function layout(doc: ReportDocument, context: RenderContext) { return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver }) } /** 构造一份含页眉、文本、明细表、页脚的报表文档。 */ function sampleDoc(): ReportDocument { const doc = createDocument({ name: "渲染测试" }) doc.datasets = { sales: { id: "sales" } } const header = doc.sections.find((s) => s.kind === "page-header")! header.children.push({ id: "h1", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "销售单" }, }) const detail = doc.sections.find((s) => s.kind === "detail")! detail.children.push( createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "sales", [ { id: "c1", width: 90, header: "商品", binding: { field: "name" } }, { id: "c2", width: 90, header: "金额", binding: { field: "amount" }, align: "right" }, ]), ) const footer = doc.sections.find((s) => s.kind === "page-footer")! footer.children.push({ id: "f1", type: "text", frame: { x: 0, y: 0, width: 100, height: 6 }, binding: { expression: "'第 ' + page.pageNumber + ' 页'" }, }) return doc } const ROWS = [ { name: "苹果", amount: 100 }, { name: "香蕉", amount: 200 }, { name: "橙子", amount: 300 }, ] describe("HTML / SVG 分页一致性", () => { it("同一文档渲染为 HTML 与 SVG 时页数一致", async () => { const doc = sampleDoc() const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) const html = renderHtml(result) const svg = renderSvg(result) expect(html.target).toBe("html") expect(svg.target).toBe("svg") expect(html.pageCount).toBe(result.pageCount) expect(svg.pageCount).toBe(result.pageCount) expect(html.pageCount).toBe(svg.pageCount) }) it("HTML 输出包含与页数一致的 .rd-page 容器", async () => { const doc = sampleDoc() const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) const html = renderHtml(result) const pageCount = (html.content.match(/class="rd-page"/g) ?? []).length expect(pageCount).toBe(result.pageCount) }) it("SVG 输出包含与页数一致的 元素", async () => { const doc = sampleDoc() const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) const svg = renderSvg(result) const svgCount = (svg.content.match(/ { const doc = sampleDoc() const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) const html = renderHtml(result) const svg = renderSvg(result) // 页眉文本 "销售单" 应出现在两种输出中 expect(html.content).toContain("销售单") expect(svg.content).toContain("销售单") }) it("明细表数据在 HTML 与 SVG 中都被渲染", async () => { const doc = sampleDoc() const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) const html = renderHtml(result) const svg = renderSvg(result) expect(html.content).toContain("苹果") expect(html.content).toContain("橙子") expect(svg.content).toContain("苹果") expect(svg.content).toContain("橙子") }) it("页脚页码占位符在渲染前已被替换", async () => { const doc = sampleDoc() const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) const html = renderHtml(result) // 页脚应有 "第 1 页" 而非原始占位符 expect(html.content).toContain("第 1 页") expect(html.content).not.toContain("{pageNumber}") }) }) describe("多页渲染", () => { it("多页文档在 HTML 与 SVG 中页数一致", async () => { const doc = sampleDoc() const manyRows = Array.from({ length: 200 }, (_, i) => ({ name: `商品${i}`, amount: i, })) const result = await layout(doc, { data: { sales: manyRows }, parameters: {} }) expect(result.pageCount).toBeGreaterThan(1) const html = renderHtml(result) const svg = renderSvg(result) const htmlPages = (html.content.match(/class="rd-page"/g) ?? []).length const svgPages = (svg.content.match(/ { it("SVG 使用 mm 标注宽度和 viewBox", async () => { const doc = sampleDoc() const result = await layout(doc, { data: { sales: ROWS }, parameters: {} }) const svg = renderSvg(result) const page = result.pages[0]! // width 与 height 用 mm,viewBox 用无单位数值 expect(svg.content).toContain(`width="${page.width}mm"`) expect(svg.content).toContain(`viewBox="0 0 ${page.width} ${page.height}"`) }) it("矩形节点渲染为 ", async () => { const doc = createDocument({ name: "矩形测试" }) const detail = doc.sections.find((s) => s.kind === "detail")! detail.children.push({ id: "r1", type: "rect", frame: { x: 5, y: 5, width: 50, height: 20 }, }) const result = await layout(doc, { data: {}, parameters: {} }) const svg = renderSvg(result) expect(svg.content).toContain(" { it("未配置后端时返回诊断而非抛异常", () => { const result = createPdfRenderer().render({ pages: [], diagnostics: [], pageCount: 0, }) expect(result.target).toBe("pdf") expect(result.bytes).toBeInstanceOf(Uint8Array) expect(result.bytes.length).toBe(0) expect(result.diagnostics.some((d) => d.code === "render.pdf-backend-missing")).toBe(true) }) it("后端抛错时返回错误诊断", () => { const backend = { name: "mock", render: () => { throw new Error("boom") }, } const result = createPdfRenderer(backend).render({ pages: [], diagnostics: [], pageCount: 0, }) expect(result.diagnostics.some((d) => d.code === "render.pdf-backend-error")).toBe(true) }) it("后端正常时返回字节", () => { const backend = { name: "mock", render: () => new Uint8Array([1, 2, 3]), } const result = createPdfRenderer(backend).render({ pages: [], diagnostics: [], pageCount: 0, }) expect(result.bytes.length).toBe(3) expect(result.diagnostics).toHaveLength(0) }) })