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