diff --git a/backend/app/api/evals.py b/backend/app/api/evals.py
index 566ac513..dc5e9419 100644
--- a/backend/app/api/evals.py
+++ b/backend/app/api/evals.py
@@ -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)
diff --git a/backend/app/eval/answer.py b/backend/app/eval/answer.py
index 46d4c667..9d281947 100644
--- a/backend/app/eval/answer.py
+++ b/backend/app/eval/answer.py
@@ -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", "运行被驳回(人的决定,不算运行失败)"
diff --git a/backend/app/eval/runner.py b/backend/app/eval/runner.py
index 1f24e540..c4c89be5 100644
--- a/backend/app/eval/runner.py
+++ b/backend/app/eval/runner.py
@@ -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))
diff --git a/backend/app/eval/score.py b/backend/app/eval/score.py
index be673a75..f471c750 100644
--- a/backend/app/eval/score.py
+++ b/backend/app/eval/score.py
@@ -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"),
diff --git a/backend/tests/test_eval_runner.py b/backend/tests/test_eval_runner.py
index 06a2c233..c98df297 100644
--- a/backend/tests/test_eval_runner.py
+++ b/backend/tests/test_eval_runner.py
@@ -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)
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index e93f53d7..9f7489df 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -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}` : '未能发起运行'),
diff --git a/frontend/src/pages/schedules/FireHistory.tsx b/frontend/src/pages/schedules/FireHistory.tsx
index e69ba212..50f311a0 100644
--- a/frontend/src/pages/schedules/FireHistory.tsx
+++ b/frontend/src/pages/schedules/FireHistory.tsx
@@ -143,6 +143,9 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu
{T.openRun}
+ ) : f.eval_run_id ? (
+ // 目标是评测题集:这一次发起的是一次评测(评测页在后续版本),这里只写编号
+ {T.evalFire(f.eval_run_id.slice(0, 6))}
) : {T.noRun}}
{f.reason && (
diff --git a/frontend/src/pages/schedules/ScheduleDetail.tsx b/frontend/src/pages/schedules/ScheduleDetail.tsx
index 70d917e4..e42c0f0d 100644
--- a/frontend/src/pages/schedules/ScheduleDetail.tsx
+++ b/frontend/src/pages/schedules/ScheduleDetail.tsx
@@ -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 }) {
- {settingsSeen && (
+ {settingsSeen && targetKindOf(schedule) !== 'workflow' && (
+
+
+
+ )}
+ {settingsSeen && targetKindOf(schedule) === 'workflow' && (
)
}
+
+/**
+ * 目标不是工作流的定时任务(评测题集、数据源维护)的设置:只读地列出名称、目标、频率、时区、补发宽限。工作流那张表单
+ * (选工作流、运行类别、输入模板、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 (
+
+
{T.targetSettingsNote(T.targetKind[kind])}
+
+ {rows.map(([label, value]) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/pages/schedules/SchedulesPage.tsx b/frontend/src/pages/schedules/SchedulesPage.tsx
index 753c67da..b01b6919 100644
--- a/frontend/src/pages/schedules/SchedulesPage.tsx
+++ b/frontend/src/pages/schedules/SchedulesPage.tsx
@@ -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 (
@@ -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}
-
+ {workflow
+ ?
+ : {T.targetKind[kind]}}
{!s.enabled && {T.pausedChip}}
-
- {s.workflow_name}
· {s.description_text}
+
+ {target} · {s.description_text}
@@ -174,9 +183,9 @@ function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: {
title={T.runNowLabel(s.name)} data-schedule-run="">
{busy === 'run' ?
:
} {T.runNow}
-
- {T.edit}
+
+ {workflow ? T.edit : T.view}
} />
diff --git a/frontend/src/pages/schedules/parts.tsx b/frontend/src/pages/schedules/parts.tsx
index 1e0e8069..ab7d4cc8 100644
--- a/frontend/src/pages/schedules/parts.tsx
+++ b/frontend/src/pages/schedules/parts.tsx
@@ -97,7 +97,8 @@ export function FireStatusChip({ fire }: { fire: ScheduleFire }) {
return (
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 {
}
}
+/** 定时任务的目标种类:老服务端不给的都是工作流 */
+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 ?? ''))
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 88deaeaa..a7655946 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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:分 时 日 月 星期 */
diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs
index b43fd823..dadcf77c 100644
--- a/scripts/check-schedules.mjs
+++ b/scripts/check-schedules.mjs
@@ -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()