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

561 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 分组测试(对应设计文档 §16 阶段 3 与 §6.1 的分组区域)。
*
* 分组语义:相邻行键值相同即同组,不改变行顺序。
*/
import { describe, expect, it } from "vitest"
import {
createDocument,
createTableElement,
type ReportDocument,
type ReportSection,
} from "@report/core"
import { createEstimateMeasurer, createPassthroughFontResolver } from "../src/measurer.js"
import { layoutDocument } from "../src/engine.js"
import { computeGroups, collectGroupLevels, isSameGroupKey } from "../src/group.js"
import type { GroupLevelSpec } from "../src/group.js"
import { walkLayoutNodes } from "../src/layout-document.js"
import type { RenderContext } from "../src/context.js"
const measurer = createEstimateMeasurer()
const fontResolver = createPassthroughFontResolver()
async function layout(doc: ReportDocument, context: RenderContext) {
return layoutDocument(doc, context, { textMeasurer: measurer, fontResolver })
}
const ROWS = [
{ region: "华北", city: "北京", amount: 100 },
{ region: "华北", city: "天津", amount: 200 },
{ region: "华东", city: "上海", amount: 300 },
{ region: "华东", city: "杭州", amount: 400 },
{ region: "华南", city: "广州", amount: 500 },
]
function baseDoc(): ReportDocument {
const doc = createDocument({ name: "分组报表" })
doc.datasets = { sales: { id: "sales" } }
doc.sections.find((s) => s.kind === "detail")!.children.push(
createTableElement("tbl", { x: 0, y: 0, width: 180, height: 20 }, "sales", [
{ id: "c1", width: 60, header: "地区", binding: { field: "region" } },
{ id: "c2", width: 60, header: "城市", binding: { field: "city" } },
{ id: "c3", width: 60, header: "金额", binding: { field: "amount" }, align: "right" },
]),
)
return doc
}
/** 插入一个分组区域。 */
function addGroupSection(
doc: ReportDocument,
kind: "group-header" | "group-footer",
children: ReportSection["children"],
extra: Partial<ReportSection> = {},
): ReportSection {
const section: ReportSection = {
id: `grp-${kind}`,
kind,
children,
groupExpression: "row.region",
groupLevel: 1,
...extra,
}
// 插在 detail 之前(组头)或之后(组脚)
const detailIndex = doc.sections.findIndex((s) => s.kind === "detail")
const insertAt = kind === "group-header" ? detailIndex : detailIndex + 1
doc.sections.splice(insertAt, 0, section)
return section
}
describe("isSameGroupKey", () => {
it("相同标量为同组", () => {
expect(isSameGroupKey("a", "a")).toBe(true)
expect(isSameGroupKey(1, 1)).toBe(true)
})
it("不同标量不同组", () => {
expect(isSameGroupKey("a", "b")).toBe(false)
expect(isSameGroupKey(1, 2)).toBe(false)
})
it("数字与字符串等价(避免 1 与 '1' 被拆成两组)", () => {
expect(isSameGroupKey(1, "1")).toBe(true)
})
it("null 与 undefined 视为同组", () => {
expect(isSameGroupKey(null, undefined)).toBe(true)
expect(isSameGroupKey(null, null)).toBe(true)
expect(isSameGroupKey(null, 0)).toBe(false)
})
})
describe("computeGroups", () => {
it("按相邻行切分", () => {
const levels: GroupLevelSpec[] = [{ level: 1, expression: "row.region" }]
const tree = computeGroups(ROWS, levels, [])
expect(tree.groups).toHaveLength(3)
expect(tree.groups[0]?.key).toBe("华北")
expect(tree.groups[1]?.key).toBe("华东")
expect(tree.groups[2]?.key).toBe("华南")
})
it("记录每组的行区间", () => {
const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], [])
expect(tree.groups[0]?.start).toBe(0)
expect(tree.groups[0]?.end).toBe(2)
expect(tree.groups[1]?.start).toBe(2)
expect(tree.groups[1]?.end).toBe(4)
expect(tree.groups[2]?.start).toBe(4)
expect(tree.groups[2]?.end).toBe(5)
})
it("每组携带该组行数据", () => {
const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], [])
expect(tree.groups[0]?.rows).toHaveLength(2)
expect(tree.groups[0]?.rows[0]?.city).toBe("北京")
})
it("标记首组与末组", () => {
const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], [])
expect(tree.groups[0]?.isFirst).toBe(true)
expect(tree.groups[0]?.isLast).toBe(false)
expect(tree.groups[2]?.isLast).toBe(true)
})
it("相同键不相邻时视为不同组(不排序)", () => {
const rows = [
{ region: "A" },
{ region: "B" },
{ region: "A" },
]
const tree = computeGroups(rows, [{ level: 1, expression: "row.region" }], [])
// 相邻切分:A / B / A 共 3 组
expect(tree.groups).toHaveLength(3)
})
it("空数据集不产生分组", () => {
const tree = computeGroups([], [{ level: 1, expression: "row.region" }], [])
expect(tree.groups).toHaveLength(0)
})
it("无分组定义时不产生分组", () => {
const tree = computeGroups(ROWS, [], [])
expect(tree.groups).toHaveLength(0)
})
it("坏表达式产生诊断而非抛异常", () => {
const diagnostics: import("@report/core").Diagnostic[] = []
const tree = computeGroups(ROWS, [{ level: 1, expression: "row." }], diagnostics)
expect(tree.groups).toHaveLength(0)
expect(diagnostics.some((d) => d.code === "expression.parse-error")).toBe(true)
})
it("支持多级分组", () => {
const rows = [
{ region: "华北", city: "北京" },
{ region: "华北", city: "北京" },
{ region: "华北", city: "天津" },
{ region: "华东", city: "上海" },
]
const tree = computeGroups(
rows,
[
{ level: 1, expression: "row.region" },
{ level: 2, expression: "row.city" },
],
[],
)
// L1: 华北(0-3), 华东(3-4);L2: 北京(0-2), 天津(2-3), 上海(3-4)
const l1 = tree.groups.filter((g) => g.level === 1)
const l2 = tree.groups.filter((g) => g.level === 2)
expect(l1).toHaveLength(2)
expect(l2).toHaveLength(3)
})
it("多级分组中各级区间相互独立", () => {
const rows = [
{ region: "A", city: "x" },
{ region: "A", city: "y" },
{ region: "B", city: "z" },
]
const tree = computeGroups(
rows,
[
{ level: 1, expression: "row.region" },
{ level: 2, expression: "row.city" },
],
[],
)
const l2 = tree.groups.filter((g) => g.level === 2)
expect(l2[0]?.rows).toHaveLength(1)
expect(l2[1]?.rows).toHaveLength(1)
})
it("分组结果按出现位置排序", () => {
const tree = computeGroups(ROWS, [{ level: 1, expression: "row.region" }], [])
const starts = tree.groups.map((g) => g.start)
expect([...starts].sort((a, b) => a - b)).toEqual(starts)
})
})
describe("collectGroupLevels", () => {
it("从区域收集分组级别", () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [])
addGroupSection(doc, "group-footer", [])
const levels = collectGroupLevels(doc.sections, [])
expect(levels).toHaveLength(1)
expect(levels[0]?.expression).toBe("row.region")
expect(levels[0]?.headerSection).toBeDefined()
expect(levels[0]?.footerSection).toBeDefined()
})
it("缺少 groupExpression 时产生诊断", () => {
const diagnostics: import("@report/core").Diagnostic[] = []
const section: ReportSection = {
id: "g1",
kind: "group-header",
children: [],
}
collectGroupLevels([section], diagnostics)
expect(diagnostics.some((d) => d.message.includes("缺少 groupExpression"))).toBe(true)
})
it("同级别表达式不一致时告警", () => {
const diagnostics: import("@report/core").Diagnostic[] = []
const sections: ReportSection[] = [
{ id: "h", kind: "group-header", children: [], groupExpression: "row.a", groupLevel: 1 },
{ id: "f", kind: "group-footer", children: [], groupExpression: "row.b", groupLevel: 1 },
]
collectGroupLevels(sections, diagnostics)
expect(diagnostics.some((d) => d.message.includes("不同的"))).toBe(true)
})
it("多级别按 level 升序返回", () => {
const sections: ReportSection[] = [
{ id: "h2", kind: "group-header", children: [], groupExpression: "row.b", groupLevel: 2 },
{ id: "h1", kind: "group-header", children: [], groupExpression: "row.a", groupLevel: 1 },
]
const levels = collectGroupLevels(sections, [])
expect(levels.map((l) => l.level)).toEqual([1, 2])
})
})
describe("分组布局输出", () => {
it("组头渲染到输出中", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{
id: "gh",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "'地区:' + row.region" },
},
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
// 三个地区各输出一次组头
expect(texts.filter((t) => t.startsWith("地区:"))).toHaveLength(3)
expect(texts).toContain("地区:华北")
expect(texts).toContain("地区:华东")
expect(texts).toContain("地区:华南")
})
it("组脚按组聚合(sum)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{
id: "gf",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "sum(group.amount) + ' / ' + group.count + ' 笔'" },
},
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
// 华北 100+200=300(2 笔),华东 300+400=700(2 笔),华南 500(1 笔)
expect(texts).toContain("300 / 2 笔")
expect(texts).toContain("700 / 2 笔")
expect(texts).toContain("500 / 1 笔")
})
it("组脚可用 avg 聚合", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{
id: "gf",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "avg(group.amount)" },
},
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
// 华北均值 150,华东 350,华南 500
expect(texts).toContain("150")
expect(texts).toContain("350")
})
it("组脚可访问 group.key", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{
id: "gf",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "string(group.key)" },
},
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
expect(texts).toContain("华北")
expect(texts).toContain("华南")
})
it("详细行全部保留(分组不丢行)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组" } },
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
let rowCount = 0
walkLayoutNodes(result, (node) => {
if (node.role === "table-row" && node.content?.kind === "table") rowCount += 1
})
expect(rowCount).toBe(ROWS.length)
})
it("分组后行顺序不变", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组" } },
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const cities: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "table" && node.role === "table-row") {
const cell = node.content.rows[0]?.cells[1]
if (cell) cities.push(cell.text)
}
})
expect(cities).toEqual(["北京", "天津", "上海", "杭州", "广州"])
})
it("组头在详细行之前、组脚之后", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 10 }, props: { text: "组头" } },
])
addGroupSection(doc, "group-footer", [
{ id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 10 }, props: { text: "组脚" } },
])
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
const order: string[] = []
walkLayoutNodes(result, (node) => {
if (node.id === "gh") order.push("H")
if (node.id === "gf") order.push("F")
if (node.role === "table-row") order.push("R")
})
// 期望:H R R F H R R F H R F(3 组)
expect(order.join("")).toBe("HRRFHRRFHRF")
})
it("无数据时不输出分组区域", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{ id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } },
])
const result = await layout(doc, { data: { sales: [] }, parameters: {} })
const texts: string[] = []
walkLayoutNodes(result, (node) => {
if (node.content?.kind === "text") texts.push(node.content.text)
})
expect(texts).not.toContain("组脚")
expect(result.diagnostics.some((d) => d.code === "data.dataset-missing")).toBe(true)
})
it("没有分组定义时行为与之前一致", async () => {
const doc = baseDoc()
const result = await layout(doc, { data: { sales: ROWS }, parameters: {} })
let rowCount = 0
walkLayoutNodes(result, (node) => {
if (node.role === "table-row") rowCount += 1
})
expect(rowCount).toBe(ROWS.length)
})
it("表头仍每页重复(分组不影响)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 6 }, props: { text: "组" } },
])
// 用大量数据迫使多页
const manyRows = Array.from({ length: 200 }, (_, i) => ({
region: `地区${Math.floor(i / 20)}`,
city: `城市${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
expect(result.pages.length).toBeGreaterThan(1)
for (const page of result.pages) {
expect(page.nodes.some((n) => n.role === "table-header")).toBe(true)
}
})
it("分组跨页时组脚仍输出", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{
id: "gf",
type: "text",
frame: { x: 0, y: 0, width: 100, height: 8 },
binding: { expression: "sum(group.amount)" },
},
])
const manyRows = Array.from({ length: 200 }, (_, i) => ({
region: `地区${Math.floor(i / 20)}`,
city: `城市${i}`,
amount: 1,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
const totals: string[] = []
walkLayoutNodes(result, (node) => {
if (node.id === "gf" && node.content?.kind === "text") totals.push(node.content.text)
})
// 10 个组,每组 20 行 × 1 = 20
expect(totals).toHaveLength(10)
expect(totals.every((t) => t === "20")).toBe(true)
})
it("组头不与组内首行分离(keepWithNext)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-header", [
{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组头" } },
])
// 大量行迫使多页;每个组头后面一定紧跟数据行
const manyRows = Array.from({ length: 200 }, (_, i) => ({
region: `地区${Math.floor(i / 20)}`,
city: `城市${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
// 逐页检查:任何一页里,组头 gh 之后必然存在至少一个数据行
for (const page of result.pages) {
const ids: string[] = []
walkLayoutNodes({ ...result, pages: [page] }, (node) => {
if (node.id === "gh") ids.push("H")
else if (node.role === "table-row") ids.push("R")
})
// 若本页有组头,则其后必须有数据行(组头不孤立)
const headerPos = ids.indexOf("H")
if (headerPos >= 0) {
expect(ids.slice(headerPos + 1)).toContain("R")
}
}
})
it("组脚不与组内末行分离(keepWithPrevious)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{ id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } },
])
const manyRows = Array.from({ length: 200 }, (_, i) => ({
region: `地区${Math.floor(i / 20)}`,
city: `城市${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
// 逐页检查:任何一页里,组脚 gf 之前必然存在数据行(组脚不孤立)
for (const page of result.pages) {
const ids: string[] = []
walkLayoutNodes({ ...result, pages: [page] }, (node) => {
if (node.id === "gf") ids.push("F")
else if (node.role === "table-row") ids.push("R")
})
const footerPos = ids.lastIndexOf("F")
if (footerPos >= 0) {
expect(ids.slice(0, footerPos)).toContain("R")
}
}
})
it("组脚不丢失(跨页后每组的组脚都在)", async () => {
const doc = baseDoc()
addGroupSection(doc, "group-footer", [
{ id: "gf", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组脚" } },
])
const manyRows = Array.from({ length: 60 }, (_, i) => ({
region: `地区${Math.floor(i / 6)}`,
city: `城市${i}`,
amount: 1,
}))
const result = await layout(doc, { data: { sales: manyRows }, parameters: {} })
const footers: string[] = []
walkLayoutNodes(result, (node) => {
if (node.id === "gf" && node.content?.kind === "text") footers.push(node.content.text)
})
// 10 个组,每个组的组脚都必须出现一次
expect(footers).toHaveLength(10)
expect(footers.every((t) => t === "组脚")).toBe(true)
})
it("pageBreakBefore 使每组独占一页起始", async () => {
const doc = baseDoc()
addGroupSection(
doc,
"group-header",
[{ id: "gh", type: "text", frame: { x: 0, y: 0, width: 100, height: 8 }, props: { text: "组头" } }],
{ pageBreakBefore: true },
)
// 3 组,每组若干行,组头声明 pageBreakBefore
const rows = Array.from({ length: 30 }, (_, i) => ({
region: `地区${Math.floor(i / 10)}`,
city: `城市${i}`,
amount: i,
}))
const result = await layout(doc, { data: { sales: rows }, parameters: {} })
// 每个组头应落在其所在页的主体顶部(即该页第一个主体块)
const pageHeaders: string[][] = []
for (const page of result.pages) {
const ids: string[] = []
walkLayoutNodes({ ...result, pages: [page] }, (node) => {
if (node.id === "gh") ids.push("H")
else if (node.role === "table-row") ids.push("R")
else if (node.role === "table-header") ids.push("T")
})
pageHeaders.push(ids)
}
// 至少应有多页;每个含组头的页,组头应是首个非表头块
const pagesWithHeader = pageHeaders.filter((ids) => ids.includes("H"))
expect(pagesWithHeader.length).toBe(3)
for (const ids of pagesWithHeader) {
// 去掉前导表头后,第一个主体块应是组头
const firstBody = ids.find((x) => x !== "T")
expect(firstBody).toBe("H")
}
})
})