定时页照实显示非工作流目标;评测停止时几臂一起收尾;工作流题的测试

为什么:这一波后端开始接受评测题集、数据源维护这两种定时目标(总方案 X4),定时页要能把它们照实列出来、点进去
不报错;界面上不能有点了没用的入口。

- 定时页列表:非工作流目标写种类(评测题集 / 数据源维护)和目标名,不标运行类别,「编辑」写成「查看」
- 详情:非工作流目标的设置只读地列出名称、目标、频率、时区、补发宽限,不出工作流那张表单(选工作流、输入模板、
  Webhook 对它们不适用);页头的启用、立即运行、删除和触发历史照常
- 立即运行发起的是一次评测时说「已发起一次评测」(以前会落到「未能发起运行」);触发历史写评测的编号
- types.ts:Schedule 加 target_kind / target_id / target_label / target_name / target_exists,触发记录加 eval_run_id;
  文案进 SCHEDULE_TEXT
- check-schedules 加一段「目标不是工作流的定时任务」
- runner:还没准备好就被停下、出错时,按 pair_id 找全几臂一起收尾
- 测试:工作流题按钉住版本跑探索运行(不发正式运行、不建图);钉住基线的接口
- 后端几句给人看的话按文案规范改(不用「跑」「钉」)
This commit is contained in:
ThearchyHelios
2026-10-16 17:04:05 +08:00
parent e29adf80d0
commit df6c3ce10d
12 changed files with 214 additions and 20 deletions
+1 -1
View File
@@ -954,7 +954,7 @@ async def pin_evaluation(evaluation_id: str, request: Request, session: AsyncSes
if not isinstance(pinned, bool):
raise CodedHTTPException(422, "pinned 需要是 true 或 false", case_rules.CASE_INVALID, field="pinned")
if pinned and row.status not in ("succeeded", "partial"):
raise CodedHTTPException(409, "只有评完了(或部分评完)的评测能钉成基线", case_rules.CASE_INVALID, field="pinned")
raise CodedHTTPException(409, "只有评完了(或部分评完)的评测能设为基线", case_rules.CASE_INVALID, field="pinned")
row.pinned = pinned
await session.commit()
await session.refresh(row)
+1 -1
View File
@@ -333,7 +333,7 @@ class Driver:
waiting = await self._pending_approvals(run_id)
await stop_run(run_id, self.ctx.actor)
t.path = "run_failed"
t.reason = APPROVAL_REASON if waiting else "运行停在断点上(服务可能重启过):评测不接着跑,已取消这次运行"
t.reason = APPROVAL_REASON if waiting else "运行停在断点上(服务可能重启过):评测不继续这次运行,已取消"
return None
if row.status == "cancelled" and row.end_reason == END_REJECTED:
t.path, t.reason = "rejected", "运行被驳回(人的决定,不算运行失败)"
+5 -3
View File
@@ -60,7 +60,7 @@ STOP_COPILOT = 3
STOP_WINDOW = 10
STOP_TIMEOUTS = 7
#: 说法
INTERRUPTED = "服务重启时评测还没做完,没有自动续跑。可以点「重试」只把没做完的题接着做"
INTERRUPTED = "服务重启时评测还没做完,没有自动继续。可以点「重试」只把没做完的题接着做"
STOPPED = "评测已停止"
NOT_STARTED = "评测停下时这道题还没开评"
LIMIT_RUN = "已到这次评测的金额上限(${limit:g}),没有再开新题"
@@ -68,7 +68,7 @@ LIMIT_DAILY = "评测今天的金额已到上限(${limit:g}),没有再开
STOP_COPILOT_TEXT = "连续 {n} 题助手调用失败,评测停下了:{reason}"
STOP_TIMEOUT_TEXT = "最近 {window} 题里有 {n} 题超时,评测停下了"
CASE_GONE = "这道题已被删除"
TIMED_OUT = "超过每题时限({s:g} 秒),已停止这一题正在跑的运行"
TIMED_OUT = "超过每题时限({s:g} 秒),已停止这一题正在执行的运行"
#: 结束后多久做清理(在后台、不挡收尾)
DETAILS_KIND = "eval_result"
DETAILS_FORMAT = 1
@@ -698,7 +698,9 @@ class EvalExecution(runtime.JobRun):
await self._settle()
async def write(session: AsyncSession) -> None:
ids = [a.run_id for a in self.arms] or [self.job_id]
# 还没准备好就停下、出错的(arms 还是空的):按 pair_id 找全几臂
ids = [a.run_id for a in self.arms] or list((await session.execute(select(EvalRun.id).where(
(EvalRun.id == self.job_id) | (EvalRun.pair_id == self.job_id)))).scalars())
item_status = "cancelled" if status in ("cancelled", "interrupted") else "failed"
await session.execute(update(EvalResult).where(EvalResult.eval_run_id.in_(ids),
EvalResult.status.in_(runtime.OPEN))
+3 -3
View File
@@ -56,8 +56,8 @@ OUTCOME_LABEL = {"traced": "对且可追溯", "untraced": "对但没引用", "wr
TEXT_OUTCOMES = ("covered", "partial", "missing", "unjudged")
TEXT_LABEL = {"covered": "都答到了", "partial": "答到一部分", "missing": "没答到", "unjudged": "没有判"}
VERDICT_LABEL = {"pass": "通过", "fail": "不通过", "ungradable": "不能评分", "skipped": "跳过"}
PATH_LABEL = {"reply": "直接回答", "asked": "反问", "ready": "只建不跑", "build_failed": "建图失败",
"run_failed": "运行失败", "ran": "跑完了", "rejected": "被驳回"}
PATH_LABEL = {"reply": "直接回答", "asked": "反问", "ready": "只建图未运行", "build_failed": "建图失败",
"run_failed": "运行失败", "ran": "运行完成", "rejected": "被驳回"}
#: 期望没写容差时,引用的原值按这个相对误差比(同双盲复算的缺省)
DEFAULT_TOLERANCE = 1e-6
#: 量纲不同:差这几个倍数(元 / 万元、比率 / 百分数……)
@@ -495,7 +495,7 @@ def score_case(case: Mapping[str, Any], gold: Mapping[str, Any] | None, answer:
return CaseScore("ungradable", False, path_reason or "运行被驳回(人的决定,不算失败)", [])
ask_only = any(e.get("kind") == "ask" for e in expectations)
if needs_numbers and path != "ran":
reason = WRONG_PATH.get(path or "", "没有跑出答案")
reason = WRONG_PATH.get(path or "", "没有得到答案")
if path_reason:
reason = f"{reason}:{path_reason}"
return CaseScore("fail", False, reason, [{"id": e["id"], "kind": e.get("kind"),
+48
View File
@@ -331,3 +331,51 @@ async def test_eval_settings_group_and_spend_endpoint(client):
assert effective_budget({"daily_max_usd": 9.0}, {"daily_max_usd": 3.0})["daily_max_usd"] == 3.0
assert effective_budget({"daily_max_usd": 1.0}, {"daily_max_usd": 3.0})["daily_max_usd"] == 1.0
await client.put("/api/settings", json={"values": {"eval": {}}})
async def test_a_workflow_case_runs_its_pinned_version_as_an_exploratory_run(client, monkeypatch, tmp_path):
"""工作流题:钉住那一版的图按输入模板跑一次探索运行(来源 eval),不发正式运行、不建图、不复核。"""
from app.core.artifact_store import graph_hash
from app.db.models import Workflow, WorkflowVersion
from tests.fixtures import dual_check_shop as shop
name, sid = await es.make_source(tmp_path)
try:
es.use_report(monkeypatch)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
nodes = [node("start", "input", fields=[{"name": "week", "required": True}]),
node("q", "tool", tool=f"db_query__{name}", args={"sql": shop.RIGHT}),
node("write", "report", instructions="写周销售"),
node("out", "output", fields=[{"name": "报告", "value": "{{ nodes.write.text }}"}])]
graph = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async with SessionLocal() as session:
wf = Workflow(name="虚构周销售", graph=graph, status="published", published_version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph, graph_hash=graph_hash(graph),
level="published"))
await session.commit()
wf_id = wf.id
suite_id = await es.make_suite(sid, cases=[{
"kind": "workflow", "workflow_id": wf_id, "workflow_version": 1,
"input_template": {"week": "{{ schedule.last_week }}"},
"gold": {"kind": "sql", "datasource_id": sid, "sql": shop.RIGHT},
"expectations": [{"kind": "scalar", "column": "gmv"}]}])
[run] = await start(suite_id)
await es.wait_eval(run.id)
[item] = await results(run.id)
assert item.path == "ran" and item.verdict == "pass", (item.error, item.scores)
async with SessionLocal() as session:
answered = await session.get(Run, item.run_id)
assert answered.run_class == "exploratory" and answered.origin_kind == "eval" and answered.workflow_id is None
assert set(answered.input) == {"week"} and answered.input["week"].count("-W") == 1
# 钉住当基线、取消
pinned = await client.post(f"/api/evals/evaluations/{run.id}/pin", json={"pinned": True})
assert pinned.status_code == 200 and pinned.json()["pinned"] is True
assert (await client.post(f"/api/evals/evaluations/{run.id}/pin", json={"pinned": "yes"})).status_code == 422
assert (await client.get("/api/evals/pairs/nope")).status_code == 404
finally:
await drop_source(sid)
+17
View File
@@ -3884,7 +3884,24 @@ export const SCHEDULE_TEXT = {
/** 启用开关遇到版本不符(409):服务端那句是写给表单保存的,开关用这句,原话放进详情 */
toggleConflict: (name: string) => `「${name}」已在别处被修改,已重新载入最新状态,请确认后再切换`,
// ---- 目标(定时只有一套:目标可以是工作流、评测题集或数据源维护)
targetKind: { workflow: '工作流', eval_suite: '评测题集', source_maintenance: '数据源维护' } as Record<
import('../types').ScheduleTargetKind, string>,
/** 目标已经不在了:名字是保存时记下的 */
targetGone: (name: string) => `${name}(已不存在)`,
view: '查看',
viewLabel: (name: string) => `查看定时任务「${name}」`,
/** 非工作流目标的设置是只读的:启用、暂停、立即运行、触发历史照常可用 */
targetSettingsTitle: '设置',
targetSettingsNote: (kind: string) => `这个定时任务的目标是${kind}:可以启用、暂停、立即运行和查看触发历史;名称、频率和目标在这里只能查看,不能修改`,
targetField: '目标',
graceField: '补发宽限',
// ---- 立即运行的结果
/** 目标是评测题集:发起的是一次评测,没有运行可看 */
evalStarted: '已发起一次评测',
/** 触发历史里评测题集那一次:评测的编号 */
evalFire: (short: string) => `评测 #${short}`,
runStarted: '已发起运行',
runSkipped: '上一次运行尚未结束,本次已按重叠策略跳过',
runFailed: (reason: string | null) => (reason ? `未能发起运行:${reason}` : '未能发起运行'),
@@ -143,6 +143,9 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu
{T.openRun}
<ChevronRight size={11} aria-hidden />
</Link>
) : f.eval_run_id ? (
// 目标是评测题集:这一次发起的是一次评测(评测页在后续版本),这里只写编号
<span className="shrink-0 text-2xs text-dim" data-fire-eval={f.eval_run_id}>{T.evalFire(f.eval_run_id.slice(0, 6))}</span>
) : <span className="shrink-0 text-2xs text-faint">{T.noRun}</span>}
</div>
{f.reason && (
@@ -8,7 +8,11 @@ import { SCHEDULE_TEXT as T } from '../../lib/terms'
import { useOnReconnect } from '../../store/catalog'
import type { Schedule, ScheduleFire } from '../../types'
import { FireHistory } from './FireHistory'
import { EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled } from './parts'
import {
EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled, targetKindOf,
targetText,
} from './parts'
import { graceLabel } from './model'
import { ScheduleEditor } from './ScheduleEditor'
const BACK = (
@@ -234,7 +238,12 @@ export function ScheduleDetailPage({ id }: { id: string }) {
<Tabs tabs={[{ key: 'settings', label: T.tabs.settings }, { key: 'history', label: T.tabs.history }]}
active={tab} onChange={pickTab} label={T.title} idPrefix="schedule" />
<div className="min-h-0 flex-1 overflow-y-auto">
{settingsSeen && (
{settingsSeen && targetKindOf(schedule) !== 'workflow' && (
<div role="tabpanel" id="schedule-panel-settings" aria-labelledby="schedule-tab-settings" hidden={tab !== 'settings'}>
<TargetSettings schedule={schedule} />
</div>
)}
{settingsSeen && targetKindOf(schedule) === 'workflow' && (
<div role="tabpanel" id="schedule-panel-settings" aria-labelledby="schedule-tab-settings" hidden={tab !== 'settings'}>
<ScheduleEditor
key={resetKey}
@@ -261,3 +270,31 @@ export function ScheduleDetailPage({ id }: { id: string }) {
</div>
)
}
/**
* 目标不是工作流的定时任务(评测题集、数据源维护)的设置:只读地列出名称、目标、频率、时区、补发宽限。工作流那张表单
* (选工作流、运行类别、输入模板、Webhook)对它们不适用;页头的启用开关、立即运行、删除和触发历史照常可用
*/
function TargetSettings({ schedule }: { schedule: Schedule }) {
const kind = targetKindOf(schedule)
const rows: [string, string][] = [
[T.name, schedule.name],
[T.targetField, `${T.targetKind[kind]} · ${targetText(schedule)}`],
[T.freq, schedule.description_text],
[T.timezone, schedule.timezone],
[T.graceField, graceLabel(schedule.misfire_grace_s)],
]
return (
<div className="mx-auto max-w-3xl space-y-3 p-4" data-schedule-target-settings={kind}>
<p className="text-xs text-dim">{T.targetSettingsNote(T.targetKind[kind])}</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-lg border bg-panel p-3 text-xs">
{rows.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-faint">{label}</dt>
<dd className="min-w-0 break-words text-fg">{value}</dd>
</div>
))}
</dl>
</div>
)
}
+18 -9
View File
@@ -8,7 +8,10 @@ import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type { Schedule } from '../../types'
import { ClassChip } from '../runs/parts'
import { ScheduleCreatePage, ScheduleDetailPage } from './ScheduleDetail'
import { EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled } from './parts'
import {
EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled, targetKindOf,
targetText,
} from './parts'
/**
* 定时任务:按设定的周期自动发起工作流运行。
@@ -145,13 +148,17 @@ function ScheduleListPage() {
}
/**
* 一行:名字(链到详情)、工作流和频率;下次运行、上次触发;右边启用开关、立即运行、编辑、删除。
* 窄屏上右边的按钮整组折到下一行,不挤名字
* 一行:名字(链到详情)、目标和频率;下次运行、上次触发;右边启用开关、立即运行、编辑、删除。
* 窄屏上右边的按钮整组折到下一行,不挤名字。目标不是工作流的(评测题集、数据源维护)标出种类、不标运行类别,
* 「编辑」写「查看」:这类定时的设置在这里只读
*/
function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: {
s: Schedule; now: number; busy?: 'toggle' | 'run' | 'delete'
onToggle: (enabled: boolean) => void; onRun: () => void; onDelete: () => void
}) {
const kind = targetKindOf(s)
const workflow = kind === 'workflow'
const target = targetText(s)
return (
<li className="rounded-lg border bg-panel px-3 py-2.5" data-schedule-row={s.id} data-enabled={String(s.enabled)}>
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
@@ -161,11 +168,13 @@ function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: {
className="min-w-0 truncate text-sm font-medium text-fg hover:underline">
{s.name}
</Link>
<ClassChip runClass={s.run_class} />
{workflow
? <ClassChip runClass={s.run_class} />
: <span className="chip shrink-0" data-schedule-target-kind={kind}>{T.targetKind[kind]}</span>}
{!s.enabled && <span className="chip shrink-0" data-schedule-paused="">{T.pausedChip}</span>}
</div>
<div className="mt-0.5 truncate text-xs text-dim" title={`${s.workflow_name} · ${s.description_text}`} data-schedule-desc="">
{s.workflow_name}<span className="text-faint"> · </span>{s.description_text}
<div className="mt-0.5 truncate text-xs text-dim" title={`${target} · ${s.description_text}`} data-schedule-desc="">
{target}<span className="text-faint"> · </span>{s.description_text}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -174,9 +183,9 @@ function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: {
title={T.runNowLabel(s.name)} data-schedule-run="">
{busy === 'run' ? <Spinner size={11} /> : <Play size={11} aria-hidden />} {T.runNow}
</button>
<Link to={`/schedules/${encodeURIComponent(s.id)}`} className="btn btn-sm btn-ghost" aria-label={T.editLabel(s.name)}
data-schedule-edit="">
{T.edit}
<Link to={`/schedules/${encodeURIComponent(s.id)}`} className="btn btn-sm btn-ghost"
aria-label={workflow ? T.editLabel(s.name) : T.viewLabel(s.name)} data-schedule-edit="">
{workflow ? T.edit : T.view}
</Link>
<IconButton label={T.deleteLabel(s.name)} onClick={onDelete} disabled={!!busy} data-schedule-delete=""
className="text-faint hover:bg-st-failed/10 hover:text-[var(--err)]" icon={<Trash2 size={12} />} />
+14 -1
View File
@@ -97,7 +97,8 @@ export function FireStatusChip({ fire }: { fire: ScheduleFire }) {
return (
<span
className="chip shrink-0"
title={fire.reason || T.fireStatusHint[fire.status] || undefined}
title={fire.reason || (fire.eval_run_id && fire.status === 'started' ? T.evalStarted : T.fireStatusHint[fire.status])
|| undefined}
data-fire-status-chip={fire.status}
style={{
color,
@@ -180,6 +181,9 @@ export async function runNow(s: Schedule, navigate: (to: string) => void): Promi
const runId = r.run_id ?? r.fire?.run_id ?? null
if (runId) {
toast.ok(T.runStarted, { action: { label: T.openRun, onClick: () => navigate(`/runs/${encodeURIComponent(runId)}`) } })
} else if (r.fire?.status === 'started' && r.fire.eval_run_id) {
// 目标是评测题集:发起的是一次评测,没有一次运行可看
toast.ok(T.evalStarted)
} else if (r.fire?.status === 'skipped_overlap' || r.fire?.status === 'skipped_misfire') {
toast.warn(r.fire.status === 'skipped_overlap' ? T.runSkipped : T.fireStatus.skipped_misfire,
r.fire.reason ? { detail: r.fire.reason } : undefined)
@@ -210,6 +214,15 @@ export async function removeSchedule(s: Schedule): Promise<boolean> {
}
}
/** 定时任务的目标种类:老服务端不给的都是工作流 */
export const targetKindOf = (s: Schedule) => s.target_kind ?? 'workflow'
/** 目标的名字(不在了标出来):工作流用 workflow_name,别的种类用 target_name */
export function targetText(s: Schedule): string {
const name = (targetKindOf(s) === 'workflow' ? s.workflow_name : s.target_name ?? s.workflow_name) || s.target_id || ''
return s.target_exists === false ? T.targetGone(name) : name
}
/** 老服务端没有定时任务接口:404 且没有机读码(有码的 404 是服务端自己说的「不存在」) */
export const isUnsupported = (e: unknown) => e instanceof ApiError && e.status === 404 && !e.code
&& /^not found$/i.test(String(e.detail ?? ''))
+14
View File
@@ -3800,6 +3800,11 @@ export type ScheduleEvent = 'finished' | 'failed' | 'awaiting_approval'
export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire'
/** 触发的结局:发起了运行、上一次还没结束而跳过、错过时间超出宽限而跳过、发起时被拒 */
export type ScheduleFireStatus = 'started' | 'skipped_overlap' | 'skipped_misfire' | 'failed_to_start'
/**
* 定时的目标种类(next-round X4:定时只有一套):工作流、评测题集、数据源维护。非工作流目标没有工作流、输入模板、运行类别
* 和 Webhook(backend/app/api/schedules.py)
*/
export type ScheduleTargetKind = 'workflow' | 'eval_suite' | 'source_maintenance'
/** 读出来的 Webhook 设置。完整地址服务端加密保存、不再返回,只露主机和末 4 位 */
export interface ScheduleWebhook {
@@ -3843,6 +3848,8 @@ export interface ScheduleFire {
run_status: RunStatus | null
/** 跳过、发起失败的原因(中文) */
reason: string | null
/** 目标是评测题集时这一次触发建的那次评测(没有 run_id);老服务端不给 */
eval_run_id?: string | null
notified: ScheduleFireNotice[]
/**
* 和同一定时任务上一次成功的运行的对比。运行没有成功结束、没有可比的、还没算、比不了、任何一边又在执行(不在终态)
@@ -3855,6 +3862,13 @@ export interface ScheduleFire {
export interface Schedule {
id: string
name: string
/** 目标:种类、id、种类的叫法、目标现在的名字(不在了是保存时记下的)、还在不在。老服务端不给(都是工作流) */
target_kind?: ScheduleTargetKind
target_id?: string
target_label?: string
target_name?: string
target_exists?: boolean
/** 非工作流目标存空串 */
workflow_id: string
workflow_name: string
/** 五段 cron:分 时 日 月 星期 */
+51
View File
@@ -472,6 +472,20 @@ function runEvents(run) {
]
}
/**
* 目标不是工作流的定时任务(next-round X4:定时只有一套):评测题集。没有工作流、输入模板、运行类别、Webhook;
* 立即运行发起的是一次评测(触发记录带 eval_run_id、没有 run_id)
*/
const S_EVAL = {
id: 's-eval', name: '零售题集 · 每周回归', target_kind: 'eval_suite', target_id: 'suite-retail', target_label: '评测题集',
target_name: '零售样例题集', target_exists: true, workflow_id: '', workflow_name: '零售样例题集', cron: '0 9 * * 1',
timezone: 'Asia/Shanghai', run_class: 'exploratory', input_template: {}, enabled: true, overlap: 'skip',
misfire_grace_s: 3600, webhook: null, next_run_at: iso(NOW + 3 * H + 5 * 60e3),
last_fire: fire('f-eval', { run_id: null, run_status: null, eval_run_id: 'ev1a2b3c4d5e' }),
description_text: '每周一 09:00(Asia/Shanghai)', created_by: '检查脚本', created_at: iso(NOW - 3 * 24 * H),
updated_at: iso(NOW - 24 * H), version: 2,
}
function freshState(patch = {}) {
return {
schedules: [structuredClone(S1), structuredClone(S2)],
@@ -795,6 +809,43 @@ await section('列表:渲染', async () => {
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('列表:目标不是工作流的定时任务', async () => {
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVAL)],
fires: [fire('f-eval', { run_id: null, run_status: null, eval_run_id: 'ev1a2b3c4d5e' })] })
state.override = (method, path) => (method === 'POST' && path === '/schedules/s-eval/run'
? { json: { fire: fire('f-eval2', { kind: 'manual', run_id: null, run_status: null, eval_run_id: 'ev9f8e7d6c5b' }),
run_id: null } }
: null)
const { page, errors } = await open('/schedules', { state })
const row = page.locator('[data-schedule-row="s-eval"]')
await row.waitFor()
const t = await row.innerText()
check('写种类「评测题集」和题集名、频率', t.includes('评测题集') && t.includes('零售样例题集') && t.includes('每周一 09:00'),
t.replace(/\s+/g, ' ').slice(0, 160))
check('不标运行类别(评测题集没有正式 / 探索之分)', !t.includes('正式运行') && !t.includes('探索运行'))
check('「编辑」写成「查看」', (await row.locator('[data-schedule-edit]').innerText()).includes('查看'))
check('工作流的那一行照旧', (await page.locator('[data-schedule-row="s-weekly"]').innerText()).includes('正式运行'))
await row.locator('[data-schedule-run]').click()
const ok = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((x) => x.includes('已发起一次评测')) ?? null)
check('立即运行:说发起了一次评测(没有运行可看,不说「未能发起」)', !!ok && !ok.includes('查看运行'), ok ?? '')
await page.goto(`${WEB}/schedules/s-eval`, { waitUntil: 'networkidle' })
const settings = page.locator('[data-schedule-target-settings="eval_suite"]')
await settings.waitFor()
const st = await settings.innerText()
check('详情的设置只读地列出名称、目标、频率', st.includes('零售题集 · 每周回归') && st.includes('评测题集 · 零售样例题集')
&& st.includes('每周一 09:00') && st.includes('1 小时'), st.replace(/\s+/g, ' ').slice(0, 200))
check('不出工作流的表单(选工作流、输入模板、Webhook、保存)', await page.locator('[data-schedule-save]').count() === 0)
check('页头的启用、立即运行、删除照常', await page.locator('[data-schedule-toggle]').count() === 1
&& await page.locator('[data-schedules-page] [data-schedule-run]').count() === 1)
await page.goto(`${WEB}/schedules/s-eval?tab=history`, { waitUntil: 'networkidle' })
const evalFire = page.locator('[data-fire-eval="ev1a2b3c4d5e"]')
await evalFire.waitFor()
check('触发历史写评测的编号,不写「未发起运行」', (await evalFire.innerText()).includes('评测 #ev1a2b')
&& !(await page.locator('[data-fire-id="f-eval"]').innerText()).includes('未发起运行'))
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('列表:空态与老服务端', async () => {
const { page, errors } = await open('/schedules', { state: freshState({ schedules: [] }) })
await page.locator('[data-schedules-empty]').waitFor()