Files
AgentLab/backend/tests/test_validation_text.py
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

209 lines
9.0 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.
"""校验和报错写给人看:用界面上的叫法,说清原因和下一步,不露 Python 内部名字。
- 分支 case 的 key 用了保留名 default:和兜底出口撞成同一个 id,跑完两条一起点亮,
看不出实际走了哪条。重复 key、空 key 更糟——那个 case 根本路由不到。
- 门禁文案写「受管模板不允许 supervisor 节点」「approval=never」「该是个 llm 节点」,
界面上它们叫「多 Agent 协作」「全部自动放行」「模型调用」,报错引用了用户从没见过的词。
- 运行失败的首行写着「工具 db_query__shop 执行失败:_make_query_tool.<locals>._run()
got an unexpected keyword argument 'query'」,恢复失败写「当前状态是 succeeded」。
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine import compiler
from app.engine.governance import lint_for_publish
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, NodeType, validate_graph
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": label or nid, "config": config}}
def _branch(cases: list[dict]) -> GraphSpec:
return GraphSpec.model_validate({
"nodes": [
node("start", "input"),
node("br", "branch", label="走哪条", mode="expression", cases=cases),
node("a", "output"), node("b", "output"),
],
"edges": [
{"source": "start", "target": "br"},
{"source": "br", "target": "a", "sourceHandle": "fast"},
{"source": "br", "target": "b", "sourceHandle": "default"},
],
})
def _issues(spec: GraphSpec, level: str) -> list[str]:
return [i.message for i in validate_graph(spec).issues if i.level == level and i.node_id == "br"]
def test_case_key_default_is_flagged_as_the_reserved_fallback():
spec = _branch([{"key": "fast", "condition": "true"},
{"key": "default", "condition": "true", "label": "协作模式"}])
warnings = _issues(spec, "warning")
assert any("default" in m and "保留" in m for m in warnings), warnings
# 现存的 ⑥ 模板就是这么写的:条件为 true 的 default 等价于兜底,运行语义自洽。
# 升成 error 会让它和所有从它复制出来的图直接不能跑
assert validate_graph(spec).ok
def test_duplicate_case_keys_are_an_error():
spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": "fast", "condition": "vars.b"}])
errors = _issues(spec, "error")
assert any("fast" in m and "重复" in m for m in errors), errors
assert not validate_graph(spec).ok
def test_blank_case_key_is_an_error():
spec = _branch([{"key": "fast", "condition": "vars.a"}, {"key": " ", "condition": "vars.b"}])
assert any("标识" in m for m in _issues(spec, "error")), _issues(spec, "error")
def test_validation_uses_the_names_on_the_canvas():
spec = GraphSpec.model_validate({
"nodes": [node("start", "input"), node("sub", "subgraph"), node("t", "tool"),
node("v", "validate"), node("out", "output")],
"edges": [{"source": "start", "target": "sub"}, {"source": "sub", "target": "t"},
{"source": "t", "target": "v"}, {"source": "v", "target": "out"}],
})
messages = " ".join(i.message for i in validate_graph(spec).issues)
assert "子工作流" in messages and "子图节点" not in messages
assert "「调用工具」" in messages
assert "结构校验" in messages
def _governed(*nodes) -> str:
spec = GraphSpec.model_validate({
"nodes": [node("start", "input"), *nodes],
"edges": [{"source": "start", "target": n["id"]} for n in nodes],
})
return " ".join(i.message for i in lint_for_publish(spec, level="governed").issues)
def test_governance_speaks_the_ui_vocabulary():
text = _governed(
node("team", "supervisor", label="协作团队"),
node("ag", "agent", label="取数员", tools=["web_search"], approval="never"),
node("bare", "agent", label="空手的"),
node("c", "code", label="算一下", network=True),
)
for internal in ("supervisor", "approval=never", "llm 节点", "contract"):
assert internal not in text, f"门禁文案里还有内部词 {internal!r}:{text}"
assert "「协作团队」" in text and "多 Agent 协作" in text
assert "审批策略" in text and "全部自动放行" in text
assert "模型调用" in text
assert "出具契约" in text
# --------------------------------------------------------------------------
# 运行期报错
# --------------------------------------------------------------------------
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def _finish(run_id: str) -> Run:
for _ in range(300):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled", "interrupted"):
return row
await asyncio.sleep(0.03)
raise AssertionError("等超时了")
RAW = "_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'"
async def test_a_python_error_reads_like_a_sentence(engine_up, monkeypatch):
async def broken(state, ctx):
raise TypeError(RAW)
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, broken)
graph = {
"nodes": [node("start", "input"), node("shape", "transform", label="整理数据",
mode="template", template="x")],
"edges": [{"source": "start", "target": "shape"}],
}
run = await run_manager.start(graph=graph, input_payload={})
row = await _finish(run.id)
assert row.status == "failed"
for text in (row.error or "",):
assert "TypeError" not in text and "<locals>" not in text, text
assert "query" in text, "原因要说到点子上:哪个参数对不上"
assert row.error_node_id == "shape"
async with SessionLocal() as session:
events = list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
)).scalars())
failed_node = next(e for e in events if e.type == "node.failed")
assert "TypeError" not in failed_node.data["error"]
assert "TypeError" in failed_node.data["detail"], "原始异常要留着,给排查的人看"
failed_run = next(e for e in events if e.type == "run.failed")
assert failed_run.data["node_id"] == "shape"
assert failed_run.data["label"] == "整理数据"
assert "TypeError" not in failed_run.data["error"]
async def test_resume_refusal_does_not_leak_the_status_enum(engine_up):
async with SessionLocal() as session:
run = Run(workflow_name="x", status="succeeded", graph={"nodes": [], "edges": []}, input={})
session.add(run)
await session.commit()
with pytest.raises(ValueError) as caught:
await run_manager.resume(run.id, {"approved": True})
assert "succeeded" not in str(caught.value)
assert "已完成" in str(caught.value)
def test_exception_wording_helper():
from app.engine.errors import describe_exception
assert "query" in describe_exception(TypeError(RAW))
assert "TypeError" not in describe_exception(TypeError(RAW))
timeout = describe_exception(TimeoutError())
assert timeout and "Timeout" not in timeout
generic = describe_exception(RuntimeError("Integer exceeds 64-bit range"))
assert "RuntimeError" not in generic and "64-bit" in generic
nested = describe_exception(RuntimeError("连接失败:ConnectError: All connection attempts failed"))
assert "Error" not in nested and "All connection attempts failed" in nested
async def test_issuance_event_says_why_it_was_downgraded(engine_up):
"""只有 gaps 时判成降档,事件里却不带 gaps:时间线上只剩一个光秃秃的「降档出具」,
横幅还说「请对照下方声明」,下方什么都没有。"""
graph = {
"nodes": [
node("start", "input"),
node("out", "output", fields=[{"name": "结论", "value": "本周 12 单"}],
contract={"metrics_from": "没有这个口径卡", "narrative": "{{ output.结论 }}"}),
],
"edges": [{"source": "start", "target": "out"}],
}
run = await run_manager.start(graph=graph, input_payload={})
row = await _finish(run.id)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
issuance = (await session.execute(
select(RunEvent).where(RunEvent.run_id == run.id, RunEvent.type == "issuance")
)).scalars().one()
assert issuance.data["tier"] == "degraded"
assert any("没有这个口径卡" in g for g in issuance.data["gaps"]), issuance.data
assert issuance.data["metrics_checked"] == 0