221 lines
7.2 KiB
TypeScript
221 lines
7.2 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|