Files
AgentLab/backend/tests/test_app_forms.py
T

136 lines
7.6 KiB
Python
Raw 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_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)