/** * 分页与布局引擎测试(对应设计文档 §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 = {}): 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) }) })