mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:21:59 +00:00
合入期 4 整合 J4:质询回答工件在记录页有名字和说明;问答串在审批列表取失败时不再漏条目;运行对比的默认基准与定时任务自动对比用同一条规则(同一任务、同一工作流、比这次先发起的上一次成功并封存的运行),触发历史在基准不是紧挨着的上一次时写明「与上一次成功的运行对比」
This commit is contained in:
+55
-29
@@ -1,9 +1,11 @@
|
||||
"""运行对比:同一个工作流的两次运行按「变化三分法」对照——数据变了、口径变了、还是模板 / SQL 变了(engine/compare.py)。
|
||||
|
||||
GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base}。这次运行是定时任务发起的,取同一
|
||||
定时任务、结束得比它早的最近一次终态运行(reason: same_schedule);不是、或者同一任务还没有更早的,取同一工作流、
|
||||
同一运行类别、结束得比它早的最近一次终态运行(reason: same_workflow)。只挑封存过的:没封存的拿来也比不了。
|
||||
找不到 404。
|
||||
GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base}。规则在 pick_base,定时任务结束后
|
||||
的自动对比(scheduler/auto_compare.py)用的是同一个函数——触发历史里的「与上一次对比」和运行详情里的同名入口必须拿到
|
||||
同一个基准,计数才对得上;触发历史里存着的那一份,事后基准不再是这里挑出来的那一次(中间失败的那次被接着运行成功
|
||||
了)就不再给出(auto_compare.outdated)。这次运行是定时任务发起的,取同一定时任务、同一工作流、比它先发起的运行里
|
||||
成功结束并封存的最近一次(reason: same_schedule);不是、或者同一任务之前没有成功的,取同一工作流、同一运行类别里
|
||||
同样条件的最近一次(reason: same_workflow)。找不到 404。
|
||||
|
||||
GET /api/runs/{run_id}/compare?base={base_id}:{target, base, summary, template, metrics, queries, notes},每个字段
|
||||
的形状见 engine/compare.py。两次运行都必须已结束、封存且封存核对一致,封存判定照搬证据接口(api/evidence._sealed):
|
||||
@@ -15,16 +17,16 @@ GET /api/runs/{run_id}/compare?base={base_id}:{target, base, summary, template
|
||||
结果里。读工件、比 SQL、重算都在线程里做:快照大的时候不挡住同一进程里别的请求。
|
||||
|
||||
路径挂在 /api/runs/{run_id}/… 下面;这个路由登记在 runs.router 之前,以后加 /api/runs/compare 这类固定路径也不会
|
||||
被 /{run_id} 吃掉。定时任务结束后自动对比(期 4)直接调 default_base、compare_runs。
|
||||
被 /{run_id} 吃掉。定时任务结束后自动对比(期 4)直接调 pick_base、compare_runs。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.api.coded import CodedHTTPException
|
||||
from app.api.evidence import _TERMINAL, RUN_NOT_FOUND, _Masks, _Sealed, _sealed, _sealed_cards
|
||||
@@ -51,8 +53,9 @@ UNSEALED_TEXT = "{who}尚未结束并封存:对比只用封存的运行记录
|
||||
#: 已经结束、只是没有封存清单(封存上线前结束的、结束时没封上的):再等也等不来封存,不能说「运行结束后才能对比」
|
||||
NO_MANIFEST_TEXT = "{who}没有封存清单,不能用于对比"
|
||||
SEAL_BROKEN_TEXT = "{who}的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果"
|
||||
NO_BASE_TEXT = ("没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、"
|
||||
"更早结束并已封存的运行")
|
||||
#: 找不到默认基准。运行详情的「与上一次对比」把它原样弹给人看,对比页把它跟在标题「没有默认基准」后面:自己得说清
|
||||
#: 找的是什么、没找到。定时任务发起的先找同一定时任务、再找同一工作流,两档都落空才到这里,条件只写后一档的
|
||||
NO_BASE_TEXT = "没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行"
|
||||
TARGET = "这次运行"
|
||||
BASE = "作为基准的运行"
|
||||
|
||||
@@ -63,8 +66,7 @@ async def compare_base(run_id: str) -> dict[str, Any]:
|
||||
found = await default_base(run_id)
|
||||
if found is None:
|
||||
raise CodedHTTPException(404, NO_BASE_TEXT, NO_BASE)
|
||||
run, reason = found
|
||||
return {"base_run_id": run.id, "reason": reason, "base": run_ref(run)}
|
||||
return {"base_run_id": found.run.id, "reason": found.reason, "base": run_ref(found.run)}
|
||||
|
||||
|
||||
@router.get("/{run_id}/compare")
|
||||
@@ -75,29 +77,53 @@ async def compare_with(run_id: str, base: str | None = None) -> dict[str, Any]:
|
||||
return await compare_runs(run_id, base.strip())
|
||||
|
||||
|
||||
async def default_base(run_id: str) -> tuple[Run, str] | None:
|
||||
"""(默认的基准运行, 怎么选的);找不到返回 None,这次运行本身不存在 404。
|
||||
class Pick(NamedTuple):
|
||||
"""挑出来的基准:哪一次、怎么选的(same_schedule | same_workflow)、是不是紧挨着的上一次(同一范围里两次之间没有
|
||||
隔着别的运行——失败、取消、还没结束的都算隔着)。"""
|
||||
run: Run
|
||||
reason: str
|
||||
previous: bool
|
||||
|
||||
「早」按结束时间比:这次运行还没结束(还在跑、停在审批)的按开始时间。只挑终态、封存过的运行——没封存的拿来
|
||||
对比只会得到 409。定时任务发起的先找同一定时任务(同一个工作流)的;它是这个任务的第一次时,退回同一工作流、
|
||||
同一运行类别。没挂在已保存的工作流上的运行(画布上没保存就运行的图)没有可比的。"""
|
||||
|
||||
async def default_base(run_id: str) -> Pick | None:
|
||||
"""这次运行默认拿哪一次来比(pick_base);找不到返回 None,这次运行本身不存在 404。"""
|
||||
async with SessionLocal() as session:
|
||||
run = await session.get(Run, run_id)
|
||||
if run is None:
|
||||
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
|
||||
if not run.workflow_id:
|
||||
return None
|
||||
before = run.finished_at or run.started_at or run.created_at
|
||||
query = (select(Run)
|
||||
.where(Run.id != run.id, Run.workflow_id == run.workflow_id, Run.status.in_(_TERMINAL),
|
||||
Run.manifest_hash.is_not(None), Run.finished_at.is_not(None), Run.finished_at < before)
|
||||
.order_by(Run.finished_at.desc(), Run.created_at.desc()).limit(1))
|
||||
if run.schedule_id:
|
||||
found = (await session.execute(query.where(Run.schedule_id == run.schedule_id))).scalars().first()
|
||||
if found is not None:
|
||||
return found, "same_schedule"
|
||||
found = (await session.execute(query.where(Run.run_class == run.run_class))).scalars().first()
|
||||
return (found, "same_workflow") if found is not None else None
|
||||
return await pick_base(session, run)
|
||||
|
||||
|
||||
async def pick_base(session: Any, target: Run, *, schedule_only: bool = False) -> Pick | None:
|
||||
"""target 的基准:比它先发起的运行里成功结束并封存的最近一次。运行详情「与上一次对比」(default_base)和定时任务
|
||||
结束后的自动对比(scheduler/auto_compare.py,schedule_only=True)都调这一个函数:两个同名入口拿到的必须是同一个
|
||||
基准,不然计数对不上。
|
||||
|
||||
- 定时任务发起的先在同一定时任务里找(same_schedule);它是这个任务的第一次、或者之前的都没成功时,退回同一工作流、
|
||||
同一运行类别(same_workflow)。schedule_only 时不退:自动对比要比的是「同一定时任务的上一次」,别的运行不算
|
||||
- 只挑成功结束并封存的:失败、取消的运行口径卡常常没执行,拿它当基准每个指标都成了「新增」「移除」;没封存的
|
||||
拿来对比只会得到 409
|
||||
- 按发起先后挑(created_at),不按结束时间:老的失败运行事后被接着运行完,结束得比后一次晚,它的上一次仍是它前面
|
||||
那次;这次还没结束(还在运行、停在审批)的也一样按发起先后
|
||||
- 都只看同一个工作流:没挂在已保存的工作流上的运行(画布上没保存就运行的图)没有可比的,定时任务改过工作流的,
|
||||
之前那个工作流的运行也不算"""
|
||||
if not target.workflow_id:
|
||||
return None
|
||||
scopes = [("same_schedule", Run.schedule_id == target.schedule_id)] if target.schedule_id else []
|
||||
if not schedule_only:
|
||||
scopes.append(("same_workflow", Run.run_class == target.run_class))
|
||||
for reason, scope in scopes:
|
||||
earlier = (scope, Run.workflow_id == target.workflow_id, Run.id != target.id,
|
||||
Run.created_at < target.created_at)
|
||||
base = (await session.execute(
|
||||
select(Run).where(*earlier, Run.status == "succeeded", Run.manifest_hash.is_not(None))
|
||||
.order_by(Run.created_at.desc()).limit(1))).scalars().first()
|
||||
if base is None:
|
||||
continue
|
||||
between = await session.scalar(select(func.count()).select_from(Run).where(
|
||||
*earlier, Run.created_at > base.created_at))
|
||||
return Pick(base, reason, not between)
|
||||
return None
|
||||
|
||||
|
||||
async def compare_runs(run_id: str, base_id: str) -> dict[str, Any]:
|
||||
|
||||
@@ -285,17 +285,20 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d
|
||||
|
||||
对比和比的时候两边的封存绑定(auto_compare):哪一边现在不在终态(被继续执行)、被删掉、清单哈希和比的时候不一样
|
||||
(重新封存过)都给 null——对比页按现在的封存现算,记下的计数和点进去看到的会对不上,或者干脆比不了。工件读不出来、
|
||||
被改过的同样给 null。"""
|
||||
被改过的同样给 null。基准已经不是现在挑出来的那一次(auto_compare.outdated:中间隔着的运行事后接着运行成功了)也
|
||||
给 null:运行详情里同名的入口比的是另一次。"""
|
||||
compared = {f.id: f.compare_artifact for f in fires if f.compare_artifact and f.run_id}
|
||||
compares = await auto_compare.briefs(list(compared.values()))
|
||||
stale = await auto_compare.outdated(session, compares)
|
||||
run_ids = {f.run_id for f in fires if f.run_id} | {brief["base_run_id"] for brief in compares.values()}
|
||||
# 运行现在的状态和清单哈希:run_status 照写,对比按它核对两边的封存
|
||||
current = {run_id: (status, seal) for run_id, status, seal in (await session.execute(
|
||||
select(Run.id, Run.status, Run.manifest_hash).where(Run.id.in_(run_ids)))).all()} if run_ids else {}
|
||||
|
||||
def compare_of(f: ScheduleFire) -> dict[str, Any] | None:
|
||||
brief = compares.get(compared.get(f.id) or "")
|
||||
if brief is None or brief["target_run_id"] != f.run_id:
|
||||
artifact = compared.get(f.id) or ""
|
||||
brief = compares.get(artifact)
|
||||
if brief is None or brief["target_run_id"] != f.run_id or artifact in stale:
|
||||
return None
|
||||
for run_id, seal in ((brief["target_run_id"], brief["target_manifest"]),
|
||||
(brief["base_run_id"], brief["base_manifest"])):
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
|
||||
- 比哪些:成功结束、封存完的运行。失败、取消的运行指标不全(口径卡常常没执行),和上一次比只会得出「移除 N 个」,
|
||||
收到通知的人会以为报告删了指标
|
||||
- 跟谁比:同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次(_previous)。失败的那次不当基准:
|
||||
拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比后一次晚,
|
||||
它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和触发历史
|
||||
写明比的是哪一次
|
||||
- 跟谁比:同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次。规则在对比接口的 pick_base,
|
||||
运行详情「与上一次对比」的默认基准用的是同一个函数:触发历史和运行详情里两个同名的入口必须比同一对运行。失败的那次
|
||||
不当基准:拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比
|
||||
后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和
|
||||
触发历史写明比的是哪一次;中间那次事后接着运行成功了,上一次成功的就成了它,触发历史不再给当时那一份(outdated)。
|
||||
这个任务的第一次不比(不像运行详情那样退回同一工作流的别的运行:那不是「上一次」)
|
||||
- 和封存绑定:运行的封存要盖住它最后一条结束事件才算封存完(sealed_now)。runner 先写终态、再另起会话封存,失败后
|
||||
接着跑完的运行在两步之间还带着失败时的清单哈希,拿它去比只比得到旧封存范围里的事件。结果里记下两边的清单哈希:
|
||||
哪一边后来变了,触发历史就不再给这份对比(api/schedules._fire_outs);对比途中变了,结果不记
|
||||
@@ -125,7 +127,8 @@ async def compare_fire(fire_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
async def _compare(fire_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
# 现成的对比函数在用到时才导入:api/compare.py 连带证据、推演接口,调度模块被导入时不必一起加载
|
||||
# 挑基准、对比都用对比接口现成的函数,在用到时才导入:api/compare.py 连带证据、推演接口,调度模块被导入时不必
|
||||
# 一起加载
|
||||
from app.api import compare as compare_api
|
||||
|
||||
# 挑基准、核对封存、看有没有比过都只是读库,不占对比的空位:前面排着慢的对比时,第一次运行这类没什么可比的
|
||||
@@ -136,10 +139,10 @@ async def _compare(fire_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
if target is None or target.status != "succeeded" or not target.schedule_id or not target.workflow_id \
|
||||
or not await sealed_now(session, target):
|
||||
return None
|
||||
found = await _previous(session, target)
|
||||
found = await compare_api.pick_base(session, target, schedule_only=True)
|
||||
if found is None:
|
||||
return None
|
||||
base, previous = found
|
||||
base, previous = found.run, found.previous
|
||||
if not await sealed_now(session, base):
|
||||
# 基准正好处在写完终态、还没封存的那一刻,或者封存没补上就停了机:拿它比只比得到旧封存里的事件
|
||||
logger.warning("作为基准的运行 %s 还没有封存到它的结束事件,运行 %s 这次不比", base.id, run_id)
|
||||
@@ -167,23 +170,6 @@ async def _compare(fire_id: str, run_id: str) -> dict[str, Any] | None:
|
||||
return _copy(brief)
|
||||
|
||||
|
||||
async def _previous(session: Any, target: Run) -> tuple[Run, bool] | None:
|
||||
"""(基准, 是不是紧挨着的上一次):同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次。
|
||||
|
||||
按发起先后挑(created_at),不按结束时间;同一工作流才比得了(任务改过工作流的,之前那个工作流的运行不算)。
|
||||
中间隔着别的运行(失败、取消、还没结束的)时第二项为 False。"""
|
||||
earlier = (Run.schedule_id == target.schedule_id, Run.workflow_id == target.workflow_id, Run.id != target.id,
|
||||
Run.created_at < target.created_at)
|
||||
base = (await session.execute(
|
||||
select(Run).where(*earlier, Run.status == "succeeded", Run.manifest_hash.is_not(None))
|
||||
.order_by(Run.created_at.desc()).limit(1))).scalars().first()
|
||||
if base is None:
|
||||
return None
|
||||
between = await session.scalar(select(func.count()).select_from(Run).where(
|
||||
*earlier, Run.created_at > base.created_at))
|
||||
return base, not between
|
||||
|
||||
|
||||
def _slots() -> asyncio.Semaphore:
|
||||
loop = asyncio.get_running_loop()
|
||||
slots = _SLOTS.get(loop)
|
||||
@@ -264,6 +250,30 @@ def brief_of(artifact: str) -> dict[str, Any] | None:
|
||||
return _copy(brief)
|
||||
|
||||
|
||||
async def outdated(session: Any, known: dict[str, dict[str, Any]]) -> set[str]:
|
||||
"""known(对比工件 id → 摘要)里基准已经不是现在挑出来的那一次的:返回这些工件的 id。
|
||||
|
||||
只有基准当时就不是紧挨着的上一次(previous 为 False)的会这样:中间隔着的那几次(失败、还没结束的)事后接着运行
|
||||
成功、封存了,这次之前同一定时任务上一次成功的就成了它。运行详情「与上一次对比」的默认基准(pick_base)比的是它,
|
||||
触发历史要是还给当时那一份,两个同名入口比的就不是同一对运行,计数也对不上。紧挨着的上一次不会这样:两次之间插不进
|
||||
新的运行(发起时间只会往后排),两边自己被继续执行、重新封存另有核对(api/schedules._fire_outs)。这类对比本来就少,
|
||||
逐个现挑一遍不费什么"""
|
||||
from app.api import compare as compare_api
|
||||
|
||||
wanted = {artifact: brief for artifact, brief in known.items() if not brief["previous"]}
|
||||
if not wanted:
|
||||
return set()
|
||||
targets = {run.id: run for run in (await session.execute(
|
||||
select(Run).where(Run.id.in_({brief["target_run_id"] for brief in wanted.values()})))).scalars()}
|
||||
stale = set()
|
||||
for artifact, brief in wanted.items():
|
||||
target = targets.get(brief["target_run_id"])
|
||||
found = await compare_api.pick_base(session, target, schedule_only=True) if target is not None else None
|
||||
if found is None or found.run.id != brief["base_run_id"]:
|
||||
stale.add(artifact)
|
||||
return stale
|
||||
|
||||
|
||||
async def briefs(artifacts: list[str]) -> dict[str, dict[str, Any]]:
|
||||
"""几份对比工件的摘要:工件 id → 摘要,读不出来的不在里面。读盘在线程里做。"""
|
||||
wanted = list(dict.fromkeys(a for a in artifacts if a))
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
同一次运行回 422;运行不存在 404
|
||||
- 对比只读:两次运行的 last_seq、事件、工件引用、工件文件一样不多
|
||||
- 遮罩列(查询当时记下的、数据源现在设的)的值不出现在结果里
|
||||
- 默认基准:同一定时任务优先,否则同一工作流、同一运行类别;只挑更早结束、封存过的终态运行
|
||||
- 默认基准:同一定时任务优先,否则同一工作流、同一运行类别;只挑比这次先发起、成功结束并封存的运行,按发起先后取
|
||||
最近一次(失败、取消的跳过,和定时任务的自动对比挑的是同一次,见 test_scheduler_compare.py)
|
||||
- 真跑:同一条 SQL 数据变了是 data_only;只是代进去的日期不同是 params_only,图一样
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -236,14 +237,15 @@ async def test_masked_columns_never_leave_the_server(client):
|
||||
|
||||
|
||||
async def bare(workflow_id, finished, *, status="succeeded", run_class="formal", schedule_id=None, sealed=True,
|
||||
resumed=False) -> str:
|
||||
resumed=False, created=None) -> str:
|
||||
"""只为挑基准用的运行记录:挑基准只看运行记录上的几列。resumed:结束、封存过又被接着运行(状态已回到运行中,
|
||||
结束时间和封存还没清掉),只能靠状态把它挡在外面。"""
|
||||
结束时间和封存还没清掉),只能靠状态把它挡在外面。created:发起时间,默认在结束前 3 分钟(发起先后和结束先后
|
||||
一致);要让两者错开的用例另给。"""
|
||||
done = status in ("succeeded", "failed", "cancelled") or resumed
|
||||
async with SessionLocal() as session:
|
||||
run = Run(workflow_id=workflow_id, workflow_name="区域周报", status=status, run_class=run_class,
|
||||
schedule_id=schedule_id, started_at=finished - timedelta(minutes=2),
|
||||
finished_at=finished if done else None,
|
||||
schedule_id=schedule_id, created_at=created or finished - timedelta(minutes=3),
|
||||
started_at=finished - timedelta(minutes=2), finished_at=finished if done else None,
|
||||
manifest_hash="ab" * 32 if sealed else None, manifest_seq=1 if sealed else None)
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
@@ -279,20 +281,60 @@ async def test_the_default_base_skips_what_cannot_be_compared(client):
|
||||
await bare(workflow, T0 + 2 * day, status="running", resumed=True) # 结束过、又被接着运行
|
||||
await bare(workflow, T0 + 3 * day, run_class="exploratory") # 别的运行类别
|
||||
await bare(await new_workflow("另一个周报"), T0 + 4 * day) # 别的工作流
|
||||
await bare(workflow, T0 + 4 * day, status="failed") # 失败的(封存过)
|
||||
await bare(workflow, T0 + 4 * day + timedelta(hours=1), status="cancelled") # 取消的(封存过)
|
||||
current = await bare(workflow, T0 + 5 * day)
|
||||
await bare(workflow, T0 + 6 * day) # 比它晚结束
|
||||
await bare(workflow, T0 + 6 * day) # 比它晚发起
|
||||
body = await get(client, f"/api/runs/{current}/compare/base")
|
||||
assert (body["base_run_id"], body["reason"]) == (good, "same_workflow")
|
||||
# 失败、取消的终态运行也算(封存过)
|
||||
failed = await bare(workflow, T0 + 5 * day - timedelta(hours=1), status="failed")
|
||||
assert (await get(client, f"/api/runs/{current}/compare/base"))["base_run_id"] == failed
|
||||
# 第一次运行、没保存的工作流:没有可比的
|
||||
body = await get(client, f"/api/runs/{good}/compare/base", 404)
|
||||
assert body["code"] == "compare_no_base" and "上一次运行" in body["detail"]
|
||||
assert body["code"] == "compare_no_base" and "上一次成功的运行" in body["detail"]
|
||||
await get(client, f"/api/runs/{await bare(None, T0)}/compare/base", 404)
|
||||
await get(client, "/api/runs/no-such-run/compare/base", 404)
|
||||
|
||||
|
||||
async def test_the_default_base_is_the_last_successful_run(client):
|
||||
"""失败、取消的不当默认基准:拿它当基准,口径卡没执行的指标都成了「新增」「移除」。同一定时任务里跳过它们,取再往前
|
||||
成功的那一次(和定时任务的自动对比同一个规则);同一工作流的退路也一样;之前一次成功的都没有就是没有基准"""
|
||||
workflow = await new_workflow()
|
||||
day = timedelta(days=1)
|
||||
first = await bare(workflow, T0, schedule_id="sched-weekly")
|
||||
await bare(workflow, T0 + day, schedule_id="sched-weekly", status="failed")
|
||||
await bare(workflow, T0 + 2 * day, schedule_id="sched-weekly", status="cancelled")
|
||||
current = await bare(workflow, T0 + 3 * day, schedule_id="sched-weekly")
|
||||
body = await get(client, f"/api/runs/{current}/compare/base")
|
||||
assert (body["base_run_id"], body["reason"]) == (first, "same_schedule")
|
||||
# 手动发起的:同一工作流、同一运行类别里成功的最近一次(定时的也算),中间失败的跳过
|
||||
await bare(workflow, T0 + 4 * day, status="failed")
|
||||
manual = await bare(workflow, T0 + 5 * day)
|
||||
body = await get(client, f"/api/runs/{manual}/compare/base")
|
||||
assert (body["base_run_id"], body["reason"]) == (current, "same_workflow")
|
||||
# 之前只有失败、取消的:没有基准,原因写明要成功结束的
|
||||
lonely = await new_workflow("门店日报")
|
||||
await bare(lonely, T0, status="failed")
|
||||
await bare(lonely, T0 + day, status="cancelled", schedule_id="sched-daily")
|
||||
alone = await bare(lonely, T0 + 2 * day, schedule_id="sched-daily")
|
||||
body = await get(client, f"/api/runs/{alone}/compare/base", 404)
|
||||
assert body["code"] == "compare_no_base" and "成功结束" in body["detail"]
|
||||
|
||||
|
||||
async def test_the_default_base_follows_launch_order(client):
|
||||
"""按发起先后挑,不按结束时间:先发起、后来才结束的那次(失败后被接着运行完)仍排在这次前面;比这次晚发起的,
|
||||
结束得再早也不算。这次还没结束(停在审批上)的也按发起先后"""
|
||||
workflow = await new_workflow()
|
||||
t = datetime(2026, 9, 1, 1, 0, tzinfo=timezone.utc)
|
||||
hour, day = timedelta(hours=1), timedelta(days=1)
|
||||
await bare(workflow, T0, created=t)
|
||||
resumed = await bare(workflow, T0 + 10 * day, created=t + hour) # 比这次先发起,结束得最晚
|
||||
current = await bare(workflow, T0 + 5 * day, created=t + 2 * hour)
|
||||
later = await bare(workflow, T0 + 4 * day, created=t + 3 * hour) # 比这次晚发起,结束得比它早
|
||||
body = await get(client, f"/api/runs/{current}/compare/base")
|
||||
assert (body["base_run_id"], body["reason"]) == (resumed, "same_workflow")
|
||||
waiting = await bare(workflow, T0 + 20 * day, status="interrupted", created=t + 4 * hour)
|
||||
assert (await get(client, f"/api/runs/{waiting}/compare/base"))["base_run_id"] == later
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 真跑:虚构的 SQLite 库,两次运行之间改了数、换了日期参数
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
- 基准是同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次:同一工作流更近的手动运行、别的
|
||||
定时任务的运行都不算;失败的那一次不当基准(拿它当基准每个指标都成了「新增」);按发起先后挑,不按结束时间——
|
||||
老的失败运行事后被接着跑完,结束得比后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的时,通知和触发
|
||||
历史写明比的是哪一次
|
||||
历史写明比的是哪一次。运行详情「与上一次对比」的默认基准(api/compare.py 的 pick_base)和这里挑的是同一次
|
||||
- 整份结果连同两边的清单哈希存成工件(kind=run_compare,不挂在运行名下、不进运行的工件列表),工件 id 记进触发
|
||||
记录的 compare_artifact。这个任务的第一次(只有同一工作流别的运行可比)、还没封存时不比;对比出错(基准的封存核对
|
||||
不一致、意外的异常)只记日志,通知照常推送、不带对比
|
||||
- 接口:Fire 多一个 compare: {base_run_id, summary, previous} 或 null,summary 同对比接口的计数,previous 说基准是不是
|
||||
紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行)
|
||||
紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行);基准不再是现在挑出来的那一次
|
||||
(中间失败的那次事后被接着运行成功)时也为 null(运行详情里同名的入口比的已是另一次)
|
||||
- 通知:结束的通知多一行「与上一次相比:只数据变 2 个、口径与数据都变 0 个、SQL 结构变 0 个」,配了对外访问地址时附
|
||||
对比页链接(/runs/compare?target=…&base=…);只给个数,报告、指标、查询里的具体数字一个不出现
|
||||
|
||||
@@ -320,11 +321,26 @@ async def test_after_a_failed_run_the_base_is_the_last_successful_one(recorder,
|
||||
assert (body["compare"]["base_run_id"], body["compare"]["previous"]) == (first, False)
|
||||
items = {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
|
||||
assert items[fire_id]["compare"] == {"base_run_id": first, "summary": SUMMARY, "previous": False}
|
||||
# 运行详情「与上一次对比」的默认基准挑的是同一次(同一个函数):两个同名入口比的是同一对运行、计数一致
|
||||
base = (await client.get(f"/api/runs/{target_id}/compare/base")).json()
|
||||
assert (base["base_run_id"], base["reason"]) == (first, "same_schedule")
|
||||
|
||||
|
||||
async def test_base_follows_launch_order_not_finish_time(recorder):
|
||||
async def fires_of(client, sid: str) -> dict[str, dict]:
|
||||
return {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
|
||||
|
||||
|
||||
async def default_base_of(client, run_id: str) -> tuple[str, str]:
|
||||
body = (await client.get(f"/api/runs/{run_id}/compare/base")).json()
|
||||
return body["base_run_id"], body["reason"]
|
||||
|
||||
|
||||
async def test_base_follows_launch_order_not_finish_time(recorder, client):
|
||||
"""第 2 周失败、第 3 周照常成功之后,有人把第 2 周接着跑完:它结束得比第 3 周晚,但它的上一次仍是第 1 周。以前
|
||||
按结束时间挑,拿后一次触发的第 3 周当基准,「与上一次相比」的变化方向是反的。第 3 周已经比过的那一份不受影响"""
|
||||
按结束时间挑,拿后一次触发的第 3 周当基准,「与上一次相比」的变化方向是反的。
|
||||
|
||||
第 3 周存下的那一份不改,但触发历史不再给它:第 3 周之前同一定时任务上一次成功的已经是第 2 周,运行详情「与上一次
|
||||
对比」比的是第 2 周。还给第 1 周那一份,两个同名入口就比的不是同一对运行,计数也对不上"""
|
||||
workflow = await new_workflow()
|
||||
sid = await new_schedule(workflow, url=None)
|
||||
first = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
|
||||
@@ -336,12 +352,65 @@ async def test_base_follows_launch_order_not_finish_time(recorder):
|
||||
await webhook.notify_due()
|
||||
third_artifact = (await fire_row(third_fire)).compare_artifact
|
||||
assert load(third_artifact)["result"]["base"]["id"] == first
|
||||
assert (await fires_of(client, sid))[third_fire]["compare"] == {"base_run_id": first, "summary": SUMMARY,
|
||||
"previous": False}
|
||||
assert await default_base_of(client, third) == (first, "same_schedule")
|
||||
|
||||
await reseal(second, week("r2", NEXT_ROWS), status="succeeded", finished=T0 + 2 * WEEK + timedelta(days=1))
|
||||
await webhook.notify_due()
|
||||
stored = load((await fire_row(second_fire)).compare_artifact)
|
||||
assert stored["result"]["base"]["id"] == first and stored["previous"] is True
|
||||
assert (await fire_row(third_fire)).compare_artifact == third_artifact
|
||||
# 运行详情里的默认基准同样按发起先后:接着跑完的第 2 周,上一次仍是第 1 周
|
||||
assert await default_base_of(client, second) == (first, "same_schedule")
|
||||
items = await fires_of(client, sid)
|
||||
assert (items[second_fire]["compare"]["base_run_id"], items[second_fire]["compare"]["previous"]) == (first, True)
|
||||
# 第 3 周:运行详情比的是第 2 周了,触发历史不再给比第 1 周的那一份(不给链接、不给旧计数)
|
||||
assert await default_base_of(client, third) == (second, "same_schedule")
|
||||
assert items[third_fire]["compare"] is None
|
||||
[listed] = [s for s in (await client.get("/api/schedules")).json()["items"] if s["id"] == sid]
|
||||
assert listed["last_fire"]["id"] == third_fire and listed["last_fire"]["compare"] is None
|
||||
|
||||
|
||||
async def test_a_stored_compare_stays_while_its_base_is_still_the_pick(recorder, client):
|
||||
"""基准当时就不是紧挨着的上一次、现在仍是挑出来的那一次:照旧给出。中间那次还停着没结束、后来失败了、被删掉了,
|
||||
第 3 周之前同一定时任务上一次成功的都还是第 1 周,运行详情比的也是它。删掉之后它其实成了紧挨着的上一次,previous
|
||||
仍照当时记下的给:「与上一次成功的运行对比」照样属实,通知里当时也是这么说的"""
|
||||
workflow = await new_workflow()
|
||||
sid = await new_schedule(workflow, url=None)
|
||||
first = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
|
||||
await fire_for(sid, first, T0 - timedelta(minutes=2), notified="succeeded")
|
||||
second = await persist(failed_week("r2"), workflow, finished=T0 + WEEK, schedule_id=sid, status="running",
|
||||
seal=False)
|
||||
await fire_for(sid, second, T0 + WEEK - timedelta(minutes=2), notified="running")
|
||||
third = await persist(week("r3", NEXT_ROWS), workflow, finished=T0 + 2 * WEEK, schedule_id=sid)
|
||||
third_fire = await fire_for(sid, third, T0 + 2 * WEEK - timedelta(minutes=2))
|
||||
await webhook.notify_due()
|
||||
shown = {"base_run_id": first, "summary": SUMMARY, "previous": False}
|
||||
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
|
||||
assert await default_base_of(client, third) == (first, "same_schedule")
|
||||
await set_run(second, status="failed")
|
||||
await seal(second)
|
||||
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
|
||||
assert await default_base_of(client, third) == (first, "same_schedule")
|
||||
assert (await client.delete(f"/api/runs/{second}", params={"force": "true"})).status_code == 204
|
||||
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
|
||||
assert await default_base_of(client, third) == (first, "same_schedule")
|
||||
|
||||
|
||||
async def test_without_an_earlier_success_there_is_no_base(recorder, client):
|
||||
"""同一定时任务之前只有失败的那次:不自动对比;运行详情里的默认基准也不拿它(同一工作流、同一类别里也没有成功的),
|
||||
回 404 说清找不到,不给一个比出来全是「新增」的基准"""
|
||||
workflow = await new_workflow()
|
||||
sid = await new_schedule(workflow, url=None)
|
||||
failed = await persist(failed_week("r1"), workflow, finished=T0, schedule_id=sid, status="failed")
|
||||
await fire_for(sid, failed, T0 - timedelta(minutes=2), notified="failed")
|
||||
target_id = await persist(week("r2", NEXT_ROWS), workflow, finished=T0 + WEEK, schedule_id=sid)
|
||||
fire_id = await fire_for(sid, target_id, T0 + WEEK - timedelta(minutes=2))
|
||||
await webhook.notify_due()
|
||||
assert (await fire_row(fire_id)).compare_artifact is None
|
||||
resp = await client.get(f"/api/runs/{target_id}/compare/base")
|
||||
assert resp.status_code == 404 and resp.json()["code"] == "compare_no_base", resp.text
|
||||
|
||||
|
||||
async def test_first_run_of_a_schedule_has_no_compare(recorder, client):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成",
|
||||
"//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成。run_artifacts 是探索运行的工件列表(照 GET /api/runs/{id}/artifacts 的样子手写,质询回答的 meta 同 engine/inquiry.py 写的 {report, unit, question: 问题的前 40 字}),给运行详情「工件」页签的用例",
|
||||
"runs": {
|
||||
"approve": "run-inq-approve",
|
||||
"formal": "run-inq-formal",
|
||||
@@ -4278,5 +4278,70 @@
|
||||
"masked": []
|
||||
}
|
||||
},
|
||||
"run_artifacts": [
|
||||
{
|
||||
"id": "8cb677489bbaafcafd3b0f0df1939c1ca57cccaa3c065e14c4efe2991a3dd9a9",
|
||||
"kind": "report_doc",
|
||||
"node_id": "write",
|
||||
"size": 18234,
|
||||
"meta": {},
|
||||
"created_at": "2026-10-05T06:30:00"
|
||||
},
|
||||
{
|
||||
"id": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434",
|
||||
"kind": "query_snapshot",
|
||||
"node_id": null,
|
||||
"size": 1460,
|
||||
"meta": {},
|
||||
"created_at": "2026-10-05T06:44:30"
|
||||
},
|
||||
{
|
||||
"id": "911780ed990217a2a2e7e3424a9408d23b53a524ba5d1812ec677b7d6b5b80aa",
|
||||
"kind": "inquiry_answer",
|
||||
"node_id": null,
|
||||
"size": 2140,
|
||||
"meta": {
|
||||
"report": "write",
|
||||
"unit": "u2",
|
||||
"question": "退款率怎么算的?"
|
||||
},
|
||||
"created_at": "2026-10-05T06:45:00"
|
||||
},
|
||||
{
|
||||
"id": "4ed3d62d2af13bbea40195283f3caf63462ca76bb5d4f7ddf1216d36dd5e0463",
|
||||
"kind": "inquiry_answer",
|
||||
"node_id": null,
|
||||
"size": 2510,
|
||||
"meta": {
|
||||
"report": "write",
|
||||
"unit": null,
|
||||
"question": "枫岭区的销售额是否包含线上订单?如果包含,线上订单按下单门店还是按收货地址归到片"
|
||||
},
|
||||
"created_at": "2026-10-05T06:52:00"
|
||||
},
|
||||
{
|
||||
"id": "1c66d590eac155dec35e2215e20c6e7497e96d0b2d73894eff1dea3fb641bb51",
|
||||
"kind": "inquiry_answer",
|
||||
"node_id": null,
|
||||
"size": 1980,
|
||||
"meta": {
|
||||
"report": "write",
|
||||
"unit": "u2"
|
||||
},
|
||||
"created_at": "2026-10-05T06:58:00"
|
||||
},
|
||||
{
|
||||
"id": "ab8f8dbfb139d3be461bd9b5f4024cc161be5588a2eea9275a47eac160bfc885",
|
||||
"kind": "inquiry_answer",
|
||||
"node_id": null,
|
||||
"size": 2380,
|
||||
"meta": {
|
||||
"report": "write",
|
||||
"unit": "u3",
|
||||
"question": "本周枫岭片区 GMV 环比下降了多少?下降的主要原因是不是 SKU 结构的变化 "
|
||||
},
|
||||
"created_at": "2026-10-05T07:04:00"
|
||||
}
|
||||
],
|
||||
"card_artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, type JSX } from 'react'
|
||||
import { useMemo, useState, type JSX } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { parseServerTime } from '../../lib/format'
|
||||
import { ArtifactsPane, useRunArtifacts } from '../../pages/runs/ArtifactsPane'
|
||||
import { EvidencePane } from '../../pages/runs/EvidencePane'
|
||||
import { ApprovalCard } from '../../run/RunPanel'
|
||||
import { emptyTrace } from '../../run/trace'
|
||||
import type { Approval, ApprovalReport, Run } from '../../types'
|
||||
import fx from './inquiry.json'
|
||||
|
||||
@@ -20,12 +23,18 @@ import fx from './inquiry.json'
|
||||
* React 树里排在前面)和右栏;「收起浮层」按钮开关浮层
|
||||
* - ?syn=inquiry-approval-steps:同一份报告先后两张审批(主管审批 → 总监审批),「处理掉这张」换下一张;卡按审批 id
|
||||
* 重新挂(同右栏)。?syn=inquiry-approval-steps-same:同一个卡片实例换审批(同画布的审批浮层,不按 id 重新挂)
|
||||
* - ?syn=inquiry-approval-two:一张卡审两份报告(写月报、写周报),两份报告的问答串各自挂着、共用这次运行的审批列表
|
||||
*
|
||||
* 运行详情的「证据」页签(pages/runs/EvidencePane,是否可问由它按运行状态判断):
|
||||
* - ?syn=inquiry-run-explore:封存了的探索运行,报告上给入口,问答追加在封存之后
|
||||
* - ?syn=inquiry-run-formal:封存了的正式运行,不给入口,审批时问过的照列
|
||||
* - ?syn=inquiry-run-waiting:停在审批上的运行,同运行详情直接打开「证据」页签:时间线里的待审批卡和证据页签同时挂着
|
||||
* 同一份报告的问答串(卡上这张审批里问的直接认,证据页签哪张审批都要对一下)
|
||||
* - ?syn=inquiry-run-artifacts:运行详情的「工件」页签(pages/runs/ArtifactsPane),工件列表里有封存后追加的质询回答
|
||||
* (运行期间产出的能跳到航迹,封存后追加的不能)
|
||||
*
|
||||
* 列表、提问、质询工件、报告文档、证据图、审批列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说无法读取。
|
||||
* 列表、提问、质询工件、报告文档、证据图、审批列表、工件列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说
|
||||
* 无法读取。
|
||||
*/
|
||||
|
||||
const RUNS = fx.runs
|
||||
@@ -115,6 +124,45 @@ function RunEvidence({ run, name }: { run: Run; name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停在审批上的运行,同运行详情直接打开「证据」页签:时间线(一直挂着)里的待审批卡在上,证据页签在下,两处挂着同一份
|
||||
* 报告的问答串。卡上的问答串认得这张审批,证据页签不认任何审批(cardId 为空),两处要为不同的几张审批取审批列表
|
||||
*/
|
||||
function Waiting({ approval, run }: { approval: Approval; run: Run }) {
|
||||
return (
|
||||
<div className="flex min-h-screen min-w-0 flex-col" data-fixture="inquiry-run-waiting">
|
||||
<div className="mx-auto w-full max-w-3xl p-4" data-fixture-card="">
|
||||
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
|
||||
<ApprovalCard approval={approval} onResolved={() => undefined} showWorkflow={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[80vh] min-w-0 border-t" data-fixture-evidence="">
|
||||
<EvidencePane run={run} output={null} labelOf={(id) => (id ? LABELS[id] : undefined)} refreshKey="0" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行详情的「工件」页签:工件列表由检查脚本按 inquiry.json 的 run_artifacts 答。航迹的起止照这次运行的开始、结束时间
|
||||
* 给(同运行详情):运行期间产出的工件能跳到航迹里那一刻,封存后追加的质询回答和它的查询快照不给跳转。点过的跳转记在
|
||||
* data-fixture-moment 上
|
||||
*/
|
||||
function RunArtifacts({ run }: { run: Run }) {
|
||||
const list = useRunArtifacts(run.id, '0')
|
||||
const trace = useMemo(() => ({
|
||||
...emptyTrace(), phase: 'succeeded' as const,
|
||||
startedAt: parseServerTime(run.started_at ?? null)?.getTime(), endedAt: parseServerTime(run.finished_at ?? null)?.getTime(),
|
||||
}), [run.started_at, run.finished_at])
|
||||
const [moment, setMoment] = useState<number | null>(null)
|
||||
return (
|
||||
<div className="h-screen min-w-0" data-fixture="inquiry-run-artifacts" data-fixture-moment={moment ?? ''}>
|
||||
<ArtifactsPane list={list} graph={null} trace={trace} labelOf={(id) => (id ? LABELS[id] : undefined)}
|
||||
onMoment={(at) => setMoment(at)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const cases: Record<string, () => JSX.Element> = {
|
||||
'inquiry-approval': () => (
|
||||
<Card name="inquiry-approval" approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)} />
|
||||
@@ -138,6 +186,18 @@ export const cases: Record<string, () => JSX.Element> = {
|
||||
),
|
||||
'inquiry-approval-steps': () => <Steps name="inquiry-approval-steps" keyed />,
|
||||
'inquiry-approval-steps-same': () => <Steps name="inquiry-approval-steps-same" keyed={false} />,
|
||||
'inquiry-approval-two': () => (
|
||||
<Card name="inquiry-approval-two" approval={{
|
||||
...approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve),
|
||||
payload: { kind: 'human_node', message: '请审核 9 月月报和第 39 周周报。',
|
||||
reports: [reports.approve, { ...reports.approve, node_id: 'write2', label: '写周报' }] },
|
||||
}} />
|
||||
),
|
||||
'inquiry-run-explore': () => <RunEvidence name="inquiry-run-explore" run={runOf(RUNS.explore, 'exploratory')} />,
|
||||
'inquiry-run-formal': () => <RunEvidence name="inquiry-run-formal" run={runOf(RUNS.sealed, 'formal')} />,
|
||||
'inquiry-run-waiting': () => (
|
||||
<Waiting approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)}
|
||||
run={{ ...runOf(RUNS.approve, 'exploratory'), status: 'interrupted', manifest_hash: null, manifest_seq: null, finished_at: null }} />
|
||||
),
|
||||
'inquiry-run-artifacts': () => <RunArtifacts run={runOf(RUNS.explore, 'exploratory')} />,
|
||||
}
|
||||
|
||||
+47
-11
@@ -3085,10 +3085,16 @@ export const SCHEDULE_TEXT = {
|
||||
|
||||
// ---- 触发历史里的对比:运行成功结束后,服务端自动和同一定时任务上一次成功的运行比一次
|
||||
/**
|
||||
* 和运行详情「更多」菜单里的入口是同一件事,用同一个叫法。RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,
|
||||
* 写成 getter 用到时再取
|
||||
* 和运行详情「更多」菜单里的入口是同一件事(服务端用同一个函数挑基准,两处比的是同一对运行),基准就是紧挨着的上一次
|
||||
* 时用同一个叫法。RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,写成 getter 用到时再取
|
||||
*/
|
||||
get compareLink() { return RUN_COMPARE_TEXT.menu },
|
||||
/**
|
||||
* 基准不是紧挨着的上一次(上一次失败了、取消了,比的是再往前成功的那一次)时链接上看得见的叫法:触屏没有悬停,
|
||||
* 键盘聚焦时也不显示 title,只在悬停里说明会让人以为比的就是上一次。运行详情「更多」里的菜单项点之前还不知道基准
|
||||
* 是哪一次,这时仍叫「与上一次对比」,两处叫法不同;进了对比页都写「默认基准:同一定时任务上一次成功的运行」
|
||||
*/
|
||||
compareLinkLastOk: '与上一次成功的运行对比',
|
||||
/**
|
||||
* 悬停写各类指标的个数,和 Webhook 通知里那一行同一个写法(服务端 scheduler/auto_compare.py 的 summary_text):
|
||||
* 变化三分法的三类总写上,0 个也写;其余各类有才写,次序同对比页;两边都没有指标时不写一串 0 个。只有个数,
|
||||
@@ -3160,7 +3166,7 @@ export const RUN_COMPARE_TEXT = {
|
||||
menuSealedOnly: '运行结束并封存后才能对比',
|
||||
/** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */
|
||||
menuNoWorkflow: '工作流未保存或已删除,无法对比',
|
||||
menuFinding: '正在查找上一次运行…',
|
||||
menuFinding: '正在查找上一次成功的运行…',
|
||||
pickManually: '手动选择基准',
|
||||
retry: '重试',
|
||||
// 顶部的两次运行
|
||||
@@ -3175,12 +3181,17 @@ export const RUN_COMPARE_TEXT = {
|
||||
runFailed: '无法读取这次运行',
|
||||
runMissing: '运行记录不存在',
|
||||
noBase: '未选择',
|
||||
/**
|
||||
* 默认基准是怎么选的,照服务端(api/compare.py 的 pick_base)的规则写:只挑比本次先发起、成功结束并封存的运行。
|
||||
* 上一次失败、取消了时基准是再往前成功的那一次,所以不能写成「上一次运行」
|
||||
*/
|
||||
reason: {
|
||||
same_schedule: '默认基准:同一定时任务的上一次运行',
|
||||
same_workflow: '默认基准:同一工作流、同一运行类别的上一次运行',
|
||||
same_schedule: '默认基准:同一定时任务上一次成功的运行',
|
||||
same_workflow: '默认基准:同一工作流、同一运行类别上一次成功的运行',
|
||||
} as Record<string, string>,
|
||||
pickBase: '更换基准',
|
||||
pickHint: '同一工作流最近的运行,未封存的不能作为基准',
|
||||
/** 默认基准只取成功结束的运行(pick_base),手动选不限:失败、取消的也能选,下拉和基准卡上写明它的状态 */
|
||||
pickHint: '同一工作流最近的运行,未封存的不能作为基准;默认基准只取成功结束的运行',
|
||||
pickLoading: '正在读取同一工作流的运行…',
|
||||
pickFailed: '无法读取同一工作流的运行',
|
||||
pickNone: '同一工作流没有其他运行',
|
||||
@@ -3188,14 +3199,17 @@ export const RUN_COMPARE_TEXT = {
|
||||
pickTargetGone: '本次运行的记录不存在,无法列出同一工作流的运行',
|
||||
pickUnsaved: '工作流未保存或已删除,没有可对比的运行',
|
||||
pickPlaceholder: '选择一次运行作为基准',
|
||||
/** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */
|
||||
pickOption: (time: string, cls: string, id: string, sealed: boolean) =>
|
||||
`${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`,
|
||||
/**
|
||||
* 下拉里的一次运行:时间 · 类别和版本 · 短 id,没有成功结束的写上状态(失败、已取消),没封存的另标。默认基准跳过的
|
||||
* 正是这几种,手动挑时也得看得出哪次没成功
|
||||
*/
|
||||
pickOption: (time: string, cls: string, id: string, sealed: boolean, status?: string | null) =>
|
||||
`${time} · ${cls} · ${id}${status ? ` · ${status}` : ''}${sealed ? '' : '(未封存)'}`,
|
||||
/** 当前的基准读不到时下拉里的那一项(短 id):如实显示选的是哪一次 */
|
||||
pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`,
|
||||
// 页面状态
|
||||
noTarget: '未指定要对比的运行',
|
||||
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行',
|
||||
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次成功的运行',
|
||||
toRuns: '前往记录',
|
||||
finding: '正在查找基准运行…',
|
||||
loading: '正在对比两次运行…',
|
||||
@@ -3213,7 +3227,11 @@ export const RUN_COMPARE_TEXT = {
|
||||
errMissingHint: '链接可能已失效,请从记录页重新打开',
|
||||
/** 本次的记录读得到、对比却回 404:缺的是基准 */
|
||||
errBaseMissingHint: '可在上方选择另一次运行作为基准',
|
||||
errNoBase: '未找到可作为基准的运行',
|
||||
/**
|
||||
* 找不到默认基准:标题只说没有默认基准,原因(服务端的 NO_BASE_TEXT:没有找到上一次成功的运行、要什么条件)跟在后面。
|
||||
* 不能写成「没有可作为基准的运行」:失败、取消但封存了的运行不当默认基准,却照样能在上方手动选
|
||||
*/
|
||||
errNoBase: '没有默认基准',
|
||||
errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准',
|
||||
/** 下拉里一次能选的都没有(只有本次、或者都没封存) */
|
||||
errNoBaseNone: '同一工作流还没有其他已封存的运行',
|
||||
@@ -3424,6 +3442,12 @@ export const INQUIRY_TEXT = {
|
||||
/** 之前几轮审批里问的、报告后来改写过(句子编号对不上),或者对不上是哪一稿:单独一组 */
|
||||
otherDoc: '报告其他版本上的质询',
|
||||
otherDocHint: '提问时的报告与当前显示的版本不同,或无法确认是否相同,所以不挂到当前报告的句子下面',
|
||||
/**
|
||||
* 别的审批里问的、审批列表又没取到:不知道提问时审的是哪一稿,不能说成「其他版本」(审批人会以为报告换过稿)。
|
||||
* 单独一组照实说没能确认,给重试
|
||||
*/
|
||||
unsureDoc: '无法确认提问时的报告版本',
|
||||
unsureDocHint: '审批记录读取失败,暂不挂到当前报告的句子下面',
|
||||
loadFailed: '无法读取之前的质询记录',
|
||||
retry: '重试',
|
||||
q: '问',
|
||||
@@ -3491,6 +3515,18 @@ export const INQUIRY_TEXT = {
|
||||
snapshotLoading: '正在读取查询结果…',
|
||||
snapshotFailed: '无法读取这次查询的结果',
|
||||
masked: (columns: string) => `${columns} 在数据源中设置了遮罩,不显示原值`,
|
||||
|
||||
// ---- 运行详情「工件」页签:回答文档按工件种类 inquiry_answer 记在运行名下(pages/runs/model.ts 的工件名和说明) ----
|
||||
artifactKind: '质询回答',
|
||||
/**
|
||||
* 服务端在工件的 meta 里只记问题的前 40 字(engine/inquiry.py 的 QUESTION_BRIEF):满 40 字的多半截断过,末尾补省略号,
|
||||
* 免得半句话读成整个问题。字数按原样数、显示时才去掉首尾空白:服务端截之前已经去过整个问题的首尾空白,摘要以空格、
|
||||
* 换行结尾只会是截断截在了空白上,先去空白再数就少了一个字、漏掉省略号
|
||||
*/
|
||||
artifactAbout: (question: string) =>
|
||||
`质询「${question.trim()}${[...question].length >= 40 ? '…' : ''}」的回答`,
|
||||
/** meta 里没有问题摘要时的说法(服务端照理都会记,这里兜底):照报告文档那一句写装的是什么 */
|
||||
artifactAboutNone: '审批质询的回答:逐段的片段和每个数字的出处',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -136,6 +136,8 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
|
||||
}
|
||||
|
||||
const t0 = trace.timed ? trace.startedAt : undefined
|
||||
// 运行结束之后才写下的(封存后追加的质询回答和它查询的快照):航迹里没有那一刻,不给跳转,只写时间
|
||||
const t1 = trace.timed ? trace.endedAt : undefined
|
||||
let budget = all ? Infinity : FIRST_ROWS
|
||||
const hidden = Math.max(0, groups.reduce((n, [, g]) => n + g.length, 0) - FIRST_ROWS)
|
||||
|
||||
@@ -174,7 +176,7 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
|
||||
budget -= take.length
|
||||
return (
|
||||
<Group key={nodeId || '—'} nodeId={nodeId} rows={take} total={rows.length} graph={graph} trace={trace}
|
||||
labelOf={labelOf} t0={t0} onOpen={setOpen} onMoment={onMoment} />
|
||||
labelOf={labelOf} t0={t0} t1={t1} onOpen={setOpen} onMoment={onMoment} />
|
||||
)
|
||||
})}
|
||||
{!all && hidden > 0 && (
|
||||
@@ -190,7 +192,7 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
|
||||
)
|
||||
}
|
||||
|
||||
function Group({ nodeId, rows, total, graph, trace, labelOf, t0, onOpen, onMoment }: {
|
||||
function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onMoment }: {
|
||||
nodeId: string
|
||||
rows: RunArtifact[]
|
||||
total: number
|
||||
@@ -198,6 +200,8 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, onOpen, onMomen
|
||||
trace: Trace
|
||||
labelOf: (id?: string | null) => string | undefined
|
||||
t0?: number
|
||||
/** 运行结束的时刻(还没结束的为空):晚于它的工件不给跳转 */
|
||||
t1?: number
|
||||
onOpen: (v: { id: string; title: string }) => void
|
||||
onMoment?: (at: number, nodeId: string | null) => void
|
||||
}) {
|
||||
@@ -226,7 +230,7 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, onOpen, onMomen
|
||||
const kind = artifactKindLabel(a.kind)
|
||||
const attempt = Number(a.meta?.attempt)
|
||||
const created = parseServerTime(a.created_at ?? null)?.getTime()
|
||||
const at = t0 != null && created != null && created >= t0 ? created : null
|
||||
const at = t0 != null && created != null && created >= t0 && (t1 == null || created <= t1) ? created : null
|
||||
const numbered = (perKind.get(a.kind) ?? 0) > 1
|
||||
const title = `${label} · ${kind}${numbered ? ` #${n}` : ''}`
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftRight, CalendarClock, RotateCw } from 'lucide-react'
|
||||
import { Skeleton, useTicker } from '../../../components/ui'
|
||||
import { Skeleton, StatusPill, useTicker } from '../../../components/ui'
|
||||
import { formatDateTime, formatTime, shortId } from '../../../lib/format'
|
||||
import { statusLabel } from '../../../lib/status'
|
||||
import { RUN_COMPARE_TEXT as T, runClassLabel, runName } from '../../../lib/terms'
|
||||
import type { RunWithSchedule } from '../../../types'
|
||||
import { ClassChip as RunClassChip } from '../parts'
|
||||
@@ -11,7 +12,8 @@ import { isGone } from './model'
|
||||
|
||||
/**
|
||||
* 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的
|
||||
* 运行详情;没封存的标出来(对比会因此被拒)。基准卡底下是换基准的下拉:同一工作流最近的运行,没封存的不能选。
|
||||
* 运行详情;没封存的标出来(对比会因此被拒),失败、取消的标出状态。基准卡底下是换基准的下拉:同一工作流最近的运行,
|
||||
* 没封存的不能选。
|
||||
*/
|
||||
export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved, reason, onPick, onSwap }: {
|
||||
target: string
|
||||
@@ -49,6 +51,12 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 封存了、却没有成功结束的(失败、已取消):能手动选作基准,默认基准却跳过它(拿它当基准,口径卡没执行的指标都成了
|
||||
* 「新增」「移除」)。下拉和卡上写明状态,人读懂「上一次成功的运行」之后自己挑得出成功的那一次
|
||||
*/
|
||||
const unsuccessful = (status: string | null | undefined) => status === 'failed' || status === 'cancelled'
|
||||
|
||||
function RunCard({ role, id, run, children }: {
|
||||
role: 'base' | 'target'; id: string | null; run: Loaded<RunWithSchedule>; children?: ReactNode
|
||||
}) {
|
||||
@@ -63,6 +71,9 @@ function RunCard({ role, id, run, children }: {
|
||||
{r?.schedule_id && (
|
||||
<span className="chip" title={T.scheduled}><CalendarClock size={10} aria-hidden /> {T.scheduled}</span>
|
||||
)}
|
||||
{r && unsuccessful(r.status) && (
|
||||
<span className="inline-flex" data-compare-run-status={r.status}><StatusPill status={r.status} /></span>
|
||||
)}
|
||||
{r && !r.manifest_hash && (
|
||||
<span className="chip" title={T.unsealedHint} data-compare-unsealed=""
|
||||
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 40%, transparent)' }}>
|
||||
@@ -113,7 +124,8 @@ function RetryButton({ onClick }: { onClick: () => void }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。
|
||||
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因;失败、
|
||||
* 取消的能选,写明状态(默认基准不取它们)。
|
||||
*
|
||||
* 当前的基准不在这几次里(更早、属于别的工作流、读不到)时也单列一项:受控的下拉值对不上任何选项时浏览器会选中
|
||||
* 第一项,看着像另一次运行被选中,而原生下拉再选当前显示的那一项不触发 change,最自然的补救点了没反应。读不到的
|
||||
@@ -146,7 +158,7 @@ function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onP
|
||||
const list = (candidates.data ?? []).filter((r) => r.id !== target)
|
||||
if (!loading && !list.length) return <p className="mt-2 text-2xs text-faint" data-compare-pick-note="none">{T.pickNone}</p>
|
||||
const label = (r: RunWithSchedule) => T.pickOption(formatTime(r.started_at ?? r.created_at ?? null, new Date(now)),
|
||||
runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash)
|
||||
runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash, unsuccessful(r.status) ? statusLabel(r.status) : null)
|
||||
const current = base && !list.some((r) => r.id === base) ? base : null
|
||||
const currentLabel = (b: string) => (baseRun.data ? label(baseRun.data)
|
||||
: baseRun.state === 'error' ? T.pickGone(shortId(b), isGone(baseRun.error)) : shortId(b))
|
||||
|
||||
@@ -17,8 +17,9 @@ export const comparePath = (target: string, base?: string | null) =>
|
||||
const NOTE_DELAY_MS = 300
|
||||
|
||||
/**
|
||||
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的
|
||||
* 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
|
||||
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务上一次成功的运行,没有就同一工作流、
|
||||
* 同一类别上一次成功的运行;定时任务触发历史里同名的入口由服务端用同一个函数挑,两处比的是同一对运行),找到了再跳到
|
||||
* 对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
|
||||
* 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据
|
||||
* 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode,
|
||||
} from '../../lib/status'
|
||||
import { formatSpan, parseServerTime } from '../../lib/format'
|
||||
import { INQUIRY_TEXT } from '../../lib/terms'
|
||||
import { hasPendingApproval } from '../../store/catalog'
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -334,6 +335,7 @@ export const ARTIFACT_KIND_LABEL: Record<string, string> = {
|
||||
schema_snapshot: '表结构快照',
|
||||
dual_check: '双盲复算对照',
|
||||
challenge: '反方审稿记录',
|
||||
inquiry_answer: INQUIRY_TEXT.artifactKind,
|
||||
}
|
||||
export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind
|
||||
|
||||
@@ -356,6 +358,9 @@ export function artifactDescription(kind: string, meta?: Record<string, any> | n
|
||||
? `双盲复算:${meta.metrics} 个指标两边的 SQL、取值与差异说明` : '双盲复算:两边的 SQL、取值与差异说明'
|
||||
// 反方审稿的完整记录(工件行的 meta 里有 {report: 审的报告节点})
|
||||
case 'challenge': return '反方审稿:每句结论的判断、理由和反方执行的查询'
|
||||
// 审批质询的回答文档(工件行的 meta 里有 {report, unit, question: 问题的前 40 字}):不挂在节点上,靠问题认出是哪一条
|
||||
case 'inquiry_answer': return typeof meta?.question === 'string' && meta.question.trim()
|
||||
? INQUIRY_TEXT.artifactAbout(meta.question) : INQUIRY_TEXT.artifactAboutNone
|
||||
default: return '工件'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ const PAGE = 20
|
||||
/**
|
||||
* 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败
|
||||
* 写原因;发起了的链到那次运行、写运行现在的状态;运行成功结束后服务端和同一任务上一次成功的运行比过的,另给
|
||||
* 「与上一次对比」;发出去的通知逐条写结果。refreshKey 变了(立即运行之后)从头重取
|
||||
* 「与上一次对比」(基准不是紧挨着的上一次时写「与上一次成功的运行对比」);发出去的通知逐条写结果。refreshKey 变了
|
||||
* (立即运行之后)从头重取
|
||||
*/
|
||||
export function FireHistory({ schedule, refreshKey }: { schedule: Schedule; refreshKey: number }) {
|
||||
const [items, setItems] = useState<ScheduleFire[] | null>(null)
|
||||
@@ -142,14 +143,17 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu
|
||||
}
|
||||
|
||||
/**
|
||||
* 「与上一次对比」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务上一次成功的运行。旁边写有几个
|
||||
* 指标有变化(触屏没有悬停,键盘聚焦也不显示悬停),悬停写各类指标的个数(和通知里那一行同一个写法),读屏把它当
|
||||
* 说明念。基准就是紧挨着的上一次时带上来历,对比页据此写「默认基准:同一定时任务的上一次运行」;不是(上一次失败了、
|
||||
* 取消了)时不带,免得说错,悬停写明比的是哪一次
|
||||
* 「与上一次对比」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务上一次成功的运行——和运行详情
|
||||
* 「更多」里同名入口的默认基准是同一个挑法(服务端 api/compare.py 的 pick_base),两处进去比的是同一对运行。旁边写有
|
||||
* 几个指标有变化(触屏没有悬停,键盘聚焦也不显示悬停),悬停写各类指标的个数(和通知里那一行同一个写法),读屏把它
|
||||
* 当说明念。
|
||||
*
|
||||
* 基准不是紧挨着的上一次(上一次失败了、取消了)时,链接上看得见的字就写「与上一次成功的运行对比」,悬停另写是哪一次。
|
||||
* 两种都带上来历:对比页据此写「默认基准:同一定时任务上一次成功的运行」,和从运行详情进去时一字不差
|
||||
*/
|
||||
function CompareLink({ runId, compare }: { runId: string; compare: ScheduleFireCompare }) {
|
||||
const previous = compare.previous !== false
|
||||
const via: CompareVia | undefined = previous ? { base: compare.base_run_id, reason: 'same_schedule' } : undefined
|
||||
const via: CompareVia = { base: compare.base_run_id, reason: 'same_schedule' }
|
||||
const count = T.compareCount(compare.summary)
|
||||
return (
|
||||
<Link
|
||||
@@ -160,7 +164,7 @@ function CompareLink({ runId, compare }: { runId: string; compare: ScheduleFireC
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-sm text-2xs text-dim outline-none hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<GitCompareArrows size={11} aria-hidden />
|
||||
<span data-fire-compare-label="">{T.compareLink}</span>
|
||||
<span data-fire-compare-label="">{previous ? T.compareLink : T.compareLinkLastOk}</span>
|
||||
{count && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
|
||||
@@ -308,7 +308,17 @@ export const useInquiry = create<InquiryState>((set, get) => ({
|
||||
},
|
||||
|
||||
loadApprovalDocs: async (runId, ids) => {
|
||||
if (get().approvalDocs[runId]?.status === 'loading') return
|
||||
const inflight = get().approvalDocs[runId]
|
||||
if (inflight?.status === 'loading') {
|
||||
// 别处已经在取(同一张卡审两份报告;运行详情的待审批卡和证据页签同时挂着,两处要对的审批不同):不再另取,但这一处
|
||||
// 要对的也记成取过——列表按运行取、不按审批,在路上的那一份本来就带着全部审批。只是提前返回的话,这一处的 unknown
|
||||
// 在请求回来之后不变、effect 不再执行,settled 一直为假:头上写着「· N 次」,别的审批里问的那几条一直不显示
|
||||
const asked = [...new Set([...inflight.asked, ...ids])]
|
||||
if (asked.length > inflight.asked.length) {
|
||||
set((s) => ({ approvalDocs: { ...s.approvalDocs, [runId]: { ...s.approvalDocs[runId]!, asked } } }))
|
||||
}
|
||||
return
|
||||
}
|
||||
set((s) => {
|
||||
const cur = s.approvalDocs[runId]
|
||||
return { approvalDocs: { ...s.approvalDocs, [runId]: {
|
||||
@@ -503,6 +513,13 @@ const groupKey = (t: { unit: string | null; quote: string | null }) =>
|
||||
|
||||
/** 问的不是眼前这一稿报告的那几条:单独一组,排在最后 */
|
||||
const OTHER = 'other'
|
||||
/** 别的审批里问的、审批列表又没取到,不知道是不是眼前这一稿:也单独一组,排在最后,给重试 */
|
||||
const UNSURE = 'unsure'
|
||||
/**
|
||||
* 不按句子挂的两组:句子编号对不上(或者不知道对不对得上),回答引用报告已有的出处时不在眼前这一稿里找;正在回答的、
|
||||
* 提问框也不挂进来
|
||||
*/
|
||||
const offDoc = (key: string) => key === OTHER || key === UNSURE
|
||||
|
||||
interface Group {
|
||||
key: string
|
||||
@@ -600,6 +617,10 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
|
||||
if (a === (cardId ?? null)) return true
|
||||
return !!a && !!docArtifact && docsSlot?.map[a]?.[report] === docArtifact
|
||||
}, [cardId, docArtifact, docsSlot, report])
|
||||
// 审批列表没取到时,别的审批里问的、那张审批又不在之前取到的列表里:不知道是不是眼前这一稿,不能算「其他版本」。
|
||||
// 取到了列表却没有那张审批的(审批记录没了)、这份报告没有报告文档(拿什么都对不上)的,重取也一样,照旧算对不上
|
||||
const unsure = useCallback((x: Inquiry) => !!docArtifact && docsSlot?.status === 'error' && !!x.approval_id
|
||||
&& !(x.approval_id in docsSlot.map), [docArtifact, docsSlot])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const out = new Map<string, Group>()
|
||||
@@ -609,9 +630,10 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
|
||||
return out.get(k)!
|
||||
}
|
||||
const others: InquiryEntry[] = []
|
||||
const unsures: InquiryEntry[] = []
|
||||
for (const e of entries) {
|
||||
if (sameDoc(e.inquiry)) add(e.inquiry.unit, e.inquiry.quote).entries.push(e)
|
||||
else if (settled) others.push(e)
|
||||
else if (settled) (unsure(e.inquiry) ? unsures : others).push(e)
|
||||
}
|
||||
// 正在回答的、没问成的、开着的提问框挂在它们问的那一句下面(按先后):那句还没有问答时另起一组
|
||||
if (mine) add(mine.target.unit, mine.target.quote)
|
||||
@@ -619,8 +641,11 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
|
||||
if (draft) add(draft.target.unit, draft.target.quote)
|
||||
const list = [...out.values()]
|
||||
if (others.length) list.push({ key: OTHER, unit: null, quote: null, entries: others })
|
||||
if (unsures.length) list.push({ key: UNSURE, unit: null, quote: null, entries: unsures })
|
||||
return list
|
||||
}, [entries, sameDoc, settled, mine, draft, failed])
|
||||
}, [entries, sameDoc, unsure, settled, mine, draft, failed])
|
||||
// 重取审批列表:列表按运行取,一份里各张审批都有。取的时候这两组先收起(同第一次取),取回来按结果重新归拢
|
||||
const retryDocs = useCallback(() => { void loadApprovalDocs(runId, foreign) }, [loadApprovalDocs, runId, foreign])
|
||||
|
||||
// 提交之后提问框拿掉了,焦点接到「正在回答」那一条上;回答回来那一条换掉了,再接到回答上。焦点已经去了别处的不抢;
|
||||
// 没问成(提问框带着报错重新打开)时焦点由提问框自己接。都只在问的那一处:同一张卡挂在两处时另一处不动
|
||||
@@ -687,15 +712,15 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
|
||||
<div className="mt-1.5 space-y-2">
|
||||
{groups.map((g) => (
|
||||
<div key={g.key} className="min-w-0"
|
||||
data-inquiry-group={g.key === OTHER ? OTHER : g.unit ?? (g.quote ? 'quote' : 'report')}>
|
||||
<GroupHead group={g} units={units} />
|
||||
data-inquiry-group={offDoc(g.key) ? g.key : g.unit ?? (g.quote ? 'quote' : 'report')}>
|
||||
<GroupHead group={g} units={units} onRetry={retryDocs} />
|
||||
<div className="mt-1 space-y-2 border-l pl-2">
|
||||
{g.entries.map((e) => (
|
||||
<EntryView key={e.id} runId={runId} entry={e} groupQuote={g.unit ? null : g.quote}
|
||||
otherDoc={g.key === OTHER} onLocate={g.key === OTHER ? undefined : onLocate}
|
||||
otherDoc={offDoc(g.key)} onLocate={offDoc(g.key) ? undefined : onLocate}
|
||||
focusRef={e.id === latest ? (el) => { lastRef.current = el } : undefined} />
|
||||
))}
|
||||
{g.key !== OTHER && (
|
||||
{!offDoc(g.key) && (
|
||||
<>
|
||||
{mine && groupKey(mine.target) === g.key && (
|
||||
<PendingView pending={mine} focusRef={(el) => { pendingRef.current = el }} />
|
||||
@@ -730,9 +755,9 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
|
||||
|
||||
/**
|
||||
* 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告。问的不是眼前这一稿的
|
||||
* 那一组只写明这一点:句子编号对不上,不去取原文
|
||||
* 那一组只写明这一点:句子编号对不上,不去取原文。审批列表没取到、不知道是哪一稿的那一组照实说,给重试(onRetry)
|
||||
*/
|
||||
function GroupHead({ group, units }: { group: Group; units: Map<string, string> }) {
|
||||
function GroupHead({ group, units, onRetry }: { group: Group; units: Map<string, string>; onRetry: () => void }) {
|
||||
if (group.key === OTHER) {
|
||||
return (
|
||||
<div className="min-w-0 text-2xs leading-relaxed" data-inquiry-about="">
|
||||
@@ -741,6 +766,17 @@ function GroupHead({ group, units }: { group: Group; units: Map<string, string>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (group.key === UNSURE) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1 text-2xs leading-relaxed" data-inquiry-about="">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-medium" style={{ color: 'var(--st-waiting)' }} data-inquiry-about-text="">{INQUIRY_TEXT.unsureDoc}</span>
|
||||
<span className="block text-faint">{INQUIRY_TEXT.unsureDocHint}</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-xs shrink-0" data-inquiry-docs-retry="" onClick={onRetry}>{INQUIRY_TEXT.retry}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const text = group.unit ? units.get(group.unit) || group.entries.find((e) => e.inquiry.quote)?.inquiry.quote || ''
|
||||
: group.quote ?? ''
|
||||
return (
|
||||
|
||||
+133
-12
@@ -9,10 +9,14 @@
|
||||
// 同一节点两条查询的 (node_id, ordinal) 相同;一边的 SQL 是 null;基准运行读不到;本次的记录读不到;不属于已保存
|
||||
// 工作流的运行;查找基准期间离开运行详情(新页面画好之后、或者地址刚换新页面还没画出来时答复才回来,走过的地址里
|
||||
// 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录
|
||||
// 不存在时不给注定失败的「重试」。
|
||||
// 不存在时不给注定失败的「重试」。默认基准的说法照服务端的挑法(api/compare.py 的 pick_base)写「上一次成功的运行」
|
||||
// ——上一次失败了,基准是再往前成功的那一次;定时任务触发历史里的「与上一次对比」和运行详情里的同名入口打开同一对
|
||||
// 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」;找不到默认基准时标题不说
|
||||
// 「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。
|
||||
//
|
||||
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
|
||||
// compare/base、compare、定时任务列表都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。
|
||||
// compare/base、compare、定时任务列表、发起它们的定时任务和触发历史都在浏览器层答掉,别的 GET 放行(外壳要的工作流、
|
||||
// 待审批),非 GET 一律拦掉。
|
||||
// 运行对比的两个接口只读,这里的假答复严格照接口约定的字段名和形状。
|
||||
//
|
||||
// node scripts/check-compare.mjs
|
||||
@@ -181,10 +185,32 @@ function comparison(target, base) {
|
||||
const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data: '口径与数据都变', sql_changed: 'SQL 结构变',
|
||||
params_only: '只参数变', added: '新增', removed: '移除', incomparable: '无法比较' }
|
||||
const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged']
|
||||
const REASON = '默认基准:同一定时任务的上一次运行'
|
||||
const FINDING = '正在查找上一次运行'
|
||||
// 默认基准只挑比本次先发起、成功结束并封存的运行(服务端 api/compare.py 的 pick_base):说法里都是「上一次成功的运行」
|
||||
const REASON = '默认基准:同一定时任务上一次成功的运行'
|
||||
const REASON_WORKFLOW = '默认基准:同一工作流、同一运行类别上一次成功的运行'
|
||||
const FINDING = '正在查找上一次成功的运行'
|
||||
/** 409、422、缺基准的 404 时下拉里没有能换的运行:不叫人去上方换,照实说 */
|
||||
const NO_OTHER = '同一工作流没有其他可换作基准的已封存运行'
|
||||
/** 服务端(api/compare.py 的 NO_BASE_TEXT)找不到默认基准时的原话 */
|
||||
const NO_BASE_SERVER = '没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行'
|
||||
|
||||
/**
|
||||
* 发起本次和基准的定时任务(RUNS 里的 schedule_id)和它的触发历史:本次那一行带服务端自动对比的计数,基准是 B、不是
|
||||
* 紧挨着的上一次(previous: false,假定 B 之后同一定时任务还有一次失败的;那一次不进 RUNS,换基准的下拉照旧)。
|
||||
* 只给「两个入口拿同一个基准」那一段用:触发历史和运行详情「更多」里的同名入口,服务端用同一个函数挑基准
|
||||
*/
|
||||
const SCHEDULE_ID = 'sch0weekly'
|
||||
const SCHEDULE = {
|
||||
id: SCHEDULE_ID, name: '区域销售周报 · 每周', workflow_id: WF, workflow_name: NAME, cron: '0 9 * * 1', timezone: 'Asia/Shanghai',
|
||||
run_class: 'formal', input_template: {}, enabled: true, overlap: 'skip', misfire_grace_s: 3600, webhook: null,
|
||||
next_run_at: '2026-10-12T01:00:00Z', last_fire: null, description_text: '每周一 09:00(Asia/Shanghai)', created_by: '值班分析员',
|
||||
created_at: day('09-01'), updated_at: day('09-01'), version: 2,
|
||||
}
|
||||
const FIRE_T = 'fire0target'
|
||||
const FIRES = [{
|
||||
id: FIRE_T, fire_at: day('10-05'), kind: 'scheduled', status: 'started', run_id: T, run_status: 'succeeded', reason: null,
|
||||
notified: [], created_at: day('10-05'), compare: { base_run_id: B, previous: false, summary: SUMMARY },
|
||||
}]
|
||||
|
||||
// ---------------------------------------------------------------- 假接口
|
||||
|
||||
@@ -226,7 +252,7 @@ function defaults(opts) {
|
||||
if (m) {
|
||||
if (opts.base) return opts.base(m[1])
|
||||
return m[1] === T ? { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } }
|
||||
: { status: 404, json: { detail: '没有找到可作为基准的运行' } }
|
||||
: { status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }
|
||||
}
|
||||
m = p.match(/^\/api\/runs\/([^/]+)\/compare$/)
|
||||
if (m) {
|
||||
@@ -247,6 +273,9 @@ function defaults(opts) {
|
||||
if (m[2] === '/artifacts') return { json: [] }
|
||||
}
|
||||
if (p === '/api/approvals' && url.searchParams.get('run_id')) return { json: [] }
|
||||
// 发起本次的定时任务和它的触发历史(只有直接打开那个定时任务的页面时才用到)
|
||||
if (p === `/api/schedules/${SCHEDULE_ID}`) return { json: SCHEDULE }
|
||||
if (p === `/api/schedules/${SCHEDULE_ID}/fires`) return { json: { items: FIRES, next_before: null } }
|
||||
// 查找基准期间离开运行详情去的那一页(定时任务列表):给空的列表,页面落定不靠服务端里有什么
|
||||
if (p === '/api/schedules') return { json: { items: [] } }
|
||||
return null
|
||||
@@ -397,7 +426,7 @@ async function findingBase(answer = { json: { base_run_id: B, reason: 'same_sche
|
||||
|
||||
await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => {
|
||||
const { page, release, ended, noted, visited, errors } = await findingBase()
|
||||
check('点了之后有「正在查找上一次运行」的提示', noted)
|
||||
check('点了之后有「正在查找上一次成功的运行」的提示', noted)
|
||||
// 改主意去了别的页面。等那一页画好、运行详情卸下之后再放行:答复晚到也不能把人拽回对比页
|
||||
await page.evaluate((to) => { history.pushState({}, '', to); dispatchEvent(new PopStateEvent('popstate')) }, AWAY)
|
||||
await awaySettled(page)
|
||||
@@ -476,6 +505,61 @@ await section('未封存的运行:入口不可点并说明原因', async () =>
|
||||
}
|
||||
})
|
||||
|
||||
/** 在运行详情里点「更多」→「与上一次对比」,等对比页落定 */
|
||||
async function viaMenu(page) {
|
||||
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
|
||||
await page.getByRole('button', { name: '更多操作' }).click()
|
||||
await page.locator('[data-menu=compare]').click()
|
||||
await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 })
|
||||
await settled(page)
|
||||
}
|
||||
|
||||
await section('默认基准的说法:上一次成功的运行', async () => {
|
||||
// 服务端只挑比本次先发起、成功结束并封存的运行:上一次失败、取消了时基准是再往前成功的那一次,「上一次运行」是错的
|
||||
for (const [reason, want] of [['same_schedule', REASON], ['same_workflow', REASON_WORKFLOW]]) {
|
||||
const base = () => ({ json: { base_run_id: B, reason, base: refOf(B) } })
|
||||
const menu = await open(`/runs/${T}`, { base })
|
||||
await viaMenu(menu.page)
|
||||
const fromMenu = (await maybeText(menu.page.locator('[data-compare-reason]'))).trim()
|
||||
const self = await open(comparePath(T), { base })
|
||||
await settled(self.page)
|
||||
const fromPage = (await maybeText(self.page.locator('[data-compare-reason]'))).trim()
|
||||
check(`${reason}:从运行详情进入、只带本次打开,都写「${want}」`, fromMenu === want && fromPage === want, `${fromMenu} | ${fromPage}`)
|
||||
const pages = [await menu.page.locator('[data-compare-page]').innerText(), await self.page.locator('[data-compare-page]').innerText()]
|
||||
check(`${reason}:页面上不出现「上一次运行」这类不分成败的说法`, pages.every((t) => !t.includes('上一次运行')))
|
||||
check(`${reason}:页面没有报错`, ![...menu.errors, ...self.errors].length, [...menu.errors, ...self.errors].join(';'))
|
||||
for (const c of opened) await c.close().catch(() => {})
|
||||
opened.clear()
|
||||
}
|
||||
})
|
||||
|
||||
await section('运行详情与触发历史的「与上一次对比」:同一个基准、同一句来历', async () => {
|
||||
// 两处的基准由服务端用同一个函数挑(api/compare.py 的 pick_base):上一次失败了,两处都是再往前成功的 B。两处真的
|
||||
// 一致由 backend/tests/test_scheduler_compare.py 守,这里守前端两个入口都原样用服务端给的基准
|
||||
const menu = await open(`/runs/${T}`)
|
||||
await viaMenu(menu.page)
|
||||
const hist = await open(`/schedules/${SCHEDULE_ID}?tab=history`)
|
||||
const link = hist.page.locator(`[data-fire-id="${FIRE_T}"] [data-fire-compare]`)
|
||||
await link.waitFor({ timeout: 10000 })
|
||||
const label = (await link.locator('[data-fire-compare-label]').innerText()).trim()
|
||||
check('触发历史:基准不是紧挨着的上一次,链接上看得见的字写「与上一次成功的运行对比」', label === '与上一次成功的运行对比', label)
|
||||
await link.click()
|
||||
await hist.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 })
|
||||
await settled(hist.page)
|
||||
const [a, b] = [new URL(menu.page.url()), new URL(hist.page.url())]
|
||||
check('两个入口打开的是同一对运行(本次和基准都一样)', a.searchParams.get('target') === T && b.searchParams.get('target') === T
|
||||
&& a.searchParams.get('base') === B && b.searchParams.get('base') === B, `${a.search} | ${b.search}`)
|
||||
const [ra, rb] = [(await maybeText(menu.page.locator('[data-compare-reason]'))).trim(),
|
||||
(await maybeText(hist.page.locator('[data-compare-reason]'))).trim()]
|
||||
check('两个入口写的默认基准一字不差:同一定时任务上一次成功的运行', ra === REASON && rb === REASON, `${ra} | ${rb}`)
|
||||
const tiles = (page) => page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) =>
|
||||
els.map((e) => `${e.getAttribute('data-compare-class')}=${e.querySelector('[data-compare-count]')?.textContent?.trim()}`).join(' '))
|
||||
const [ta, tb] = [await tiles(menu.page), await tiles(hist.page)]
|
||||
check('两边的变化摘要一致', !!ta && ta === tb, `${ta} | ${tb}`)
|
||||
check('没有发出写请求', ![...menu.blocked, ...hist.blocked].length, [...menu.blocked, ...hist.blocked].join(';'))
|
||||
check('页面没有报错', ![...menu.errors, ...hist.errors].length, [...menu.errors, ...hist.errors].join(';'))
|
||||
})
|
||||
|
||||
await section('两次运行与摘要', async () => {
|
||||
const { page, errors } = await open(comparePath(T, B))
|
||||
await settled(page)
|
||||
@@ -791,6 +875,41 @@ await section('换基准、互换', async () => {
|
||||
`${listsBefore} → ${lists()}`)
|
||||
})
|
||||
|
||||
await section('换基准:失败、取消的运行写明状态,默认基准只取成功结束的', async () => {
|
||||
// 默认基准跳过失败、取消的运行(服务端 pick_base),手动选不限:下拉和基准卡上得看得出哪次没成功,人读懂「上一次成功的
|
||||
// 运行」之后才挑得出成功的那一次。两次都封存过,都能选
|
||||
const F = 'c0ffee0failed0000000000000000005'
|
||||
const C = 'c0ffee0cancel0000000000000000006'
|
||||
RUNS[F] = runOf(F, { status: 'failed', version: 3, created_at: day('10-01'), started_at: day('10-01'), finished_at: '2026-10-01T01:01:00Z' })
|
||||
RUNS[C] = runOf(C, { status: 'cancelled', version: 3, created_at: day('10-02'), started_at: day('10-02'), finished_at: '2026-10-02T01:01:00Z' })
|
||||
try {
|
||||
const { page, errors } = await open(comparePath(T, F))
|
||||
await settled(page)
|
||||
const picker = page.locator('select[data-compare-base-picker]')
|
||||
await until(async () => (await picker.locator('option').count()) > 2)
|
||||
const options = Object.fromEntries(await picker.locator('option').evaluateAll((els) =>
|
||||
els.map((o) => [o.value, { text: o.textContent ?? '', disabled: o.disabled }])))
|
||||
check('下拉:失败的那次写「失败」,照样能选', !!options[F]?.text.includes('· 失败') && !options[F].disabled, JSON.stringify(options[F]))
|
||||
check('下拉:取消的那次写「已取消」,照样能选', !!options[C]?.text.includes('· 已取消') && !options[C].disabled, JSON.stringify(options[C]))
|
||||
check('下拉:成功结束的不另写状态', !!options[B] && !/失败|已取消|已完成/.test(options[B].text), options[B]?.text)
|
||||
const card = page.locator('[data-compare-run="base"]')
|
||||
check('下拉下面的说明写明默认基准只取成功结束的运行', (await card.innerText()).includes('默认基准只取成功结束的运行'),
|
||||
(await card.innerText()).replace(/\s+/g, ' '))
|
||||
const status = card.locator('[data-compare-run-status]')
|
||||
check('基准卡:失败的那次标出「失败」', await status.count() === 1 && (await status.innerText()).trim() === '失败',
|
||||
await status.innerText().catch(() => '没有'))
|
||||
check('本次卡:成功结束的不标状态', await page.locator('[data-compare-run="target"] [data-compare-run-status]').count() === 0)
|
||||
await picker.selectOption(B)
|
||||
await until(async () => new URL(page.url()).searchParams.get('base') === B)
|
||||
await settled(page)
|
||||
check('换成成功结束的那次:基准卡不再标状态', await until(async () => await card.locator('[data-compare-run-status]').count() === 0))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
} finally {
|
||||
delete RUNS[F]
|
||||
delete RUNS[C]
|
||||
}
|
||||
})
|
||||
|
||||
await section('过时的答复不盖掉新的', async () => {
|
||||
let release
|
||||
const gate = new Promise((r) => { release = r })
|
||||
@@ -878,15 +997,18 @@ await section('出错与找不到基准', async () => {
|
||||
await settled(flaky.page)
|
||||
check('其他错误:给「重试」,重试后正常显示', await flaky.page.locator('tr[data-compare-metric]').count() === METRICS.length)
|
||||
|
||||
// 只带本次:页面自己找基准,找不到时写原因,下拉照样能选
|
||||
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) })
|
||||
// 只带本次:页面自己找基准,找不到时写原因,下拉照样能选。标题只说没有默认基准:失败、取消但封存了的运行不当默认
|
||||
// 基准,却照样能手动选,写成「没有可作为基准的运行」就和下面「可在上方选择」打架
|
||||
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }) })
|
||||
await settled(lone.page, 'error')
|
||||
const noBase = lone.page.locator('[data-compare-error="no-base"]')
|
||||
check('找不到基准:写明原因', (await noBase.locator('[data-compare-error-title]').innerText()).includes('未找到可作为基准的运行')
|
||||
&& (await noBase.innerText()).includes('同一工作流没有其他已封存的正式运行'))
|
||||
check('找不到基准:标题写「没有默认基准」,后面跟服务端的原因', (await noBase.locator('[data-compare-error-title]').innerText()).trim() === '没有默认基准'
|
||||
&& (await noBase.innerText()).includes(NO_BASE_SERVER), (await noBase.innerText()).replace(/\s+/g, ' '))
|
||||
check('找不到基准:仍能手动选', await lone.page.locator('select[data-compare-base-picker]').isEnabled())
|
||||
check('找不到基准、下拉里有能选的:说可在上方选', await until(async () => (await noBase.innerText()).includes('可在上方选择')),
|
||||
(await noBase.innerText()).replace(/\s+/g, ' '))
|
||||
check('找不到基准:不说「没有可作为基准的运行」这类和「可在上方选择」矛盾的话', !/可作为基准的运行|没有可以对比/.test(await noBase.innerText()),
|
||||
(await noBase.innerText()).replace(/\s+/g, ' '))
|
||||
// 同一工作流只有一次没封存的:下拉里没有能选的,不能再叫人去上方选
|
||||
const bare = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行', code: 'compare_no_base' } }),
|
||||
list: () => ({ json: [RUNS[T], RUNS[U]] }) })
|
||||
@@ -991,8 +1113,7 @@ await section('不属于已保存工作流的运行:对比页的说明前后
|
||||
try {
|
||||
// 只带本次:服务端找不到基准(这种运行没有可比的)
|
||||
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: {
|
||||
detail: '没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、更早结束并已封存的运行',
|
||||
code: 'compare_no_base' } }) })
|
||||
detail: NO_BASE_SERVER, code: 'compare_no_base' } }) })
|
||||
await settled(lone.page, 'error')
|
||||
await until(async () => (await lone.page.locator('[data-compare-run="base"]').innerText()).includes('未保存或已删除'), 3000)
|
||||
const card = (await lone.page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ')
|
||||
|
||||
+193
-20
@@ -1,6 +1,7 @@
|
||||
// 审批质询的回归检查:报告上的入口(run/EvidenceDoc.tsx 的句末「质询」、划选原文浮出的「质询」)、问答串和提问框
|
||||
// (run/Inquiry.tsx)、审批卡上的挂法(run/ApprovalReport.tsx、run/RunPanel.tsx)、运行详情「证据」页签的挂法
|
||||
// (pages/runs/EvidencePane.tsx)。
|
||||
// (pages/runs/EvidencePane.tsx),以及运行详情「工件」页签里质询回答的名字(pages/runs/model.ts)和航迹跳转
|
||||
// (pages/runs/ArtifactsPane.tsx)。
|
||||
//
|
||||
// 守的是这些「看着没坏、其实用不了」的地方:悬停、键盘聚焦一句都不出「质询」(或者出了 Tab 却到不了、整份报告多出一串
|
||||
// Tab 位)、没有可点内容的句子键盘走不到、划选原文不浮出入口(或者带上的原文混进了徽标和「质询」本身)、提交的请求体
|
||||
@@ -8,11 +9,15 @@
|
||||
// 进入审批卡时问答串没有恢复、问答串写不出问的是哪一句、回答里的数字点了不去取质询工件(或者每点一次取一次、标不出
|
||||
// 那一格)、引用报告已有出处的数字点了没反应、查询的 SQL / 行数 / 截断 / 遮罩不显示、触顶和没答成不说、recorded 为假
|
||||
// 不醒目、409 / 422 不照服务端的中文说明显示(或者问题文字丢了)、Esc 关不掉提问框(或者连带收起了别的、焦点掉到页面上)、
|
||||
// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
|
||||
// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;同一次运行的几处问答串要对的审批不同、审批列表
|
||||
// 取不到时后一处一直不列别的审批里问的(头上写着次数,下面一条没有),或者把没能确认的说成「报告其他版本」、不给重试;
|
||||
// 工件页签里质询的回答只写英文的种类名、截断截在空格上的问题摘要不补省略号、封存后追加的工件给一个跳到运行结束之后的
|
||||
// 航迹时刻;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
|
||||
//
|
||||
// 跑在预览页上:/preview.html?syn=inquiry-*(用例见 frontend/src/dev/fixtures/inquiry.tsx)。夹具在
|
||||
// frontend/src/dev/fixtures/inquiry.json,报告文档和回答文档是后端 build_catalog + compose_doc 真实组装的(虚构的数据)。
|
||||
// 质询的三个接口、工件、证据图、片段接口都在这里伪造;别的写请求一律拦掉并记下来,别的 GET 答 404,不碰真实服务。
|
||||
// 质询的三个接口、工件、证据图、片段接口、审批列表、运行的工件列表都在这里伪造;别的写请求一律拦掉并记下来,别的 GET
|
||||
// 答 404,不碰真实服务。
|
||||
//
|
||||
// node scripts/check-inquiry.mjs
|
||||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-inquiry.mjs
|
||||
@@ -66,8 +71,9 @@ const answerFor = (body, extra = {}) => ({
|
||||
/**
|
||||
* 开一个预览用例。列表按运行答夹具里的问答(state.lists 可改),提问按 state.reply 答({status, json} 或按请求体算的函数;
|
||||
* state.gate 是个没兑现的 Promise 时先挂着,测「回答期间」;state.listGate 同样挂着列表)。审批列表按 state.approvals
|
||||
* (运行 → 审批数组)答,设置按 state.settings 答;state.docs、state.snaps 盖过夹具里的文档、快照。setup 在打开页面之前
|
||||
* 改 state(进入时就要取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
|
||||
* (运行 → 审批数组)答,没有的答 404,state.approvalsGate 同样先挂着;设置按 state.settings 答;state.docs、state.snaps
|
||||
* 盖过夹具里的文档、快照;运行的工件列表答夹具里的 run_artifacts(探索运行)。setup 在打开页面之前改 state(进入时就要
|
||||
* 取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
|
||||
*/
|
||||
async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws, waitUntil = 'networkidle' } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
|
||||
@@ -79,6 +85,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
|
||||
reply: (body) => ({ status: 200, json: answerFor(body) }),
|
||||
gate: null,
|
||||
listGate: null,
|
||||
approvalsGate: null,
|
||||
approvals: {},
|
||||
settings: {},
|
||||
docs: {},
|
||||
@@ -114,6 +121,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
|
||||
if (path === '/api/approvals') {
|
||||
const run = url.searchParams.get('run_id')
|
||||
hits.approvals.push({ run, status: url.searchParams.get('status') })
|
||||
if (state.approvalsGate) await state.approvalsGate
|
||||
const list = state.approvals[run]
|
||||
return list ? route.fulfill({ json: list }) : route.fulfill({ status: 404, json: { detail: '检查脚本没有伪造这个接口' } })
|
||||
}
|
||||
@@ -141,6 +149,8 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
|
||||
hits.segments.push(decodeURIComponent(seg[2]))
|
||||
return route.fulfill({ json: {} })
|
||||
}
|
||||
const arts = path.match(/^\/api\/runs\/([^/]+)\/artifacts$/)
|
||||
if (arts) return route.fulfill({ json: decodeURIComponent(arts[1]) === RUNS.explore ? fx.run_artifacts : [] })
|
||||
const graph = path.match(/^\/api\/runs\/([^/]+)\/evidence$/)
|
||||
if (graph) {
|
||||
const run = decodeURIComponent(graph[1])
|
||||
@@ -822,6 +832,53 @@ await section('运行详情「证据」页签:封存了的探索运行有入
|
||||
}
|
||||
})
|
||||
|
||||
await section('运行详情「工件」页签:质询的回答有名字和说明,不出现英文的工件种类名;封存后追加的不给航迹跳转', async () => {
|
||||
const { ctx, page, errors } = await open('inquiry-run-artifacts')
|
||||
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
|
||||
// 每一行:种类(行首的标签)、说明(同一种类有几件时末尾带「#n」,去掉再比)、悬停
|
||||
const rows = await page.locator('[data-artifact-id]').evaluateAll((els) => els.map((el) => {
|
||||
const open = el.querySelector('[data-action="artifact-open"]')
|
||||
const spans = open ? [...open.children] : []
|
||||
return { id: el.getAttribute('data-artifact-id'), kind: spans[0]?.textContent?.trim() ?? '',
|
||||
about: (spans[1]?.textContent ?? '').replace(/\s*#\d+$/, '').trim(), title: open?.getAttribute('title') ?? '' }
|
||||
}))
|
||||
const answers = fx.run_artifacts.filter((a) => a.kind === 'inquiry_answer').map((a) => rows.find((r) => r.id === a.id))
|
||||
check('质询的回答:种类写「质询回答」', answers.length === 4 && answers.every((r) => r?.kind === '质询回答'), JSON.stringify(answers))
|
||||
check('有问题摘要的:说明写「质询「问题」的回答」', answers[0]?.about === '质询「退款率怎么算的?」的回答', answers[0]?.about)
|
||||
check('问题摘要满 40 字(服务端只记前 40 字):末尾补省略号',
|
||||
answers[1]?.about === '质询「枫岭区的销售额是否包含线上订单?如果包含,线上订单按下单门店还是按收货地址归到片…」的回答', answers[1]?.about)
|
||||
check('没有问题摘要的:也有一句说明', answers[2]?.about === '审批质询的回答:逐段的片段和每个数字的出处', answers[2]?.about)
|
||||
// 第 40 个字是空格:服务端截下来的摘要以空格结尾(截之前整个问题已经去过首尾空白),去掉空白再数只有 39 字
|
||||
check('截断截在空格上的摘要:照样补省略号,不当成完整的问题',
|
||||
answers[3]?.about === '质询「本周枫岭片区 GMV 环比下降了多少?下降的主要原因是不是 SKU 结构的变化…」的回答', answers[3]?.about)
|
||||
// 航迹跳转:运行期间产出的报告文档能跳到航迹里那一刻;封存后追加的质询回答和它查询的快照晚于运行结束,航迹里没有
|
||||
// 那一刻(以前给「T+」几十分钟、几天的跳转,点了把航迹拖到运行早就结束之后),只写时间
|
||||
const moments = Object.fromEntries(await page.locator('[data-artifact-id]').evaluateAll((els) => els.map((el) =>
|
||||
[el.getAttribute('data-artifact-id'), el.querySelector('[data-action="artifact-moment"]')?.textContent?.trim() ?? null])))
|
||||
const doc = fx.run_artifacts.find((a) => a.kind === 'report_doc')
|
||||
const late = fx.run_artifacts.filter((a) => a.kind !== 'report_doc')
|
||||
check('运行期间产出的报告文档:给航迹跳转', /^T\+/.test(moments[doc.id] ?? ''), moments[doc.id])
|
||||
check('封存后追加的质询回答、它查询的快照:不给航迹跳转,只写时间', late.every((a) => moments[a.id] === null)
|
||||
&& (await page.locator(`[data-artifact-id="${late[0].id}"] > span`).last().innerText()).trim() !== '',
|
||||
JSON.stringify(late.map((a) => moments[a.id])))
|
||||
await page.locator(`[data-artifact-id="${doc.id}"] [data-action="artifact-moment"]`).click()
|
||||
const jumped = await page.locator('[data-fixture="inquiry-run-artifacts"]').getAttribute('data-fixture-moment')
|
||||
check('……点报告文档的跳转:到它产出的那一刻', Number(jumped) === Date.parse(`${doc.created_at}Z`), jumped)
|
||||
check('悬停写「打开质询回答」', answers.every((r) => r?.title.startsWith('打开质询回答')), answers.map((r) => r?.title).join(' / '))
|
||||
const pane = await text(page.locator('[data-run-artifacts]'))
|
||||
check('工件页签里不出现英文的工件种类名', !/inquiry_answer|report_doc|query_snapshot/.test(pane) && rows.every((r) => !/[a-z_]{4,}/.test(r.kind)),
|
||||
rows.map((r) => r.kind).join('、'))
|
||||
check('不挂在节点上的写「不属于某个节点」', (await text(page.locator('[data-artifact-group=""] h3'))).startsWith('不属于某个节点'))
|
||||
// 打开一件:弹窗标题也是中文的种类名
|
||||
await page.locator(`[data-artifact-id="${answers[0]?.id}"] [data-action="artifact-open"]`).click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await dialog.waitFor()
|
||||
const head = await text(dialog.locator('h2').first())
|
||||
check('打开的弹窗标题写「质询回答」,不写英文', head.includes('质询回答') && !head.includes('inquiry_answer'), head)
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await section('有表格的报告:悬停句子、表格的格子都不挪动排版;表格的格子不挂「质询」', async () => {
|
||||
// 报告文档换成带表格的那份(run/__tests__/evidence-doc.json):表里带出处的格子也是结论句
|
||||
const ev = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
|
||||
@@ -1026,12 +1083,18 @@ await section('之前几轮审批里问的:报告改写过的不按句子编
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
{
|
||||
// 审批列表取不到:别的审批里问的一律单独一组(宁可不挂,不挂错)
|
||||
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => { s.lists[RUNS.approve] = [prev, level, ...fx.items] } })
|
||||
// 审批列表取不到:别的审批里问的一律单独一组(宁可不挂,不挂错)。不知道是哪一稿,不能说成「其他版本」(审批人会以为
|
||||
// 报告换过稿):写明没能确认、给重试。这里用浅色主题量那句的颜色(深色在下一段量)
|
||||
const { ctx, page, errors } = await open('inquiry-approval', { theme: 'light', setup: (s) => { s.lists[RUNS.approve] = [prev, level, ...fx.items] } })
|
||||
await entry(page, 12).waitFor()
|
||||
const g = await groupsOf(page)
|
||||
check('审批列表取不到:别的审批里问的都单独一组', JSON.stringify(g.at(-1)) !== undefined && g.at(-1).unit === 'other'
|
||||
check('审批列表取不到:别的审批里问的都单独一组', JSON.stringify(g.at(-1)) !== undefined && g.at(-1).unit === 'unsure'
|
||||
&& JSON.stringify(g.at(-1).entries) === JSON.stringify(['12', '20']), JSON.stringify(g.map((x) => [x.unit, x.entries])))
|
||||
check('审批列表取不到:那一组写「无法确认提问时的报告版本」,不说「其他版本」,给重试',
|
||||
g.at(-1)?.about.startsWith('无法确认提问时的报告版本') && !g.at(-1)?.about.includes('其他版本')
|
||||
&& await group(page, 'unsure').locator('[data-inquiry-docs-retry]').count() === 1, g.at(-1)?.about)
|
||||
const unsureRatio = await ratioOf(page, '[data-inquiry-group="unsure"] [data-inquiry-about-text]')
|
||||
check('审批列表取不到(浅色):那一句对比度 ≥ 4.5', unsureRatio >= 4.5, unsureRatio.toFixed(2))
|
||||
check('审批列表取不到:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
@@ -1054,6 +1117,96 @@ await section('之前几轮审批里问的:报告改写过的不按句子编
|
||||
}
|
||||
})
|
||||
|
||||
/** 一处问答串:抬头写的次数、实际列出的条数 */
|
||||
const tally = async (page, scope) => ({
|
||||
head: (await page.locator(`${scope} [data-inquiry-count]`).getAttribute('data-inquiry-count').catch(() => null)) ?? '',
|
||||
shown: await page.locator(`${scope} [data-inquiry-entry]`).count(),
|
||||
})
|
||||
|
||||
await section('两处要对的审批不同、审批列表取不到:两处的问答都列出来,次数和条数一致,写明没能确认、重试取到后各归其位', async () => {
|
||||
// 一张卡审两份报告:写月报上有 ap-x 里问的,写周报上有 ap-y 里问的,两份报告的问答串共用这次运行的审批列表。先发的那一处
|
||||
// 请求还在路上时,后一处要对的审批也得记上:不然请求回来(这里是 404)之后后一处一直等着,头上写「· 1 次」,下面一条没有
|
||||
const x = { ...clone(fx.items[0]), seq: 41, approval_id: 'ap-x', question: '写月报上一轮问的:退款率怎么算?', at: '2026-10-05T05:30:00Z' }
|
||||
const y = { ...clone(fx.items[1]), seq: 42, report: 'write2', approval_id: 'ap-y', question: '写周报上一轮问的:新客怎么界定?',
|
||||
at: '2026-10-05T05:35:00Z' }
|
||||
let release
|
||||
// 审批列表压着,页面等不到网络空闲:打开到 load 就往下走
|
||||
const { ctx, page, hits, errors } = await open('inquiry-approval-two', { waitUntil: 'load', setup: (s) => {
|
||||
s.lists[RUNS.approve] = [x, y]
|
||||
s.approvalsGate = new Promise((r) => { release = r })
|
||||
} })
|
||||
// 两份报告的问答串都取回来了(头上写着次数),审批列表的请求还压着
|
||||
await page.locator('[data-inquiry="write"] [data-inquiry-count]').waitFor()
|
||||
await page.locator('[data-inquiry="write2"] [data-inquiry-count]').waitFor()
|
||||
await until(() => hits.approvals.length >= 1)
|
||||
await page.waitForTimeout(200)
|
||||
release()
|
||||
const both = await until(async () => (await tally(page, '[data-inquiry="write"]')).shown === 1
|
||||
&& (await tally(page, '[data-inquiry="write2"]')).shown === 1)
|
||||
const [gw, gw2] = [await groupsOf(page, '[data-inquiry="write"]'), await groupsOf(page, '[data-inquiry="write2"]')]
|
||||
check('写月报:审批列表没取到,那张审批里问的单独一组(没能确认)', JSON.stringify(gw.map((g) => [g.unit, g.entries])) === JSON.stringify([['unsure', ['41']]]),
|
||||
JSON.stringify(gw.map((g) => [g.unit, g.entries])))
|
||||
check('写周报:请求是另一处先发的,回来之后照样列出、单独一组', both
|
||||
&& JSON.stringify(gw2.map((g) => [g.unit, g.entries])) === JSON.stringify([['unsure', ['42']]]), JSON.stringify(gw2.map((g) => [g.unit, g.entries])))
|
||||
const [tw, tw2] = [await tally(page, '[data-inquiry="write"]'), await tally(page, '[data-inquiry="write2"]')]
|
||||
check('两份报告头上的次数都和列出的条数一致', tw.head === '1' && tw.shown === 1 && tw2.head === '1' && tw2.shown === 1,
|
||||
JSON.stringify({ tw, tw2 }))
|
||||
check('按运行只取一次审批列表(一份列表里各张审批都有)', hits.approvals.length === 1, JSON.stringify(hits.approvals))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
{
|
||||
// 运行详情直接打开「证据」页签、运行停在审批上:时间线里的待审批卡只为更早那一轮(ap-inq-0)取审批列表,证据页签不认
|
||||
// 任何审批,ap-inq-0 和这张卡的 ap-inq-1 都要对。卡那一处先发了请求,证据页签那一处的条目在请求回来之后也得列出来
|
||||
const early = { ...clone(fx.items[0]), seq: 12, approval_id: 'ap-inq-0', question: '上一轮:退款率 3.5% 是怎么算的?', at: '2026-10-05T05:40:00Z' }
|
||||
const { ctx, page, hits, state, errors } = await open('inquiry-run-waiting', { waitUntil: 'load', setup: (s) => {
|
||||
s.lists[RUNS.approve] = [early, clone(fx.items[1])]
|
||||
s.approvalsGate = new Promise((r) => { release = r })
|
||||
} })
|
||||
const card = '[data-fixture-card] [data-inquiry="write"]'
|
||||
const pane = '[data-fixture-evidence] [data-inquiry="write"]'
|
||||
await page.locator(`${card} [data-inquiry-count]`).waitFor()
|
||||
await page.locator(`${pane} [data-inquiry-count]`).waitFor()
|
||||
await until(() => hits.approvals.length >= 1)
|
||||
await page.waitForTimeout(200)
|
||||
release()
|
||||
const listed = await until(async () => (await tally(page, card)).shown === 2 && (await tally(page, pane)).shown === 2)
|
||||
const [gc, gp] = [await groupsOf(page, card), await groupsOf(page, pane)]
|
||||
check('待审批卡:这张审批里问的挂句子下面,没能确认的那一轮单独一组',
|
||||
JSON.stringify(gc.map((g) => [g.unit, g.entries])) === JSON.stringify([['u3', ['34']], ['unsure', ['12']]]),
|
||||
JSON.stringify(gc.map((g) => [g.unit, g.entries])))
|
||||
check('证据页签:两条都列出来,没能确认的单独一组(不再一直等着)', listed
|
||||
&& JSON.stringify(gp.map((g) => [g.unit, g.entries])) === JSON.stringify([['unsure', ['12', '34']]]), JSON.stringify(gp.map((g) => [g.unit, g.entries])))
|
||||
const [tc, tp] = [await tally(page, card), await tally(page, pane)]
|
||||
check('两处头上的次数都和列出的条数一致', tc.head === '2' && tc.shown === 2 && tp.head === '2' && tp.shown === 2, JSON.stringify({ tc, tp }))
|
||||
check('运行详情:按运行只取一次审批列表', hits.approvals.length === 1, JSON.stringify(hits.approvals))
|
||||
// 没读到审批记录不能说成「其他版本」:卡上同一条挂在句子下面,下面证据页签却说是别的版本,审批人会以为报告换过稿
|
||||
const heads = (await page.locator('[data-inquiry-group="unsure"] [data-inquiry-about-text]').allInnerTexts()).map((t) => t.trim())
|
||||
check('没能确认的那一组(两处):写「无法确认提问时的报告版本」,不说「其他版本」',
|
||||
heads.length === 2 && heads.every((t) => t === '无法确认提问时的报告版本'), heads.join(' | '))
|
||||
check('……两处都给「重试」', await page.locator(`${card} [data-inquiry-group="unsure"] [data-inquiry-docs-retry]`).count() === 1
|
||||
&& await page.locator(`${pane} [data-inquiry-group="unsure"] [data-inquiry-docs-retry]`).count() === 1)
|
||||
const unsureRatio = await ratioOf(page, `${pane} [data-inquiry-group="unsure"] [data-inquiry-about-text]`)
|
||||
check('……那一句(深色)对比度 ≥ 4.5', unsureRatio >= 4.5, unsureRatio.toFixed(2))
|
||||
// 重试,这回取得到:这张审批审的就是眼前这一稿,更早那一轮审的是另一稿
|
||||
state.approvals[RUNS.approve] = [approvalRow(fx.approval_id, RUNS.approve, fx.reports.approve.doc_artifact),
|
||||
approvalRow('ap-inq-0', RUNS.approve, '0'.repeat(64))]
|
||||
await page.locator(`${pane} [data-inquiry-docs-retry]`).click()
|
||||
const want = JSON.stringify([['u3', ['34']], ['other', ['12']]])
|
||||
const placed = await until(async () => JSON.stringify((await groupsOf(page, pane)).map((g) => [g.unit, g.entries])) === want
|
||||
&& JSON.stringify((await groupsOf(page, card)).map((g) => [g.unit, g.entries])) === want)
|
||||
const [gc2, gp2] = [await groupsOf(page, card), await groupsOf(page, pane)]
|
||||
check('重试取到之后(证据页签):这张审批里问的回到句子下面,更早那一轮算报告其他版本', placed
|
||||
&& JSON.stringify(gp2.map((g) => [g.unit, g.entries])) === want, JSON.stringify(gp2.map((g) => [g.unit, g.entries])))
|
||||
check('重试取到之后(待审批卡):更早那一轮也改写成报告其他版本', JSON.stringify(gc2.map((g) => [g.unit, g.entries])) === want
|
||||
&& gc2.at(-1)?.about.startsWith('报告其他版本上的质询'), JSON.stringify(gc2.map((g) => [g.unit, g.about.slice(0, 12)])))
|
||||
check('……重试只再取一次审批列表,两处共用', hits.approvals.length === 2, JSON.stringify(hits.approvals))
|
||||
const [tc2, tp2] = [await tally(page, card), await tally(page, pane)]
|
||||
check('……两处的次数仍和条数一致', tc2.head === '2' && tc2.shown === 2 && tp2.head === '2' && tp2.shown === 2, JSON.stringify({ tc2, tp2 }))
|
||||
check('运行详情:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
await section('回答期间另开了提问框,正在回答的那一条没问成:新写的不被覆盖', async () => {
|
||||
const { ctx, page, state, errors } = await open('inquiry-approval')
|
||||
await expand(page)
|
||||
@@ -1273,18 +1426,23 @@ function contrast(fg, bg, base) {
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
||||
}
|
||||
|
||||
/** 某个元素的字色对它所在那一块底色的对比度:底色往上找第一个不透明的,最底下是 body */
|
||||
const ratioOf = (page, sel) => page.evaluate((s) => {
|
||||
const el = document.querySelector(s)
|
||||
if (!el) return null
|
||||
let bgEl = el
|
||||
let bg = 'rgba(0, 0, 0, 0)'
|
||||
while (bgEl && /rgba\(0, 0, 0, 0\)|transparent/.test(bg)) {
|
||||
bg = getComputedStyle(bgEl).backgroundColor
|
||||
bgEl = bgEl.parentElement
|
||||
}
|
||||
return { fg: getComputedStyle(el).color, bg, base: getComputedStyle(document.body).backgroundColor }
|
||||
}, sel).then((c) => (c ? contrast(c.fg, c.bg, c.base) : 0))
|
||||
/**
|
||||
* 某个元素的字色对它所在那一块底色的对比度:底色往上找第一个不透明的,最底下是 body。写成函数声明:前面几段(审批列表
|
||||
* 取不到的那一组)也要用
|
||||
*/
|
||||
function ratioOf(page, sel) {
|
||||
return page.evaluate((s) => {
|
||||
const el = document.querySelector(s)
|
||||
if (!el) return null
|
||||
let bgEl = el
|
||||
let bg = 'rgba(0, 0, 0, 0)'
|
||||
while (bgEl && /rgba\(0, 0, 0, 0\)|transparent/.test(bg)) {
|
||||
bg = getComputedStyle(bgEl).backgroundColor
|
||||
bgEl = bgEl.parentElement
|
||||
}
|
||||
return { fg: getComputedStyle(el).color, bg, base: getComputedStyle(document.body).backgroundColor }
|
||||
}, sel).then((c) => (c ? contrast(c.fg, c.bg, c.base) : 0))
|
||||
}
|
||||
|
||||
await section('窄屏与深浅色', async () => {
|
||||
for (const theme of ['dark', 'light']) {
|
||||
@@ -1366,6 +1524,21 @@ await section('窄屏与深浅色', async () => {
|
||||
check('390px 运行详情:页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
for (const theme of ['dark', 'light']) {
|
||||
// 运行详情「工件」页签:质询回答的说明可能很长(问题的前 40 字),窄屏上截断、不撑宽;种类和说明深浅色都看得清
|
||||
const { ctx, page, errors } = await open('inquiry-run-artifacts', { width: 390, height: 844, theme })
|
||||
const long = fx.run_artifacts.find((a) => a.kind === 'inquiry_answer' && [...(a.meta.question ?? '')].length >= 40)
|
||||
const row = `[data-artifact-id="${long.id}"] [data-action="artifact-open"]`
|
||||
await page.locator(row).waitFor()
|
||||
const o = await overflow(page, '[data-run-artifacts]')
|
||||
const ratios = { kind: await ratioOf(page, `${row} > span:nth-child(1)`), about: await ratioOf(page, `${row} > span:nth-child(2)`) }
|
||||
check(`390px 工件页签(${theme}):质询回答的长说明截断、不横向溢出,种类和说明对比度 ≥ 4.5`,
|
||||
o.page <= 0 && o.box <= 0 && ratios.kind >= 4.5 && ratios.about >= 4.5,
|
||||
JSON.stringify({ o, kind: Number(ratios.kind.toFixed(2)), about: Number(ratios.about.toFixed(2)) }))
|
||||
await shot(page, `run-artifacts-narrow-${theme}`)
|
||||
check(`390px 工件页签(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
// 立即运行之后没有下文、触发历史只有一页、保存过的 Webhook 地址又被原样显示出来、改了别的字段却把地址清掉、
|
||||
// 记录页看不出哪些运行来自定时任务、390px 窄屏被表单撑出横向滚动。运行成功结束后的自动对比:触发历史里「与上一次
|
||||
// 对比」(和运行详情「更多」菜单同一个叫法)链到对比页,旁边写有几个指标有变化,悬停只写各类指标的个数,基准不是
|
||||
// 紧挨着的上一次时写明是哪一次、不带「默认基准」的来历;运行详情页头的「由定时任务「X」发起」是键盘到得了的真链接
|
||||
// (记录页的「定时」标记在整行按钮里,只能鼠标点),带日历图标,窄窗口里(768、820、900 宽,探索运行多一个「提取为
|
||||
// 草稿」)不被挤没,定时任务删掉之后写成纯文本。
|
||||
// 紧挨着的上一次时链接上看得见的字写「与上一次成功的运行对比」、悬停写明是哪一次;两种都带「默认基准:同一定时任务
|
||||
// 上一次成功的运行」的来历,和运行详情「更多」里的同名入口打开同一对运行(服务端用同一个函数挑基准)。运行详情页头的
|
||||
// 「由定时任务「X」发起」是键盘到得了的真链接(记录页的「定时」标记在整行按钮里,只能鼠标点),带日历图标,窄窗口里
|
||||
// (768、820、900 宽,探索运行多一个「提取为草稿」)不被挤没,定时任务删掉之后写成纯文本。
|
||||
// 还有几处时序和口径:晚到的刷新把刚切换的开关盖回去、先开触发历史再开设置时带着旧版本撞假的 409、保存途中又改了
|
||||
// 别的字段时替换过的地址明文留在框里、服务端会拒的组合(未发布 + 正式运行、必填没填、已发布版本里没有的字段)
|
||||
// 照样放行、测试消息的结果把服务端的整句又套一层、错过已跳过的那条悬停另说一个原因(和本条记的原因对不上)。
|
||||
@@ -267,9 +268,15 @@ await section('纯函数:服务端会拒的组合、保存回来怎么接、
|
||||
check(`测试结果:服务端给整句、给补充说明都只说一遍(${tests.length} 种)`, !wrong.length, wrong.map(([r]) => t(r)).join(';'))
|
||||
})
|
||||
|
||||
/** 对比页写的默认基准来历(定时任务发起的):只挑比本次先发起、成功结束并封存的运行,服务端 api/compare.py 的 pick_base */
|
||||
const REASON_SCHEDULE = '默认基准:同一定时任务上一次成功的运行'
|
||||
|
||||
await section('纯函数:触发历史对比的文案', async () => {
|
||||
const { SCHEDULE_TEXT: T, RUN_COMPARE_TEXT: C } = await import(await load('/src/lib/terms.ts'))
|
||||
check('「与上一次对比」和运行详情「更多」菜单同一个叫法', T.compareLink === C.menu && T.compareLink === '与上一次对比', T.compareLink)
|
||||
check('基准不是紧挨着的上一次时的叫法:与上一次成功的运行对比', T.compareLinkLastOk === '与上一次成功的运行对比', T.compareLinkLastOk)
|
||||
check('默认基准的来历照服务端的挑法写「上一次成功的运行」', C.reason.same_schedule === REASON_SCHEDULE
|
||||
&& C.reason.same_workflow === '默认基准:同一工作流、同一运行类别上一次成功的运行', JSON.stringify(C.reason))
|
||||
const zero = { unchanged: 0, data_only: 0, caliber_and_data: 0, sql_changed: 0, params_only: 0, added: 0, removed: 0, incomparable: 0 }
|
||||
const cases = [
|
||||
['有变化的几类合起来', { ...zero, data_only: 3, caliber_and_data: 1, params_only: 2, unchanged: 4 }, '6 个指标有变化'],
|
||||
@@ -1462,6 +1469,11 @@ await section('详情:触发历史里的「与上一次对比」', async () =>
|
||||
const f10 = row('f10').locator('[data-fire-compare]')
|
||||
const t10 = (await f10.getAttribute('title').catch(() => '')) ?? ''
|
||||
check('上一次失败了、基准是再往前成功的那次:悬停写明是哪一次', t10.startsWith('与上一次成功的运行(#run-f1)相比:只数据变 1 个'), t10)
|
||||
// 触屏没有悬停、键盘聚焦也不显示 title:比的不是上一次这件事得写在看得见的字里
|
||||
const l10 = f10.locator('[data-fire-compare-label]')
|
||||
check('……链接上看得见的字写「与上一次成功的运行对比」,不只靠悬停', (await l10.innerText().catch(() => '')).trim() === '与上一次成功的运行对比'
|
||||
&& await l10.isVisible(), (await l10.innerText().catch(() => '没有')).trim())
|
||||
check('……紧挨着的上一次那一行照旧写「与上一次对比」', (await row('f00').locator('[data-fire-compare-label]').innerText()).trim() === '与上一次对比')
|
||||
const linked = await page.locator('[data-fire-compare]').evaluateAll((els) => els.map((e) => e.closest('[data-fire-id]')?.getAttribute('data-fire-id')))
|
||||
check('没有对比的(compare 为 null、老服务端不给、没有运行)不给链接', JSON.stringify(linked) === '["f00","f08","f10"]', JSON.stringify(linked))
|
||||
check('运行链接照旧在', await row('f00').locator('a[href="/runs/run-f00"]').count() === 1)
|
||||
@@ -1475,21 +1487,78 @@ await section('详情:触发历史里的「与上一次对比」', async () =>
|
||||
})
|
||||
check('……回车打开对比页,带着本次和基准', !!url && url.searchParams.get('target') === 'run-f00'
|
||||
&& url.searchParams.get('base') === 'run-f02', page.url())
|
||||
// 来历跟着这一次跳转走(路由的 state),对比页据此写「默认基准:同一定时任务的上一次运行」
|
||||
// 来历跟着这一次跳转走(路由的 state),对比页据此写「默认基准:同一定时任务上一次成功的运行」
|
||||
const via = await page.evaluate(() => history.state?.usr ?? null)
|
||||
check('……紧挨着的上一次:带上「同一定时任务」的来历', via?.reason === 'same_schedule' && via?.base === 'run-f02', JSON.stringify(via))
|
||||
|
||||
// 基准不是紧挨着的上一次:来历照样带上。对比页写的是「上一次成功的运行」,比的正是这一次,和从运行详情进去一字不差
|
||||
const second = await open('/schedules/s-weekly?tab=history')
|
||||
await second.page.locator('[data-fire-id="f10"] [data-fire-compare]').click()
|
||||
await second.page.locator('[data-fire-id="f10"] [data-fire-compare]').focus()
|
||||
const focusedLabel = await second.page.evaluate(() => document.activeElement?.querySelector('[data-fire-compare-label]')?.textContent?.trim() ?? '')
|
||||
check('……键盘聚焦在那一行的链接上时,看得见的字就是「与上一次成功的运行对比」', focusedLabel === '与上一次成功的运行对比', focusedLabel)
|
||||
await second.page.keyboard.press('Enter')
|
||||
await until(() => new URL(second.page.url()).pathname === '/runs/compare')
|
||||
const via10 = await second.page.evaluate(() => history.state?.usr ?? null)
|
||||
check('……基准不是紧挨着的上一次:不带来历,对比页不说「上一次」', new URL(second.page.url()).searchParams.get('base') === 'run-f12'
|
||||
&& via10 == null, JSON.stringify(via10))
|
||||
const reason10 = await until(async () => (await second.page.locator('[data-compare-reason]').innerText().catch(() => '')).trim())
|
||||
check('……基准不是紧挨着的上一次:带上来历,对比页写「同一定时任务上一次成功的运行」', new URL(second.page.url()).searchParams.get('base') === 'run-f12'
|
||||
&& via10?.reason === 'same_schedule' && via10?.base === 'run-f12' && reason10 === REASON_SCHEDULE, `${JSON.stringify(via10)} ${reason10}`)
|
||||
const real = [...writes(sent, /./), ...writes(second.sent, /./)].filter((x) => x.key !== 'POST /schedules/preview')
|
||||
check('只读:没有写请求', real.length === 0, real.map((x) => x.key).join('、'))
|
||||
check('没有页面报错', ![...errors, ...second.errors].length, [...errors, ...second.errors].join(' | '))
|
||||
})
|
||||
|
||||
await section('详情:触发历史和运行详情的「与上一次对比」拿同一个基准', async () => {
|
||||
// f10 的上一次(f11)失败了,服务端两处都挑再往前成功的 f12:自动对比和运行详情的默认基准用的是同一个函数
|
||||
// (api/compare.py 的 pick_base),基准事后变了的旧对比触发历史不再给,这些由 backend/tests/test_scheduler_compare.py
|
||||
// 守。这里守前端:两个入口都原样用服务端给的基准,打开同一对运行、写同一句来历
|
||||
const runOf = (id, at) => ({ ...RUNS[0], id, started_by: '定时任务:门店周报 · 每周', created_at: iso(at), started_at: iso(at),
|
||||
finished_at: iso(at + 4200), manifest_hash: 'ab'.repeat(32), manifest_seq: 2 })
|
||||
const f10 = runOf('run-f10', nextMonday9(-11))
|
||||
const f12 = runOf('run-f12', nextMonday9(-13))
|
||||
const ref = (r) => ({ id: r.id, workflow_name: r.workflow_name, version: r.version, run_class: r.run_class,
|
||||
started_at: r.started_at, finished_at: r.finished_at, schedule_id: r.schedule_id })
|
||||
const compareApi = (method, path, body, url) => {
|
||||
if (method !== 'GET') return null
|
||||
if (path === '/runs/run-f10/compare/base') return { json: { base_run_id: 'run-f12', reason: 'same_schedule', base: ref(f12) } }
|
||||
if (path === '/runs/run-f10/compare' && url.searchParams.get('base') === 'run-f12') {
|
||||
return { json: { target: ref(f10), base: ref(f12), summary: COMPARE_F10.summary, metrics: [], queries: [], notes: [],
|
||||
template: { base_version: 3, target_version: 3, same_graph: true, nodes: [] } } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const stateOf = () => ({ ...freshState({ runs: [...RUNS, f10, f12] }), override: compareApi })
|
||||
const landed = (page) => until(() => {
|
||||
const u = new URL(page.url())
|
||||
return u.pathname === '/runs/compare' ? u : null
|
||||
})
|
||||
const reasonOf = (page) => until(async () => (await page.locator('[data-compare-reason]').innerText().catch(() => '')).trim())
|
||||
|
||||
const menu = await open('/runs/run-f10', { state: stateOf() })
|
||||
await menu.page.locator('[data-run-detail="run-f10"]').waitFor({ timeout: 10000 })
|
||||
await menu.page.getByRole('button', { name: '更多操作' }).click()
|
||||
await menu.page.locator('[data-menu=compare]').click()
|
||||
const a = await landed(menu.page)
|
||||
const hist = await open('/schedules/s-weekly?tab=history', { state: stateOf() })
|
||||
const link = hist.page.locator('[data-fire-id="f10"] [data-fire-compare]')
|
||||
await link.waitFor()
|
||||
const label = (await link.locator('[data-fire-compare-label]').innerText()).trim()
|
||||
await link.click()
|
||||
const b = await landed(hist.page)
|
||||
check('运行详情「更多」和触发历史两个入口:打开同一对运行(本次 run-f10,基准是跳过失败那次的 run-f12)',
|
||||
!!a && !!b && a.searchParams.get('target') === 'run-f10' && b.searchParams.get('target') === 'run-f10'
|
||||
&& a.searchParams.get('base') === 'run-f12' && b.searchParams.get('base') === 'run-f12', `${a?.search} | ${b?.search}`)
|
||||
const [ra, rb] = [await reasonOf(menu.page), await reasonOf(hist.page)]
|
||||
check('……两处写的默认基准一字不差:同一定时任务上一次成功的运行', ra === REASON_SCHEDULE && rb === REASON_SCHEDULE, `${ra} | ${rb}`)
|
||||
check('……触发历史上看得见的字说明比的是上一次成功的运行', label === '与上一次成功的运行对比', label)
|
||||
const tiles = (page) => until(async () => (await page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) =>
|
||||
els.map((e) => `${e.getAttribute('data-compare-class')}=${e.querySelector('[data-compare-count]')?.textContent?.trim()}`))).join(' '))
|
||||
const [ta, tb] = [await tiles(menu.page), await tiles(hist.page)]
|
||||
check('……两边的变化摘要一致', !!ta && ta === tb, `${ta} | ${tb}`)
|
||||
const real = [...writes(menu.sent, /./), ...writes(hist.sent, /./)]
|
||||
check('只读:没有写请求', real.length === 0, real.map((x) => x.key).join('、'))
|
||||
check('没有页面报错', ![...menu.errors, ...hist.errors].length, [...menu.errors, ...hist.errors].join(' | '))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 运行详情
|
||||
|
||||
/**
|
||||
@@ -1739,6 +1808,8 @@ await section('窄屏 390px 与浅色', async () => {
|
||||
check(`${theme}:触发历史不横向溢出`, await overflow(hist.page) <= 1, `${await overflow(hist.page)}px`)
|
||||
check(`${theme}:「与上一次对比」和旁边的计数在屏幕里`, await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare]')
|
||||
&& await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare-count]'))
|
||||
check(`${theme}:更长的「与上一次成功的运行对比」连同计数也在屏幕里`, await inViewX(hist.page, '[data-fire-id="f10"] [data-fire-compare]')
|
||||
&& await inViewX(hist.page, '[data-fire-id="f10"] [data-fire-compare-count]'))
|
||||
const cmp = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare]')
|
||||
check(`${theme}:「与上一次对比」对比度 ≥ 4.5`, !!cmp && cmp.ratio >= 4.5, JSON.stringify(cmp))
|
||||
const cnt = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare-count]')
|
||||
|
||||
Reference in New Issue
Block a user