/** * 聚合函数测试(对应设计文档 §16 阶段 3 的"跨页合计"能力基础)。 * * 语义约定: * - 空数组:sum → 0,avg/min/max → null,count → 0 * - "合计为 0" 与 "没有数据" 是两种不同语义,前者显示 0,后者显示空白 */ import { describe, expect, it } from "vitest" import { evaluateValue } from "../src/evaluator.js" import type { EvalScope } from "../src/scope.js" const scope: EvalScope = { row: { amounts: [10, 20, 30], prices: [1.5, 2.5], mixed: [1, "abc", 3, null], empty: [], names: ["甲", "乙", "丙"], }, params: {}, } describe("sum", () => { it("数组求和", () => { expect(evaluateValue("sum(row.amounts)", scope)).toBe(60) }) it("空数组返回 0(合计为零应显示 0)", () => { expect(evaluateValue("sum(row.empty)", scope)).toBe(0) }) it("忽略非数字元素", () => { expect(evaluateValue("sum(row.mixed)", scope)).toBe(4) }) it("标量视为单元素", () => { expect(evaluateValue("sum(5)", scope)).toBe(5) }) it("null 返回 0", () => { expect(evaluateValue("sum(row.nothing)", scope)).toBe(0) }) it("小数求和", () => { expect(evaluateValue("sum(row.prices)", scope)).toBe(4) }) it("可与其他运算组合", () => { expect(evaluateValue("sum(row.amounts) * 2", scope)).toBe(120) }) }) describe("avg", () => { it("平均值为数字", () => { expect(evaluateValue("avg(row.amounts)", scope)).toBe(20) }) it("空数组返回 null(无数据应显示空白)", () => { expect(evaluateValue("avg(row.empty)", scope)).toBeNull() }) it("忽略非数字元素后再平均", () => { // 1 和 3 是有效数字,'abc' 与 null 被忽略 expect(evaluateValue("avg(row.mixed)", scope)).toBe(2) }) it("小数的平均值", () => { expect(evaluateValue("avg(row.prices)", scope)).toBe(2) }) }) describe("count", () => { it("统计元素个数", () => { expect(evaluateValue("count(row.amounts)", scope)).toBe(3) }) it("空数组返回 0", () => { expect(evaluateValue("count(row.empty)", scope)).toBe(0) }) it("统计非空元素个数", () => { // mixed = [1, 'abc', 3, null],null 不计入 expect(evaluateValue("count(row.mixed)", scope)).toBe(3) }) it("无参数返回 0", () => { expect(evaluateValue("count()", scope)).toBe(0) }) it("标量计为 1", () => { expect(evaluateValue("count(5)", scope)).toBe(1) }) it("null 计为 0", () => { expect(evaluateValue("count(row.nothing)", scope)).toBe(0) }) }) describe("min / max 双形态", () => { it("多参数形态(标量比较)", () => { expect(evaluateValue("min(3, 1, 2)", scope)).toBe(1) expect(evaluateValue("max(3, 1, 2)", scope)).toBe(3) }) it("单参数数组形态(聚合)", () => { expect(evaluateValue("min(row.amounts)", scope)).toBe(10) expect(evaluateValue("max(row.amounts)", scope)).toBe(30) }) it("空数组返回 null", () => { expect(evaluateValue("min(row.empty)", scope)).toBeNull() expect(evaluateValue("max(row.empty)", scope)).toBeNull() }) it("忽略非数字", () => { expect(evaluateValue("max(row.mixed)", scope)).toBe(3) }) it("单元素数组与标量结果一致", () => { expect(evaluateValue("min(5)", scope)).toBe(5) expect(evaluateValue("min(row.prices)", scope)).toBe(1.5) }) }) describe("聚合与分组作用域协作", () => { it("可通过 group 作用域聚合", () => { const groupScope: EvalScope = { row: { name: "甲" }, group: { key: "华北", amounts: [100, 200, 300], count: 3, }, params: {}, } expect(evaluateValue("sum(group.amounts)", groupScope)).toBe(600) expect(evaluateValue("avg(group.amounts)", groupScope)).toBe(200) expect(evaluateValue("group.count", groupScope)).toBe(3) }) it("可通过 dataset 作用域做全表合计", () => { const datasetScope: EvalScope = { row: {}, dataset: { amounts: [1, 2, 3, 4], count: 4 }, params: {}, } expect(evaluateValue("sum(dataset.amounts)", datasetScope)).toBe(10) }) it("聚合结果可被格式化", () => { const groupScope: EvalScope = { row: {}, group: { amounts: [1234.5, 2.25] }, params: {}, } expect(evaluateValue("format(sum(group.amounts), '#,##0.00')", groupScope)).toBe("1,236.75") }) it("空组不产生 NaN", () => { const groupScope: EvalScope = { row: {}, group: { amounts: [] }, params: {} } const sum = evaluateValue("sum(group.amounts)", groupScope) expect(sum).toBe(0) expect(Number.isNaN(sum as number)).toBe(false) }) }) describe("聚合函数仍在安全白名单内", () => { it("sum/avg/count 可调用", () => { expect(evaluateValue("sum(1)", scope)).toBe(1) expect(evaluateValue("avg(1)", scope)).toBe(1) expect(evaluateValue("count(1)", scope)).toBe(1) }) it("未登记的聚合名仍被拒绝", () => { // median 等未实现,应在解析期就被拒绝 const scope2: EvalScope = { row: {} } const result = evaluateValue("median(row.x)", scope2) expect(result).toBeNull() }) })