From 544d44bdc219fff1f8bc70704c5625d3f227266b Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 11:56:31 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E7=9A=84=E9=BB=98=E8=AE=A4=E5=9F=BA=E5=87=86=E5=92=8C=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E4=BB=BB=E5=8A=A1=E7=9A=84=E8=87=AA=E5=8A=A8=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E5=85=B1=E7=94=A8=E4=B8=80=E4=B8=AA=E6=8C=91=E6=B3=95?= =?UTF-8?q?=EF=BC=9A=E5=A4=B1=E8=B4=A5=E3=80=81=E5=8F=96=E6=B6=88=E7=9A=84?= =?UTF-8?q?=E4=B8=8D=E5=BD=93=E5=9F=BA=E5=87=86=EF=BC=8C=E6=8C=89=E5=8F=91?= =?UTF-8?q?=E8=B5=B7=E5=85=88=E5=90=8E=E5=8F=96=E4=B8=8A=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E7=9A=84=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运行详情「更多」里的「与上一次对比」原先走 default_base:任何终态都算、按结束时间排,上一次失败时拿失败的那次当基准; 触发历史里同名的入口用的是自动对比的 _previous(同一定时任务里成功结束并封存的最近一次、按发起先后)。两处拿到的基准 不同,计数也对不上。 - api/compare.py 新增 pick_base:比这次先发起的运行里成功结束并封存的最近一次,同时给出是不是紧挨着的上一次。定时任务 发起的先找同一定时任务,没有再退回同一工作流、同一运行类别(同样只挑成功的、按发起先后);schedule_only 时不退 - default_base 与自动对比(scheduler/auto_compare.py,原 _previous 删掉)都调它;找不到基准的说明改成「上一次成功的运行」 - 测试:上一次失败、取消时默认基准跳过它们,和自动对比挑的是同一次;按发起先后而非结束时间(含停在审批上的这次); 之前没有成功的运行时回 404 --- backend/app/api/compare.py | 82 ++++++++++++++++--------- backend/app/scheduler/auto_compare.py | 33 +++------- backend/tests/test_compare_api.py | 62 ++++++++++++++++--- backend/tests/test_scheduler_compare.py | 25 +++++++- 4 files changed, 137 insertions(+), 65 deletions(-) diff --git a/backend/app/api/compare.py b/backend/app/api/compare.py index 6d5a7b2b..a1f36123 100644 --- a/backend/app/api/compare.py +++ b/backend/app/api/compare.py @@ -1,9 +1,10 @@ """运行对比:同一个工作流的两次运行按「变化三分法」对照——数据变了、口径变了、还是模板 / 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)用的是同一个函数——触发历史里的「与上一次对比」和运行详情里的同名入口必须拿到 +同一个基准,计数才对得上。这次运行是定时任务发起的,取同一定时任务、同一工作流、比它先发起的运行里成功结束并封存的 +最近一次(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 +16,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 +52,8 @@ UNSEALED_TEXT = "{who}尚未结束并封存:对比只用封存的运行记录 #: 已经结束、只是没有封存清单(封存上线前结束的、结束时没封上的):再等也等不来封存,不能说「运行结束后才能对比」 NO_MANIFEST_TEXT = "{who}没有封存清单,不能用于对比" SEAL_BROKEN_TEXT = "{who}的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果" -NO_BASE_TEXT = ("没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、" - "更早结束并已封存的运行") +NO_BASE_TEXT = ("没有找到可以对比的上一次成功的运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、" + "比这次先发起、成功结束并已封存的运行") TARGET = "这次运行" BASE = "作为基准的运行" @@ -63,8 +64,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 +75,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]: diff --git a/backend/app/scheduler/auto_compare.py b/backend/app/scheduler/auto_compare.py index 83a209b4..575b0cfd 100644 --- a/backend/app/scheduler/auto_compare.py +++ b/backend/app/scheduler/auto_compare.py @@ -6,10 +6,11 @@ - 比哪些:成功结束、封存完的运行。失败、取消的运行指标不全(口径卡常常没执行),和上一次比只会得出「移除 N 个」, 收到通知的人会以为报告删了指标 -- 跟谁比:同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次(_previous)。失败的那次不当基准: - 拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比后一次晚, - 它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和触发历史 - 写明比的是哪一次 +- 跟谁比:同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次。规则在对比接口的 pick_base, + 运行详情「与上一次对比」的默认基准用的是同一个函数:触发历史和运行详情里两个同名的入口必须比同一对运行。失败的那次 + 不当基准:拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比 + 后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和 + 触发历史写明比的是哪一次。这个任务的第一次不比(不像运行详情那样退回同一工作流的别的运行:那不是「上一次」) - 和封存绑定:运行的封存要盖住它最后一条结束事件才算封存完(sealed_now)。runner 先写终态、再另起会话封存,失败后 接着跑完的运行在两步之间还带着失败时的清单哈希,拿它去比只比得到旧封存范围里的事件。结果里记下两边的清单哈希: 哪一边后来变了,触发历史就不再给这份对比(api/schedules._fire_outs);对比途中变了,结果不记 @@ -125,7 +126,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 +138,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 +169,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) diff --git a/backend/tests/test_compare_api.py b/backend/tests/test_compare_api.py index eda3efdf..2257f070 100644 --- a/backend/tests/test_compare_api.py +++ b/backend/tests/test_compare_api.py @@ -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 库,两次运行之间改了数、换了日期参数 # -------------------------------------------------------------------------- diff --git a/backend/tests/test_scheduler_compare.py b/backend/tests/test_scheduler_compare.py index 3c9af745..5ffc5f98 100644 --- a/backend/tests/test_scheduler_compare.py +++ b/backend/tests/test_scheduler_compare.py @@ -4,7 +4,7 @@ - 基准是同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次:同一工作流更近的手动运行、别的 定时任务的运行都不算;失败的那一次不当基准(拿它当基准每个指标都成了「新增」);按发起先后挑,不按结束时间—— 老的失败运行事后被接着跑完,结束得比后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的时,通知和触发 - 历史写明比的是哪一次 + 历史写明比的是哪一次。运行详情「与上一次对比」的默认基准(api/compare.py 的 pick_base)和这里挑的是同一次 - 整份结果连同两边的清单哈希存成工件(kind=run_compare,不挂在运行名下、不进运行的工件列表),工件 id 记进触发 记录的 compare_artifact。这个任务的第一次(只有同一工作流别的运行可比)、还没封存时不比;对比出错(基准的封存核对 不一致、意外的异常)只记日志,通知照常推送、不带对比 @@ -320,9 +320,12 @@ 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 test_base_follows_launch_order_not_finish_time(recorder, client): """第 2 周失败、第 3 周照常成功之后,有人把第 2 周接着跑完:它结束得比第 3 周晚,但它的上一次仍是第 1 周。以前 按结束时间挑,拿后一次触发的第 3 周当基准,「与上一次相比」的变化方向是反的。第 3 周已经比过的那一份不受影响""" workflow = await new_workflow() @@ -342,6 +345,24 @@ async def test_base_follows_launch_order_not_finish_time(recorder): 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 周 + base = (await client.get(f"/api/runs/{second}/compare/base")).json() + assert (base["base_run_id"], base["reason"]) == (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): From a18b24ab6f84bf58697bc30827132c000c06e618 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:05:40 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=AF=B9=E6=AF=94=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=8E=86=E5=8F=B2=E7=85=A7=E5=AE=9E=E8=AF=B4?= =?UTF-8?q?=E3=80=8C=E4=B8=8A=E4=B8=80=E6=AC=A1=E6=88=90=E5=8A=9F=E7=9A=84?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E3=80=8D=EF=BC=9A=E4=B8=A4=E4=B8=AA=E5=90=8C?= =?UTF-8?q?=E5=90=8D=E5=85=A5=E5=8F=A3=E6=89=93=E5=BC=80=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E5=AF=B9=E8=BF=90=E8=A1=8C=E3=80=81=E5=86=99=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E5=8F=A5=E6=9D=A5=E5=8E=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端的默认基准和自动对比已经共用一个挑法(只挑比本次先发起、成功结束并封存的运行),界面上的说法跟着改: - 对比页的「默认基准:…」改成「同一定时任务上一次成功的运行」「同一工作流、同一运行类别上一次成功的运行」;运行详情 查找基准时的提示、没带本次时的说明同样改成「上一次成功的运行」 - 触发历史的「与上一次对比」:基准不是紧挨着的上一次(previous 为 false)时,链接上看得见的字写「与上一次成功的运行 对比」,触屏、键盘聚焦都看得到,悬停照旧写是哪一次。两种都带上「同一定时任务」的来历:新的说法对这两种都成立, 对比页和从运行详情进去时一字不差 - check-compare:两种来历的说法;运行详情「更多」和触发历史两个入口打开同一对运行、写同一句来历、摘要一致 - check-schedules:previous 为 false 时看得见的字(含键盘聚焦、390px 窄屏);两个入口拿同一个基准 --- frontend/src/lib/terms.ts | 21 +++-- frontend/src/pages/runs/compare/entry.tsx | 5 +- frontend/src/pages/schedules/FireHistory.tsx | 19 +++-- scripts/check-compare.mjs | 90 ++++++++++++++++++-- scripts/check-schedules.mjs | 84 ++++++++++++++++-- 5 files changed, 192 insertions(+), 27 deletions(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8ea7b9fb..76b9a95e 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3085,10 +3085,15 @@ 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 +3165,7 @@ export const RUN_COMPARE_TEXT = { menuSealedOnly: '运行结束并封存后才能对比', /** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */ menuNoWorkflow: '工作流未保存或已删除,无法对比', - menuFinding: '正在查找上一次运行…', + menuFinding: '正在查找上一次成功的运行…', pickManually: '手动选择基准', retry: '重试', // 顶部的两次运行 @@ -3175,9 +3180,13 @@ export const RUN_COMPARE_TEXT = { runFailed: '无法读取这次运行', runMissing: '运行记录不存在', noBase: '未选择', + /** + * 默认基准是怎么选的,照服务端(api/compare.py 的 pick_base)的规则写:只挑比本次先发起、成功结束并封存的运行。 + * 上一次失败、取消了时基准是再往前成功的那一次,所以不能写成「上一次运行」 + */ reason: { - same_schedule: '默认基准:同一定时任务的上一次运行', - same_workflow: '默认基准:同一工作流、同一运行类别的上一次运行', + same_schedule: '默认基准:同一定时任务上一次成功的运行', + same_workflow: '默认基准:同一工作流、同一运行类别上一次成功的运行', } as Record, pickBase: '更换基准', pickHint: '同一工作流最近的运行,未封存的不能作为基准', @@ -3195,7 +3204,7 @@ export const RUN_COMPARE_TEXT = { pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`, // 页面状态 noTarget: '未指定要对比的运行', - noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行', + noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次成功的运行', toRuns: '前往记录', finding: '正在查找基准运行…', loading: '正在对比两次运行…', diff --git a/frontend/src/pages/runs/compare/entry.tsx b/frontend/src/pages/runs/compare/entry.tsx index cee05d99..f0e55a3e 100644 --- a/frontend/src/pages/runs/compare/entry.tsx +++ b/frontend/src/pages/runs/compare/entry.tsx @@ -17,8 +17,9 @@ export const comparePath = (target: string, base?: string | null) => const NOTE_DELAY_MS = 300 /** - * 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的 - * 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。 + * 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务上一次成功的运行,没有就同一工作流、 + * 同一类别上一次成功的运行;定时任务触发历史里同名的入口由服务端用同一个函数挑,两处比的是同一对运行),找到了再跳到 + * 对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。 * 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据 * 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。 * diff --git a/frontend/src/pages/schedules/FireHistory.tsx b/frontend/src/pages/schedules/FireHistory.tsx index aeafd8de..1c8546d7 100644 --- a/frontend/src/pages/schedules/FireHistory.tsx +++ b/frontend/src/pages/schedules/FireHistory.tsx @@ -14,7 +14,8 @@ const PAGE = 20 /** * 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败 * 写原因;发起了的链到那次运行、写运行现在的状态;运行成功结束后服务端和同一任务上一次成功的运行比过的,另给 - * 「与上一次对比」;发出去的通知逐条写结果。refreshKey 变了(立即运行之后)从头重取 + * 「与上一次对比」(基准不是紧挨着的上一次时写「与上一次成功的运行对比」);发出去的通知逐条写结果。refreshKey 变了 + * (立即运行之后)从头重取 */ export function FireHistory({ schedule, refreshKey }: { schedule: Schedule; refreshKey: number }) { const [items, setItems] = useState(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 ( - {T.compareLink} + {previous ? T.compareLink : T.compareLinkLastOk} {count && ( <> · diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index f980e46d..c00a9177 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -9,10 +9,13 @@ // 同一节点两条查询的 (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,11 +184,31 @@ 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 = '同一工作流没有其他可换作基准的已封存运行' +/** + * 发起本次和基准的定时任务(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 }, +}] + // ---------------------------------------------------------------- 假接口 function events() { @@ -247,6 +270,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 +423,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 +502,60 @@ 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 + 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) diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index f5c5b0bc..634fb9dc 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -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,77 @@ 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)。这里守前端:两个入口都原样用服务端给的基准,打开同一对运行、写同一句来历 + 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 +1807,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]') From a358fbd244e12867e76ce9152abfd97de03a9789 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:19:13 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=EF=BC=9A=E5=B7=A5=E4=BB=B6=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E8=AE=A4=E5=BE=97=E8=B4=A8=E8=AF=A2=E5=9B=9E=E7=AD=94=EF=BC=9B?= =?UTF-8?q?=E5=87=A0=E5=A4=84=E9=97=AE=E7=AD=94=E4=B8=B2=E8=A6=81=E5=AF=B9?= =?UTF-8?q?=E7=9A=84=E5=AE=A1=E6=89=B9=E4=B8=8D=E5=90=8C=E3=80=81=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=88=97=E8=A1=A8=E5=8F=96=E4=B8=8D=E5=88=B0=E6=97=B6?= =?UTF-8?q?=E5=90=84=E8=87=AA=E7=85=A7=E6=A0=B7=E5=88=97=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工件名:质询的回答文档以 inquiry_answer 登记在运行名下,工件页签原先直接显示英文原值。ARTIFACT_KIND_LABEL 加「质询 回答」,说明在 meta.question 有值时写「质询「…」的回答」(服务端只记前 40 字,满 40 字的补省略号),没有时写「审批 质询的回答:逐段的片段和每个数字的出处」;文案放在 INQUIRY_TEXT - 问答串的竞态:loadApprovalDocs 发现别处正在取审批列表就直接返回,没把这一处要对的审批记进 asked。请求回来后这一处的 unknown 不变、effect 不再执行,settled 一直为假,头上写着「· N 次」,别的审批里问的那几条一直不显示(一张卡审两份 报告;运行详情的待审批卡和证据页签同时挂着、审批列表失败时)。现在提前返回前也把这一处的 id 并进 asked:列表按运行取, 在路上的那一份本来就带着全部审批,不另发请求 - 预览用例:inquiry-approval-two(一张卡审两份报告)、inquiry-run-waiting(待审批卡和证据页签同时挂着)、 inquiry-run-artifacts(工件页签);夹具补探索运行的工件列表 run_artifacts - check-inquiry:审批列表压住、再答 404,两处的条目都列出(查不到审批的单独成组),头上的次数和列出的条数一致、审批 列表只取一次;工件页签里有「质询回答」的名字和说明、不出现英文的种类名、打开的弹窗标题也是中文;390px 深浅色下不溢出、 看得清 --- frontend/src/dev/fixtures/inquiry.json | 55 ++++++++++- frontend/src/dev/fixtures/inquiry.tsx | 53 +++++++++- frontend/src/lib/terms.ts | 13 +++ frontend/src/pages/runs/model.ts | 5 + frontend/src/run/Inquiry.tsx | 12 ++- scripts/check-inquiry.mjs | 131 ++++++++++++++++++++++++- 6 files changed, 260 insertions(+), 9 deletions(-) diff --git a/frontend/src/dev/fixtures/inquiry.json b/frontend/src/dev/fixtures/inquiry.json index dcf1c971..d7d2a0d8 100644 --- a/frontend/src/dev/fixtures/inquiry.json +++ b/frontend/src/dev/fixtures/inquiry.json @@ -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,58 @@ "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" + } + ], "card_artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e" } diff --git a/frontend/src/dev/fixtures/inquiry.tsx b/frontend/src/dev/fixtures/inquiry.tsx index 88e69b3d..608dd590 100644 --- a/frontend/src/dev/fixtures/inquiry.tsx +++ b/frontend/src/dev/fixtures/inquiry.tsx @@ -1,7 +1,9 @@ -import { useState, type JSX } from 'react' +import { useMemo, useState, type JSX } from 'react' import { createPortal } from 'react-dom' +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 +22,17 @@ 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 +122,36 @@ function RunEvidence({ run, name }: { run: Run; name: string }) { ) } +/** + * 停在审批上的运行,同运行详情直接打开「证据」页签:时间线(一直挂着)里的待审批卡在上,证据页签在下,两处挂着同一份 + * 报告的问答串。卡上的问答串认得这张审批,证据页签不认任何审批(cardId 为空),两处要为不同的几张审批取审批列表 + */ +function Waiting({ approval, run }: { approval: Approval; run: Run }) { + return ( +
+
+
+ undefined} showWorkflow={false} /> +
+
+
+ (id ? LABELS[id] : undefined)} refreshKey="0" /> +
+
+ ) +} + +/** 运行详情的「工件」页签:工件列表由检查脚本按 inquiry.json 的 run_artifacts 答 */ +function RunArtifacts({ run }: { run: Run }) { + const list = useRunArtifacts(run.id, '0') + const trace = useMemo(() => emptyTrace(), []) + return ( +
+ (id ? LABELS[id] : undefined)} /> +
+ ) +} + export const cases: Record JSX.Element> = { 'inquiry-approval': () => ( @@ -138,6 +175,18 @@ export const cases: Record JSX.Element> = { ), 'inquiry-approval-steps': () => , 'inquiry-approval-steps-same': () => , + 'inquiry-approval-two': () => ( + + ), 'inquiry-run-explore': () => , 'inquiry-run-formal': () => , + 'inquiry-run-waiting': () => ( + + ), + 'inquiry-run-artifacts': () => , } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 76b9a95e..f9e6ff1b 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3500,6 +3500,19 @@ 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) => { + const chars = [...question.trim()] + return `质询「${chars.join('')}${chars.length >= 40 ? '…' : ''}」的回答` + }, + /** 没记问题摘要的(这一版之前存的):照报告文档的说法写装的是什么 */ + artifactAboutNone: '审批质询的回答:逐段的片段和每个数字的出处', } /** diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 0c8ed4c7..2380b144 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -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 = { 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 | 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 '工件' } } diff --git a/frontend/src/run/Inquiry.tsx b/frontend/src/run/Inquiry.tsx index 90f4ff41..58cba9c6 100644 --- a/frontend/src/run/Inquiry.tsx +++ b/frontend/src/run/Inquiry.tsx @@ -308,7 +308,17 @@ export const useInquiry = create((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]: { diff --git a/scripts/check-inquiry.mjs b/scripts/check-inquiry.mjs index 94f3ba91..b4c017ca 100644 --- a/scripts/check-inquiry.mjs +++ b/scripts/check-inquiry.mjs @@ -1,6 +1,6 @@ // 审批质询的回归检查:报告上的入口(run/EvidenceDoc.tsx 的句末「质询」、划选原文浮出的「质询」)、问答串和提问框 // (run/Inquiry.tsx)、审批卡上的挂法(run/ApprovalReport.tsx、run/RunPanel.tsx)、运行详情「证据」页签的挂法 -// (pages/runs/EvidencePane.tsx)。 +// (pages/runs/EvidencePane.tsx),以及运行详情「工件」页签里质询回答的名字(pages/runs/model.ts)。 // // 守的是这些「看着没坏、其实用不了」的地方:悬停、键盘聚焦一句都不出「质询」(或者出了 Tab 却到不了、整份报告多出一串 // Tab 位)、没有可点内容的句子键盘走不到、划选原文不浮出入口(或者带上的原文混进了徽标和「质询」本身)、提交的请求体 @@ -8,11 +8,14 @@ // 进入审批卡时问答串没有恢复、问答串写不出问的是哪一句、回答里的数字点了不去取质询工件(或者每点一次取一次、标不出 // 那一格)、引用报告已有出处的数字点了没反应、查询的 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 +69,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 +83,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 +119,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 +147,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 +830,37 @@ 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 === 3 && 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) + 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')) @@ -1054,6 +1093,73 @@ 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([['other', ['41']]]), + JSON.stringify(gw.map((g) => [g.unit, g.entries]))) + check('写周报:请求是另一处先发的,回来之后照样列出、单独一组', both + && JSON.stringify(gw2.map((g) => [g.unit, g.entries])) === JSON.stringify([['other', ['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, 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']], ['other', ['12']]]), + JSON.stringify(gc.map((g) => [g.unit, g.entries]))) + check('证据页签:两条都列出来,查不到审批的单独一组(不再一直等着)', listed + && JSON.stringify(gp.map((g) => [g.unit, g.entries])) === JSON.stringify([['other', ['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)) + check('运行详情:页面没有报错', errors.length === 0, errors.join(';')) + await ctx.close() + } +}) + await section('回答期间另开了提问框,正在回答的那一条没问成:新写的不被覆盖', async () => { const { ctx, page, state, errors } = await open('inquiry-approval') await expand(page) @@ -1366,6 +1472,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() From aa2b93f5870c77096bb1641ff0220c88d5cf74f2 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:30:44 +0800 Subject: [PATCH 4/7] =?UTF-8?q?check-compare=20=E9=87=8C=E6=89=BE=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E5=9F=BA=E5=87=86=E7=9A=84=E5=81=87=E7=AD=94=E5=A4=8D?= =?UTF-8?q?=E7=85=A7=E6=9C=8D=E5=8A=A1=E7=AB=AF=E7=9A=84=E6=96=B0=E8=AF=B4?= =?UTF-8?q?=E6=B3=95=E5=86=99=EF=BC=9B=E8=A7=A6=E5=8F=91=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E7=9A=84=E5=AF=B9=E6=AF=94=E9=93=BE=E6=8E=A5=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E6=B2=A1=E4=BA=BA=E7=94=A8=E7=9A=84=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「不属于已保存工作流」那一段伪造的 404 说明原先抄的是旧的 NO_BASE_TEXT(更早结束并已封存),改成服务端现在的 「上一次成功的运行……比这次先发起、成功结束并已封存的运行」,假答复和接口约定保持一致 - FireHistory 的 CompareLink 去掉 data-fire-compare-previous:检查只看看得见的字,不靠这个标记 --- frontend/src/pages/schedules/FireHistory.tsx | 1 - scripts/check-compare.mjs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/pages/schedules/FireHistory.tsx b/frontend/src/pages/schedules/FireHistory.tsx index 1c8546d7..cb4a83c8 100644 --- a/frontend/src/pages/schedules/FireHistory.tsx +++ b/frontend/src/pages/schedules/FireHistory.tsx @@ -161,7 +161,6 @@ function CompareLink({ runId, compare }: { runId: string; compare: ScheduleFireC state={via} title={T.compareTitle(compare.summary, previous ? null : shortId(compare.base_run_id))} data-fire-compare={compare.base_run_id} - data-fire-compare-previous={previous ? '1' : '0'} 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)]" > diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index c00a9177..f41e06ea 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -1071,7 +1071,7 @@ await section('不属于已保存工作流的运行:对比页的说明前后 try { // 只带本次:服务端找不到基准(这种运行没有可比的) const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { - detail: '没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、更早结束并已封存的运行', + detail: '没有找到可以对比的上一次成功的运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、比这次先发起、成功结束并已封存的运行', code: 'compare_no_base' } }) }) await settled(lone.page, 'error') await until(async () => (await lone.page.locator('[data-compare-run="base"]').innerText()).includes('未保存或已删除'), 3000) From 238fe94fee44edaed019c2c61b4bc3ff645907ec Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 12:32:15 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E8=B4=A8=E8=AF=A2=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E5=B7=A5=E4=BB=B6=E5=85=9C=E5=BA=95=E8=AF=B4=E6=98=8E=E7=9A=84?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E7=85=A7=E5=AE=9E=E5=86=99=EF=BC=9A=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E9=83=BD=E4=BC=9A=E8=AE=B0=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E6=91=98=E8=A6=81=EF=BC=8C=E6=B2=A1=E6=9C=89=E6=97=B6=E5=8F=AA?= =?UTF-8?q?=E6=98=AF=E5=85=9C=E5=BA=95=EF=BC=8C=E4=B8=8D=E8=AF=B4=E6=88=90?= =?UTF-8?q?=E3=80=8C=E8=BF=99=E4=B8=80=E7=89=88=E4=B9=8B=E5=89=8D=E5=AD=98?= =?UTF-8?q?=E7=9A=84=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/terms.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index f9e6ff1b..33bbf293 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3511,7 +3511,7 @@ export const INQUIRY_TEXT = { const chars = [...question.trim()] return `质询「${chars.join('')}${chars.length >= 40 ? '…' : ''}」的回答` }, - /** 没记问题摘要的(这一版之前存的):照报告文档的说法写装的是什么 */ + /** meta 里没有问题摘要时的说法(服务端照理都会记,这里兜底):照报告文档那一句写装的是什么 */ artifactAboutNone: '审批质询的回答:逐段的片段和每个数字的出处', } From 1bee9b510035e983cb3b85b020f7e77666236dd7 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 13:33:40 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=BB=99=E5=9F=BA=E5=87=86=E5=B7=B2=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E8=87=AA=E5=8A=A8=E5=AF=B9=E6=AF=94=EF=BC=9B?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E9=A1=B5=E6=89=BE=E4=B8=8D=E5=88=B0=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=9F=BA=E5=87=86=E6=97=B6=E6=A0=87=E9=A2=98=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=92=8C=E3=80=8C=E5=8F=AF=E5=9C=A8=E4=B8=8A=E6=96=B9?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E3=80=8D=E6=89=93=E6=9E=B6=EF=BC=8C=E6=8D=A2?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E7=9A=84=E4=B8=8B=E6=8B=89=E5=92=8C=E5=9F=BA?= =?UTF-8?q?=E5=87=86=E5=8D=A1=E5=86=99=E6=98=8E=E5=A4=B1=E8=B4=A5=E3=80=81?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E7=9A=84=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 中间失败的那次事后被接着运行成功之后,第 3 周存着的那份对比(比的是第 1 周,previous 为假)和运行详情「与上一次 对比」的默认基准(现在是第 2 周)又对不上,两处却都写「上一次成功的运行」。auto_compare.outdated 对基准当时就不是 紧挨着的上一次的那几份按 pick_base 现挑一遍,基准变了的算过时;api/schedules._fire_outs 对过时的给 null(不给链接、 不给旧计数),两个同名入口比的始终是同一对运行。基准没变的照旧给:中间那次还没结束、后来失败、被删掉都一样。紧挨着 的上一次不会过时(两次之间插不进新的运行),不核 - test_base_follows_launch_order_not_finish_time 补上:接着运行完之后第 3 周的触发记录不再带对比、任务列表的 last_fire 同样不带、运行详情的默认基准是第 2 周;另加一条基准仍是挑出来的那一次时照旧给出 - 对比页找不到默认基准:标题由「未找到可作为基准的运行」改为「没有默认基准」——失败、取消但封存了的运行不当默认基准, 却照样能在上方手动选;服务端的原因改成「没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功 结束并已封存的运行」,运行详情弹出的提示单独读也说得清 - 换基准的下拉给失败、已取消的运行写上状态,基准卡也标出来;下拉说明补一句默认基准只取成功结束的运行 - 运行详情「更多」的菜单项点之前不知道基准是哪一次,基准不是紧挨着的上一次时仍叫「与上一次对比」,和触发历史的叫法 不同:注释照实写明,留作后续 --- backend/app/api/compare.py | 12 +++-- backend/app/api/schedules.py | 9 ++-- backend/app/scheduler/auto_compare.py | 27 +++++++++- backend/tests/test_scheduler_compare.py | 56 +++++++++++++++++-- frontend/src/lib/terms.ts | 25 ++++++--- frontend/src/pages/runs/compare/RunPair.tsx | 20 +++++-- scripts/check-compare.mjs | 59 +++++++++++++++++---- scripts/check-schedules.mjs | 3 +- 8 files changed, 176 insertions(+), 35 deletions(-) diff --git a/backend/app/api/compare.py b/backend/app/api/compare.py index a1f36123..44bf258e 100644 --- a/backend/app/api/compare.py +++ b/backend/app/api/compare.py @@ -2,9 +2,10 @@ GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base}。规则在 pick_base,定时任务结束后 的自动对比(scheduler/auto_compare.py)用的是同一个函数——触发历史里的「与上一次对比」和运行详情里的同名入口必须拿到 -同一个基准,计数才对得上。这次运行是定时任务发起的,取同一定时任务、同一工作流、比它先发起的运行里成功结束并封存的 -最近一次(reason: same_schedule);不是、或者同一任务之前没有成功的,取同一工作流、同一运行类别里同样条件的最近一次 -(reason: same_workflow)。找不到 404。 +同一个基准,计数才对得上;触发历史里存着的那一份,事后基准不再是这里挑出来的那一次(中间失败的那次被接着运行成功 +了)就不再给出(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): @@ -52,8 +53,9 @@ UNSEALED_TEXT = "{who}尚未结束并封存:对比只用封存的运行记录 #: 已经结束、只是没有封存清单(封存上线前结束的、结束时没封上的):再等也等不来封存,不能说「运行结束后才能对比」 NO_MANIFEST_TEXT = "{who}没有封存清单,不能用于对比" SEAL_BROKEN_TEXT = "{who}的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果" -NO_BASE_TEXT = ("没有找到可以对比的上一次成功的运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、" - "比这次先发起、成功结束并已封存的运行") +#: 找不到默认基准。运行详情的「与上一次对比」把它原样弹给人看,对比页把它跟在标题「没有默认基准」后面:自己得说清 +#: 找的是什么、没找到。定时任务发起的先找同一定时任务、再找同一工作流,两档都落空才到这里,条件只写后一档的 +NO_BASE_TEXT = "没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行" TARGET = "这次运行" BASE = "作为基准的运行" diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index e45c2e0a..5ca0a2d1 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -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"])): diff --git a/backend/app/scheduler/auto_compare.py b/backend/app/scheduler/auto_compare.py index 575b0cfd..8ba0f332 100644 --- a/backend/app/scheduler/auto_compare.py +++ b/backend/app/scheduler/auto_compare.py @@ -10,7 +10,8 @@ 运行详情「与上一次对比」的默认基准用的是同一个函数:触发历史和运行详情里两个同名的入口必须比同一对运行。失败的那次 不当基准:拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比 后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和 - 触发历史写明比的是哪一次。这个任务的第一次不比(不像运行详情那样退回同一工作流的别的运行:那不是「上一次」) + 触发历史写明比的是哪一次;中间那次事后接着运行成功了,上一次成功的就成了它,触发历史不再给当时那一份(outdated)。 + 这个任务的第一次不比(不像运行详情那样退回同一工作流的别的运行:那不是「上一次」) - 和封存绑定:运行的封存要盖住它最后一条结束事件才算封存完(sealed_now)。runner 先写终态、再另起会话封存,失败后 接着跑完的运行在两步之间还带着失败时的清单哈希,拿它去比只比得到旧封存范围里的事件。结果里记下两边的清单哈希: 哪一边后来变了,触发历史就不再给这份对比(api/schedules._fire_outs);对比途中变了,结果不记 @@ -249,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)) diff --git a/backend/tests/test_scheduler_compare.py b/backend/tests/test_scheduler_compare.py index 5ffc5f98..d1bcb71e 100644 --- a/backend/tests/test_scheduler_compare.py +++ b/backend/tests/test_scheduler_compare.py @@ -9,7 +9,8 @@ 记录的 compare_artifact。这个任务的第一次(只有同一工作流别的运行可比)、还没封存时不比;对比出错(基准的封存核对 不一致、意外的异常)只记日志,通知照常推送、不带对比 - 接口:Fire 多一个 compare: {base_run_id, summary, previous} 或 null,summary 同对比接口的计数,previous 说基准是不是 - 紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行) + 紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行);基准不再是现在挑出来的那一次 + (中间失败的那次事后被接着运行成功)时也为 null(运行详情里同名的入口比的已是另一次) - 通知:结束的通知多一行「与上一次相比:只数据变 2 个、口径与数据都变 0 个、SQL 结构变 0 个」,配了对外访问地址时附 对比页链接(/runs/compare?target=…&base=…);只给个数,报告、指标、查询里的具体数字一个不出现 @@ -325,9 +326,21 @@ async def test_after_a_failed_run_the_base_is_the_last_successful_one(recorder, assert (base["base_run_id"], base["reason"]) == (first, "same_schedule") +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) @@ -339,6 +352,9 @@ async def test_base_follows_launch_order_not_finish_time(recorder, client): 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() @@ -346,8 +362,40 @@ async def test_base_follows_launch_order_not_finish_time(recorder, client): assert stored["result"]["base"]["id"] == first and stored["previous"] is True assert (await fire_row(third_fire)).compare_artifact == third_artifact # 运行详情里的默认基准同样按发起先后:接着跑完的第 2 周,上一次仍是第 1 周 - base = (await client.get(f"/api/runs/{second}/compare/base")).json() - assert (base["base_run_id"], base["reason"]) == (first, "same_schedule") + 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): diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 33bbf293..a5bd8393 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3085,13 +3085,14 @@ export const SCHEDULE_TEXT = { // ---- 触发历史里的对比:运行成功结束后,服务端自动和同一定时任务上一次成功的运行比一次 /** - * 和运行详情「更多」菜单里的入口是同一件事(服务端用同一个函数挑基准,两处比的是同一对运行),用同一个叫法。 - * RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,写成 getter 用到时再取 + * 和运行详情「更多」菜单里的入口是同一件事(服务端用同一个函数挑基准,两处比的是同一对运行),基准就是紧挨着的上一次 + * 时用同一个叫法。RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,写成 getter 用到时再取 */ get compareLink() { return RUN_COMPARE_TEXT.menu }, /** * 基准不是紧挨着的上一次(上一次失败了、取消了,比的是再往前成功的那一次)时链接上看得见的叫法:触屏没有悬停, - * 键盘聚焦时也不显示 title,只在悬停里说明会让人以为比的就是上一次 + * 键盘聚焦时也不显示 title,只在悬停里说明会让人以为比的就是上一次。运行详情「更多」里的菜单项点之前还不知道基准 + * 是哪一次,这时仍叫「与上一次对比」,两处叫法不同;进了对比页都写「默认基准:同一定时任务上一次成功的运行」 */ compareLinkLastOk: '与上一次成功的运行对比', /** @@ -3189,7 +3190,8 @@ export const RUN_COMPARE_TEXT = { same_workflow: '默认基准:同一工作流、同一运行类别上一次成功的运行', } as Record, pickBase: '更换基准', - pickHint: '同一工作流最近的运行,未封存的不能作为基准', + /** 默认基准只取成功结束的运行(pick_base),手动选不限:失败、取消的也能选,下拉和基准卡上写明它的状态 */ + pickHint: '同一工作流最近的运行,未封存的不能作为基准;默认基准只取成功结束的运行', pickLoading: '正在读取同一工作流的运行…', pickFailed: '无法读取同一工作流的运行', pickNone: '同一工作流没有其他运行', @@ -3197,9 +3199,12 @@ 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 ? '记录不存在' : '无法读取'})`, // 页面状态 @@ -3222,7 +3227,11 @@ export const RUN_COMPARE_TEXT = { errMissingHint: '链接可能已失效,请从记录页重新打开', /** 本次的记录读得到、对比却回 404:缺的是基准 */ errBaseMissingHint: '可在上方选择另一次运行作为基准', - errNoBase: '未找到可作为基准的运行', + /** + * 找不到默认基准:标题只说没有默认基准,原因(服务端的 NO_BASE_TEXT:没有找到上一次成功的运行、要什么条件)跟在后面。 + * 不能写成「没有可作为基准的运行」:失败、取消但封存了的运行不当默认基准,却照样能在上方手动选 + */ + errNoBase: '没有默认基准', errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准', /** 下拉里一次能选的都没有(只有本次、或者都没封存) */ errNoBaseNone: '同一工作流还没有其他已封存的运行', diff --git a/frontend/src/pages/runs/compare/RunPair.tsx b/frontend/src/pages/runs/compare/RunPair.tsx index d04e27a0..6d076091 100644 --- a/frontend/src/pages/runs/compare/RunPair.tsx +++ b/frontend/src/pages/runs/compare/RunPair.tsx @@ -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; children?: ReactNode }) { @@ -63,6 +71,9 @@ function RunCard({ role, id, run, children }: { {r?.schedule_id && ( {T.scheduled} )} + {r && unsuccessful(r.status) && ( + + )} {r && !r.manifest_hash && ( @@ -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

{T.pickNone}

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)) diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index f41e06ea..d898a447 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -11,7 +11,8 @@ // 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录 // 不存在时不给注定失败的「重试」。默认基准的说法照服务端的挑法(api/compare.py 的 pick_base)写「上一次成功的运行」 // ——上一次失败了,基准是再往前成功的那一次;定时任务触发历史里的「与上一次对比」和运行详情里的同名入口打开同一对 -// 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」。 +// 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」;找不到默认基准时标题不说 +// 「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。 // // 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、 // compare/base、compare、定时任务列表、发起它们的定时任务和触发历史都在浏览器层答掉,别的 GET 放行(外壳要的工作流、 @@ -190,6 +191,8 @@ 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、不是 @@ -249,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) { @@ -531,7 +534,8 @@ await section('默认基准的说法:上一次成功的运行', async () => { }) await section('运行详情与触发历史的「与上一次对比」:同一个基准、同一句来历', async () => { - // 两处的基准由服务端用同一个函数挑(api/compare.py 的 pick_base):上一次失败了,两处都是再往前成功的 B + // 两处的基准由服务端用同一个函数挑(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`) @@ -871,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 }) @@ -958,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]] }) }) @@ -1071,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, ' ') diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index 634fb9dc..ad8332d3 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -1509,7 +1509,8 @@ await section('详情:触发历史里的「与上一次对比」', async () => await section('详情:触发历史和运行详情的「与上一次对比」拿同一个基准', async () => { // f10 的上一次(f11)失败了,服务端两处都挑再往前成功的 f12:自动对比和运行详情的默认基准用的是同一个函数 - // (api/compare.py 的 pick_base)。这里守前端:两个入口都原样用服务端给的基准,打开同一对运行、写同一句来历 + // (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)) From f815b1232a586e56ad6885d56a11f1d7097826da Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 13:33:54 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=EF=BC=9A=E5=AE=A1=E6=89=B9=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8F=96=E4=B8=8D=E5=88=B0=E6=97=B6=E5=88=AB=E7=9A=84=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E9=87=8C=E9=97=AE=E7=9A=84=E5=8D=95=E7=8B=AC=E4=B8=80?= =?UTF-8?q?=E7=BB=84=E5=86=99=E3=80=8C=E6=97=A0=E6=B3=95=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E6=8F=90=E9=97=AE=E6=97=B6=E7=9A=84=E6=8A=A5=E5=91=8A=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E3=80=8D=E5=B9=B6=E7=BB=99=E9=87=8D=E8=AF=95=EF=BC=9B?= =?UTF-8?q?=E8=B4=A8=E8=AF=A2=E5=9B=9E=E7=AD=94=E7=9A=84=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E6=91=98=E8=A6=81=E6=88=AA=E5=9C=A8=E7=A9=BA=E6=A0=BC=E4=B8=8A?= =?UTF-8?q?=E4=B9=9F=E8=A1=A5=E7=9C=81=E7=95=A5=E5=8F=B7=EF=BC=9B=E5=B7=A5?= =?UTF-8?q?=E4=BB=B6=E9=A1=B5=E7=AD=BE=E9=87=8C=E8=BF=90=E8=A1=8C=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E4=B9=8B=E5=90=8E=E6=89=8D=E5=86=99=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E5=B7=A5=E4=BB=B6=E4=B8=8D=E7=BB=99=E8=88=AA=E8=BF=B9=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 审批列表没取到时,那几条以前被标成「报告其他版本上的质询」:同一屏上卡里挂在句子下面的一条,证据页签却说是别的 版本,审批人会以为报告换过稿,也没有办法重取。现在单独一组照实说没能确认、给「重试」,取到后按结果各归其位(这张 审批里问的回到句子下面,审的是另一稿的才算其他版本);取到了列表却没有那张审批的、报告没有报告文档的照旧算对不上 - 质询回答工件的说明:服务端先去掉问题首尾的空白再截前 40 字,摘要以空格结尾只会是截断截在了空白上。以前先去空白再 数字数,少一个字就漏了省略号,半句话读成整个问题;改为按原样数、显示时才去空白 - 运行详情「工件」页签:晚于运行结束的工件(封存后追加的质询回答和它查询的快照)以前也给「T+…」跳转,点了把航迹拖到 运行结束之后几十分钟、几天;现在只写时间。pages/runs/ArtifactsPane.tsx 只加了结束时刻这一道边界,运行中、停在审批上 的运行照旧(航迹还没有结束时刻) - 预览用例 inquiry-run-artifacts 换成带起止时间的航迹、多一件以空格结尾的问题摘要;check-inquiry 补对应断言,以及审批 列表取不到时的标题、重试、重试后的归拢和深浅色对比度 --- frontend/src/dev/fixtures/inquiry.json | 12 +++ frontend/src/dev/fixtures/inquiry.tsx | 21 ++++- frontend/src/lib/terms.ts | 15 ++- frontend/src/pages/runs/ArtifactsPane.tsx | 10 +- frontend/src/run/Inquiry.tsx | 42 +++++++-- scripts/check-inquiry.mjs | 108 ++++++++++++++++------ 6 files changed, 159 insertions(+), 49 deletions(-) diff --git a/frontend/src/dev/fixtures/inquiry.json b/frontend/src/dev/fixtures/inquiry.json index d7d2a0d8..c0639825 100644 --- a/frontend/src/dev/fixtures/inquiry.json +++ b/frontend/src/dev/fixtures/inquiry.json @@ -4329,6 +4329,18 @@ "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" diff --git a/frontend/src/dev/fixtures/inquiry.tsx b/frontend/src/dev/fixtures/inquiry.tsx index 608dd590..1b2979f3 100644 --- a/frontend/src/dev/fixtures/inquiry.tsx +++ b/frontend/src/dev/fixtures/inquiry.tsx @@ -1,5 +1,6 @@ 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' @@ -29,7 +30,8 @@ import fx from './inquiry.json' * - ?syn=inquiry-run-formal:封存了的正式运行,不给入口,审批时问过的照列 * - ?syn=inquiry-run-waiting:停在审批上的运行,同运行详情直接打开「证据」页签:时间线里的待审批卡和证据页签同时挂着 * 同一份报告的问答串(卡上这张审批里问的直接认,证据页签哪张审批都要对一下) - * - ?syn=inquiry-run-artifacts:运行详情的「工件」页签(pages/runs/ArtifactsPane),工件列表里有质询的回答 + * - ?syn=inquiry-run-artifacts:运行详情的「工件」页签(pages/runs/ArtifactsPane),工件列表里有封存后追加的质询回答 + * (运行期间产出的能跳到航迹,封存后追加的不能) * * 列表、提问、质询工件、报告文档、证据图、审批列表、工件列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说 * 无法读取。 @@ -141,13 +143,22 @@ function Waiting({ approval, run }: { approval: Approval; run: Run }) { ) } -/** 运行详情的「工件」页签:工件列表由检查脚本按 inquiry.json 的 run_artifacts 答 */ +/** + * 运行详情的「工件」页签:工件列表由检查脚本按 inquiry.json 的 run_artifacts 答。航迹的起止照这次运行的开始、结束时间 + * 给(同运行详情):运行期间产出的工件能跳到航迹里那一刻,封存后追加的质询回答和它的查询快照不给跳转。点过的跳转记在 + * data-fixture-moment 上 + */ function RunArtifacts({ run }: { run: Run }) { const list = useRunArtifacts(run.id, '0') - const trace = useMemo(() => emptyTrace(), []) + 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(null) return ( -
- (id ? LABELS[id] : undefined)} /> +
+ (id ? LABELS[id] : undefined)} + onMoment={(at) => setMoment(at)} />
) } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index a5bd8393..c5ba7aaf 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3442,6 +3442,12 @@ export const INQUIRY_TEXT = { /** 之前几轮审批里问的、报告后来改写过(句子编号对不上),或者对不上是哪一稿:单独一组 */ otherDoc: '报告其他版本上的质询', otherDocHint: '提问时的报告与当前显示的版本不同,或无法确认是否相同,所以不挂到当前报告的句子下面', + /** + * 别的审批里问的、审批列表又没取到:不知道提问时审的是哪一稿,不能说成「其他版本」(审批人会以为报告换过稿)。 + * 单独一组照实说没能确认,给重试 + */ + unsureDoc: '无法确认提问时的报告版本', + unsureDocHint: '审批记录读取失败,暂不挂到当前报告的句子下面', loadFailed: '无法读取之前的质询记录', retry: '重试', q: '问', @@ -3514,12 +3520,11 @@ export const INQUIRY_TEXT = { artifactKind: '质询回答', /** * 服务端在工件的 meta 里只记问题的前 40 字(engine/inquiry.py 的 QUESTION_BRIEF):满 40 字的多半截断过,末尾补省略号, - * 免得半句话读成整个问题 + * 免得半句话读成整个问题。字数按原样数、显示时才去掉首尾空白:服务端截之前已经去过整个问题的首尾空白,摘要以空格、 + * 换行结尾只会是截断截在了空白上,先去空白再数就少了一个字、漏掉省略号 */ - artifactAbout: (question: string) => { - const chars = [...question.trim()] - return `质询「${chars.join('')}${chars.length >= 40 ? '…' : ''}」的回答` - }, + artifactAbout: (question: string) => + `质询「${question.trim()}${[...question].length >= 40 ? '…' : ''}」的回答`, /** meta 里没有问题摘要时的说法(服务端照理都会记,这里兜底):照报告文档那一句写装的是什么 */ artifactAboutNone: '审批质询的回答:逐段的片段和每个数字的出处', } diff --git a/frontend/src/pages/runs/ArtifactsPane.tsx b/frontend/src/pages/runs/ArtifactsPane.tsx index 22f73e85..335b5f6f 100644 --- a/frontend/src/pages/runs/ArtifactsPane.tsx +++ b/frontend/src/pages/runs/ArtifactsPane.tsx @@ -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 ( + 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 ( diff --git a/frontend/src/run/Inquiry.tsx b/frontend/src/run/Inquiry.tsx index 58cba9c6..941bccaf 100644 --- a/frontend/src/run/Inquiry.tsx +++ b/frontend/src/run/Inquiry.tsx @@ -513,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 @@ -610,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() @@ -619,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) @@ -629,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]) // 提交之后提问框拿掉了,焦点接到「正在回答」那一条上;回答回来那一条换掉了,再接到回答上。焦点已经去了别处的不抢; // 没问成(提问框带着报错重新打开)时焦点由提问框自己接。都只在问的那一处:同一张卡挂在两处时另一处不动 @@ -697,15 +712,15 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
{groups.map((g) => (
- + data-inquiry-group={offDoc(g.key) ? g.key : g.unit ?? (g.quote ? 'quote' : 'report')}> +
{g.entries.map((e) => ( { lastRef.current = el } : undefined} /> ))} - {g.key !== OTHER && ( + {!offDoc(g.key) && ( <> {mine && groupKey(mine.target) === g.key && ( { pendingRef.current = el }} /> @@ -740,9 +755,9 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul /** * 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告。问的不是眼前这一稿的 - * 那一组只写明这一点:句子编号对不上,不去取原文 + * 那一组只写明这一点:句子编号对不上,不去取原文。审批列表没取到、不知道是哪一稿的那一组照实说,给重试(onRetry) */ -function GroupHead({ group, units }: { group: Group; units: Map }) { +function GroupHead({ group, units, onRetry }: { group: Group; units: Map; onRetry: () => void }) { if (group.key === OTHER) { return (
@@ -751,6 +766,17 @@ function GroupHead({ group, units }: { group: Group; units: Map
) } + if (group.key === UNSURE) { + return ( +
+ + {INQUIRY_TEXT.unsureDoc} + {INQUIRY_TEXT.unsureDocHint} + + +
+ ) + } const text = group.unit ? units.get(group.unit) || group.entries.find((e) => e.inquiry.quote)?.inquiry.quote || '' : group.quote ?? '' return ( diff --git a/scripts/check-inquiry.mjs b/scripts/check-inquiry.mjs index b4c017ca..cca4fbce 100644 --- a/scripts/check-inquiry.mjs +++ b/scripts/check-inquiry.mjs @@ -1,6 +1,7 @@ // 审批质询的回归检查:报告上的入口(run/EvidenceDoc.tsx 的句末「质询」、划选原文浮出的「质询」)、问答串和提问框 // (run/Inquiry.tsx)、审批卡上的挂法(run/ApprovalReport.tsx、run/RunPanel.tsx)、运行详情「证据」页签的挂法 -// (pages/runs/EvidencePane.tsx),以及运行详情「工件」页签里质询回答的名字(pages/runs/model.ts)。 +// (pages/runs/EvidencePane.tsx),以及运行详情「工件」页签里质询回答的名字(pages/runs/model.ts)和航迹跳转 +// (pages/runs/ArtifactsPane.tsx)。 // // 守的是这些「看着没坏、其实用不了」的地方:悬停、键盘聚焦一句都不出「质询」(或者出了 Tab 却到不了、整份报告多出一串 // Tab 位)、没有可点内容的句子键盘走不到、划选原文不浮出入口(或者带上的原文混进了徽标和「质询」本身)、提交的请求体 @@ -9,8 +10,9 @@ // 那一格)、引用报告已有出处的数字点了没反应、查询的 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 真实组装的(虚构的数据)。 @@ -830,7 +832,7 @@ await section('运行详情「证据」页签:封存了的探索运行有入 } }) -await section('运行详情「工件」页签:质询的回答有名字和说明,不出现英文的工件种类名', async () => { +await section('运行详情「工件」页签:质询的回答有名字和说明,不出现英文的工件种类名;封存后追加的不给航迹跳转', async () => { const { ctx, page, errors } = await open('inquiry-run-artifacts') await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor() // 每一行:种类(行首的标签)、说明(同一种类有几件时末尾带「#n」,去掉再比)、悬停 @@ -841,11 +843,27 @@ await section('运行详情「工件」页签:质询的回答有名字和说 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 === 3 && answers.every((r) => r?.kind === '质询回答'), JSON.stringify(answers)) + 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)), @@ -1065,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() } @@ -1099,7 +1123,7 @@ const tally = async (page, scope) => ({ shown: await page.locator(`${scope} [data-inquiry-entry]`).count(), }) -await section('两处要对的审批不同、审批列表取不到:两处的问答都列出来,次数和条数一致', async () => { +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' } @@ -1120,10 +1144,10 @@ await section('两处要对的审批不同、审批列表取不到:两处的 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([['other', ['41']]]), + 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([['other', ['42']]]), JSON.stringify(gw2.map((g) => [g.unit, g.entries]))) + && 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 })) @@ -1134,7 +1158,7 @@ await section('两处要对的审批不同、审批列表取不到:两处的 // 运行详情直接打开「证据」页签、运行停在审批上:时间线里的待审批卡只为更早那一轮(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, errors } = await open('inquiry-run-waiting', { waitUntil: 'load', setup: (s) => { + 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 }) } }) @@ -1147,14 +1171,37 @@ await section('两处要对的审批不同、审批列表取不到:两处的 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']], ['other', ['12']]]), + 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([['other', ['12', '34']]]), JSON.stringify(gp.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() } @@ -1379,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']) {