181 lines
5.1 KiB
TypeScript
181 lines
5.1 KiB
TypeScript
/**
|
||
* 聚合函数测试(对应设计文档 §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()
|
||
})
|
||
})
|