mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:51:57 +00:00
136 lines
7.6 KiB
Python
136 lines
7.6 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_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)
|