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

240 lines
13 KiB
Python

"""流程应用的实例页和待办怎么说(apps/resolve.py 的流程组件、process/tasks.py 的待办描述)。
端到端验收(报销审批:财务会签、超时升级给总监)发现的几处「说得不对」:
- 发起人打开实例页,待办表单去取一条不是派给他的待办,红框写「待办不存在」:服务端解析时就判定读者是不是处理人,
不是的只给这一步的状态(派给了谁、会签进度、时限),不让界面去取;
- 会签进度把登录核实过的审批人写成「自报署名」、没有处理时间:页面解析给的派给谁要带上这两项;
- 同意过的人再打开写「没有派给你」:待办上带这个人的回复,写「你已处理」;
- 走了「已退回」分支的实例写「已完成」:实例的状态说流程走到了哪个成果节点;
- 升级之后写「0/3 已同意」,其实总监一人同意就通过:会签人数不算升级加派的那一份,升级后的规则单列;
- 同一个人两笔报销,待办列表里标题一样分不开:待办带上实例标题和开出的时间。
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import timedelta
import pytest
from sqlalchemy import select
from app.api.deps import Principal
from app.db.base import SessionLocal, utcnow
from app.db.models import App, AppVersion, Group, GroupMember, Run, Task, User
from app.engine.runner import run_manager
from app.process import tasks
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
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 expense() -> dict:
nodes = [node("start", "input", "发起报销", fields=[{"name": "applicant"}, {"name": "amount", "type": "number"}]),
node("review", "human", "财务会签", mode="approve", title="报销审批:{{ input.applicant }}",
assignee={"kind": "slot", "slot": "finance"}, policy="all", due="PT1H",
on_timeout={"action": "escalate", "to_slot": "director"}),
node("ok", "output", "已通过", fields=[{"name": "结论", "value": "已通过"}]),
node("no", "output", "已退回", fields=[{"name": "结论", "value": "已退回"}])]
edges = [{"source": "start", "target": "review"},
{"source": "review", "target": "ok", "sourceHandle": "approved"},
{"source": "review", "target": "no", "sourceHandle": "rejected"}]
return {"nodes": nodes, "edges": edges}
SPEC = {"format": "agentlab.app/1", "name": "报销", "pages": [{"id": "instance", "title": "实例", "body": [
{"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"},
{"id": "form", "type": "task_form", "bind": {"task": "review"}},
{"id": "signs", "type": "signoffs", "bind": {"task": "review"}},
{"id": "mine", "type": "task_list", "props": {"scope": "mine"}}]}]}
def user(name: str) -> Principal:
return Principal(username=name, role="user", mode="local", verified=True)
async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row and row.status in statuses and not run_manager.is_active(run_id):
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
async def _setup() -> tuple[str, str, str, str]:
"""一个应用:财务岗位绑一个组(两个人),总监岗位绑一个人。返回 (应用 id, 财务甲, 财务乙, 总监)。"""
u = uuid.uuid4().hex[:6]
a, b, boss, group = f"zhang{u}", f"wang{u}", f"zhao{u}", f"finance{u}"
async with SessionLocal() as session:
people = [User(username=n, role="user") for n in (a, b)]
session.add_all(people)
g = Group(name=group)
session.add(g)
await session.flush()
session.add_all([GroupMember(group_id=g.id, user_id=p.id) for p in people])
app = App(name=f"报销-{u}", published_version=1)
session.add(app)
await session.flush()
session.add(AppVersion(app_id=app.id, version=1, spec=SPEC, spec_hash="x" * 64, bindings={"slots": {
"finance": [{"kind": "group", "name": group}], "director": [{"kind": "user", "name": boss}]}}))
await session.commit()
return app.id, a, b, boss
async def _start(app_id: str, title: str, amount: float = 1280.5) -> str:
run = await run_manager.start(graph=expense(), input_payload={"applicant": "钱七", "amount": amount},
app_id=app_id, app_version=1, started_by="qianqi", run_class="formal",
origin_kind="app_entry", origin={"entry": "start", "title": title})
await _wait(run.id, ("interrupted",))
return run.id
async def _task(run_id: str) -> Task:
async with SessionLocal() as session:
return (await session.execute(select(Task).where(Task.run_id == run_id))).scalars().one()
async def _page(app_id: str, run_id: str, reader: Principal) -> dict:
from app.apps.resolve import resolve_page
async with SessionLocal() as session:
app = await session.get(App, app_id)
version = (await session.execute(select(AppVersion).where(AppVersion.app_id == app_id))).scalars().one()
run = await session.get(Run, run_id)
vm = await resolve_page(session, app, page_id="instance", principal=reader, role="use", instance=run,
version=version)
return {n["id"]: n.get("data") for n in vm["tree"]}
async def test_the_initiator_sees_the_step_not_a_missing_task():
app_id, a, b, _ = await _setup()
run_id = await _start(app_id, "报销审批·钱七")
task = await _task(run_id)
view = await _page(app_id, run_id, user("qianqi"))
form = view["form"]
# 发起人不是处理人:不让界面去取待办(取了是 404),给这一步的状态
assert form["handler"] is False
assert sorted((x["principal"], x["status"]) for x in form["assignments"]) == \
sorted([(a, "pending"), (b, "pending")])
assert form["progress"] == {"policy": "all", "voters": 2, "need": 2, "agreed": 0, "rejected": 0}
assert form["task"]["due_at"] and form["task"]["instance_title"] == "报销审批·钱七"
assert tasks.visible(await tasks.detail(task.id, user("qianqi")), user("qianqi")) is False
# 处理人:给待办卡(界面按 id 取完整的待办)
assert (await _page(app_id, run_id, user(a)))["form"]["handler"] is True
# 关登录时人人都算处理人(同待办服务)
assert (await _page(app_id, run_id, Principal(username=None, role="admin", mode="off")))["form"]["handler"] is True
hdr = view["hdr"]
assert (hdr["status_code"], hdr["status_text"]) == ("waiting", "等待审批")
assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "等待审批"
async def test_signoffs_say_who_verified_and_when_and_the_voter_sees_their_own_answer():
app_id, a, b, _ = await _setup()
run_id = await _start(app_id, "报销审批·钱七")
task = await _task(run_id)
assert (await tasks.respond(task.id, user(a), decision="approve"))["resolved"] is False
signs = (await _page(app_id, run_id, user("qianqi")))["signs"]
done = next(x for x in signs["assignments"] if x["principal"] == a)
# 登录核实过的写「已登录核实」,不是「自报署名」;带处理时间
assert done["status"] == "responded" and done["verified"] is True and done["responded_at"]
assert done["decision"] == "approve" and done["source"] == "slot:finance"
assert signs["progress"]["agreed"] == 1 and signs["progress"]["need"] == 2
# 同意过的人再打开:不能再回复,带上他的回复(写「你已处理:同意」,不写「没有派给你」)
mine = await tasks.detail(task.id, user(a))
assert mine["can_respond"] is False and mine["my_response"]["decision"] == "approve"
assert mine["my_response"]["at"] and mine["my_response"]["verified"] is True
assert (await tasks.detail(task.id, user(b)))["my_response"] is None
assert (await _page(app_id, run_id, user(a)))["form"]["my_response"]["decision"] == "approve"
async def test_the_instance_says_which_outcome_it_reached():
app_id, a, b, _ = await _setup()
passed = await _start(app_id, "报销审批·钱七")
task = await _task(passed)
await tasks.respond(task.id, user(a), decision="approve")
await tasks.respond(task.id, user(b), decision="approve")
await _wait(passed, ("succeeded",))
view = await _page(app_id, passed, user("qianqi"))
assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("succeeded", "已通过")
review = next(s for s in view["tl"]["steps"] if s["node"] == "review")
assert (review["state"], review["text"]) == ("done", "已同意")
back = await _start(app_id, "报销审批·钱七")
await tasks.respond((await _task(back)).id, user(b), decision="reject", note="票据不全")
await _wait(back, ("succeeded",))
view = await _page(app_id, back, user("qianqi"))
# 走了退回分支:运行是「已完成」,流程的结论是「已退回」
assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("succeeded", "已退回")
assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "已驳回"
# 判定之后没回复的都撤回了:会签人数按判定那一刻的算,不缩成 1
assert view["signs"]["progress"] == {"policy": "all", "voters": 2, "need": 2, "agreed": 0, "rejected": 1}
from app.apps.resolve import list_instances
async with SessionLocal() as session:
rows = {r["run_id"]: r for r in await list_instances(session, app_id, mine="qianqi")}
assert (rows[back]["status_text"], rows[passed]["status_text"]) == ("已退回", "已通过")
async def test_outputs_reached_in_parallel_are_not_one_outcome():
"""几个成果节点并行都走到了(「周报」「明细」一起出):那不是哪一种结局,写「已完成」,不写最后走到的那个节点的名字。"""
from app.apps.resolve import instance_rows
graph = {"nodes": [node("start", "input", "发起", fields=[{"name": "week"}]),
node("weekly", "output", "周报", fields=[{"name": "周", "value": "{{ input.week }}"}]),
node("detail", "output", "明细", fields=[{"name": "周", "value": "{{ input.week }}"}])],
"edges": [{"source": "start", "target": "weekly"}, {"source": "start", "target": "detail"}]}
run = await run_manager.start(graph=graph, input_payload={"week": "2026-W41"})
done = await _wait(run.id, ("succeeded", "failed"))
assert done.status == "succeeded", done.error
async with SessionLocal() as session:
[row] = await instance_rows(session, [await session.get(Run, run.id)])
assert (row["status_code"], row["status_text"]) == ("succeeded", "已完成")
async def test_after_escalation_the_rule_is_one_director_not_zero_of_three():
app_id, a, b, boss = await _setup()
run_id = await _start(app_id, "报销审批·钱七")
task = await _task(run_id)
await tasks.respond(task.id, user(a), decision="approve")
assert await tasks.tick(utcnow() + timedelta(hours=2)) >= 1
detail = await tasks.detail(task.id, user(boss))
p = detail["progress"]
# 升级加派的那一份不算进会签人数:还是 1/2,另写升级给了谁、他一人说了算
assert (p["voters"], p["need"], p["agreed"]) == (2, 2, 1)
assert p["escalation"] == {"slot": "director", "assignees": [{"kind": "user", "name": boss}], "decided": None,
"by": None}
assert detail["can_respond"] is True
assert (await tasks.respond(task.id, user(boss), decision="approve"))["resolved"] is True
await _wait(run_id, ("succeeded",))
p = (await tasks.detail(task.id, user(boss)))["progress"]
assert (p["voters"], p["agreed"]) == (2, 1) and (p["escalation"]["decided"], p["escalation"]["by"]) == \
("approve", boss)
signs = (await _page(app_id, run_id, user("qianqi")))["signs"]
assert signs["progress"]["escalation"]["by"] == boss
async def test_tasks_with_the_same_title_carry_the_instance_and_when():
app_id, a, _, _ = await _setup()
first = await _start(app_id, "报销审批·钱七·第一笔")
second = await _start(app_id, "报销审批·钱七·第二笔", amount=300)
listed = [t for t in await tasks.list_tasks(user(a), app_id=app_id)]
assert [t["title"] for t in listed] == ["报销审批:钱七", "报销审批:钱七"]
assert {t["run_id"]: t["instance_title"] for t in listed} == {first: "报销审批·钱七·第一笔",
second: "报销审批·钱七·第二笔"}
rows = (await _page(app_id, first, user(a)))["mine"]["items"]
assert {r["instance_title"] for r in rows} == {"报销审批·钱七·第一笔", "报销审批·钱七·第二笔"}
assert all(r["created_at"] for r in rows)