"""参数注册表与公式模板注册表测试。 守的是:注册表是参数种子数据与运行期校验的唯一来源 —— 一旦编码或默认值 与迁移种子漂移,参数解析会取到错误口径;一旦取值校验失守, 畸形值(如 "1/0")会落库并在核算时崩溃或算错钱。 """ from fractions import Fraction from types import SimpleNamespace import pytest from app.core.exceptions import BadRequestError from app.services.calc_templates import FORMULAS, TEMPLATES, get_formula from app.services.param_registry import ( PARAM_REGISTRY, get_spec, parse_fraction, validate_and_normalize, ) # 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项 # + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12; # V10 增工作台预警规则三项 #10) EXPECTED_CODES = { "approval.adjustment.enabled", "approval.monthly_settlement.enabled", "approval.secondary_allocation.enabled", "barter.discount", "coefficient.personal.boundary", "coefficient.personal.first", "coefficient.personal.floor", "coefficient.personal.slope", "coefficient.personal.start", "enterprise.rate", "info.rate", "manager.personal.included", "manager.share.company", "manager.share.team", "payment.start.ratio", "payment.weight.mode", "prepay.rate", "probation.annual.threshold", "product.threshold", "receipt.confirmation.enabled", "salary.basic.mode", "salesperson.days.boundary", "salesperson.rate.floor", "salesperson.rate.slope", "salesperson.rate.start", "warning.approval.timeout.days", "warning.enabled", "warning.receipt.overdue.days" } def test_目录说明使用当前规则且不改写已有参数值(monkeypatch): """旧数据库的说明可更新,历史生效值和日期必须保持原样。""" from datetime import date from app.services import param_service spec = get_spec("approval.monthly_settlement.enabled") definition = SimpleNamespace( param_code=spec.code, param_name=spec.name, param_group=spec.group, value_type=spec.value_type, unit=spec.unit, dimension_type=spec.dimension_type, default_value=spec.default_value, value_min=spec.value_min, value_max=spec.value_max, description="旧说明:关闭后自动记录通过痕迹", ) monkeypatch.setattr(param_service.param_repository, "list_definitions", lambda *a, **k: [definition]) monkeypatch.setattr( param_service.param_repository, "resolve_values", lambda *a: [(spec.code, "", "1", date(2026, 1, 1))], ) item = param_service.get_catalog(None, as_of=date(2026, 11, 2))[0] assert item.description == spec.description assert item.values[0].value_text == "1" assert item.values[0].effective_date == date(2026, 1, 1) class TestRegistry: """注册表元数据完整性(与迁移种子同源)。""" def test_注册表共27项(self) -> None: assert len(PARAM_REGISTRY) == 28 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 elif spec.value_type == "BOOL": assert spec.default_value in {"0", "1"}, f"{spec.code} 默认值非 0/1" elif spec.value_type == "ENUM": assert spec.options, f"{spec.code} 为 ENUM 但无选项" assert spec.default_value in spec.options def test_BOOL开关_仅接受0与1(self) -> None: """审批开关 BOOL 组(V6 两个 + V7 调整单):合法值与非法值边界。""" for code in ( "approval.secondary_allocation.enabled", "approval.monthly_settlement.enabled", "approval.adjustment.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") 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 assert len(FORMULAS) == 5 def test_内置模板参数绑定均在注册表内且覆盖骨架角色(self) -> None: for tpl in TEMPLATES.values(): spec = get_formula(tpl.formula_type) assert set(tpl.params) == set(spec.roles), tpl.code for code in tpl.params.values(): assert code in PARAM_REGISTRY, f"{tpl.code} 声明了未注册参数 {code}" def test_取模板声明参数(self) -> None: params = TEMPLATES["TPL_SALESPERSON_DECAY"].params assert params["slope"] == "salesperson.rate.slope" def test_未知骨架抛ValueError(self) -> None: with pytest.raises(ValueError): get_formula("NOT_EXIST")