Files
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

170 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""表单 agentlab.form/1(应用平台 §3.3)与可见性表达式的 roots / arithmetic(A2、F10)。
- 表单只认子集:pattern 一律不收(JS 和 Python 的正则语义不同);数组只能是枚举字符串的多选
- 校验错误统一 {path, code, message},code 取那九种;没填的非必填字段不校验格式
- 输入节点字段 → 表单:没写类型按 string;有默认值的必填字段不逼人填
- 表达式:不给 roots / arithmetic 时和以前一模一样;应用里禁止算术、只认 APP_ROOTS
"""
from __future__ import annotations
import pytest
from app.apps.forms import input_schema, validate_form, validate_form_schema
from app.apps.spec import APP_ROOTS
from app.engine.expressions import ExpressionArithmetic, ExpressionError, eval_expression, parse_expression
FORM = {
"format": "agentlab.form/1", "type": "object", "required": ["amount", "kind"],
"properties": {
"amount": {"type": "number", "title": "金额(元)", "minimum": 0, "maximum": 100000},
"kind": {"type": "string", "title": "类别", "enum": ["差旅", "餐饮"], "x-enum-labels": ["差旅", "餐饮"]},
"note": {"type": "string", "title": "说明", "maxLength": 10, "x-widget": "textarea"},
"day": {"type": "string", "title": "日期", "format": "date"},
"mail": {"type": "string", "title": "邮箱", "format": "email"},
"tags": {"type": "array", "title": "标签", "items": {"type": "string", "enum": ["a", "b", "c"]}, "maxItems": 2},
"count": {"type": "integer", "title": "张数"},
"urgent": {"type": "boolean", "title": "加急"},
},
"x-order": ["amount", "kind", "note", "day", "mail", "tags", "count", "urgent"],
}
def test_the_subset_is_accepted():
assert validate_form_schema(FORM) == []
@pytest.mark.parametrize("patch, needle", [
({"properties": {"x": {"type": "string", "pattern": "^a+$"}}}, "不支持正则校验"),
({"properties": {"x": {"type": "object"}}}, "类型只能是"),
({"properties": {"x": {"type": "string", "format": "ipv4"}}}, "格式只能是"),
({"properties": {"x": {"type": "string", "x-widget": "slider"}}}, "控件只能是"),
({"properties": {"x": {"type": "array", "items": {"type": "number"}}}}, "多选"),
({"properties": {"x": {"type": "string", "minLength": -1}}}, "最少字数"),
({"required": ["ghost"], "properties": {"x": {"type": "string"}}}, "必填项「ghost」"),
({"properties": {"x": {"type": "string"}}, "allOf": []}, "不支持这些写法"),
])
def test_anything_outside_the_subset_is_refused(patch, needle):
form = {"type": "object", **patch}
problems = validate_form_schema(form)
assert any(needle in p for p in problems), problems
def test_values_are_checked_with_one_error_shape():
good = {"amount": 12.5, "kind": "差旅", "note": "出差", "day": "2026-10-14", "mail": "a@b.cn", "tags": ["a"],
"count": 2, "urgent": True}
assert validate_form(FORM, good) == []
bad = {"amount": -1, "kind": "住宿", "note": "这是一段很长很长的说明文字", "day": "2026/10/14", "mail": "nope",
"tags": ["a", "b", "c"], "count": 1.5, "urgent": "是"}
errors = validate_form(FORM, bad)
got = {(e["path"], e["code"]) for e in errors}
assert got == {("amount", "minimum"), ("kind", "enum"), ("note", "max_length"), ("day", "format"),
("mail", "format"), ("tags", "max_items"), ("count", "type"), ("urgent", "type")}
assert all(set(e) == {"path", "code", "message"} and e["message"] for e in errors)
assert next(e for e in errors if e["path"] == "amount")["message"] == "「金额(元)」不能小于 0"
def test_required_and_blank_values():
errors = validate_form(FORM, {"amount": "", "day": ""})
assert {(e["path"], e["code"]) for e in errors} == {("amount", "required"), ("kind", "required")}
assert next(e for e in errors if e["path"] == "kind")["message"] == "请填写「类别」"
# 没填的非必填字段不查格式;类型不对的不再另报格式
assert validate_form(FORM, {"amount": 1, "kind": "差旅", "mail": ""}) == []
assert [(e["path"], e["code"]) for e in validate_form(FORM, {"amount": "abc", "kind": "差旅"})] == [("amount", "type")]
assert validate_form(FORM, None)[0]["code"] == "type"
def test_input_fields_become_a_form():
fields = [
{"name": "week", "required": True, "description": "统计周"},
{"name": "region", "type": "string", "enum": ["华东", "华南"], "default": "华东", "required": True},
{"name": "day", "type": "string", "format": "date"},
{"name": "top", "type": "integer", "label": "前几名"},
{"name": "regions", "type": "array", "enum": ["华东", "华南"]},
{"name": "loose", "type": "array"},
{"name": "odd", "type": "object"},
{"name": ""},
]
form = input_schema(fields)
assert form["format"] == "agentlab.form/1" and form["type"] == "object"
props = form["properties"]
assert list(props) == ["week", "region", "day", "top", "regions", "loose", "odd"] == form["x-order"]
assert props["week"] == {"type": "string", "title": "week", "description": "统计周"}
assert props["region"] == {"type": "string", "title": "region", "enum": ["华东", "华南"], "default": "华东"}
assert props["day"]["format"] == "date" and props["top"] == {"type": "integer", "title": "前几名"}
assert props["regions"] == {"type": "array", "title": "regions", "items": {"type": "string", "enum": ["华东", "华南"]}}
# 没写可选项的多选、认不出的类型退回文字
assert props["loose"]["type"] == props["odd"]["type"] == "string"
# 有默认值的必填字段:没填时入口节点补默认值,不逼人填
assert form["required"] == ["week"]
assert validate_form_schema(form) == []
def test_number_bounds_carry_into_the_form_and_are_enforced():
"""数字字段的上下限带进表单(只带数字类型、写成数的);服务端校验照它拦,前端同一份子集(lib/schemaForm.ts)照它拦。"""
form = input_schema([
{"name": "amount", "type": "number", "minimum": 0.01, "maximum": 50000, "required": True},
{"name": "count", "type": "integer", "minimum": 1},
{"name": "note", "minimum": 3},
{"name": "bad", "type": "number", "minimum": "零"},
])
props = form["properties"]
assert (props["amount"]["minimum"], props["amount"]["maximum"]) == (0.01, 50000) and props["count"]["minimum"] == 1
assert "minimum" not in props["note"] and "minimum" not in props["bad"]
assert validate_form_schema(form) == []
assert [e["code"] for e in validate_form(form, {"amount": -5})] == ["minimum"]
assert [e["code"] for e in validate_form(form, {"amount": 0})] == ["minimum"]
assert validate_form(form, {"amount": 60000})[0]["message"] == "「amount」不能大于 50000"
assert validate_form(form, {"amount": 0.01, "count": 1}) == []
def test_the_seeded_expense_template_refuses_a_negative_amount():
"""样例⑩:金额是数、不能小于 0.01(表单子集没有 exclusiveMinimum,「大于 0」写成 0.01);待办标题带上金额。"""
from app.apps.tree import input_fields
from app.seed import TEMPLATES
template = next(t for t in TEMPLATES if t["name"] == "⑩ 报销审批(会签)")
form = input_schema(input_fields({"nodes": template["nodes"]}))
assert form["properties"]["amount"] == {"type": "number", "title": "amount", "description": "金额(元)",
"minimum": 0.01}
assert validate_form(form, {"applicant": "钱七", "amount": -5, "reason": "出差"})[0]["code"] == "minimum"
assert validate_form(form, {"applicant": "钱七", "amount": "很多", "reason": "出差"})[0]["code"] == "type"
assert validate_form(form, {"applicant": "钱七", "amount": 1280.5, "reason": "出差"}) == []
review = next(n for n in template["nodes"] if n["id"] == "review")["data"]["config"]
assert "{{ input.amount }}" in review["title"] and "{{ input.amount }}" in review["message"]
# --------------------------------------------------------------------------
# 表达式
# --------------------------------------------------------------------------
def test_defaults_keep_todays_behaviour():
assert eval_expression("vars.a * 2 + 1", {"vars": {"a": 3}}) == 7
assert parse_expression("vars.a - nodes.b")[2] == []
assert parse_expression("run.tier == 'formal'")[2] == ["run"]
@pytest.mark.parametrize("expr", [
"metric.kpi.gmv * 2 > 100", "run.version + 1 == 3", "-metric.kpi.gmv < 0", "abs(metric.kpi.gmv) > 1",
"round(metric.kpi.gmv) == 4", "max(metric.kpi.gmv, 1) > 0", "metric.kpi.gmv % 2 == 0",
])
def test_apps_refuse_arithmetic(expr):
with pytest.raises(ExpressionArithmetic):
parse_expression(expr, roots=APP_ROOTS, arithmetic=False)
with pytest.raises(ExpressionArithmetic):
eval_expression(expr, {"metric": {"kpi": {"gmv": 4}}, "run": {"version": 2}}, roots=APP_ROOTS,
arithmetic=False)
def test_apps_allow_comparison_boolean_lookup_and_in():
ctx = {"run": {"tier": "degraded", "status": "succeeded"}, "metric": {"kpi": {"gmv": -5}},
"user": {"role": "user", "groups": ["财务"]}, "vars": {"secret": 1}}
expr = "run.tier != 'formal' and run.status in ['succeeded'] and metric.kpi.gmv < -1 and '财务' in user.groups"
assert parse_expression(expr, roots=APP_ROOTS, arithmetic=False)[2] == []
assert eval_expression(expr, ctx, roots=APP_ROOTS, arithmetic=False) is True
# 不认识的根名字报出来;就算上下文里有,也够不着
assert parse_expression("vars.secret == 1", roots=APP_ROOTS, arithmetic=False)[2] == ["vars"]
assert eval_expression("vars.secret", ctx, roots=APP_ROOTS, arithmetic=False) is None
with pytest.raises(ExpressionError):
parse_expression("run.tier ==", roots=APP_ROOTS, arithmetic=False)