561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
/**
|
||
* 分组测试(对应设计文档 §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")
|
||
}
|
||
})
|
||
})
|