20260920173439
This commit is contained in:
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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user