diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index 5d641070..a7d08b7f 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -349,7 +349,22 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d 对比和比的时候两边的封存绑定(auto_compare):哪一边现在不在终态(被继续执行)、被删掉、清单哈希和比的时候不一样 (重新封存过)都给 null——对比页按现在的封存现算,记下的计数和点进去看到的会对不上,或者干脆比不了。工件读不出来、 被改过的同样给 null。基准已经不是现在挑出来的那一次(auto_compare.outdated:中间隔着的运行事后接着运行成功了)也 - 给 null:运行详情里同名的入口比的是另一次。""" + 给 null:运行详情里同名的入口比的是另一次。 + + inputs 只在目标是数据源维护时给:{steps, jobs: [{step, kind, id, tables, scope?}], skipped: [{step, reason}]}——这一次 + 建了哪几个探查、剖析任务,哪几步没执行和原因(app/data/source_maintenance 记在触发上的那一份)。维护没有运行,触发历史、 + 「上次触发」和立即运行的答复靠它写出建了什么,不写「未发起运行」。别的目标给 null:工作流触发的 inputs 是按模板渲染的 + 工作流输入,触发历史不列。""" + maintenance = set((await session.execute(select(Schedule.id).where( + Schedule.id.in_({f.schedule_id for f in fires}), Schedule.target_kind == "source_maintenance"))).scalars()) \ + if fires else set() + + def inputs_of(f: ScheduleFire) -> dict[str, Any] | None: + if f.schedule_id not in maintenance: + return None + raw = f.inputs if isinstance(f.inputs, dict) else {} + return {key: raw[key] for key in ("steps", "jobs", "skipped") if key in raw} + 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) @@ -389,6 +404,7 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d "run_status": current.get(f.run_id, (None, None))[0] if f.run_id else None, # 目标是评测题集时这一次触发建的那次评测(没有运行 id) "eval_run_id": f.eval_run_id, + "inputs": inputs_of(f), "reason": None if f.id in launching else f.reason, "notified": notified.get(f.id, []), "compare": compare_of(f), diff --git a/backend/tests/test_maintenance_fire_history.py b/backend/tests/test_maintenance_fire_history.py new file mode 100644 index 00000000..1d49b235 --- /dev/null +++ b/backend/tests/test_maintenance_fire_history.py @@ -0,0 +1,92 @@ +"""定时任务页的触发历史认得数据源维护(复核 W4R-05)。 + +数据源维护的一次触发建的是探查、剖析任务,没有运行:触发记录的 inputs({steps, jobs, skipped},app/data/source_maintenance) +要交到触发历史、页头的「上次触发」和「立即运行」的答复里,界面才写得出建了哪几个任务、哪几步没执行,不再写「未发起运行」。 +工作流目标的触发不给 inputs(那是按模板渲染的工作流输入,触发历史不列)。 + +库、表、名字都是虚构的。 +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete + +from app.db.base import SessionLocal, utcnow +from app.db.models import Schedule, ScheduleFire +from app.main import app +from app.scheduler import service +from tests.fixtures.sources import drop_source +from tests.test_source_maintenance import _schedule, _source, _wait_jobs + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture +async def made(): + ids: list[str] = [] + yield ids + async with SessionLocal() as session: + await session.execute(delete(Schedule).where(Schedule.id.in_(ids))) + await session.commit() + + +async def test_a_maintenance_fire_carries_the_jobs_it_started_and_the_steps_not_run(client, tmp_path, made): + sid = await _source(client, tmp_path / "shop.db", profile=False) + try: + schedule_id = await _schedule(sid) + made.append(schedule_id) + ran = (await client.post(f"/api/schedules/{schedule_id}/run")).json() + fire = ran["fire"] + assert ran["run_id"] is None and fire["status"] == "started" and fire["run_id"] is None + jobs = fire["inputs"]["jobs"] + assert [j["kind"] for j in jobs] == ["introspect"] and jobs[0]["id"] + assert {s["step"] for s in fire["inputs"]["skipped"]} == {"profile_needed", "observe"} + async with SessionLocal() as session: + await _wait_jobs(await session.get(ScheduleFire, fire["id"])) + + listed = (await client.get(f"/api/schedules/{schedule_id}/fires")).json()["items"] + assert [f["id"] for f in listed] == [fire["id"]] + assert listed[0]["inputs"] == fire["inputs"] + assert set(listed[0]["inputs"]) == {"steps", "jobs", "skipped"} + head = (await client.get(f"/api/schedules/{schedule_id}")).json() + assert head["last_fire"]["inputs"]["jobs"] == jobs + rows = (await client.get("/api/schedules")).json()["items"] + assert [s["last_fire"]["inputs"]["jobs"] for s in rows if s["id"] == schedule_id] == [jobs] + finally: + await drop_source(sid) + + +async def test_a_workflow_fire_does_not_list_its_rendered_inputs(client, made): + async with SessionLocal() as session: + row = Schedule(name="虚构周报 · 每周", cron="0 9 * * 1", timezone="Asia/Shanghai", + **service.target_columns("workflow", "wf-fictional"), input_template={"date": "{{ schedule.yesterday }}"}) + session.add(row) + await session.flush() + session.add(ScheduleFire(schedule_id=row.id, fire_at=utcnow().replace(microsecond=0), kind="manual", + status="failed_to_start", reason="虚构的原因", inputs={"date": "2026-10-15"})) + await session.commit() + made.append(row.id) + [got] = (await client.get(f"/api/schedules/{row.id}/fires")).json()["items"] + assert got["inputs"] is None + + +def test_the_steps_and_their_names_on_the_schedules_page_match_the_server(): + """定时任务页照服务端的步骤和叫法写(types.ts 的 ScheduleMaintenanceStep、SCHEDULE_TEXT.maintenanceStep):服务端加了一步, + 界面没跟上就写出英文原值。直接读两份 .ts 比(同 test_frontend_type_unions 的写法)。""" + import re + from pathlib import Path + + from app.data.source_maintenance import STEP_LABEL, STEPS + + src = Path(__file__).resolve().parents[2] / "frontend" / "src" + types = (src / "types.ts").read_text(encoding="utf-8") + union = re.search(r"export type ScheduleMaintenanceStep =([^\n]*)", types) + assert union is not None and re.findall(r"'([^']+)'", union.group(1)) == list(STEPS) + terms = (src / "lib" / "terms.ts").read_text(encoding="utf-8") + labels = re.search(r"\n\s*maintenanceStep: \{([^}]*)\}", terms) + assert labels is not None and dict(re.findall(r"(\w+): '([^']+)'", labels.group(1))) == STEP_LABEL diff --git a/backend/tests/test_schedules_api.py b/backend/tests/test_schedules_api.py index 10b623f1..e3d5f753 100644 --- a/backend/tests/test_schedules_api.py +++ b/backend/tests/test_schedules_api.py @@ -43,8 +43,8 @@ SCHEDULE_KEYS = {"id", "name", "workflow_id", "workflow_name", "cron", "timezone # 定时的目标(next-round X4):种类、id、叫法、现在的名字、还在不在 "target_kind", "target_id", "target_label", "target_name", "target_exists"} FIRE_KEYS = {"id", "fire_at", "kind", "status", "run_id", "run_status", "reason", "notified", "compare", "created_at", - # 目标是评测题集时这一次触发建的那次评测 - "eval_run_id"} + # 目标是评测题集时这一次触发建的那次评测;目标是数据源维护时这一次建了哪些任务、哪几步没执行(别的目标为 null) + "eval_run_id", "inputs"} @pytest.fixture(autouse=True) diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 28259d5f..083cb89d 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3953,6 +3953,29 @@ export const SCHEDULE_TEXT = { evalStarted: '已发起一次评测', /** 触发历史里评测题集那一次:评测的编号 */ evalFire: (short: string) => `评测 #${short}`, + /** + * 数据源维护的一次触发:建的是探查、剖析任务,没有运行。和数据源页「定时维护」的「最近一次」同一个写法(步骤的叫法同服务端 + * source_maintenance.STEP_LABEL) + */ + maintenanceStep: { introspect: '探查结构', profile_needed: '剖析需要的表', observe: '取值观察' } as Record< + import('../types').ScheduleMaintenanceStep, string>, + maintenanceJobKind: { introspect: '探查任务', profile: '剖析任务' } as Record<'introspect' | 'profile', string>, + maintenanceJobs: '建了任务', + maintenanceJob: (step: string, kind: string, id: string, tables: number | null) => + `${step}:${kind} #${id.slice(0, 6)}${tables != null ? `(${formatNumber(tables)} 张表)` : ''}`, + maintenanceJobTitle: { + introspect: '去数据源页看这个数据源的探查结果', + profile: '去数据目录看这个剖析任务的进度和结果', + } as Record<'introspect' | 'profile', string>, + maintenanceSkipped: (step: string, reason: string) => `${step}没有执行:${reason}`, + /** 维护没有建任务(发起失败、跳过):不写「未发起运行」,维护本来就不发起运行 */ + maintenanceNoJobs: '没有建任务', + /** 刚触发、任务还在建(服务端按已发起给,任务还没记上) */ + maintenanceLaunching: '正在建维护任务', + maintenanceStarted: '已开始维护', + maintenanceBusy: '上一次维护(或别的探查、剖析)还没做完,这一次已跳过', + maintenanceFailed: (reason: string | null) => (reason ? `没有开始维护:${reason}` : '没有开始维护'), + maintenanceStartedHint: '已建好这一次维护的任务', 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 ffc2a77f..d6f8a67e 100644 --- a/frontend/src/pages/schedules/FireHistory.tsx +++ b/frontend/src/pages/schedules/FireHistory.tsx @@ -7,13 +7,15 @@ import { formatDateTime, parseServerTime, shortId } from '../../lib/format' import { SCHEDULE_TEXT as T } from '../../lib/terms' import type { Schedule, ScheduleFire, ScheduleFireCompare } from '../../types' import { comparePath, type CompareVia } from '../runs/compare/entry' -import { FireKindChip, FireStatusChip, ZonedTime } from './parts' +import { maintenanceJobPath, maintenanceStepText } from './model' +import { FireKindChip, FireStatusChip, ZonedTime, targetKindOf } from './parts' const PAGE = 20 /** * 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败 - * 写原因;发起了的链到那次运行、写运行现在的状态;运行成功结束后服务端和同一任务上一次成功的运行比过的,另给 + * 写原因;发起了的链到那次运行、写运行现在的状态(目标是评测题集的链到那次评测;目标是数据源维护的没有运行,逐个列出建了的 + * 探查、剖析任务和没执行的步骤,见 MaintenanceFire);运行成功结束后服务端和同一任务上一次成功的运行比过的,另给 * 「与上一次对比」(基准不是紧挨着的上一次时写「与上一次成功的运行对比」);发出去的通知逐条写结果。refreshKey 变了 * (立即运行之后)从头重取。 * @@ -96,7 +98,7 @@ export function FireHistory({ schedule, refreshKey, latest, onNewest }: { body = (
{T.shown(items.length)} @@ -126,8 +128,14 @@ export function FireHistory({ schedule, refreshKey, latest, onNewest }: { ) } -function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: number }) { +function FireRow({ fire: f, schedule, now }: { fire: ScheduleFire; schedule: Schedule; now: number }) { const at = parseServerTime(f.fire_at) + const tz = schedule.timezone + const maintenance = targetKindOf(schedule) === 'source_maintenance' + const jobs = maintenance ? f.inputs?.jobs ?? [] : [] + const skipped = maintenance ? f.inputs?.skipped ?? [] : [] + // 维护有没执行的步骤时,服务端记的原因就是这几步的原因连成一句:逐步列出来,不再重复写一遍 + const reason = skipped.length ? null : f.reason return (
  • @@ -150,11 +158,21 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu {T.evalFire(f.eval_run_id.slice(0, 6))} + ) : maintenance ? ( + // 目标是数据源维护:建的是任务、不是运行。建了的在下面逐个列出;没建任务照实说(刚触发时任务还在建) + !jobs.length && ( + + {f.status === 'started' ? T.maintenanceLaunching : T.maintenanceNoJobs} + + ) ) : {T.noRun}}
    - {f.reason && ( + {maintenance && (jobs.length > 0 || skipped.length > 0) && ( + + )} + {reason && (
    - {T.reason}:{f.reason} + {T.reason}:{reason}
    )} {f.notified?.length > 0 && ( @@ -172,6 +190,41 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu ) } +/** + * 数据源维护的一次触发建了什么(触发记录的 inputs,和数据源页「定时维护」的「最近一次」同一份、同一个写法):每个探查、剖析 + * 任务一行,链到能看它的地方——探查到数据源页那张卡片(探查的进度和结果都在卡片上),剖析到数据目录里那个任务的进度和结果; + * 没执行的步骤逐条写原因 + */ +function MaintenanceFire({ sourceId, jobs, skipped }: { + sourceId: string + jobs: NonNullable['jobs']> + skipped: NonNullable['skipped']> +}) { + return ( +
    + {jobs.length > 0 && ( +
    + {T.maintenanceJobs} + {jobs.map((j) => ( + + {T.maintenanceJob(maintenanceStepText(j.step), T.maintenanceJobKind[j.kind] ?? j.kind, j.id, + j.tables ?? null)} + + + ))} +
    + )} + {skipped.map((k) => ( +
    + {T.maintenanceSkipped(maintenanceStepText(k.step), k.reason)} +
    + ))} +
    + ) +} + /** * 「与上一次对比」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务上一次成功的运行——和运行详情 * 「更多」里同名入口的默认基准是同一个挑法(服务端 api/compare.py 的 pick_base),两处进去比的是同一对运行。旁边写有 diff --git a/frontend/src/pages/schedules/ScheduleDetail.tsx b/frontend/src/pages/schedules/ScheduleDetail.tsx index fc249129..2807ab5d 100644 --- a/frontend/src/pages/schedules/ScheduleDetail.tsx +++ b/frontend/src/pages/schedules/ScheduleDetail.tsx @@ -15,7 +15,7 @@ import { EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled, targetKindOf, targetText, } from './parts' -import { CREATABLE_TARGETS, graceLabel } from './model' +import { CREATABLE_TARGETS, graceLabel, sourceCardPath } from './model' import { ScheduleEditor } from './ScheduleEditor' import { TargetEditor } from './TargetEditor' @@ -312,8 +312,9 @@ export function ScheduleDetailPage({ id }: { id: string }) { } /** - * 数据源维护的定时任务的设置:只读地列出名称、目标、频率、时区、补发宽限,链到数据源页(维护要做哪几步、频率和时段在数据源页的 - * 「定时维护」里改,next-round 03 B4)。页头的启用开关、立即运行、删除和触发历史照常可用 + * 数据源维护的定时任务的设置:只读地列出名称、目标、频率、时区、补发宽限,链到数据源页上这个数据源的卡片(model.sourceCardPath: + * 数据源页认 #source-、滚到那张卡片;维护要做哪几步、频率和时段在卡片的「定时维护」里改,next-round 03 B4)。页头的启用开关、 + * 立即运行、删除和触发历史照常可用 */ function TargetSettings({ schedule }: { schedule: Schedule }) { const kind = targetKindOf(schedule) @@ -327,7 +328,7 @@ function TargetSettings({ schedule }: { schedule: Schedule }) { return (

    {T.maintenanceNote}

    - {T.toDataSource} diff --git a/frontend/src/pages/schedules/model.ts b/frontend/src/pages/schedules/model.ts index e376cce7..6d06a478 100644 --- a/frontend/src/pages/schedules/model.ts +++ b/frontend/src/pages/schedules/model.ts @@ -5,8 +5,8 @@ */ import { SCHEDULE_TEXT as T } from '../../lib/terms' import type { - Schedule, ScheduleCreate, ScheduleEvent, SchedulePatch, ScheduleRunClass, ScheduleTargetCreate, ScheduleTargetKind, - ScheduleWebhookFormat, ScheduleWebhookIn, ScheduleWebhookTest, Workflow, WorkflowVersion, + Schedule, ScheduleCreate, ScheduleEvent, ScheduleMaintenanceInputs, SchedulePatch, ScheduleRunClass, ScheduleTargetCreate, + ScheduleTargetKind, ScheduleWebhookFormat, ScheduleWebhookIn, ScheduleWebhookTest, Workflow, WorkflowVersion, } from '../../types' export * from '../../lib/freq' import { DEFAULT_FREQ, DEFAULT_GRACE_S, browserTimezone, cronOf, freqOf, freqProblem, type Freq } from '../../lib/freq' @@ -235,6 +235,41 @@ export function targetProblems(f: TargetForm): Partial,列表载入后滚到那张卡片(目录页后台任务条上探查的「查看」同一个 + * 约定)。维护的设置(做哪几步、频率)在卡片的「定时维护」里,探查的结果也在卡片上 + */ +export function sourceCardPath(sourceId: string): string { + return `/data/databases#source-${encodeURIComponent(sourceId)}` +} + +/** 数据目录里这个剖析任务的进度和结果:目录页认 ?pjob=<任务 id>,打开那个任务的进度(数据源页「定时维护」的「最近一次」同一个约定) */ +export function profileJobPath(sourceId: string, jobId: string): string { + return `/data/catalog/${encodeURIComponent(sourceId)}?pjob=${encodeURIComponent(jobId)}` +} + +/** 维护建的一个任务链到哪里:探查到数据源卡片,剖析到目录里那个任务的进度 */ +export function maintenanceJobPath(sourceId: string, job: { kind: 'introspect' | 'profile'; id: string }): string { + return job.kind === 'profile' ? profileJobPath(sourceId, job.id) : sourceCardPath(sourceId) +} + +/** 维护的一个步骤(或剖析、观察合成一个任务时的 a+b)照服务端的叫法写;认不出的照原样 */ +export function maintenanceStepText(step: string): string { + return step.split('+').map((x) => (T.maintenanceStep as Record)[x] ?? x).join('、') +} + +/** 立即运行一次维护之后 toast 的详情:建了哪几个任务、哪几步没执行(逐行,同触发历史里的写法) */ +export function maintenanceSummary(inputs: ScheduleMaintenanceInputs | null | undefined): string { + const jobs = (inputs?.jobs ?? []).map((j) => T.maintenanceJob(maintenanceStepText(j.step), T.maintenanceJobKind[j.kind] ?? j.kind, + j.id, j.tables ?? null)) + const skipped = (inputs?.skipped ?? []).map((k) => T.maintenanceSkipped(maintenanceStepText(k.step), k.reason)) + return [...jobs, ...skipped].join('\n') +} + /** * 服务端 422 指的那一项在表单里的值,拿来比:人改过那一项就不再摆着那句说明(改回原值时说明又出来)。 * 运行类别、输入连带工作流一起算:换了工作流,「没发布」「字段对不上」也就不一定成立了。认不出的项返回 null diff --git a/frontend/src/pages/schedules/parts.tsx b/frontend/src/pages/schedules/parts.tsx index ab7d4cc8..7b6e48a4 100644 --- a/frontend/src/pages/schedules/parts.tsx +++ b/frontend/src/pages/schedules/parts.tsx @@ -7,7 +7,7 @@ import { StatusPill, confirmDialog, toast } from '../../components/ui' import { formatDateTime, parseServerTime } from '../../lib/format' import { SCHEDULE_TEXT as T } from '../../lib/terms' import type { Schedule, ScheduleFire, ScheduleFireStatus, ScheduleRunResult } from '../../types' -import { browserTimezone, formatUntil, formatZoned } from './model' +import { browserTimezone, formatUntil, formatZoned, maintenanceSummary } from './model' // ------------------------------------------------------------------------- // 启用开关 @@ -97,7 +97,8 @@ export function FireStatusChip({ fire }: { fire: ScheduleFire }) { return ( void): Promise { @@ -184,6 +186,12 @@ export async function runNow(s: Schedule, navigate: (to: string) => void): Promi } else if (r.fire?.status === 'started' && r.fire.eval_run_id) { // 目标是评测题集:发起的是一次评测,没有一次运行可看 toast.ok(T.evalStarted) + } else if (r.fire && targetKindOf(s) === 'source_maintenance') { + // 目标是数据源维护:建的是探查、剖析任务(在触发历史里逐个链得到),跳过、没开始的照服务端的原因说 + const detail = maintenanceSummary(r.fire.inputs) + if (r.fire.status === 'started') toast.ok(T.maintenanceStarted, detail ? { detail } : undefined) + else if (r.fire.status === 'skipped_overlap') toast.warn(T.maintenanceBusy, r.fire.reason ? { detail: r.fire.reason } : undefined) + else toast.error(T.maintenanceFailed(r.fire.reason ?? null)) } 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) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 49163c1d..264b5c81 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3867,6 +3867,20 @@ export interface ScheduleFireCompare { } /** 一次触发(触发历史的一行) */ +/** 数据源定时维护的步骤(app/data/source_maintenance.STEPS) */ +export type ScheduleMaintenanceStep = 'introspect' | 'profile_needed' | 'observe' + +/** + * 数据源维护的一次触发建了什么(app/data/source_maintenance 记在触发上的 inputs):要做的步骤、建了的探查 / 剖析任务(step 是 + * 这个任务做的那几步,剖析、观察合成一个任务时写成 profile_needed+observe)、没执行的步骤和原因。和数据源页「定时维护」的 + * 「最近一次」读的是同一份 + */ +export interface ScheduleMaintenanceInputs { + steps?: ScheduleMaintenanceStep[] + jobs?: { step: string; kind: 'introspect' | 'profile'; id: string; tables?: number; scope?: string }[] + skipped?: { step: ScheduleMaintenanceStep; reason: string }[] +} + export interface ScheduleFire { id: string /** 计划触发的时间;手动运行是按下的那一刻 */ @@ -3880,6 +3894,8 @@ export interface ScheduleFire { reason: string | null /** 目标是评测题集时这一次触发建的那次评测(没有 run_id);老服务端不给 */ eval_run_id?: string | null + /** 目标是数据源维护时这一次建了哪些任务、哪几步没执行和原因(触发记录的 inputs);别的目标为 null,老服务端不给 */ + inputs?: ScheduleMaintenanceInputs | null notified: ScheduleFireNotice[] /** * 和同一定时任务上一次成功的运行的对比。运行没有成功结束、没有可比的、还没算、比不了、任何一边又在执行(不在终态) diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index 3a5d3e34..8af36ea7 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -226,6 +226,22 @@ await section('纯函数:非工作流目标(评测题集)的表单 ⇄ 请 JSON.stringify(probs)) }) +await section('纯函数:数据源维护链到哪里、建了什么怎么写', async () => { + const m = await import(await load('/src/pages/schedules/model.ts')) + check('数据源卡片:数据源页认的 #source-(id 编码)', m.sourceCardPath('ds-shop') === '/data/databases#source-ds-shop' + && m.sourceCardPath('ds 1/2') === '/data/databases#source-ds%201%2F2') + check('剖析任务:目录页认的 ?pjob=', m.profileJobPath('ds-shop', 'pj-1') === '/data/catalog/ds-shop?pjob=pj-1') + check('探查任务链到卡片、剖析任务链到目录', m.maintenanceJobPath('ds-shop', { kind: 'introspect', id: 'ij-1' }) === '/data/databases#source-ds-shop' + && m.maintenanceJobPath('ds-shop', { kind: 'profile', id: 'pj-1' }) === '/data/catalog/ds-shop?pjob=pj-1') + check('步骤照服务端的叫法写,合成的一个任务写成「a、b」,认不出的照原样', + m.maintenanceStepText('introspect') === '探查结构' && m.maintenanceStepText('profile_needed+observe') === '剖析需要的表、取值观察' + && m.maintenanceStepText('later_step') === 'later_step') + const sum = m.maintenanceSummary({ jobs: [{ step: 'introspect', kind: 'introspect', id: 'ij1a2b3c4d', tables: 1200 }], + skipped: [{ step: 'observe', reason: '没有开启数据剖析' }] }) + check('立即运行的详情:一个任务一行、没执行的一行', sum === '探查结构:探查任务 #ij1a2b(1,200 张表)\n取值观察没有执行:没有开启数据剖析', sum) + check('什么都没有:空串', m.maintenanceSummary(null) === '' && m.maintenanceSummary({}) === '') +}) + await section('纯函数:服务端会拒的组合、保存回来怎么接、测试结果', async () => { const m = await import(await load('/src/pages/schedules/model.ts')) const fields = [{ name: 'date', required: true }, { name: 'region', required: true, default: '华东' }, { name: 'note' }, @@ -513,6 +529,30 @@ const S_MAINT = { target_name: '门店库', workflow_name: '门店库', cron: '0 2 * * *', description_text: '每天 02:00(Asia/Shanghai)', last_fire: null, version: 5, } +/** + * 数据源维护的触发记录(触发记录的 inputs:建了哪些任务、哪几步没执行,app/data/source_maintenance):维护没有运行。 + * fm1 探查、剖析各建一个;fm2 剖析没开,只建了探查,另两步没执行(服务端记的原因就是这两步的原因连成一句);fm3 上一次的 + * 探查还没做完、跳过;fm4 数据源不在了、没有建任务 + */ +const MAINT_JOBS = [ + { step: 'introspect', kind: 'introspect', id: 'ij1a2b3c4d5e', tables: 12 }, + { step: 'profile_needed+observe', kind: 'profile', id: 'pj9f8e7d6c5b', scope: 'maintenance', tables: 3 }, +] +const STEPS3 = ['introspect', 'profile_needed', 'observe'] +const NOT_PROFILED = '这个数据源没有开启数据剖析' +function maintFires() { + const m = { run_id: null, run_status: null } + return [ + fire('fm1', { ...m, kind: 'manual', inputs: { steps: STEPS3, jobs: MAINT_JOBS, skipped: [] } }), + fire('fm2', { ...m, fire_at: iso(NOW - 2 * 24 * H), created_at: iso(NOW - 2 * 24 * H), + reason: `剖析需要的表没有执行:${NOT_PROFILED};取值观察没有执行:${NOT_PROFILED}`, + inputs: { steps: STEPS3, jobs: [MAINT_JOBS[0]], skipped: [{ step: 'profile_needed', reason: NOT_PROFILED }, { step: 'observe', reason: NOT_PROFILED }] } }), + fire('fm3', { ...m, status: 'skipped_overlap', fire_at: iso(NOW - 3 * 24 * H), created_at: iso(NOW - 3 * 24 * H), + reason: '数据源「门店库」上一次的维护(或别的探查、剖析)还没做完:正在探查结构,这次触发已跳过', inputs: { steps: STEPS3 } }), + fire('fm4', { ...m, status: 'failed_to_start', fire_at: iso(NOW - 4 * 24 * H), created_at: iso(NOW - 4 * 24 * H), + reason: '数据源不存在,可能已被删除', inputs: {} }), + ] +} /** 评测题集(新建评测题集的定时任务时选) */ const SUITES = [ { id: 'suite-retail', name: '零售样例题集', description: '虚构零售库上的几道问法', archived: false }, @@ -936,13 +976,62 @@ await section('目标种类:新建评测题集的定时、改名称和频率 const ro = page.locator('[data-schedule-target-settings="source_maintenance"]') await ro.waitFor() check('数据源维护的详情只读,写明在数据源页设置,链到数据源页', (await ro.innerText()).includes('在数据源页') - && (await ro.locator('[data-schedule-to-source]').getAttribute('href')) === '/data/databases?source=ds-shop' + && (await ro.locator('[data-schedule-to-source]').getAttribute('href')) === '/data/databases#source-ds-shop' && 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) check('没有页面报错、原生对话框', !errors.length, errors.join(' | ')) }) +await section('数据源维护的触发历史:列出建了的任务和没执行的步骤,不写「未发起运行」', async () => { + const state = freshState({ schedules: [structuredClone(S1), { ...structuredClone(S_MAINT), last_fire: maintFires()[0] }], fires: maintFires() }) + const { page, errors } = await open('/schedules/s-maint?tab=history', { state }) + await page.locator('[data-fire-id="fm4"]').waitFor() + const row = (id) => page.locator(`[data-fire-id="${id}"]`) + const all = await page.locator('[data-fire-list]').innerText() + check('没有一行写「未发起运行」(维护本来就不发起运行)', !all.includes('未发起运行'), all.replace(/\s+/g, ' ').slice(0, 200)) + const jobs = row('fm1').locator('[data-fire-job]') + check('建了两个任务就列两个:步骤、任务种类、编号、表数', await jobs.count() === 2 + && (await jobs.nth(0).innerText()).includes('探查结构:探查任务 #ij1a2b(12 张表)') + && (await jobs.nth(1).innerText()).includes('剖析需要的表、取值观察:剖析任务 #pj9f8e(3 张表)'), + (await jobs.allInnerTexts()).join(' | ')) + check('探查任务链到数据源页那张卡片(#source-,同数据源页认的约定)', + await row('fm1').locator('[data-fire-job-kind="introspect"]').getAttribute('href') === '/data/databases#source-ds-shop') + check('剖析任务链到数据目录里那个任务的进度(?pjob=,同「定时维护」的「最近一次」)', + await row('fm1').locator('[data-fire-job-kind="profile"]').getAttribute('href') === '/data/catalog/ds-shop?pjob=pj9f8e7d6c5b') + check('建了任务的那一行不另写「没有建任务」', await row('fm1').locator('[data-fire-maintenance-none]').count() === 0) + const skipped = row('fm2').locator('[data-fire-skipped]') + check('没执行的步骤逐条写原因', await skipped.count() === 2 + && (await skipped.nth(0).innerText()).includes(`剖析需要的表没有执行:${NOT_PROFILED}`) + && (await skipped.nth(1).innerText()).includes(`取值观察没有执行:${NOT_PROFILED}`), (await skipped.allInnerTexts()).join(' | ')) + check('……服务端连成一句的原因不再重复写一遍', !(await row('fm2').innerText()).includes('原因:')) + check('只建了探查的那一次照样列出探查任务', await row('fm2').locator('[data-fire-job]').count() === 1) + check('跳过的那一次:写「没有建任务」和原因', (await row('fm3').locator('[data-fire-maintenance-none]').innerText()).includes('没有建任务') + && (await row('fm3').innerText()).includes('还没做完:正在探查结构')) + check('数据源不在了的那一次:写「没有建任务」和原因', (await row('fm4').locator('[data-fire-maintenance-none]').innerText()).includes('没有建任务') + && (await row('fm4').innerText()).includes('数据源不存在')) + await shot(page, 'maintenance-history') + + // 立即运行:建的是任务,说开始维护、详情写建了什么;不说「未能发起」 + state.override = (method, path) => (method === 'POST' && path === '/schedules/s-maint/run' + ? { json: { fire: fire('fm-now', { kind: 'manual', run_id: null, run_status: null, inputs: { steps: STEPS3, jobs: MAINT_JOBS, skipped: [] } }), run_id: null } } + : null) + await page.locator('[data-schedules-page] [data-schedule-run]').click() + const started = page.locator('[data-toast]', { hasText: '已开始维护' }) + await started.first().waitFor() + const detail = (await started.first().locator('details').textContent().catch(() => '')) ?? '' + check('立即运行一次维护:说已开始维护,详情写建了哪几个任务', detail.includes('探查结构:探查任务 #ij1a2b') && detail.includes('剖析任务 #pj9f8e'), detail) + check('……不说「未能发起」', !(await page.locator('[data-toast]').allInnerTexts()).some((t) => t.includes('未能发起'))) + state.override = (method, path) => (method === 'POST' && path === '/schedules/s-maint/run' + ? { json: { fire: fire('fm-busy', { kind: 'manual', status: 'skipped_overlap', run_id: null, run_status: null, + reason: '数据源「门店库」上一次的维护(或别的探查、剖析)还没做完:正在剖析,这次触发已跳过', inputs: { steps: STEPS3 } }), run_id: null } } + : null) + await page.locator('[data-schedules-page] [data-schedule-run]').click() + const busy = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((t) => t.includes('上一次维护')) ?? null) + check('上一次维护还没做完:照实说跳过了,不说「上一次运行」', !!busy && !busy.includes('上一次运行'), busy ?? '') + check('没有页面报错、原生对话框', !errors.length, errors.join(' | ')) +}) + await section('列表:空态与老服务端', async () => { const { page, errors } = await open('/schedules', { state: freshState({ schedules: [] }) }) await page.locator('[data-schedules-empty]').waitFor()