20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
1 parent f4bfc1ddfc
commit 6f03d92425
174 files changed
+23490 -157

No files matched your search

@@ -0,0 +1,220 @@
/**
* 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 输出包含与页数一致的 <svg> 元素", async () => {
const doc = sampleDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const svg = renderSvg(result)
const svgCount = (svg.content.match(/<svg /g) ?? []).length
expect(svgCount).toBe(result.pageCount)
})
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(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(/<svg /g) ?? []).length
expect(htmlPages).toBe(result.pageCount)
expect(svgPages).toBe(result.pageCount)
})
})
describe("SVG 输出结构", () => {
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("矩形节点渲染为 <rect>", 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("<rect")
expect(svg.content).toContain('id="r1"')
})
})
describe("PDF 后端契约", () => {
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)
})
})