20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
1 parent f4bfc1ddfc
commit 6f03d92425
174 files changed
+23490 -157

No files matched your search

@@ -0,0 +1,560 @@
/**
* 分组测试(对应设计文档 §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")
}
})
})