2026-10-12 04:22:32 -04:00
|
|
|
|
"""参数注册表与公式模板注册表测试。
|
|
|
|
|
|
|
|
|
|
|
|
守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值
|
|
|
|
|
|
与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守,
|
|
|
|
|
|
畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from fractions import Fraction
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.exceptions import BadRequestError
|
|
|
|
|
|
from app.services.calc_templates import TEMPLATES, get_template, required_params
|
|
|
|
|
|
from app.services.param_registry import (
|
|
|
|
|
|
PARAM_REGISTRY,
|
|
|
|
|
|
get_spec,
|
|
|
|
|
|
parse_fraction,
|
|
|
|
|
|
validate_and_normalize,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-10-15 05:25:23 -04:00
|
|
|
|
# 与 db/migration 迁移种子保持一致的 18 项编码(V3 十六项 + V6 审批开关两项)
|
2026-10-12 04:22:32 -04:00
|
|
|
|
EXPECTED_CODES = {
|
|
|
|
|
|
"salesperson.rate.start",
|
|
|
|
|
|
"salesperson.rate.slope",
|
|
|
|
|
|
"salesperson.rate.floor",
|
|
|
|
|
|
"salesperson.days.boundary",
|
|
|
|
|
|
"manager.share.team",
|
|
|
|
|
|
"manager.share.company",
|
|
|
|
|
|
"enterprise.rate",
|
|
|
|
|
|
"info.rate",
|
|
|
|
|
|
"prepay.rate",
|
|
|
|
|
|
"barter.discount",
|
|
|
|
|
|
"payment.start.ratio",
|
|
|
|
|
|
"payment.weight.mode",
|
|
|
|
|
|
"product.threshold",
|
|
|
|
|
|
"salary.basic.mode",
|
|
|
|
|
|
"probation.annual.threshold",
|
|
|
|
|
|
"probation.salary.basic",
|
2026-10-15 05:25:23 -04:00
|
|
|
|
"approval.secondary_allocation.enabled",
|
|
|
|
|
|
"approval.monthly_settlement.enabled",
|
2026-10-12 04:22:32 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRegistry:
|
|
|
|
|
|
"""注册表元数据完整性(与迁移种子同源)。"""
|
|
|
|
|
|
|
2026-10-15 05:25:23 -04:00
|
|
|
|
def test_注册表共18项(self) -> None:
|
|
|
|
|
|
assert len(PARAM_REGISTRY) == 18
|
2026-10-12 04:22:32 -04:00
|
|
|
|
|
|
|
|
|
|
def test_编码集合与迁移种子一致(self) -> None:
|
|
|
|
|
|
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
|
|
|
|
|
|
|
|
|
|
|
def test_每项必填字段非空(self) -> None:
|
|
|
|
|
|
for spec in PARAM_REGISTRY.values():
|
|
|
|
|
|
assert spec.code, "code 为空"
|
|
|
|
|
|
assert spec.name, f"{spec.code} 缺中文名称"
|
|
|
|
|
|
assert spec.group, f"{spec.code} 缺分组"
|
|
|
|
|
|
assert spec.value_type in {"DECIMAL", "INT", "BOOL", "ENUM", "TEXT"}
|
|
|
|
|
|
|
|
|
|
|
|
def test_默认值可按类型解析(self) -> None:
|
|
|
|
|
|
for spec in PARAM_REGISTRY.values():
|
|
|
|
|
|
if spec.default_value is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if spec.value_type in {"DECIMAL", "INT"}:
|
|
|
|
|
|
parse_fraction(spec.default_value) # 不可解析会抛 BadRequestError
|
2026-10-15 05:25:23 -04:00
|
|
|
|
elif spec.value_type == "BOOL":
|
|
|
|
|
|
assert spec.default_value in {"0", "1"}, f"{spec.code} 默认值非 0/1"
|
2026-10-12 04:22:32 -04:00
|
|
|
|
elif spec.value_type == "ENUM":
|
|
|
|
|
|
assert spec.options, f"{spec.code} 为 ENUM 但无选项"
|
|
|
|
|
|
assert spec.default_value in spec.options
|
|
|
|
|
|
|
2026-10-15 05:25:23 -04:00
|
|
|
|
def test_BOOL开关_仅接受0与1(self) -> None:
|
|
|
|
|
|
"""V6 首例 BOOL 注册的两个审批开关:合法值与非法值边界。"""
|
|
|
|
|
|
for code in (
|
|
|
|
|
|
"approval.secondary_allocation.enabled",
|
|
|
|
|
|
"approval.monthly_settlement.enabled",
|
|
|
|
|
|
):
|
|
|
|
|
|
spec = PARAM_REGISTRY[code]
|
|
|
|
|
|
assert spec.value_type == "BOOL"
|
|
|
|
|
|
assert validate_and_normalize(spec, "0") == "0"
|
|
|
|
|
|
assert validate_and_normalize(spec, "1") == "1"
|
|
|
|
|
|
with pytest.raises(BadRequestError):
|
|
|
|
|
|
validate_and_normalize(spec, "true")
|
|
|
|
|
|
with pytest.raises(BadRequestError):
|
|
|
|
|
|
validate_and_normalize(spec, "2")
|
|
|
|
|
|
|
2026-10-12 04:22:32 -04:00
|
|
|
|
def test_取值域上下限可解析(self) -> None:
|
|
|
|
|
|
for spec in PARAM_REGISTRY.values():
|
|
|
|
|
|
if spec.value_min is not None:
|
|
|
|
|
|
parse_fraction(spec.value_min)
|
|
|
|
|
|
if spec.value_max is not None:
|
|
|
|
|
|
parse_fraction(spec.value_max)
|
|
|
|
|
|
|
|
|
|
|
|
def test_取未注册编码抛ValueError_属代码缺陷(self) -> None:
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
|
get_spec("not.exist.code")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestParseFraction:
|
|
|
|
|
|
"""Fraction 精确解析:4/360 不许经 float 中转。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_分数式精确解析_4除以360(self) -> None:
|
|
|
|
|
|
assert parse_fraction("4/360") == Fraction(1, 90)
|
|
|
|
|
|
|
|
|
|
|
|
def test_整数解析(self) -> None:
|
|
|
|
|
|
assert parse_fraction("10") == Fraction(10)
|
|
|
|
|
|
|
|
|
|
|
|
def test_小数解析(self) -> None:
|
|
|
|
|
|
assert parse_fraction("12.5") == Fraction(25, 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_除零分数拒绝(self) -> None:
|
|
|
|
|
|
with pytest.raises(BadRequestError):
|
|
|
|
|
|
parse_fraction("1/0")
|
|
|
|
|
|
|
|
|
|
|
|
def test_非数字拒绝(self) -> None:
|
|
|
|
|
|
with pytest.raises(BadRequestError):
|
|
|
|
|
|
parse_fraction("abc")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestValidateAndNormalize:
|
|
|
|
|
|
"""参数值校验:类型/取值域/枚举(审查重点 1:畸形值不得落库)。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_去除首尾空白后返回规范值(self) -> None:
|
|
|
|
|
|
spec = PARAM_REGISTRY["barter.discount"]
|
|
|
|
|
|
assert validate_and_normalize(spec, " 80 ") == "80"
|
|
|
|
|
|
|
|
|
|
|
|
def test_超取值域上限拒绝(self) -> None:
|
|
|
|
|
|
spec = PARAM_REGISTRY["barter.discount"] # value_max=100
|
|
|
|
|
|
with pytest.raises(BadRequestError):
|
|
|
|
|
|
validate_and_normalize(spec, "120")
|
|
|
|
|
|
|
|
|
|
|
|
def test_低于取值域下限拒绝(self) -> None:
|
|
|
|
|
|
spec = PARAM_REGISTRY["barter.discount"] # value_min=0
|
|
|
|
|
|
with pytest.raises(BadRequestError):
|
|
|
|
|
|
validate_and_normalize(spec, "-1")
|
|
|
|
|
|
|
|
|
|
|
|
def test_枚举越界拒绝(self) -> None:
|
|
|
|
|
|
spec = PARAM_REGISTRY["payment.weight.mode"]
|
|
|
|
|
|
with pytest.raises(BadRequestError):
|
|
|
|
|
|
validate_and_normalize(spec, "PARTIAL")
|
|
|
|
|
|
|
|
|
|
|
|
def test_枚举合法值通过(self) -> None:
|
|
|
|
|
|
spec = PARAM_REGISTRY["payment.weight.mode"]
|
|
|
|
|
|
assert validate_and_normalize(spec, "BALANCE") == "BALANCE"
|
|
|
|
|
|
|
|
|
|
|
|
def test_整数类型拒绝小数(self) -> None:
|
|
|
|
|
|
spec = PARAM_REGISTRY["salesperson.days.boundary"]
|
|
|
|
|
|
with pytest.raises(BadRequestError):
|
|
|
|
|
|
validate_and_normalize(spec, "360.5")
|
|
|
|
|
|
|
|
|
|
|
|
def test_分数式斜率原样保留不截断(self) -> None:
|
|
|
|
|
|
spec = PARAM_REGISTRY["salesperson.rate.slope"]
|
|
|
|
|
|
assert validate_and_normalize(spec, "4/360") == "4/360"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTemplates:
|
|
|
|
|
|
"""公式模板注册表:有限集合,声明参数必须真实存在。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_共五个模板(self) -> None:
|
|
|
|
|
|
assert len(TEMPLATES) == 5
|
|
|
|
|
|
|
|
|
|
|
|
def test_模板声明参数均在注册表内(self) -> None:
|
|
|
|
|
|
for tpl in TEMPLATES.values():
|
|
|
|
|
|
for code in tpl.params:
|
|
|
|
|
|
assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}"
|
|
|
|
|
|
|
|
|
|
|
|
def test_取模板声明参数(self) -> None:
|
|
|
|
|
|
params = required_params("TPL_SALESPERSON_DECAY")
|
|
|
|
|
|
assert "salesperson.rate.slope" in params
|
|
|
|
|
|
|
|
|
|
|
|
def test_未知模板抛ValueError_属代码缺陷(self) -> None:
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
|
get_template("TPL_NOT_EXIST")
|