Files
workspace/code/one-designer/packages/report-layout/tests/pagination.test.ts
T
2026-09-20 17:34:40 +08:00

559 lines
19 KiB
TypeScript

/**
* 分页与布局引擎测试(对应设计文档 §14 的"页眉、页脚和多页分页测试"、
* "固定元素和明细表的布局快照测试")。
*/
import { describe, expect, it } from "vitest"
import {
createDocument,
createTableElement,
type ReportDocument,
} from "@report/core"
import { createEstimateMeasurer, createPassthroughFontResolver } from "../src/measurer.js"
import { layoutDocument } from "../src/engine.js"
import { computePageGeometry } from "../src/paginate.js"
import { countLayoutNodes, walkLayoutNodes } from "../src/layout-document.js"
import type { RenderContext } from "../src/context.js"
const measurer = createEstimateMeasurer()
const fontResolver = createPassthroughFontResolver()
function emptyContext(parameters: Record<string, unknown> = {}): RenderContext {
return { data: {}, parameters }
}
async function layout(doc: ReportDocument, context = emptyContext()) {
return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver })
}
/** 一份含页眉、明细文本、页脚的文档。 */
function documentWith(configure: (doc: ReportDocument) => void): ReportDocument {
const doc = createDocument({ name: "测试报表" })
configure(doc)
return doc
}
describe("页面几何", () => {
it("A4 纵向尺寸正确", () => {
const doc = createDocument({ page: { paper: "A4", orientation: "portrait" } })
const geometry = computePageGeometry(doc)
expect(geometry.width).toBe(210)
expect(geometry.height).toBe(297)
// 默认边距 15mm
expect(geometry.contentWidth).toBe(180)
expect(geometry.contentHeight).toBe(267)
})
it("横向交换宽高", () => {
const doc = createDocument({ page: { paper: "A4", orientation: "landscape" } })
const geometry = computePageGeometry(doc)
expect(geometry.width).toBe(297)
expect(geometry.height).toBe(210)
})
it("A5 尺寸正确", () => {
const doc = createDocument({ page: { paper: "A5" } })
const geometry = computePageGeometry(doc)
expect(geometry.width).toBe(148)
expect(geometry.height).toBe(210)
})
it("自定义纸张生效", () => {
const doc = createDocument({
page: { paper: "custom", customSize: { width: 100, height: 150 }, margin: { top: 5, right: 5, bottom: 5, left: 5 } },
})
const geometry = computePageGeometry(doc)
expect(geometry.width).toBe(100)
expect(geometry.height).toBe(150)
expect(geometry.contentWidth).toBe(90)
})
})
describe("单页布局", () => {
it("空文档产出一页", async () => {
const result = await layout(createDocument())
expect(result.pages).toHaveLength(1)
expect(result.pageCount).toBe(1)
})
it("页面尺寸写入布局结果", async () => {
const result = await layout(createDocument({ page: { paper: "A5" } }))
expect(result.pages[0]?.width).toBe(148)
expect(result.pages[0]?.height).toBe(210)
})
it("页眉元素被渲染", async () => {
const doc = documentWith((d) => {
const header = d.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 result = await layout(doc)
const ids: string[] = []
walkLayoutNodes(result, (node) => ids.push(node.id))
expect(ids).toContain("h1")
})
it("文本内容被写入布局节点", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "t1",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
props: { text: "正文" },
})
})
const result = await layout(doc)
let found: string | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "t1" && node.content?.kind === "text") found = node.content.text
})
expect(found).toBe("正文")
})
it("页脚每页重复", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "page-footer")!.children.push({
id: "f1",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 6 },
props: { text: "页脚" },
})
})
const result = await layout(doc)
expect(result.pages).toHaveLength(1)
const footerNode = result.pages[0]?.nodes.find((n) => n.id === "f1")
expect(footerNode).toBeDefined()
})
it("元素坐标为绝对坐标", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "t1",
type: "text",
frame: { x: 10, y: 5, width: 50, height: 8 },
props: { text: "x" },
})
})
const result = await layout(doc)
let frame: { x: number; y: number } | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "t1") frame = node.frame
})
// x 保持元素自身偏移;y = 内容区起点(15) + 页眉高度(0) + 元素 y(5)
expect(frame?.x).toBe(10)
expect(frame?.y).toBeGreaterThanOrEqual(15)
})
it("无 TextMeasurer 时产生诊断", async () => {
const result = await layoutDocument(createDocument(), emptyContext(), { fontResolver })
expect(result.diagnostics.some((d) => d.message.includes("估算度量器"))).toBe(true)
})
it("提供 TextMeasurer 时不产生该诊断", async () => {
const result = await layout(createDocument())
expect(result.diagnostics.some((d) => d.message.includes("估算度量器"))).toBe(false)
})
})
describe("多页分页", () => {
it("内容超出页面时产生多页", async () => {
const doc = documentWith((d) => {
const detail = d.sections.find((s) => s.kind === "detail")!
// 明细区放置一个很高的表格,迫使分页
d.datasets = { ds1: { id: "ds1" } }
detail.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 90, header: "名称" },
{ id: "c2", width: 90, header: "数量" },
]),
)
})
const context: RenderContext = {
data: {
ds1: Array.from({ length: 100 }, (_, i) => ({ name: `项目${i}`, qty: i })),
},
parameters: {},
}
const result = await layout(doc, context)
expect(result.pages.length).toBeGreaterThan(1)
expect(result.pageCount).toBe(result.pages.length)
})
it("页码连续递增", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 60 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
result.pages.forEach((page, index) => {
expect(page.pageNumber).toBe(index + 1)
})
})
it("页眉在每一页都出现", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "page-header")!.children.push({
id: "hdr",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
props: { text: "页眉" },
})
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 80 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
expect(result.pages.length).toBeGreaterThan(1)
for (const page of result.pages) {
expect(page.nodes.some((n) => n.id === "hdr")).toBe(true)
}
})
it("页脚在每一页都出现", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "page-footer")!.children.push({
id: "ftr",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 6 },
props: { text: "页脚" },
})
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 80 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
for (const page of result.pages) {
expect(page.nodes.some((n) => n.id === "ftr")).toBe(true)
}
})
it("不存在内容溢出的页(除超高大块外)", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 50 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
expect(result.diagnostics.filter((d) => d.code === "layout.page-overflow")).toEqual([])
})
// 回归测试:曾出现"表头只出现在第一页"的问题——
// 分页器记录了 isHeader 标记却没有真正在后续页面补插表头。
it("明细表表头在每一页都重复", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
expect(result.pages.length).toBeGreaterThan(1)
for (const page of result.pages) {
const headers = page.nodes.filter((n) => n.role === "table-header")
expect(headers).toHaveLength(1)
}
})
it("表头重复时不超出内容区", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
d.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl1", { x: 0, y: 0, width: 180, height: 20 }, "ds1", [
{ id: "c1", width: 180, header: "值" },
]),
)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
// 内容区底部 = 15 + 267 = 282mm
const contentBottom = 282
for (const page of result.pages) {
for (const node of page.nodes) {
expect(node.frame.y + node.frame.height).toBeLessThanOrEqual(contentBottom + 0.01)
}
}
})
it("repeatHeaderOnEachPage 为 false 时表头只出现一次", async () => {
const doc = documentWith((d) => {
d.datasets = { ds1: { id: "ds1" } }
const table = createTableElement(
"tbl1",
{ x: 0, y: 0, width: 180, height: 20 },
"ds1",
[{ id: "c1", width: 180, header: "值" }],
)
table.repeatHeaderOnEachPage = false
d.sections.find((s) => s.kind === "detail")!.children.push(table)
})
const context: RenderContext = {
data: { ds1: Array.from({ length: 200 }, (_, i) => ({ value: i })) },
parameters: {},
}
const result = await layout(doc, context)
expect(result.pages.length).toBeGreaterThan(1)
// 仅首页有表头
expect(result.pages[0]!.nodes.some((n) => n.role === "table-header")).toBe(true)
for (let i = 1; i < result.pages.length; i += 1) {
expect(result.pages[i]!.nodes.some((n) => n.role === "table-header")).toBe(false)
}
})
})
describe("页眉页脚过高", () => {
it("页眉页脚占满内容区时报错并返回空页集", async () => {
const doc = documentWith((d) => {
const header = d.sections.find((s) => s.kind === "page-header")!
const footer = d.sections.find((s) => s.kind === "page-footer")!
// 页眉 200mm + 页脚 200mm 远超 267mm 内容区
header.children.push({
id: "big-header",
type: "rect",
frame: { x: 0, y: 0, width: 180, height: 200 },
})
footer.children.push({
id: "big-footer",
type: "rect",
frame: { x: 0, y: 0, width: 180, height: 200 },
})
})
const result = await layout(doc)
expect(result.pages).toHaveLength(0)
expect(result.diagnostics.some((d) => d.code === "layout.page-overflow")).toBe(true)
})
})
describe("Y 坐标分层", () => {
it("页眉在上、页脚在下、主体居中", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "page-header")!.children.push({
id: "hdr",
type: "rect",
frame: { x: 0, y: 0, width: 100, height: 10 },
})
d.sections.find((s) => s.kind === "page-footer")!.children.push({
id: "ftr",
type: "rect",
frame: { x: 0, y: 0, width: 100, height: 10 },
})
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "body",
type: "rect",
frame: { x: 0, y: 0, width: 100, height: 10 },
})
})
const result = await layout(doc)
const page = result.pages[0]!
const hdr = page.nodes.find((n) => n.id === "hdr")!
const body = page.nodes.find((n) => n.id === "body")!
const ftr = page.nodes.find((n) => n.id === "ftr")!
expect(hdr.frame.y).toBeLessThan(body.frame.y)
expect(body.frame.y).toBeLessThan(ftr.frame.y)
// 页脚贴内容区底部:15 + 267 - 10 = 272
expect(ftr.frame.y).toBeCloseTo(272, 4)
})
})
describe("区域记录", () => {
it("页面记录区域落位信息", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "page-header")!.children.push({
id: "hdr",
type: "rect",
frame: { x: 0, y: 0, width: 100, height: 10 },
})
})
const result = await layout(doc)
const kinds = result.pages[0]!.sections.map((s) => s.kind)
expect(kinds).toContain("page-header")
})
})
describe("元素类型渲染", () => {
it("rect 产出 rect 内容", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "r1",
type: "rect",
frame: { x: 0, y: 0, width: 20, height: 10 },
})
})
const result = await layout(doc)
let kind: string | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "r1") kind = node.content?.kind
})
expect(kind).toBe("rect")
})
it("line 产出 line 内容", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "l1",
type: "line",
frame: { x: 0, y: 0, width: 50, height: 0.2 },
props: { direction: "horizontal" },
})
})
const result = await layout(doc)
let content: { kind: string; direction?: string } | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "l1" && node.content?.kind === "line") content = node.content
})
expect(content?.kind).toBe("line")
expect(content?.direction).toBe("horizontal")
})
it("image 产出 image 内容", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "i1",
type: "image",
frame: { x: 0, y: 0, width: 20, height: 20 },
props: { src: "logo.png" },
})
})
const result = await layout(doc)
let src: string | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "i1" && node.content?.kind === "image") src = node.content.src
})
expect(src).toBe("logo.png")
})
it("container 子元素被递归布局", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "c1",
type: "container",
frame: { x: 0, y: 0, width: 50, height: 30 },
children: [
{
id: "child1",
type: "text",
frame: { x: 2, y: 2, width: 20, height: 6 },
props: { text: "子元素" },
},
],
})
})
const result = await layout(doc)
const ids: string[] = []
walkLayoutNodes(result, (node) => ids.push(node.id))
expect(ids).toContain("child1")
})
})
describe("动态高度", () => {
it("长文本自动增高", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "long",
type: "text",
// 高度只给 5mm,但内容需要多行
frame: { x: 0, y: 0, width: 20, height: 5 },
props: { text: "这是一段很长的中文文本需要换行显示" },
})
})
const result = await layout(doc)
let height: number | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "long") height = node.frame.height
})
expect(height).toBeGreaterThan(5)
})
it("短文本保持原高度", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "short",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 20 },
props: { text: "短" },
})
})
const result = await layout(doc)
let height: number | undefined
walkLayoutNodes(result, (node) => {
if (node.id === "short") height = node.frame.height
})
expect(height).toBe(20)
})
})
describe("未知区域处理", () => {
it("V1 之外的区域被跳过并产生提示", async () => {
const doc = createDocument()
doc.sections.push({
id: "sum1",
kind: "summary",
children: [
{ id: "s1", type: "text", frame: { x: 0, y: 0, width: 50, height: 8 }, props: { text: "汇总" } },
],
})
const result = await layout(doc)
const ids: string[] = []
walkLayoutNodes(result, (node) => ids.push(node.id))
expect(ids).not.toContain("s1")
expect(result.diagnostics.some((d) => d.message.includes("不在 V1 渲染范围内"))).toBe(true)
})
})
describe("节点统计", () => {
it("countLayoutNodes 统计所有页节点", async () => {
const doc = documentWith((d) => {
d.sections.find((s) => s.kind === "detail")!.children.push({
id: "a",
type: "text",
frame: { x: 0, y: 0, width: 50, height: 8 },
props: { text: "a" },
})
})
const result = await layout(doc)
expect(countLayoutNodes(result)).toBeGreaterThan(0)
})
})