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