diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 89763c3a..e15408e5 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2891,6 +2891,8 @@ export const PROFILE_TEXT = { // 停止时正在执行的那条查询由数据库按单条时限截止,不是立刻停 stopping: '正在停止:等正在执行的那条查询结束(不超过单条查询时限),已做完的项照常写进目录…', progress: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 张表`, + /** 结束以后:做完了几张(停止、失败、跳过的不算) */ + progressDone: (ok: number, total: number) => `完成 ${formatNumber(ok)} / ${formatNumber(total)} 张表`, queriesSent: (n: number) => `已发 ${formatNumber(n)} 条查询`, queriesBad: (timeouts: number, errors: number) => [ timeouts ? `超时 ${formatNumber(timeouts)} 条` : '', errors ? `失败 ${formatNumber(errors)} 条` : '', diff --git a/frontend/src/pages/catalog/ProfileDialog.tsx b/frontend/src/pages/catalog/ProfileDialog.tsx index c3ad94e1..1b7ee42e 100644 --- a/frontend/src/pages/catalog/ProfileDialog.tsx +++ b/frontend/src/pages/catalog/ProfileDialog.tsx @@ -125,7 +125,10 @@ export function ProfileDialog({ const tooMany = !!picked.body.tables && picked.tables.length > PROFILE_MAX_TABLES const [mode, setMode] = useState<'setup' | 'job' | 'blocked'>(initial === 'job' && job ? 'job' : 'setup') - const [blocked, setBlocked] = useState<{ kind: ProfileBlock; message: string; active: CatalogProfileJob | null; body: ProfileStartBody } | null>(null) + /** 开始不了(409):哪种情况、服务端原话、正忙时进行中的那一个、当时的范围和剖析设置(就地开启后据此回到选范围) */ + const [blocked, setBlocked] = useState<{ + kind: ProfileBlock; message: string; active: CatalogProfileJob | null; body: ProfileStartBody; settings: CatalogProfileSettings + } | null>(null) const [starting, setStarting] = useState(false) const [startError, setStartError] = useState(null) /** 是 409(正忙)转过来显示的那一个 */ @@ -160,7 +163,7 @@ export function ProfileDialog({ if (e instanceof ApiError && e.status === 409) { const kind = profileBlockOf(e) const active = isProfileBusy(e) ? await api.dataCatalog.profileJobs.active(sourceId).then((r) => r.job, () => null) : null - setBlocked({ kind, message: e.message, active, body }) + setBlocked({ kind, message: e.message, active, body, settings }) setMode('blocked') } else { setStartError(PT.startFailed(errorMessage(e))) @@ -227,13 +230,13 @@ export function ProfileDialog({ setMode('job') } - // 因为没开启被拒的:就地开了(设置存好、这里拿到的设置变成开启)就回到选范围,可以直接开始 + // 因为没开启被拒的:就地开了(被拒之后设置存过、这里拿到的是新的一份,并且开着)就回到选范围,可以直接开始 useEffect(() => { - if (mode === 'blocked' && blocked?.kind === 'disabled' && settings.enabled) { + if (mode === 'blocked' && blocked?.kind === 'disabled' && settings !== blocked.settings && settings.enabled) { setBlocked(null) setMode('setup') } - }, [mode, blocked, settings.enabled]) + }, [mode, blocked, settings]) const phase: 'setup' | 'blocked' | 'running' | 'done' = inSetup ? 'setup' : mode === 'blocked' ? 'blocked' : isJobActive(job) ? 'running' : 'done' const canStart = settings.enabled && picked.tables.length > 0 && !tooMany && !starting @@ -441,10 +444,13 @@ function JobView({ sourceId, job, running, stopping, busy, filled, returnTo, onS const started = job.started_at ? Date.parse(job.started_at) : NaN const ended = job.finished_at ? Date.parse(job.finished_at) : NaN const elapsedMs = Number.isFinite(started) ? (running ? Math.max(now, Date.now()) : Number.isFinite(ended) ? ended : started) - started : null - const pct = job.total ? (job.done / job.total) * 100 : 0 + // 进行中按已结束的表数走(真进度);结束以后写做完了几张(停止、失败、跳过的不算「完成」),同起草 + const ok = job.counts.ok ?? job.tasks.filter((t) => t.status === 'ok').length + const value = running ? job.done : ok + const pct = job.total ? (value / job.total) * 100 : 0 const bad = job.tasks.some((t) => PROFILE_RETRYABLE.has(t.status)) const tone = job.status === 'failed' ? 'var(--err)' : running ? 'var(--accent)' : bad ? 'var(--warn)' : 'var(--st-done)' - const progress = PT.progress(job.done, job.total) + const progress = running ? PT.progress(job.done, job.total) : PT.progressDone(ok, job.total) const badQueries = PT.queriesBad(job.timeouts, job.errors) const notice = running ? job.notice?.trim() ?? '' : '' const changed = job.added + job.updated + job.removed @@ -480,7 +486,7 @@ function JobView({ sourceId, job, running, stopping, busy, filled, returnTo, onS
+ aria-valuemin={0} aria-valuemax={job.total} aria-valuenow={value}>
{queued && job.waiting_for === 'draft' && ( @@ -595,7 +601,8 @@ function TaskItem({ sourceId, job, task: t, open, onToggle, filled, focusAfter, const size = t.status !== 'skipped' ? sizeText(t.row_estimate) : null const usedUp = t.queries > 0 && t.queries >= job.settings.table_max_queries const note = taskNote(t, job.error) - const started = t.status !== 'pending' + // 没开始就停下的表(停止、止损、服务重启时还在排队)不写各类检查的 0/N:它一项都没做 + const started = !!t.started_at || t.queries > 0 const panelId = `catalog-profile-task-${t.seq}` return (
  • @@ -688,13 +695,15 @@ function TaskDetailLoader({ sourceId, jobId, task: t, filled, focusAfter, onOpen * mode detail:表详情里,写行数和来源、日期范围、发现、跳过的项、发过的查询(检查清单在任务里看)。 * onFill 不给时码值不带「填写含义」 */ -export function ProfileTaskBody({ detail: d, mode, filled = {}, focusAfter = null, onOpenTable, onFill }: { +export function ProfileTaskBody({ detail: d, mode, filled = {}, focusAfter = null, onOpenTable, onFill, canFill }: { detail: CatalogProfileTaskDetail mode: 'job' | 'detail' filled?: Record focusAfter?: string | null onOpenTable?: (table: string) => void onFill?: (column: string) => void + /** 这一列能不能填含义(码值候选还在目录里);不给就是都能填 */ + canFill?: (column: string) => boolean }) { const relations = d.findings.filter((f): f is CatalogProfileRelationFinding => f.kind === 'relation') const codes = d.findings.filter((f): f is CatalogProfileCodesFinding => f.kind === 'codes') @@ -746,7 +755,7 @@ export function ProfileTaskBody({ detail: d, mode, filled = {}, focusAfter = nul {codes.map((f) => ( onFill(f.column) : undefined} /> + onFill={onFill && (!canFill || canFill(f.column)) ? () => onFill(f.column) : undefined} /> ))} )} diff --git a/frontend/src/pages/catalog/TableDetail.tsx b/frontend/src/pages/catalog/TableDetail.tsx index ebfbc768..e6f0d69a 100644 --- a/frontend/src/pages/catalog/TableDetail.tsx +++ b/frontend/src/pages/catalog/TableDetail.tsx @@ -1191,7 +1191,8 @@ function ProfileSection({ table, detail, row, enabled, profile, onFillCodes }: { {PT.findings(last.summary, true) && (

    {PT.findings(last.summary, true)}

    )} - + {/* 码值候选还在目录里(没被改掉、删掉)才给「填写含义」:码值弹窗改的是目录里的那一项 */} + !!detail.notes.columns?.[col]?.codes} /> )}
  • diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 81505e44..48befcc9 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -82,6 +82,7 @@ const ORDER = [ 'versions', // 上传表格的版本:回滚、清单、移除一期、清除原件 'catalog', // 数据目录:表清单、起草、单项审阅、批量确认、编辑即确认、409 'catalog-loop', // 数据目录闭环:表助手、审阅队列、导出导入、防错事实的说明 + 'catalog-profile', // 数据剖析:逐表的后台任务、表清单的剖析一列、表详情的一节、剖析设置 // 复核套件与导出 'schedules', // 定时任务 'compare', // 运行对比 @@ -109,7 +110,7 @@ const ORDER = [ const ESTIMATE = { challenge: 30, stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87, - evidence: 91, versions: 60, catalog: 150, 'catalog-loop': 60, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, + evidence: 91, versions: 60, catalog: 150, 'catalog-loop': 60, 'catalog-profile': 90, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, // 占位,换成真检查时按实测改 schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30, // 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据) diff --git a/scripts/check-catalog-profile.mjs b/scripts/check-catalog-profile.mjs new file mode 100644 index 00000000..3dadac4f --- /dev/null +++ b/scripts/check-catalog-profile.mjs @@ -0,0 +1,1439 @@ +// 数据剖析(数据目录页的「数据剖析」、表清单的「剖析」一列、表详情的「数据剖析」一节、数据源的剖析设置)的交互回归。 +// +// 剖析会对业务库发查询,是服务端逐表进行的后台任务(/catalog/profile-jobs)。这里查的是正常路径的截图看不出来的那些: +// 「开始前的范围和个数是不是按清单算的」「预算是不是按表写的、估了这次最多几条查询」「明说只读、一条一条发、可以停」 +// 「同源有起草任务时是不是写排队」「进行中每张表到哪了(各类检查几项、发了几条查询、正在做哪一项)」「停得下来、停下来 +// 写清做完的保留了什么」「展开一张表看得到检查清单和发过的查询(字面量换成了 ?)」「结束后能按有发现 / 有跳过 / 失败筛、 +// 只重试没做完的表」「止损、服务重启的原因照实写」「各种 409 各有下一步」「表清单的剖析一列和需要剖析的筛选」「表详情的一节 +// 和剖析这张表」「设置的两项新字段、被拒时报错落到那一项」「起草排在剖析后面时怎么说」。 +// +// 写法同 check-catalog:数据源列表、目录清单、单表目录、剖析任务、起草任务都用 page.route 伪造(虚构的网店业务库,表名 +// orders、customers、visits……都是通用的假名字),任务怎么往前走由检查自己推(settle、finish、stopJob),页面每 1.5 秒 +// 取一次,看到的就是推到的那一步。没配的写请求一律拦成 503,不写库、更不会发到业务库;最后核对真库里的数据源和目录 +// 数量没有变化。 +// 跑之前前后端都得起着(./scripts/dev.sh),默认连 5273 / 8000;对别的实例跑时带上地址: +// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-catalog-profile.mjs +// 只跑其中几节:CHECK_ONLY=设置,409 node scripts/check-catalog-profile.mjs(按节名包含匹配)。 +// 截图:CHECK_SHOTS=/某个目录 时把关键状态存下来;CHECK_THEME=light 换浅色跑一遍。 +import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api' +const SHOTS = process.env.CHECK_SHOTS ?? '' +const THEME = process.env.CHECK_THEME === 'light' ? 'light' : 'dark' +// 不用 playwright install:系统 Chrome 就够了 +const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' +/** 本机署名:写请求带的 X-Actor */ +const ACTOR = '检查脚本' +/** 夹具里另一个人建的剖析任务 */ +const OTHER = '检查脚本乙' + +let failed = 0 +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + if (!cond) failed++ +} + +try { + const r = await fetch(`${API}/datasources/kinds`) + if (!r.ok) throw new Error(`HTTP ${r.status}`) +} catch (e) { + console.error(`✗ 连不上后端(${API})——先起沙箱后端\n ${e.message}`) + process.exit(1) +} + +// --------------------------------------------------------------------------- +// 真库的数量:开头记一次,最后再数一次,必须一样(检查本身不许写库) +// --------------------------------------------------------------------------- +async function realCounts() { + const sources = await (await fetch(`${API}/datasources`)).json() + let entries = 0 + let versions = 0 + for (const s of sources) { + const r = await fetch(`${API}/datasources/${s.id}/catalog`) + if (!r.ok) continue + const d = await r.json() + for (const t of d.tables ?? []) { + if (t.version > 0) entries++ + versions += t.version + } + } + return { sources: sources.length, entries, versions } +} +const before = await realCounts() + +const browser = await chromium.launch({ executablePath: CHROME }) +const opened = new Set() + +/** + * 开一页:handlers 按「METHOD 路径正则」匹配(GET 也可以配),没配的 GET 放行到后端,没配的写请求一律 + * 拦成 503。所有原生对话框都算失败 + */ +async function open(path, { handlers = [], viewport = { width: 1440, height: 900 } } = {}) { + const ctx = await browser.newContext({ viewport, colorScheme: THEME, timezoneId: 'Asia/Shanghai' }) + opened.add(ctx) + ctx.setDefaultTimeout(6000) + ctx.setDefaultNavigationTimeout(30000) + await ctx.addInitScript(([t, actor]) => { + try { + localStorage.setItem('agentlab.theme', t) + localStorage.removeItem('agentlab.health') + localStorage.setItem('agentlab_actor', actor) + } catch { /* noop */ } + }, [THEME, ACTOR]) + const page = await ctx.newPage() + const sent = [] + const errors = [] + const natives = [] + page.on('pageerror', (e) => errors.push(e.message)) + page.on('dialog', (d) => { natives.push(`${d.type()}: ${d.message().slice(0, 40)}`); void d.dismiss() }) + await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => { + const r = route.request() + const url = new URL(r.url()) + const key = `${r.method()} ${decodeURIComponent(url.pathname.replace(/^\/api/, ''))}` + for (const [pattern, handle] of handlers) { + if (pattern.test(key)) { + let body = null + try { body = r.postDataJSON() } catch { body = r.postData() } + sent.push({ key, body, actor: r.headers()['x-actor'] ?? null }) + return handle(route, { body, url, key }) + } + } + if (r.method() === 'GET') return route.continue() + sent.push({ key, body: null, blocked: true }) + return route.fulfill({ status: 503, body: '' }) + }) + await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' }) + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), THEME) + await page.waitForTimeout(400) + // 关之前先撤掉拦截、忽略还在路上的那几条:满负荷并行时,取真实返回的拦截可能在页面关掉之后才回来 + return { page, sent, errors, natives, close: async () => { + opened.delete(ctx) + await page.unrouteAll({ behavior: 'ignoreErrors' }).catch(() => {}) + return ctx.close() + } } +} +const goto = async (page, path) => { + await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' }) + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), THEME) + await page.waitForTimeout(300) +} +const json = (data, status = 200) => (route) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) }) +const until = async (fn, ms = 6000, step = 120) => { + const end = Date.now() + ms + let v = await fn() + while (!v && Date.now() < end) { await new Promise((r) => setTimeout(r, step)); v = await fn() } + return v +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const shot = async (page, name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` }) } +const writes = (sent, re) => sent.filter((s) => re.test(s.key) && !s.key.startsWith('GET ')) +const count = (sent, re) => sent.filter((s) => re.test(s.key)).length +const lastBody = (sent, re) => sent.filter((s) => re.test(s.key)).at(-1)?.body ?? null + +const ONLY = process.env.CHECK_ONLY?.split(',').map((x) => x.trim()).filter(Boolean) +async function section(name, fn) { + if (ONLY?.length && !ONLY.some((k) => name.includes(k))) return + console.log(`\n=== ${name} ===`) + try { + await fn() + } catch (e) { + check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) + } finally { + for (const c of opened) await c.close().catch(() => {}) + opened.clear() + } +} + +// --------------------------------------------------------------------------- +// 夹具:虚构的网店业务库(订单、客户、访问记录、商品、退款、一张空的日志表、一个视图、一张没有可剖析项的配置表、一张只剩 +// 目录的旧表)、一个没开剖析的库(剖析设置里还留着旧的两个键)、一个导入表格的源。名字、数字全是假的 +// --------------------------------------------------------------------------- +const isoNow = () => new Date().toISOString() +const ago = (days, hours = 0) => new Date(Date.now() - days * 86_400_000 - hours * 3_600_000).toISOString() +const T0 = ago(3) +const clone = (x) => JSON.parse(JSON.stringify(x)) +const item = (value, source = 'llm', status = 'proposed') => ({ value, source, status, updated_at: T0 }) +const col = (name, type = 'INTEGER', extra = {}) => ({ name, type, pk: false, not_null: false, comment: null, ...extra }) +const pk = (name = 'id') => col(name, 'INTEGER', { pk: true, not_null: true }) + +const S1 = 'check-prof-shop' +const S2 = 'check-prof-off' +const S3 = 'check-prof-upload' +const NAME1 = 'zzprofshop' +const NAME2 = 'zzprofoff' +const NAME3 = 'zzprofupload' + +/** + * 一张表。plan:按当前目录和表结构这张表要做的检查(关系写「列 → 表.列」,码值、日期写列名),profile_checks 就是它的项数; + * newRelations:剖析之后目录里又出现了待核实的关系(profile_due_reason 为 new_relations) + */ +const table = (name, usage, columns, plan, extra = {}) => ({ + name, usage, columns, plan: { relation: [], codes: [], date: [], ...plan }, notes: extra.notes ?? null, version: extra.notes ? 2 : 0, + is_view: false, missing: false, newRelations: false, updated_at: extra.notes ? T0 : null, updated_by: extra.notes ? '审核甲' : null, ...extra, +}) +const checksOf = (t) => (t.missing || t.is_view ? 0 : t.plan.relation.length + t.plan.codes.length + t.plan.date.length) + +const ORDERS_PLAN = { + relation: ['customer_id → customers.id', 'product_id → products.id', 'store_id → stores.id', 'coupon_id → coupons.id'], + codes: ['status', 'channel', 'pay_type', 'region', 'level', 'source'], + date: ['created_at'], +} +function shopTables() { + return [ + table('orders', 40, [pk(), col('order_no', 'TEXT'), col('customer_id'), col('product_id'), col('store_id'), col('coupon_id'), col('status'), + col('channel', 'TEXT'), col('pay_type'), col('region', 'TEXT'), col('level'), col('source', 'TEXT'), col('created_at', 'TEXT'), col('amount', 'REAL')], + ORDERS_PLAN, { notes: { + label: item('订单'), + columns: { + status: { codes: item({ 1: '已下单', 2: '', 9: '' }, 'profile') }, + channel: { codes: item({ web: '', app: '' }, 'profile') }, + }, + } }), + table('customers', 30, [pk(), col('customer_no', 'TEXT'), col('level'), col('joined_at', 'TEXT')], + { relation: [], codes: ['level'], date: ['joined_at'] }), + table('visits', 22, [pk(), col('customer_id'), col('page', 'TEXT'), col('device'), col('visited_at', 'TEXT')], + { relation: ['customer_id → customers.id'], codes: ['device', 'page'], date: ['visited_at'] }), + table('products', 15, [pk(), col('category_id'), col('state'), col('listed_at', 'TEXT')], + { relation: ['category_id → categories.id'], codes: ['state'], date: ['listed_at'] }, { newRelations: true }), + table('refunds', 10, [pk(), col('order_id'), col('reason')], { relation: ['order_id → orders.id'], codes: ['reason'] }), + table('tmp_log', 0, [pk(), col('logged_at', 'TEXT')], { date: ['logged_at'] }), + table('order_summary', 5, [col('day', 'TEXT'), col('total', 'REAL')], {}, { is_view: true }), + table('settings_kv', 1, [col('k', 'TEXT'), col('v', 'TEXT')], {}), + table('legacy_coupons', 0, null, {}, { missing: true, notes: { label: item('旧版优惠券', 'human', 'confirmed') } }), + ] +} + +const blank = { + kind: 'sqlite', host: null, port: null, username: null, readonly: true, description: '', enabled: true, + password_masked: '', has_password: false, cached_schema: '', schema_error: '', available_schemas: [], + last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null, current_snapshot: null, +} +const manual = (id, name, count, options) => ({ + ...blank, id, name, origin: 'manual', database: `/tmp/check/${name}.db`, table_count: count, tools: [`db_query__${name}`], + schema_synced_at: T0, options, +}) +/** 开着剖析的库:每张表最多 40 条、60 秒,单条 10 秒(缺省) */ +const PROFILE_ON = { enabled: true } +const sources = () => [ + manual(S1, NAME1, 8, { catalog_profile: PROFILE_ON }), + // 没开剖析的库:剖析设置里还留着旧的两个键(整次的查询次数上限、总时长上限),界面不认、保存时不带 + manual(S2, NAME2, 2, { catalog_profile: { enabled: false, max_queries: 60, max_total_s: 120 } }), + { + ...blank, id: S3, name: NAME3, origin: 'upload', table_count: 1, tools: [`db_query__${NAME3}`], schema_synced_at: T0, + database: `/tmp/check/uploads/${S3}.db`, import_mode: 'simple', current_recipe: null, open_staging: null, options: { mask_columns: ['联系电话'] }, + current_snapshot: { id: 'snap-check-prof', created_at: T0, file_name: '客流日报.xlsx', raw_state: 'kept' }, + }, +] +const NAMES = [NAME1, NAME2, NAME3] + +const DEFAULTS = { enabled: false, table_max_queries: 40, table_max_s: 60, query_timeout_s: 10, sample_size: 2000, max_scan_rows: 100000 } +const settingsOf = (state, src) => { + const raw = state.sources.find((s) => s.id === src)?.options?.catalog_profile ?? {} + const out = { ...DEFAULTS, enabled: raw.enabled === true } + for (const k of Object.keys(DEFAULTS)) if (k !== 'enabled' && typeof raw[k] === 'number') out[k] = raw[k] + return out +} + +// --------------------------------------------------------------------------- +// 假服务端的剖析任务:照接口约定 C 的形状,逐表行由检查推进的状态算出来 +// --------------------------------------------------------------------------- +const JOB_ACTIVE = new Set(['queued', 'running']) +const TASK_END = new Set(['ok', 'partial', 'failed', 'skipped', 'cancelled']) +const RETRYABLE = new Set(['failed', 'partial', 'cancelled']) +const UNFINISHED = new Set(['budget', 'deadline', 'failed', 'timeout', 'error', 'rejected', 'slow', 'cancelled']) +const KINDS = ['relation', 'codes', 'date'] + +function newProfileJob(state, src, { tables, scope, retry_of = null, created_by = ACTOR, at = null }) { + const id = `check-pjob-${++state.pjobSeq}` + const tasks = tables.map((name, i) => { + const t = state.tables[src].find((x) => x.name === name) + const why = !t || t.missing ? `表结构里没有 ${name},请先重新探查结构` : t.is_view ? '视图不剖析' : !checksOf(t) ? '没有可剖析的项' : null + const checks = why ? [] : KINDS.flatMap((kind) => t.plan[kind].map((target) => ({ + kind, target, path: kind === 'relation' ? `relations.${target}` : kind === 'codes' ? `columns.${target}.codes` : 'business_date', + state: 'pending', reason: null, detail: null, queries: 0, + }))) + return { + seq: i + 1, table_name: name, status: why ? 'skipped' : 'pending', checks, current: null, queries: 0, elapsed_ms: 0, row_estimate: null, + findings: [], skipped: [], date_ranges: [], query_log: [], added: 0, updated: 0, removed: 0, version: t?.version ?? 0, error: why, + started_at: null, finished_at: why ? (at ?? isoNow()) : null, + } + }) + const job = { + id, source_id: src, status: 'queued', scope, settings: settingsOf(state, src), created_at: at ?? isoNow(), started_at: null, finished_at: null, + created_by, retry_of, waiting_for: activeDraft(state, src) ? 'draft' : null, stop_reason: null, notice: null, error: null, tasks, + } + state.pjobs.push(job) + return job +} + +const zero = () => ({ relations_checked: 0, relations_verified: 0, codes: 0, codes_pending: 0, business_dates: 0 }) +function summaryOf(findings) { + const s = zero() + for (const f of findings) { + if (f.kind === 'relation') { s.relations_checked++; if (f.status === 'verified') s.relations_verified++ } + if (f.kind === 'codes') { s.codes++; s.codes_pending += f.pending ?? 0 } + if (f.kind === 'business_date') s.business_dates++ + } + return s +} +function taskRow(t) { + const byKind = Object.fromEntries(KINDS.map((k) => { + const list = t.checks.filter((c) => c.kind === k) + return [k, [list.filter((c) => c.state === 'done' || c.state === 'skipped').length, list.length]] + })) + const { checks, findings, skipped, date_ranges, query_log, ...rest } = t + return { + ...rest, checks_total: checks.length, checks_done: checks.filter((c) => c.state === 'done' || c.state === 'skipped').length, + checks_by_kind: byKind, summary: summaryOf(findings), skipped_count: skipped.length, + unfinished: skipped.filter((s) => UNFINISHED.has(s.reason)).length, + } +} +const taskDetail = (job, t) => clone({ ...taskRow(t), job_id: job.id, job_status: job.status, created_by: job.created_by, checks: t.checks, + findings: t.findings, skipped: t.skipped, date_ranges: t.date_ranges, query_log: t.query_log }) +function jobOut(job) { + const tasks = job.tasks.map(taskRow) + const counts = { pending: 0, running: 0, ok: 0, partial: 0, failed: 0, skipped: 0, cancelled: 0 } + for (const t of tasks) counts[t.status]++ + const sum = (k) => tasks.reduce((n, t) => n + t[k], 0) + const summary = zero() + for (const t of tasks) for (const k of Object.keys(summary)) summary[k] += t.summary[k] + const log = job.tasks.flatMap((t) => t.query_log) + return clone({ + ...Object.fromEntries(Object.entries(job).filter(([k]) => k !== 'tasks' && k !== 'stopAsked')), + total: tasks.length, done: tasks.filter((t) => TASK_END.has(t.status)).length, counts, + queries: sum('queries'), timeouts: log.filter((q) => q.outcome === 'timeout').length, errors: log.filter((q) => q.outcome === 'error').length, + added: sum('added'), updated: sum('updated'), removed: sum('removed'), summary, tasks, + }) +} + +/** + * 推进一张表。o.status 默认 ok(每项都有结论);running 时 o.done 写各类做完了几项,下一项是「进行中」。 + * o.skip:[[类, 第几项, 原因, 整句]],跳过的项也记进 skipped。o.rest:结束时剩下没做的项一律记这个原因(停止、止损) + */ +function settle(state, job, name, o = {}) { + const task = job.tasks.find((t) => t.table_name === name) + if (job.status === 'queued') Object.assign(job, { status: 'running', started_at: isoNow(), waiting_for: null }) + const status = o.status ?? 'ok' + task.started_at ??= isoNow() + for (const c of task.checks) if (c.state === 'running') c.state = 'pending' + for (const k of KINDS) { + const list = task.checks.filter((c) => c.kind === k) + const n = status === 'ok' ? list.length : o.done?.[k] ?? list.filter((c) => c.state !== 'pending').length + list.forEach((c, i) => { + if (i < n && c.state === 'pending') Object.assign(c, { state: 'done', queries: k === 'relation' ? 3 : 1, detail: c.detail ?? `${c.target}:有结论` }) + }) + } + for (const [k, i, reason, detail] of o.skip ?? []) { + const c = task.checks.filter((x) => x.kind === k)[i] + Object.assign(c, { state: 'skipped', reason, detail }) + task.skipped.push({ kind: k, target: c.target, path: c.path, reason, detail }) + } + if (o.rest) { + for (const c of task.checks) { + if (c.state !== 'pending') continue + Object.assign(c, { state: 'skipped', reason: o.rest[0], detail: o.rest[1] }) + task.skipped.push({ kind: c.kind, target: c.target, path: c.path, reason: o.rest[0], detail: o.rest[1] }) + } + } + if (status === 'running') { const next = task.checks.find((c) => c.state === 'pending'); if (next) next.state = 'running' } + const { done: _d, skip: _s, rest: _r, status: _st, ...fields } = o + Object.assign(task, fields, { status }) + if (status !== 'running') task.current = null + if (TASK_END.has(status)) { + task.finished_at = isoNow() + const t = state.tables[job.source_id].find((x) => x.name === name) + if (t && (status === 'ok' || status === 'partial')) t.newRelations = false + if (t && task.added + task.updated > 0) { + t.version += 1 + t.updated_at = isoNow() + t.updated_by = ACTOR + t.notes ??= {} + task.version = t.version + } + } +} +/** 收尾:有失败、部分完成、停止的表就是 partial */ +function finish(job, status) { + const bad = job.tasks.some((t) => RETRYABLE.has(t.status)) + Object.assign(job, { status: status ?? (bad ? 'partial' : 'succeeded'), finished_at: isoNow(), notice: null }) + job.started_at ??= job.finished_at + delete job.stopAsked +} +/** 停下(使用者停止、止损、服务重启):正在做的表写入已做完的项、其余项记原因;没开始的表记已停止,原因同一句 */ +function stopJob(job, { status = 'cancelled', reason = 'cancelled', current = null, rest = null, error = null } = {}) { + for (const t of job.tasks) { + if (t.status === 'running') { + const left = t.checks.filter((c) => c.state === 'pending' || c.state === 'running') + for (const c of left) { + Object.assign(c, { state: 'skipped', reason, detail: current ?? '已停止,这一项没有查' }) + t.skipped.push({ kind: c.kind, target: c.target, path: c.path, reason, detail: c.detail }) + } + Object.assign(t, { status: 'cancelled', current: null, finished_at: isoNow(), error: rest ?? error }) + } else if (t.status === 'pending') { + Object.assign(t, { status: 'cancelled', finished_at: isoNow(), error: error ?? '已停止,这张表没有剖析' }) + } + } + if (error) job.error = error + finish(job, status) +} + +const activeProfile = (state, src) => state.pjobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status)) ?? null +const pjobOf = (state, key) => { + const [, , src, , , id] = key.split(' ')[1].split('/') + return state.pjobs.find((j) => j.id === id && j.source_id === src) ?? null +} +/** 一张表最近一次剖析(清单行上的 last_profile、单表目录的 last_profile) */ +function lastTaskOf(state, src, name) { + for (const job of [...state.pjobs].reverse()) { + if (job.source_id !== src) continue + const t = job.tasks.find((x) => x.table_name === name) + if (t) return { job, t } + } + return null +} +function dueOf(state, src, t) { + if (t.missing || t.is_view || !checksOf(t)) return null + const last = lastTaskOf(state, src, t.name) + if (!last) return 'never' + if (last.t.status === 'pending' || last.t.status === 'running') return null + if (RETRYABLE.has(last.t.status)) return 'unfinished' + return t.newRelations ? 'new_relations' : null +} +/** 范围 needed / all:服务端的口径(有待核实关系的在前,这里按使用次数,同清单顺序) */ +const scopeTables = (state, src, scope) => state.tables[src].filter((t) => !t.missing).sort((a, b) => b.usage - a.usage) + .filter((t) => (scope === 'all' ? checksOf(t) > 0 && !t.is_view : dueOf(state, src, t) != null)).map((t) => t.name) + +// --------------------------------------------------------------------------- +// 假服务端的起草任务:只做排队要用的部分(建、取、停),起草本身不推 +// --------------------------------------------------------------------------- +function newDraftJob(state, src, tables) { + const id = `check-djob-${++state.djobSeq}` + const job = { + id, source_id: src, status: 'queued', use_model: false, model: null, guidance: null, note: null, created_at: isoNow(), started_at: null, + finished_at: null, created_by: ACTOR, usage: { calls: 0, input_tokens: 0, output_tokens: 0 }, error: null, notice: null, + waiting_for: activeProfile(state, src) ? 'profile' : null, + tasks: tables.map((name) => ({ table_name: name, status: 'pending', columns: 3, parts_total: null, parts_done: 0, added: 0, updated: 0, removed: 0, + version: 0, model_items: null, error: null, model_error: null, started_at: null, finished_at: null })), + } + state.djobs.push(job) + return job +} +function draftOut(job) { + const counts = { pending: 0, running: 0, ok: 0, partial: 0, failed: 0, skipped: 0, cancelled: 0 } + for (const t of job.tasks) counts[t.status]++ + return clone({ ...job, total: job.tasks.length, done: job.tasks.filter((t) => TASK_END.has(t.status)).length, counts, added: 0, updated: 0, removed: 0 }) +} +const activeDraft = (state, src) => state.djobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status)) ?? null + +// --------------------------------------------------------------------------- +// 假服务端:清单、单表、剖析任务、起草任务、保存数据源 +// --------------------------------------------------------------------------- +const freshState = () => { + const state = { sources: sources(), tables: { [S1]: shopTables(), [S2]: [ + table('orders', 3, [pk(), col('status')], { codes: ['status'] }), + table('order_summary', 1, [col('day', 'TEXT')], {}, { is_view: true }), + ], [S3]: [table('日客流', 2, [col('统计日期', 'DATE'), col('入园人数')], { date: ['统计日期'] })] }, + pjobs: [], pjobSeq: 0, djobs: [], djobSeq: 0 } + // 三天前别人做的一次剖析(部分完成):客户、商品做完了,访问记录没做完,退款写入一直冲突 + const old = newProfileJob(state, S1, { tables: ['customers', 'visits', 'products', 'refunds'], scope: 'needed', created_by: OTHER, at: T0 }) + settle(state, old, 'customers', { + queries: 4, elapsed_ms: 2300, row_estimate: { rows: 12000, method: 'stats', at_least: null }, + findings: [{ kind: 'codes', path: 'columns.level.codes', column: 'level', values: [{ value: '1', rows: 9000 }, { value: '2', rows: 3000 }], rows: 12000, + status: 'proposed', pending: 2, summary: '2 个取值' }], + date_ranges: [{ column: 'joined_at', min: '2021-03-01 08:00:00', max: '2026-10-01 21:30:00' }], + skip: [], query_log: [ + { at: T0, kind: 'row_estimate', target: 'customers', sql: 'SELECT COUNT(*) FROM (SELECT 1 FROM customers LIMIT ?) t', ms: 12, rows: 1, outcome: 'ok', detail: null }, + { at: T0, kind: 'codes', target: 'level', sql: 'SELECT level, COUNT(*) FROM customers GROUP BY level ORDER BY COUNT(*) DESC LIMIT ?', ms: 40, rows: 2, outcome: 'ok', detail: null }, + { at: T0, kind: 'date', target: 'joined_at', sql: 'SELECT MIN(joined_at), MAX(joined_at) FROM customers', ms: 25, rows: 1, outcome: 'ok', detail: null }, + ], + }) + // 码值候选之外的一项按规则不查(遮罩),不算没做完 + old.tasks[0].skipped.push({ kind: 'codes', target: 'phone', path: 'columns.phone.codes', reason: 'masked', detail: 'customers.phone 在数据源设置中被遮罩,不取值' }) + settle(state, old, 'visits', { status: 'partial', done: { relation: 0, codes: 1, date: 1 }, queries: 40, elapsed_ms: 31000, + skip: [['relation', 0, 'budget', '这张表的查询次数用完(40 条),这一项没有查,保留原来的结论'], ['codes', 1, 'budget', '这张表的查询次数用完(40 条),这一项没有查,保留原来的结论']] }) + settle(state, old, 'products', { queries: 6, elapsed_ms: 3100 }) + settle(state, old, 'refunds', { status: 'failed', queries: 3, elapsed_ms: 900, error: '写入一直冲突:剖析期间这张表的数据目录被反复修改,结论没有写进去' }) + for (const t of old.tasks) { t.started_at = T0; t.finished_at = T0 } + Object.assign(old, { started_at: T0 }) + finish(old) + old.finished_at = T0 + // 那次剖析之后,目录里又出现了商品的待核实关系 + state.tables[S1].find((t) => t.name === 'products').newRelations = true + return state +} + +const parts = (key) => key.split(' ')[1].split('/') // ['', 'datasources', id, 'catalog', ...] +function rowOf(state, src, t) { + const last = lastTaskOf(state, src, t.name) + const lp = last ? (({ seq, status, started_at, finished_at, summary, skipped_count, unfinished, error, row_estimate }) => + ({ job_id: last.job.id, seq, status, started_at, finished_at, summary, skipped_count, unfinished, error, row_estimate }))(taskRow(last.t)) : null + const counts = { proposed: 0, verified: 0, confirmed: 0, rejected: 0 } + return { + table_name: t.name, qualified: t.name, is_view: t.is_view, in_schema: !t.missing, label: t.notes?.label?.value ?? null, + label_status: t.notes?.label?.status ?? null, kind: null, counts: t.notes ? { ...counts, proposed: 1 } : counts, relations: 0, usage: t.usage, + version: t.version, updated_at: t.updated_at, updated_by: t.updated_by, last_draft: null, + last_profile: lp, profile_due: dueOf(state, src, t) != null, profile_due_reason: dueOf(state, src, t), profile_checks: checksOf(t), + } +} +function listOf(state, src) { + const all = state.tables[src] + const rows = all.filter((t) => !t.missing).sort((a, b) => b.usage - a.usage).map((t) => rowOf(state, src, t)) + rows.push(...all.filter((t) => t.missing).map((t) => rowOf(state, src, t))) + const pa = activeProfile(state, src) + const da = activeDraft(state, src) + return { + tables: rows, system_notes: src === S3, schema_note: null, schema_truncated: false, schema_total: rows.length, guidance: null, + active_job: da ? (({ id, status, total, done, waiting_for }) => ({ id, status, total, done, waiting_for }))(draftOut(da)) : null, + active_profile_job: pa ? (({ id, status, total, done, waiting_for }) => ({ id, status, total, done, waiting_for }))(jobOut(pa)) : null, + } +} +function detailOf(state, src, t) { + const last = lastTaskOf(state, src, t.name) + return { + table_name: t.name, in_schema: !t.missing, notes: t.notes ?? {}, version: t.version, updated_at: t.updated_at, updated_by: t.updated_by, + structure: t.missing ? null : { qualified: t.name, is_view: t.is_view, comment: null, columns: t.columns, + primary_key: t.columns.filter((c) => c.pk).map((c) => c.name), foreign_keys: [], unique: [] }, + system_notes: src === S3, usage: t.usage, last_profile: last ? taskDetail(last.job, last.t) : null, + } +} +const busyOf = (state, job) => { + const out = jobOut(job) + return { detail: `数据源「${state.sources.find((s) => s.id === job.source_id)?.name}」正在进行数据剖析(已完成 ${out.done} / ${out.total} 张表),` + + '请等这次剖析结束(或先停止它)再开始新的剖析', code: 'profile_busy' } +} +const NO_PJOB = json({ detail: '剖析任务不存在,可能属于别的数据源,或者已随数据源删除' }, 404) + +function handlers(state, replies = {}) { + const reply = (k, fallback) => (route, ctx) => { + const q = replies[k] + const next = Array.isArray(q) ? q.shift() : q + return (next ?? fallback)(route, ctx) + } + const P = 'check-prof-[a-z]+' + return [ + [/^GET \/datasources$/, (route) => json(state.sources)(route)], + [new RegExp(`^GET /datasources/${P}/schema$`), (route, { key }) => + json({ tables: state.tables[parts(key)[2]].filter((t) => !t.missing).map((t) => t.name), summary: '', synced_at: T0 })(route)], + [new RegExp(`^GET /datasources/${P}/catalog$`), (route, { key }) => json(listOf(state, parts(key)[2]))(route)], + // ---- 剖析任务 + [new RegExp(`^POST /datasources/${P}/catalog/profile-jobs$`), reply('create', (route, { key, body }) => { + const src = parts(key)[2] + const s = state.sources.find((x) => x.id === src) + if (!settingsOf(state, src).enabled) { + return json({ detail: `数据源「${s.name}」未开启数据剖析。剖析会对业务库发查询,请先在数据源设置中开启数据剖析`, code: 'profile_disabled' }, 409)(route) + } + const active = activeProfile(state, src) + if (active) return json(busyOf(state, active), 409)(route) + if ((body?.tables?.length ?? 0) > 500) return json({ detail: '一次最多剖析 500 张表' }, 422)(route) + const tables = body?.tables?.length ? [...new Set(body.tables)] : scopeTables(state, src, body?.scope ?? 'needed') + if (!tables.length) { + return json({ detail: '按「需要剖析的表」挑出 0 张表:表都剖析过、上次都做完了,也没有新的待核实关联关系。可以改选「全部表」,或指定要剖析的表', + code: 'profile_nothing' }, 409)(route) + } + const job = newProfileJob(state, src, { tables, scope: body?.tables?.length ? 'selected' : body?.scope ?? 'needed' }) + return json({ job: jobOut(job) }, 202)(route) + })], + [new RegExp(`^GET /datasources/${P}/catalog/profile-jobs/active$`), (route, { key }) => { + const job = activeProfile(state, parts(key)[2]) + return json({ job: job && jobOut(job) })(route) + }], + [new RegExp(`^GET /datasources/${P}/catalog/profile-jobs/latest$`), (route, { key }) => { + const job = state.pjobs.filter((j) => j.source_id === parts(key)[2]).at(-1) ?? null + return json({ job: job && jobOut(job) })(route) + }], + [new RegExp(`^GET /datasources/${P}/catalog/profile-jobs/[^/]+/tasks/\\d+$`), (route, { key }) => { + const job = pjobOf(state, key) + const t = job?.tasks.find((x) => x.seq === Number(parts(key).at(-1))) + return t ? json({ task: taskDetail(job, t) })(route) : json({ detail: '任务里没有这张表' }, 404)(route) + }], + // 停止:排队的直接停;进行中的记下来,等检查用 stopJob 推到底(这期间页面应当写「正在停止」) + [new RegExp(`^POST /datasources/${P}/catalog/profile-jobs/[^/]+/cancel$`), reply('cancel', (route, { key }) => { + const job = pjobOf(state, key) + if (!job) return NO_PJOB(route) + if (!JOB_ACTIVE.has(job.status)) return json({ detail: '这次剖析已经结束了,不需要停止', code: 'catalog_profile_finished' }, 409)(route) + if (job.status === 'queued') stopJob(job) + else job.stopAsked = true + return json({ job: jobOut(job) })(route) + })], + [new RegExp(`^POST /datasources/${P}/catalog/profile-jobs/[^/]+/retry$`), reply('retry', (route, { key }) => { + const old = pjobOf(state, key) + if (!old) return NO_PJOB(route) + const active = activeProfile(state, old.source_id) + if (active) return json(busyOf(state, active), 409)(route) + const tables = old.tasks.filter((t) => RETRYABLE.has(t.status)).map((t) => t.table_name) + if (!tables.length) return json({ detail: '这次剖析里没有失败、部分完成或停止了的表,不需要重试' }, 422)(route) + const job = newProfileJob(state, old.source_id, { tables, scope: 'selected', retry_of: old.id }) + return json({ job: jobOut(job) }, 202)(route) + })], + [new RegExp(`^GET /datasources/${P}/catalog/profile-jobs/[^/]+$`), (route, { key }) => { + const job = pjobOf(state, key) + return job ? json({ job: jobOut(job) })(route) : NO_PJOB(route) + }], + // 旧的同步剖析接口:前端不该再用它,调了就答 410,下面的检查会数它 + [new RegExp(`^POST /datasources/${P}/catalog/profile$`), json({ detail: '检查脚本:前端不该再调用同步剖析接口' }, 410)], + // ---- 起草任务(排队那一节用) + [new RegExp(`^POST /datasources/${P}/catalog/draft-jobs$`), (route, { key, body }) => { + const src = parts(key)[2] + if (activeDraft(state, src)) return json({ detail: '正在起草', code: 'catalog_draft_busy' }, 409)(route) + const job = newDraftJob(state, src, body?.tables?.length ? body.tables : state.tables[src].filter((t) => !t.missing).map((t) => t.name).slice(0, 20)) + return json({ job: draftOut(job) }, 202)(route) + }], + [new RegExp(`^GET /datasources/${P}/catalog/draft-jobs/active$`), (route, { key }) => { + const job = activeDraft(state, parts(key)[2]) + return json({ job: job && draftOut(job) })(route) + }], + [new RegExp(`^GET /datasources/${P}/catalog/draft-jobs/latest$`), (route, { key }) => { + const job = state.djobs.filter((j) => j.source_id === parts(key)[2]).at(-1) ?? null + return json({ job: job && draftOut(job) })(route) + }], + [new RegExp(`^GET /datasources/${P}/catalog/draft-jobs/[^/]+$`), (route, { key }) => { + const job = state.djobs.find((j) => j.id === parts(key).at(-1)) + return job ? json({ job: draftOut(job) })(route) : json({ detail: '起草任务不存在' }, 404)(route) + }], + [/^GET \/settings$/, async (route) => { + const real = await route.fetch().catch(() => null) + if (!real) return route.abort().catch(() => {}) + const body = await real.json().catch(() => ({})) + return route.fulfill({ response: real, json: { ...body, catalog_draft: { provider: null, model: null } } }).catch(() => {}) + }], + [/^GET \/copilot\/model$/, json({ configured: true, provider: null, model: null, effective_provider: 'check-gateway', effective_model: 'check-model', error: null })], + // ---- 单表、保存数据源 + [new RegExp(`^GET /datasources/${P}/catalog/[^/]+/impact$`), (route, { key }) => json({ table: parts(key)[4], templates: [] })(route)], + [new RegExp(`^PATCH /datasources/${P}$`), reply('patch', (route, { key, body }) => { + const id = parts(key)[2] + state.sources = state.sources.map((s) => (s.id === id ? { ...s, ...body, password: undefined } : s)) + return json(state.sources.find((s) => s.id === id))(route) + })], + [new RegExp(`^GET /datasources/${P}/catalog/[^/]+$`), (route, { key }) => { + const src = parts(key)[2] + const t = state.tables[src]?.find((x) => x.name === parts(key)[4]) + return t ? json(detailOf(state, src, t))(route) : json({ detail: `数据源中没有表 ${parts(key)[4]}` }, 404)(route) + }], + [new RegExp(`^PUT /datasources/${P}/catalog/[^/]+$`), (route, { key, body }) => { + const src = parts(key)[2] + const t = state.tables[src].find((x) => x.name === parts(key)[4]) + if (body.if_version !== t.version) return json({ detail: `表「${t.name}」的数据目录刚被修改过,请重新载入后再提交` }, 409)(route) + Object.assign(t, { notes: body.notes, version: t.version + 1, updated_at: isoNow(), updated_by: ACTOR }) + return json(detailOf(state, src, t))(route) + }], + ] +} + +// --------------------------------------------------------------------------- +// 页面上的定位 +// --------------------------------------------------------------------------- +const box = (page) => page.locator('[data-profile-dialog]') +/** 剖析对话框在哪一步:setup / blocked / running / done;没开着为 null */ +const phase = (page) => box(page).getAttribute('data-profile-dialog', { timeout: 500 }).catch(() => null) +/** 某一处的文字(空白压成一个空格);没有这一处为空串 */ +const textOf = async (loc) => (await loc.first().innerText({ timeout: 800 }).catch(() => '')).replace(/\s+/g, ' ').trim() +const inBox = (page, sel) => textOf(box(page).locator(sel)) +const taskLine = (page, name) => box(page).locator(`[data-profile-task="${name}"]`) +const head = (page) => page.locator('[data-catalog-profile]') +/** 表清单一行的「剖析」一格(去掉给读屏的「数据剖析:」);没有这一格为空串 */ +const rowProfile = async (page, name) => (await textOf(page.locator(`[data-catalog-row="${name}"] [data-row-profile]`))).replace(/^数据剖析:\s*/, '') +const toasts = (page) => page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => '') +async function openDialog(page) { + await head(page).click() + await box(page).waitFor() +} +// 界面不该露的机读码:状态、跳过原因、检查类别、查询结果的英文值 +const CODE_RE = /\b(?:queued|running|succeeded|partial|failed|cancelled|interrupted|pending|skipped|budget|deadline|timeout|rejected|masked|too_large|slow|nothing|row_estimate|relation|codes|verified|proposed|unfinished|new_relations|never)\b/ +async function cleanCopy(page, where) { + let t = await box(page).innerText().catch(() => '') + // 表名、列名、SQL 是库里的标识,照原样显示,先去掉再查 + t = t.replace(/SELECT[\s\S]*?(?=\n|$)/g, '').replace(/[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)?/g, (w) => (CODE_RE.test(w) && !/_/.test(w) ? w : '')) + const code = t.match(CODE_RE)?.[0] + check(`${where}:不露机读码`, !code, code ?? '') +} + +// --------------------------------------------------------------------------- + +try { + const probe = await open('/data') + const overlay = await probe.page.locator('vite-error-overlay').count() + await probe.close() + if (overlay) { + console.error('✗ 页面上是 vite 的报错层——有文件正在改,等一会儿再跑') + process.exit(1) + } +} catch (e) { + console.error(`✗ 打不开前端(${WEB})——先起沙箱前端\n ${e.message}`) + process.exit(1) +} + +await section('剖析 · 设置:每张表的两项预算、被拒时报错落到那一项', async () => { + const state = freshState() + const replies = { patch: [] } + const { page, sent, errors, natives, close } = await open('/data/databases', { handlers: handlers(state, replies) }) + // 没开剖析的库(设置里还留着旧的两个键):编辑框里「数据剖析」默认关闭,预算收着 + await page.locator(`[data-source="${NAME2}"]`).getByRole('button', { name: '编辑', exact: true }).click() + const sec = page.locator('[data-profile-settings]') + await sec.waitFor() + check('编辑框里有「数据剖析」,关着、预算收着', (await sec.getAttribute('data-profile-settings')) === 'off' + && await sec.locator('[data-profile-field]').count() === 0) + await sec.locator('[data-profile-enabled]').check() + const labels = await sec.locator('label.label').allInnerTexts() + check('开启后五项预算展开:每张表最多查询数、每张表时长上限在前,没有整次的查询次数上限、总时长上限', + labels.join('、') === '每张表最多查询数、每张表时长上限、单条查询时限、抽样键值数、整表统计行数上限', labels.join('、')) + const units = await sec.locator('[data-profile-field] span').allInnerTexts() + check('……每项带单位', units.join(',') === '条,秒,秒,个,行', units.join(',')) + const hints = await sec.locator('[id$="-hint"]').allInnerTexts() + check('……写明范围和默认值,说清用完只停这张表', hints.some((h) => h.startsWith('范围 1–200 条,默认 40') && h.includes('用完只停这张表')) + && hints.some((h) => h.startsWith('范围 10–600 秒,默认 60') && h.includes('只停这张表')), hints.join(' | ')) + check('……旧的两个键不回显成输入框的值', await page.locator('#ds-profile-table_max_queries').inputValue() === '' + && await page.locator('#ds-profile-table_max_s').inputValue() === '') + const risk = () => sec.locator('[data-profile-risk]').innerText().catch(() => '') + check('……风险说明按表写:一条一条地发、可随时停止,每张表最多 40 条、60 秒,单条 10 秒', + (await risk()).includes('一条一条地发,可随时停止:每张表最多 40 条、60 秒,单条不超过 10 秒'), await risk()) + await page.locator('#ds-profile-table_max_queries').fill('30') + check('……风险说明跟着填的数走', (await risk()).includes('每张表最多 30 条'), await risk()) + await page.locator('#ds-profile-table_max_queries').fill('500') + const err = await page.locator('#ds-profile-table_max_queries-error').innerText().catch(() => '') + check('填错(500 条,超过 200):就地写明范围,保存按钮禁用', err === '请填写 1 到 200 之间的整数' && await page.locator('[data-source-save]').isDisabled(), err) + check('……输入框标出错', (await page.locator('#ds-profile-table_max_queries').getAttribute('aria-invalid')) === 'true') + await page.locator('#ds-profile-table_max_queries').fill('30') + await page.locator('#ds-profile-table_max_s').fill('90') + // 服务端按字段拒收(422):带机读码和出错的那一项,界面按它落到那一格,不从原话里认 + const REJECT = '数据剖析的「每张表时长上限(秒)」需要填写 10 到 600 之间的数;当前为「90」' + replies.patch.push(json({ detail: REJECT, code: 'profile_settings_invalid', field: 'table_max_s' }, 422)) + await page.locator('[data-source-save]').click() + check('保存被拒(422,field 是每张表时长上限):服务端原话写在那一项下面', await until(async () => + (await page.locator('#ds-profile-table_max_s-error').innerText().catch(() => '')) === REJECT), + await page.locator('#ds-profile-table_max_s-error').innerText().catch(() => '')) + check('……光标落在那一项上,框标出错', await until(async () => page.evaluate(() => document.activeElement?.id === 'ds-profile-table_max_s')) + && (await page.locator('#ds-profile-table_max_s').getAttribute('aria-invalid')) === 'true') + replies.patch.push(json({ detail: '数据剖析的「每张表最多查询数」需要填写 1 到 200 之间的整数;当前为「30」', code: 'profile_settings_invalid', + field: 'table_max_queries' }, 422)) + await page.locator('#ds-profile-table_max_s').fill('95') + await page.locator('[data-source-save]').click() + check('……field 是每张表最多查询数:落在那一格', await until(async () => + (await page.locator('#ds-profile-table_max_queries').getAttribute('aria-invalid')) === 'true' + && page.evaluate(() => document.activeElement?.id === 'ds-profile-table_max_queries'))) + if (SHOTS) await sleep(350) + await shot(page, 'profile-settings-422') + await page.locator('#ds-profile-table_max_queries').fill('30') + await page.locator('#ds-profile-table_max_s').fill('90') + await page.locator('[data-source-save]').click() + await until(async () => (await page.locator('[data-profile-settings]').count()) === 0) + const saved = writes(sent, /^PATCH /).at(-1)?.body + check('保存:只写开关和填了的新项(数字),旧的两个键不带', JSON.stringify(saved?.options?.catalog_profile) + === JSON.stringify({ enabled: true, table_max_queries: 30, table_max_s: 90 }), JSON.stringify(saved?.options)) + check('……卡片上标「数据剖析已开启」,悬停按表写预算', await until(async () => (await page.locator(`[data-source="${NAME2}"] [data-profile-on]`).count()) === 1) + && (await page.locator(`[data-source="${NAME2}"] [data-profile-on]`).getAttribute('title')) === '数据剖析已开启:每张表最多 30 条只读查询、90 秒,单条不超过 10 秒', + await page.locator(`[data-source="${NAME2}"] [data-profile-on]`).getAttribute('title').catch(() => '')) + await page.locator(`[data-source="${NAME2}"]`).getByRole('button', { name: '编辑', exact: true }).click() + await page.locator('[data-profile-settings="on"]').waitFor() + check('再打开编辑框:开关和填的数回显', await page.locator('#ds-profile-table_max_queries').inputValue() === '30' + && await page.locator('#ds-profile-table_max_s').inputValue() === '90') + await page.getByRole('button', { name: '取消', exact: true }).click() + + // 导入表格的源没有编辑框:卡片上单独设置,options 的其余键原样带上 + await goto(page, '/data/tables') + await page.locator(`[data-source="${NAME3}"] [data-edit-profile]`).click() + const dialog = page.locator('[data-profile-settings-dialog]') + await dialog.waitFor() + check('导入表格的源:卡片上「设置数据剖析」打开只改这一项的弹窗,写明对之后新建的剖析生效', + (await textOf(page.locator('[role="dialog"]').last())).includes('对之后新建的剖析生效')) + await dialog.locator('[data-profile-enabled]').check() + await page.locator(`#profile-${S3}-table_max_s`).fill('120') + await page.locator('[data-profile-save]').click() + await until(async () => (await dialog.count()) === 0) + const body = writes(sent, new RegExp(`^PATCH /datasources/${S3}$`)).at(-1)?.body + check('……保存只换剖析设置,遮罩列原样带上', JSON.stringify(body) + === JSON.stringify({ options: { mask_columns: ['联系电话'], catalog_profile: { enabled: true, table_max_s: 120 } } }), JSON.stringify(body)) + check('写请求只有保存数据源', writes(sent, /./).every((s) => /^PATCH \/datasources\/check-prof-/.test(s.key)), + JSON.stringify(writes(sent, /./).map((s) => s.key))) + check('没有原生对话框', natives.length === 0, natives.join(' | ')) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 开始前:范围、个数与预算', async () => { + const state = freshState() + const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) }) + await page.locator('[data-catalog-row]').first().waitFor() + check('页头写「数据剖析」', (await head(page).getAttribute('aria-label')) === '数据剖析' && (await head(page).getAttribute('data-catalog-profile')) === '') + await openDialog(page) + const scopes = await box(page).locator('[data-profile-scope]').evaluateAll((els) => els.map((e) => `${e.getAttribute('data-profile-scope')}:${e.getAttribute('data-count')}`)) + // 需要剖析:从没剖析过的订单、空日志表;上次没做完的访问记录、退款;剖析后有新关系的商品。全部:再加上客户,不含视图、 + // 没有可剖析项的配置表、只剩目录的旧表 + check('没选表、没筛选:范围是「需要剖析的 5 张表」(默认)和「全部 6 张表」', scopes.join(',') === 'needed:5,all:6' + && await box(page).locator('[data-profile-scope="needed"] input').isChecked(), scopes.join(',')) + check('……写明需要剖析的口径、全部不含视图', (await inBox(page, '[data-profile-scope="needed"]')).includes('从没剖析过、上次没做完,或剖析之后又出现了待核实关联关系的表') + && (await inBox(page, '[data-profile-scope="all"]')).includes('不含视图')) + const budget = await inBox(page, '[data-profile-budget]') + check('预算按表写:每张表最多 40 条、60 秒,单条 10 秒,抽样、整表统计门槛,按哪个源的设置', + ['每张表最多 40 条查询、60 秒,用完只停这张表', '单条查询不超过 10 秒', '抽样 2,000 个键值', '行数超过 100,000 的表不做整表统计', `按「${NAME1}」的数据剖析设置`] + .every((x) => budget.includes(x)), budget) + check('……按这次的表数估上限:「这次 5 张表,最多约 200 条查询、5 分钟」', (await inBox(page, '[data-profile-budget-total]')) === '这次 5 张表,最多约 200 条查询、5 分钟', + await inBox(page, '[data-profile-budget-total]')) + await box(page).locator('[data-profile-scope="all"]').click() + check('……换成全部 6 张:估算跟着变', (await inBox(page, '[data-profile-budget-total]')) === '这次 6 张表,最多约 240 条查询、6 分钟') + check('醒目一句:只读查询、一条一条地发、可随时停止', (await inBox(page, '[data-profile-readonly]')).includes('只读查询,一条一条地发,可随时停止')) + check('……不再说「无法中途停止」', !(await textOf(box(page))).includes('无法中途停止')) + check('……没有起草任务:按钮写「开始剖析」', (await textOf(page.locator('[data-profile-start]'))).includes('开始剖析') + && (await page.locator('[data-profile-start]').getAttribute('data-profile-start')) === 'start') + check('上一次剖析没做完:设置里写一行,可以点开', (await inBox(page, '[data-profile-last]')).includes('剖析完成,部分表没做完') + && await box(page).locator('[data-profile-last-open]').count() === 1, await inBox(page, '[data-profile-last]')) + check('还没开始:没有写请求', writes(sent, /./).length === 0) + if (SHOTS) await sleep(350) + await shot(page, 'profile-setup') + await cleanCopy(page, '开始前') + await page.keyboard.press('Escape') + await until(async () => (await box(page).count()) === 0) + + // 已选三张(其中一张是视图):剖析不了的不交给服务端,写明另有几张不剖析 + for (const n of ['orders', 'customers', 'order_summary']) await page.locator(`[data-catalog-select="${n}"]`).click() + await page.locator('[data-catalog-profile-selected]').click() + await box(page).waitFor() + check('剖析所选:范围默认是「已选的 2 张表」,写明另有 1 张不剖析', await box(page).locator('[data-profile-scope="selected"] input').isChecked() + && (await inBox(page, '[data-profile-scope="selected"]')).includes('已选的 2 张表') + && (await inBox(page, '[data-profile-scope="selected"]')).includes('另有 1 张不剖析'), await inBox(page, '[data-profile-scope="selected"]')) + await page.keyboard.press('Escape') + await page.locator('[data-catalog-clear]').click() + // 搜索「order」:当前筛选里的订单、退款(id 列里有 order)……按清单的筛选结果算 + await page.locator('[data-catalog-search]').fill('order') + await openDialog(page) + check('有筛选:多一项「当前筛选结果中的 N 张表」', await box(page).locator('[data-profile-scope="filtered"]').count() === 1 + && (await inBox(page, '[data-profile-scope="filtered"]')).startsWith('当前筛选结果中的 1 张表'), await inBox(page, '[data-profile-scope="filtered"]')) + check('没有调用旧的同步剖析接口', count(sent, /catalog\/profile$/) === 0) + check('没有原生对话框', natives.length === 0, natives.join(' | ')) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 进行中:逐表进度、后台继续与停止', async () => { + const state = freshState() + const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) }) + await page.locator('[data-catalog-row]').first().waitFor() + await openDialog(page) + await page.locator('[data-profile-start]').click() + check('开始:进入进行中,建任务的请求带「需要剖析」范围和本机署名', await until(async () => (await phase(page)) === 'running') + && JSON.stringify(lastBody(sent, /profile-jobs$/)) === JSON.stringify({ scope: 'needed' }) + && decodeURIComponent(writes(sent, /profile-jobs$/).at(-1)?.actor ?? '') === ACTOR, JSON.stringify(lastBody(sent, /profile-jobs$/))) + const job = state.pjobs.at(-1) + check('……刚建好还在排队:标题写「排队中」,逐表 5 行都写「排队」', (await inBox(page, '[data-profile-title]')).startsWith('排队中') + && (await box(page).locator('[data-profile-task]').count()) === 5 && (await inBox(page, '[data-profile-task="orders"] [data-profile-task-status]')) === '排队') + // 订单表做到一半:关系 3/4、码值 5/6、日期 1/1,发了 23 条查询,正在核对第 4 条关系 + settle(state, job, 'orders', { status: 'running', done: { relation: 3, codes: 5, date: 1 }, queries: 23, elapsed_ms: 18000, + current: '正在核对 orders.coupon_id → coupons.id' }) + check('进行中:逐表一行写各类检查的进度「关系 3/4 · 码值 5/6 · 日期 1/1」', await until(async () => + (await inBox(page, '[data-profile-task="orders"] [data-profile-task-checks]')) === '关系 3/4 · 码值 5/6 · 日期 1/1'), + await inBox(page, '[data-profile-task="orders"]')) + check('……查询数、用时、正在做哪一项', (await inBox(page, '[data-profile-task="orders"] [data-profile-task-queries]')) === '23 条查询' + && (await inBox(page, '[data-profile-task="orders"] [data-profile-task-elapsed]')) === '18 秒' + && (await inBox(page, '[data-profile-task="orders"] [data-profile-task-note]')) === '正在核对 orders.coupon_id → coupons.id' + && (await inBox(page, '[data-profile-task="orders"] [data-profile-task-status]')) === '剖析中') + check('……头部:几张 / 共几张、已发几条查询、用时', (await inBox(page, '[data-profile-progress]')) === '0 / 5 张表' + && (await inBox(page, '[data-profile-queries]')).includes('已发 23 条查询') && (await inBox(page, '[data-profile-elapsed]')).startsWith('· 用时'), + await textOf(box(page).locator('[role="status"]'))) + check('……标题写「正在剖析…」', (await inBox(page, '[data-profile-title]')) === '正在剖析…') + settle(state, job, 'orders', { + queries: 31, elapsed_ms: 26000, added: 3, updated: 2, row_estimate: { rows: 250000, method: 'stats', at_least: null }, + findings: [ + { kind: 'relation', path: 'relations.customer_id → customers.id', target: 'customer_id → customers.id', columns: ['customer_id'], to_table: 'customers', + to_columns: ['id'], status: 'verified', confirmed: false, previous_status: 'proposed', coverage: 0.991, cardinality: 'many_to_one', cardinality_checked: true, + sample: 2000, matched: 1982, summary: '' }, + { kind: 'codes', path: 'columns.status.codes', column: 'status', values: [{ value: '1', rows: 200000 }, { value: '2', rows: 40000 }, { value: '9', rows: 10000 }], + rows: 250000, status: 'proposed', pending: 2, summary: '' }, + { kind: 'codes', path: 'columns.channel.codes', column: 'channel', values: [{ value: 'web', rows: 150000 }, { value: 'app', rows: 100000 }], + rows: 250000, status: 'proposed', pending: 2, summary: '' }, + ], + query_log: [ + { at: isoNow(), kind: 'row_estimate', target: 'orders', sql: 'SELECT reltuples FROM pg_class WHERE relname = ?', ms: 8, rows: 1, outcome: 'ok', detail: null }, + { at: isoNow(), kind: 'relation', target: 'customer_id → customers.id', sql: 'SELECT DISTINCT customer_id FROM orders WHERE customer_id IS NOT NULL LIMIT ?', + ms: 120, rows: 2000, outcome: 'ok', detail: null }, + { at: isoNow(), kind: 'relation', target: 'customer_id → customers.id', sql: 'SELECT id FROM customers WHERE id IN (?, ?, ?)', ms: 95, rows: 1982, outcome: 'ok', detail: null }, + { at: isoNow(), kind: 'relation', target: 'coupon_id → coupons.id', sql: 'SELECT DISTINCT coupon_id FROM orders WHERE coupon_id IS NOT NULL LIMIT ?', + ms: 10000, rows: null, outcome: 'timeout', detail: '超过单条查询时限(10 秒),数据库已中断这条查询' }, + ], + }) + const lists0 = count(sent, new RegExp(`^GET /datasources/${S1}/catalog$`)) + check('一张表剖析完:「1 / 5 张表」,进度条读屏可读', await until(async () => (await inBox(page, '[data-profile-progress]')) === '1 / 5 张表') + && (await box(page).locator('[role="progressbar"]').getAttribute('aria-valuenow')) === '1' + && (await box(page).locator('[role="progressbar"]').getAttribute('aria-valuemax')) === '5') + check('……这张表写结论:核实了几条关系、取到几列码值候选(几个含义待填写)', (await inBox(page, '[data-profile-task="orders"] [data-profile-task-note]')) + === '核实 1 条关系,取到 2 列码值候选(4 个含义待填写)', await inBox(page, '[data-profile-task="orders"] [data-profile-task-note]')) + check('……写行数估算', (await inBox(page, '[data-profile-task="orders"]')).includes('约 250,000 行')) + check('……头部写出超时的查询', (await inBox(page, '[data-profile-queries-bad]')).includes('超时 1 条'), await inBox(page, '[data-profile-queries-bad]')) + check('写完的表马上并入目录:清单重取', await until(async () => count(sent, new RegExp(`^GET /datasources/${S1}/catalog$`)) > lists0)) + if (SHOTS) await sleep(350) + await shot(page, 'profile-running') + await cleanCopy(page, '进行中') + + // 关掉对话框:任务不停,页头换成进度入口;表清单的「剖析」一格写排队、剖析中 + settle(state, job, 'visits', { status: 'running', done: { relation: 0, codes: 1, date: 0 }, queries: 3, elapsed_ms: 1500, current: '正在取 visits.page 的码值候选' }) + await until(async () => (await inBox(page, '[data-profile-task="visits"] [data-profile-task-status]')) === '剖析中') + await page.locator('[data-profile-hide]').click() + check('「在后台继续」:对话框关上,没有发停止', await until(async () => (await box(page).count()) === 0) && count(sent, /\/cancel$/) === 0) + check('……页头换成「剖析 1/5」,读屏念得出', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === 'running') + && (await head(page).getAttribute('aria-label')) === '剖析 1/5' && (await textOf(head(page))) === '剖析 1/5', await textOf(head(page))) + check('……表清单:剖析完的写「刚刚剖析」,正在剖析的写「剖析中」,后面的写「排队剖析」', await until(async () => + (await rowProfile(page, 'orders')) === '刚刚剖析' && (await rowProfile(page, 'visits')) === '剖析中' && (await rowProfile(page, 'products')) === '排队剖析'), + [await rowProfile(page, 'orders'), await rowProfile(page, 'visits'), await rowProfile(page, 'products')].join(' / ')) + // 最近查询多数超时:服务端给提醒,关着对话框时页头的入口带提醒色,悬停写全文 + const NOTICE = '最近 6 条查询有 4 条超时,数据库响应变慢了;剖析会继续,可以停止,换在业务不忙的时候再剖析' + job.notice = NOTICE + check('多数超时的提醒:页头入口带提醒标记,悬停写全文', await until(async () => (await page.locator('[data-catalog-profile-notice]').count()) === 1) + && (await head(page).getAttribute('title')).includes(NOTICE)) + await shot(page, 'profile-header') + await head(page).click() + check('点页头:回到对话框,直接是进行中的任务,提醒写在进度条下面', await until(async () => (await phase(page)) === 'running') + && (await inBox(page, '[data-profile-notice]')) === NOTICE && await box(page).locator('[data-profile-scope]').count() === 0) + + // 停止:调停止接口;正在执行的那条查询由数据库按单条时限截止,这期间写「正在停止」 + await page.locator('[data-profile-stop]').click() + check('点「停止」:调停止接口,写「正在停止:等正在执行的那条查询结束…」,停止按钮不能再点', await until(async () => + (await inBox(page, '[data-profile-title]')).startsWith('正在停止:等正在执行的那条查询结束')) + && count(sent, new RegExp(`^POST .*profile-jobs/${job.id}/cancel$`)) === 1 && await page.locator('[data-profile-stop]').isDisabled(), + await inBox(page, '[data-profile-title]')) + stopJob(job, { rest: '已停止:做完的 1 项已写进目录,其余 3 项保留原来的结论' }) + check('……停下来:标题写「已停止」,写明做完的已写进目录、没做的保留原来的结论', await until(async () => (await phase(page)) === 'done') + && (await inBox(page, '[data-profile-title]')) === '已停止' && (await inBox(page, '[data-profile-cancelled]')).includes('没做的项保留原来的结论'), + `${await inBox(page, '[data-profile-title]')} · ${await inBox(page, '[data-profile-cancelled]')}`) + check('……正在剖析的那张写已停止和服务端的原因;后面的表都记已停止', (await inBox(page, '[data-profile-task="visits"] [data-profile-task-status]')) === '已停止' + && (await inBox(page, '[data-profile-task="visits"] [data-profile-task-note]')).includes('其余 3 项保留原来的结论') + && (await inBox(page, '[data-profile-task="tmp_log"] [data-profile-task-status]')) === '已停止' + && await taskLine(page, 'tmp_log').locator('[data-profile-task-checks]').count() === 0) + check('……汇总照实写:5 张表,完成 1,已停止 4', (await inBox(page, '[data-profile-counts]')) === '5 张表:完成 1,已停止 4', await inBox(page, '[data-profile-counts]')) + check('……可以重试没做完的 4 张', (await textOf(page.locator('[data-profile-retry]'))).includes('重试没做完的表(4 张)')) + check('……页头回到「数据剖析」', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === '')) + const polls = () => count(sent, new RegExp(`^GET .*profile-jobs/${job.id}$`)) + const n = polls() + await sleep(3200) + check('结束以后不再取进度', polls() === n, `${n} → ${polls()}`) + check('写请求只有建任务和停止', writes(sent, /./).every((s) => /profile-jobs(\/[^/]+\/cancel)?$/.test(s.key)), JSON.stringify(writes(sent, /./).map((s) => s.key))) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 结束后:汇总、筛选、展开与重试', async () => { + const state = freshState() + const replies = { retry: [] } + const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state, replies) }) + await page.locator('[data-catalog-row]').first().waitFor() + await openDialog(page) + await box(page).locator('[data-profile-scope="all"]').click() + await page.locator('[data-profile-start]').click() + await until(async () => (await phase(page)) === 'running') + check('全部表:请求带「全部」范围', JSON.stringify(lastBody(sent, /profile-jobs$/)) === JSON.stringify({ scope: 'all' })) + const job = state.pjobs.at(-1) + settle(state, job, 'orders', { queries: 31, elapsed_ms: 26000, added: 2, updated: 1, findings: [ + { kind: 'relation', path: 'relations.customer_id → customers.id', target: 'customer_id → customers.id', columns: ['customer_id'], to_table: 'customers', + to_columns: ['id'], status: 'verified', confirmed: false, previous_status: 'proposed', coverage: 0.991, cardinality: 'many_to_one', cardinality_checked: true, + sample: 2000, matched: 1982, summary: '' }, + { kind: 'relation', path: 'relations.coupon_id → coupons.id', target: 'coupon_id → coupons.id', columns: ['coupon_id'], to_table: 'coupons', to_columns: ['id'], + status: 'proposed', confirmed: false, previous_status: 'proposed', coverage: 0.4, cardinality: 'many_to_one', cardinality_checked: false, sample: 500, matched: 200, summary: '' }, + { kind: 'codes', path: 'columns.status.codes', column: 'status', values: [{ value: '1', rows: 9 }, { value: '2', rows: 3 }, { value: '9', rows: 1 }], rows: 13, + status: 'proposed', pending: 2, summary: '' }, + { kind: 'codes', path: 'columns.channel.codes', column: 'channel', values: [{ value: 'web', rows: 8 }, { value: 'app', rows: 5 }], rows: 13, + status: 'proposed', pending: 2, summary: '' }, + { kind: 'business_date', path: 'business_date', column: 'created_at', min: '2024-01-01 00:00:00', max: '2026-10-14 23:59:00', status: 'proposed', summary: '' }, + ] }) + settle(state, job, 'customers', { queries: 4, elapsed_ms: 2000 }) + // 访问记录:这张表的 40 条查询用完了,2 项没查成(旧结论保留) + settle(state, job, 'visits', { status: 'partial', done: { relation: 0, codes: 1, date: 1 }, queries: 40, elapsed_ms: 33000, + skip: [['relation', 0, 'budget', '这张表的查询次数用完(40 条),这一项没有查,保留原来的结论'], ['codes', 1, 'timeout', '超过单条查询时限(10 秒),这一项没有查成']], + query_log: [ + { at: isoNow(), kind: 'row_estimate', target: 'visits', sql: 'SELECT COUNT(*) FROM (SELECT 1 FROM visits LIMIT ?) t', ms: 30, rows: 1, outcome: 'ok', detail: null }, + { at: isoNow(), kind: 'codes', target: 'page', sql: 'SELECT page, COUNT(*) FROM visits GROUP BY page ORDER BY COUNT(*) DESC LIMIT ?', ms: 10000, rows: null, + outcome: 'timeout', detail: '超过单条查询时限(10 秒),数据库已中断这条查询' }, + { at: isoNow(), kind: 'date', target: 'visited_at', sql: "SELECT MIN(visited_at), MAX(visited_at) FROM visits WHERE visited_at > ?", ms: 60, rows: 1, outcome: 'ok', detail: null }, + ] }) + settle(state, job, 'products', { queries: 6, elapsed_ms: 3000 }) + settle(state, job, 'refunds', { status: 'failed', queries: 3, elapsed_ms: 800, error: '写入一直冲突:剖析期间这张表的数据目录被反复修改,结论没有写进去' }) + settle(state, job, 'tmp_log', { status: 'skipped', queries: 1, elapsed_ms: 100, error: '空表,没有可剖析的数据' }) + finish(job) + check('结束:标题写「剖析完成,部分表没做完」', await until(async () => (await phase(page)) === 'done') + && (await inBox(page, '[data-profile-title]')) === '剖析完成,部分表没做完', await inBox(page, '[data-profile-title]')) + check('……进度写做完了几张「完成 3 / 6 张表」(跳过、失败、部分完成的不算),进度条同', (await inBox(page, '[data-profile-progress]')) === '完成 3 / 6 张表' + && (await box(page).locator('[role="progressbar"]').getAttribute('aria-valuenow')) === '3', await inBox(page, '[data-profile-progress]')) + check('……汇总:6 张表:完成 3,部分完成 1,跳过 1,失败 1', (await inBox(page, '[data-profile-counts]')) === '6 张表:完成 3,部分完成 1,跳过 1,失败 1', + await inBox(page, '[data-profile-counts]')) + check('……发现的合计:核实了几条、几条仍为推断、几列码值候选(几个含义待填写)、几张表的业务日期', (await inBox(page, '[data-profile-findings-summary]')) + === '核实 1 条关系,1 条关系仍为推断,取到 2 列码值候选(4 个含义待填写),提议 1 张表的业务日期', await inBox(page, '[data-profile-findings-summary]')) + check('……目录的变化', (await inBox(page, '[data-profile-changes]')) === '目录新增 2 项、更新 1 项') + check('……查询次数用完的表写「40 条查询(已用完)」,没查成的项数', (await inBox(page, '[data-profile-task="visits"] [data-profile-task-queries]')) === '40 条查询(已用完)' + && (await inBox(page, '[data-profile-task="visits"] [data-profile-task-note]')).includes('2 项没查成'), await inBox(page, '[data-profile-task="visits"]')) + check('……空表、写入冲突照服务端的原话', (await inBox(page, '[data-profile-task="tmp_log"] [data-profile-task-note]')) === '空表,没有可剖析的数据' + && (await inBox(page, '[data-profile-task="refunds"] [data-profile-task-note]')).startsWith('写入一直冲突')) + // 筛选:有发现 / 有跳过 / 失败 + const chips = await box(page).locator('[data-profile-filter-key]').evaluateAll((els) => els.map((e) => e.innerText.replace(/\s+/g, ''))) + check('筛选带数量:全部 6、有发现 1、有跳过 2、失败 1', chips.join('|') === '全部6|有发现1|有跳过2|失败1', chips.join('|')) + const shown = () => box(page).locator('[data-profile-task]').evaluateAll((els) => els.map((e) => e.getAttribute('data-profile-task')).join(',')) + await box(page).locator('[data-profile-filter-key="failed"]').click() + check('「失败」:只有写入冲突的退款', (await shown()) === 'refunds', await shown()) + await box(page).locator('[data-profile-filter-key="skipped"]').click() + check('「有跳过」:访问记录(有项没查成)、空日志表', (await shown()) === 'visits,tmp_log', await shown()) + await box(page).locator('[data-profile-filter-key="findings"]').click() + check('「有发现」:订单', (await shown()) === 'orders', await shown()) + await box(page).locator('[data-profile-filter-key="all"]').click() + + // 展开一张表:检查清单(每项状态、原因、几条查询)、发过的查询(字面量换成了 ?) + await taskLine(page, 'visits').locator('[data-profile-task-toggle]').click() + const visits = box(page).locator('[data-profile-task-detail="visits"]') + check('点一行展开:按 seq 取这张表的完整结果', await until(async () => (await visits.locator('[data-profile-checks]').count()) === 1) + && count(sent, new RegExp(`^GET .*profile-jobs/${job.id}/tasks/3$`)) >= 1 + && (await taskLine(page, 'visits').locator('[data-profile-task-toggle]').getAttribute('aria-expanded')) === 'true') + const checksList = await visits.locator('[data-profile-check]').evaluateAll((els) => els.map((e) => `${e.getAttribute('data-profile-check')}:${e.getAttribute('data-state')}`)) + check('……检查清单 4 项:关系没查成、码值一项完成一项没查成、日期完成', checksList.join(',') === 'relation:skipped,codes:done,codes:skipped,date:done', checksList.join(',')) + check('……没查成的写原因(中文)和服务端的整句', (await textOf(visits.locator('[data-profile-check="relation"]'))).includes('本表查询次数用完') + && (await textOf(visits.locator('[data-profile-check="relation"]'))).includes('保留原来的结论') + && (await textOf(visits.locator('[data-profile-check="codes"][data-state="skipped"]'))).includes('查询超时')) + check('……检查清单的标题写几项结束了', (await textOf(visits.locator('[data-profile-checks] > div').first())) === '检查清单(4 / 4 项)') + const log = visits.locator('[data-profile-queries-log]') + check('……发过的查询折着:「看发过的查询(3 条)」', (await textOf(log.locator('summary'))) === '看发过的查询(3 条)') + await log.locator('summary').click() + const sqls = await log.locator('[data-profile-query-sql]').allInnerTexts() + check('……展开:每条的 SQL 照服务端给的写(字面量都是 ?),写明为什么', sqls.length === 3 && sqls.every((q) => q.includes('?')) + && (await textOf(log)).includes('SQL 里的取值都换成了「?」'), sqls.join(' | ')) + check('……结果写中文:成功、超时(带原因),写用时和行数', (await textOf(log.locator('[data-profile-query][data-outcome="timeout"]'))).includes('超时') + && (await textOf(log.locator('[data-profile-query][data-outcome="timeout"]'))).includes('数据库已中断这条查询') + && (await textOf(log.locator('[data-profile-query]').first())).includes('30 毫秒')) + if (SHOTS) await sleep(350) + await shot(page, 'profile-expanded') + // 有发现的表:关系的结论、码值的「填写含义」 + await taskLine(page, 'orders').locator('[data-profile-task-toggle]').click() + const orders = box(page).locator('[data-profile-task-detail="orders"]') + await until(async () => (await orders.locator('[data-profile-finding]').count()) > 0) + check('展开有发现的表:关系的结论「升为已验证」、覆盖率不足的「保持推断」', (await textOf(orders.locator('[data-profile-outcome="raised"]'))) === '升为已验证' + && (await textOf(orders.locator('[data-profile-outcome="kept"]'))).startsWith('保持推断:覆盖率不足')) + check('……码值候选给「填写含义」,提议的业务日期', await orders.locator('[data-profile-fill="status"]').count() === 1 + && (await textOf(orders.locator('[data-profile-finding="business_date"]'))).includes('提议按 created_at 作为业务日期')) + await cleanCopy(page, '结束后的结果') + // 填写含义:对话框收起,打开那张表、弹出那一列的码值;取消后回到结果,那张表照旧展开,焦点落在下一列的「填写含义」上 + await orders.locator('[data-profile-fill="status"]').click() + const codes = page.locator('[data-codes-dialog="status"]') + check('点「填写含义」:对话框收起,弹出这一列的码值', await until(async () => (await codes.count()) === 1) && (await box(page).count()) === 0) + check('……每个码值一个输入框,空着的写「含义待填写」,光标在第一个空着的上面', await codes.locator('input[data-code]').count() === 3 + && (await codes.locator('input[data-code="2"]').getAttribute('placeholder')) === '含义待填写' + && await until(async () => page.evaluate(() => document.activeElement?.getAttribute('data-code') === '2'))) + check('……没填时保存禁用', await page.locator('[data-codes-save]').isDisabled()) + await page.keyboard.press('Escape') + check('……取消:回到结果,那张表照旧展开,焦点在下一列(channel)的「填写含义」上', await until(async () => (await phase(page)) === 'done') + && await until(async () => (await box(page).locator('[data-profile-task-detail="orders"] [data-profile-fill]').count()) === 2) + && await until(async () => page.evaluate(() => document.activeElement?.getAttribute('data-profile-fill') === 'channel'), 2000), + await page.evaluate(() => document.activeElement?.outerHTML?.slice(0, 120) ?? '')) + + // 接着填下一列:填完回车保存(整份提交,只换这一列码值的含义,带读到的版本),回到结果,这一列写「含义已填写」 + await page.keyboard.press('Enter') + const channel = page.locator('[data-codes-dialog="channel"]') + check('……回车接着填下一列', await until(async () => (await channel.count()) === 1)) + const v0 = state.tables[S1].find((t) => t.name === 'orders').version + await channel.locator('input[data-code="web"]').fill('网站') + await channel.locator('input[data-code="app"]').fill('应用') + await page.keyboard.press('Enter') + const put = () => lastBody(sent, /^PUT .*catalog\/orders$/) + check('回车保存:整份提交,只换这一列码值的含义,带读到的版本', await until(async () => (await channel.count()) === 0 && !!put()) + && JSON.stringify(put()?.notes?.columns?.channel?.codes?.value) === JSON.stringify({ web: '网站', app: '应用' }) && put()?.if_version === v0, + JSON.stringify(put()?.notes?.columns?.channel)) + const channelLine = box(page).locator('[data-profile-task-detail="orders"] [data-profile-finding="codes"][data-column="channel"]') + check('……回到结果:这一列写「含义已填写」,按钮改成「修改含义」,前一列照旧待填写', await until(async () => (await phase(page)) === 'done') + && await until(async () => (await textOf(channelLine.locator('[data-codes-filled]'))).includes('含义已填写')) + && (await textOf(channelLine.locator('[data-profile-fill]'))) === '修改含义' && await channelLine.locator('[data-codes-pending]').count() === 0 + && (await textOf(box(page).locator('[data-profile-task-detail="orders"] [data-column="status"] [data-codes-pending]'))) === '2 个含义待填写', + await textOf(channelLine)) + + // 重试没做完的表:别处又建了一个(正忙)时写清是谁的;之后只重试访问记录、退款 + replies.retry.push(json({ detail: `数据源「${NAME1}」正在进行数据剖析(已完成 0 / 3 张表),请等这次剖析结束(或先停止它)再开始新的剖析`, code: 'profile_busy' }, 409)) + await page.locator('[data-profile-retry]').click() + check('重试撞上正忙(409):转到「开始不了」,写明正忙和下一步', await until(async () => (await phase(page)) === 'blocked') + && (await inBox(page, '[data-profile-blocked="busy"]')).includes('这个数据源已有剖析任务'), await inBox(page, '[data-profile-blocked]')) + await page.locator('[data-profile-back]').click() + await page.locator('[data-profile-last-open]').click() + await until(async () => (await phase(page)) === 'done') + check('……「返回」再点开上一次:还是这次的结果', (await inBox(page, '[data-profile-counts]')).startsWith('6 张表')) + await page.locator('[data-profile-retry]').click() + check('重试:只含部分完成和失败的 2 张,进入新任务的进行中', await until(async () => (await phase(page)) === 'running') + && JSON.stringify(state.pjobs.at(-1).tasks.map((t) => t.table_name)) === JSON.stringify(['visits', 'refunds']) + && state.pjobs.at(-1).retry_of === job.id && (await box(page).locator('[data-profile-task]').count()) === 2) + const again = state.pjobs.at(-1) + // 关着对话框时结束:右下角提示,带「查看报告」 + await page.locator('[data-profile-hide]').click() + settle(state, again, 'visits', { queries: 12, elapsed_ms: 9000 }) + settle(state, again, 'refunds', { queries: 3, elapsed_ms: 900 }) + finish(again) + const toastBox = page.locator('[data-toast]').filter({ hasText: '数据剖析完成' }) + check('关着对话框时结束:提示「数据剖析完成」,带「查看报告」', await until(async () => (await toastBox.count()) > 0), await toasts(page)) + await toastBox.getByRole('button', { name: '查看报告' }).click() + check('……点「查看报告」打开这一次的结果', await until(async () => (await phase(page)) === 'done') && (await inBox(page, '[data-profile-title]')) === '剖析完成') + check('……都做完了:不再给重试', await page.locator('[data-profile-retry]').count() === 0) + await page.locator('[data-profile-close]').click() + await openDialog(page) + check('结果看过了,再点页头就是重新选范围', (await phase(page)) === 'setup') + check('写请求只有建任务、重试和保存码值的含义', writes(sent, /./).every((s) => /profile-jobs(\/[^/]+\/retry)?$|^PUT .*catalog\/orders$/.test(s.key)), + JSON.stringify(writes(sent, /./).map((s) => s.key))) + check('没有原生对话框', natives.length === 0, natives.join(' | ')) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 止损与服务重启', async () => { + const state = freshState() + const { page, errors, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) }) + await page.locator('[data-catalog-row]').first().waitFor() + await openDialog(page) + await page.locator('[data-profile-start]').click() + await until(async () => (await phase(page)) === 'running') + const job = state.pjobs.at(-1) + settle(state, job, 'orders', { queries: 31, elapsed_ms: 26000 }) + settle(state, job, 'visits', { status: 'running', done: { relation: 1 }, queries: 9, elapsed_ms: 70000 }) + // 最近 10 条查询里 7 条超时:任务停下,后面的表记已停止、原因同一句 + const SLOW = '数据库响应慢:最近 10 条查询 7 条超时,已停止,避免给业务库加压' + job.stop_reason = 'slow' + stopJob(job, { status: 'partial', reason: 'slow', current: '数据库响应慢,这一项没有查', error: SLOW, rest: SLOW }) + check('止损(多数超时):写「数据库响应慢,剖析已停止」和服务端的整句、下一步,给「修改设置」', await until(async () => (await phase(page)) === 'done') + && (await inBox(page, '[data-profile-stopped="slow"]')).includes('数据库响应慢,剖析已停止') + && (await inBox(page, '[data-profile-stopped="slow"]')).includes(SLOW) + && (await inBox(page, '[data-profile-stopped="slow"]')).includes('业务库不忙的时候重试没做完的表') + && await box(page).locator('[data-profile-stopped] [data-profile-settings-open]').count() === 1, await inBox(page, '[data-profile-stopped]')) + check('……同一句原因不逐表重复', await box(page).locator('[data-profile-task-note]').filter({ hasText: SLOW }).count() === 0) + check('……后面的表记已停止,可以重试', (await inBox(page, '[data-profile-task="products"] [data-profile-task-status]')) === '已停止' + && (await textOf(page.locator('[data-profile-retry]'))).includes('(4 张)')) + await shot(page, 'profile-slow') + await page.locator('[data-profile-close]').click() + + // 连续失败:写出第一行报错,下一步是先测试连接 + await openDialog(page) + await page.locator('[data-profile-start]').click() + await until(async () => (await phase(page)) === 'running') + const job2 = state.pjobs.at(-1) + const FAIL = '连续 3 条查询失败,已停止:could not connect to server: Connection refused' + job2.stop_reason = 'failed' + settle(state, job2, job2.tasks[0].table_name, { status: 'running', queries: 3 }) + stopJob(job2, { status: 'partial', reason: 'failed', error: FAIL, rest: FAIL }) + check('止损(连续失败):写「连续多条查询失败」、服务端的第一行报错、先测试连接', await until(async () => (await phase(page)) === 'done') + && (await inBox(page, '[data-profile-stopped="failed"]')).includes('连续多条查询失败,剖析已停止') + && (await inBox(page, '[data-profile-stop-error]')) === FAIL && (await inBox(page, '[data-profile-stopped="failed"]')).includes('测试连接')) + await page.locator('[data-profile-close]').click() + + // 服务重启:任务标中断,不自动续跑 + await openDialog(page) + await page.locator('[data-profile-start]').click() + await until(async () => (await phase(page)) === 'running') + const job3 = state.pjobs.at(-1) + settle(state, job3, job3.tasks[0].table_name, { queries: 5 }) + stopJob(job3, { status: 'interrupted', error: '服务重启,剖析中断' }) + check('服务重启:标题写「服务重启,剖析中断」,写明不会自动续上、可以重试没做完的表', await until(async () => (await phase(page)) === 'done') + && (await inBox(page, '[data-profile-title]')) === '服务重启,剖析中断' && (await inBox(page, '[data-profile-interrupted]')).includes('不会自动续上')) + check('……和任务相同的原因不逐表重复', await box(page).locator('[data-profile-task-note]').filter({ hasText: '服务重启' }).count() === 0) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 开始不了(409)', async () => { + const state = freshState() + const replies = { create: [], patch: [] } + // 这个源在数据源列表里没开剖析(服务端也会说没开) + state.sources = state.sources.map((s) => (s.id === S1 ? { ...s, options: {} } : s)) + const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state, replies) }) + await page.locator('[data-catalog-row]').first().waitFor() + await openDialog(page) + check('没开启:写明需要先开启,「开始剖析」禁用,没有预算', (await inBox(page, '[data-profile-disabled]')).includes('未开启数据剖析') + && await page.locator('[data-profile-start]').isDisabled() && await box(page).locator('[data-profile-budget]').count() === 0) + await box(page).locator('[data-profile-disabled] [data-profile-enable]').click() + const settings = page.locator('[data-profile-settings-dialog]') + await settings.waitFor() + await settings.locator('[data-profile-enabled]').check() + await page.locator(`#profile-${S1}-table_max_queries`).fill('25') + await page.locator('[data-profile-save]').click() + check('就地开启:保存后回到剖析,预算按刚存的设置', await until(async () => (await settings.count()) === 0) + && await until(async () => (await inBox(page, '[data-profile-budget]')).includes('每张表最多 25 条查询')) + && !(await page.locator('[data-profile-start]').isDisabled())) + check('……保存的是这个源的剖析设置', JSON.stringify(writes(sent, /^PATCH /).at(-1)?.body) === JSON.stringify({ options: { catalog_profile: { enabled: true, table_max_queries: 25 } } }), + JSON.stringify(writes(sent, /^PATCH /).at(-1)?.body)) + + // 服务端的 409 都带机读码,界面按它分情况,不从原话里认 + const cases = [ + ['disabled', '数据源「zzprofshop」未开启数据剖析。剖析会对业务库发查询,请先在数据源设置中开启数据剖析', '未开启数据剖析', 'data-profile-enable', 'profile_disabled'], + ['inactive', '数据源「zzprofshop」已停用,无法进行数据剖析。请先在数据源设置中启用', '数据源已停用', 'data-profile-go-source', 'datasource_inactive'], + ['noSchema', '尚未探查结构,无法进行数据剖析。请先在数据源卡片上点「探查结构」', '还没有表结构', 'data-profile-go-source', 'schema_missing'], + ['tampered', '数据文件与登记的版本不一致,可能被修改过,已拒绝查询', '数据文件核对未通过', 'data-profile-go-source', 'snapshot_tampered'], + ['nothing', '按「需要剖析的表」挑出 0 张表:表都剖析过、上次都做完了,也没有新的待核实关联关系。可以改选「全部表」,或指定要剖析的表', '没有可剖析的表', + 'data-profile-choose-all', 'profile_nothing'], + ] + for (const [kind, detail, title, action, code] of cases) { + replies.create.push(json({ detail, code }, 409)) + await page.locator('[data-profile-start]').click() + const blocked = box(page).locator(`[data-profile-blocked="${kind}"]`) + const ok = await until(async () => (await blocked.count()) === 1) + const text = ok ? await textOf(blocked) : '' + check(`409(${title}):写明是哪种情况、服务端原话和下一步,给对应的按钮`, ok && text.includes(title) && text.includes(detail) + && await blocked.locator(`[${action}]`).count() === 1, text.slice(0, 160)) + if (kind === 'nothing') { + // 改选全部表:这回建得起来 + await blocked.locator('[data-profile-choose-all]').click() + check('……「改选全部表」:按全部表再建一次,进入进行中', await until(async () => (await phase(page)) === 'running') + && JSON.stringify(lastBody(sent, /profile-jobs$/)) === JSON.stringify({ scope: 'all' })) + stopJob(state.pjobs.at(-1)) + await until(async () => (await phase(page)) === 'done') + await page.locator('[data-profile-again]').click() + await until(async () => (await phase(page)) === 'setup') + continue + } + if (kind === 'disabled') { + // 服务端说没开(列表里的设置过时了):同样就地开启 + await blocked.locator('[data-profile-enable]').click() + await settings.waitFor() + await page.locator(`#profile-${S1}-table_max_queries`).fill('30') + await page.locator('[data-profile-save]').click() + check('……就地开启后回到选范围', await until(async () => (await phase(page)) === 'setup')) + continue + } + await page.locator('[data-profile-back]').click() + await until(async () => (await phase(page)) === 'setup') + } + // 没带机读码的 409(老服务端):不从原话里猜,按「无法开始剖析」说,给重试 + replies.create.push(json({ detail: '数据源正在进行数据剖析,请等待完成后再试' }, 409)) + await page.locator('[data-profile-start]').click() + check('409 没带机读码:不从原话里猜,按「无法开始剖析」说,给重试', await until(async () => (await box(page).locator('[data-profile-blocked="other"]').count()) === 1) + && await box(page).locator('[data-profile-retry-start]').count() === 1) + await box(page).locator('[data-profile-retry-start]').click() + check('……「重试」同一个范围再发一次,这次建起来', await until(async () => (await phase(page)) === 'running')) + stopJob(state.pjobs.at(-1)) + await until(async () => (await phase(page)) === 'done') + const latestN = count(sent, /profile-jobs\/latest$/) + await page.locator('[data-profile-again]').click() + await until(async () => (await phase(page)) === 'setup') + // 正忙:别处(另一个标签页)在这一页打开以后建了一个,写清是谁的、到哪了,可以去看它的进度。先等设置阶段把上一次剖析 + // 取回来,免得它晚到、取回的已经是别处那个进行中的任务(那样会直接转去显示它,走不到 409) + await until(async () => count(sent, /profile-jobs\/latest$/) > latestN) + await sleep(300) + const other = newProfileJob(state, S1, { tables: ['orders', 'customers', 'visits'], scope: 'selected', created_by: OTHER }) + settle(state, other, 'orders', { queries: 20 }) + await page.locator('[data-profile-start]').click() + const busy = box(page).locator('[data-profile-blocked="busy"]') + check('409(正忙):写服务端原话、谁发起的、已完成几张,下一步可以查看它的进度', await until(async () => (await busy.count()) === 1) + && (await textOf(busy)).includes('已完成 1 / 3 张表') && (await inBox(page, '[data-profile-busy-at]')) === `由「${OTHER}」发起,已完成 1 / 3 张表` + && await busy.locator('[data-profile-view-active]').count() === 1, await textOf(busy)) + if (SHOTS) await sleep(350) + await shot(page, 'profile-blocked') + await busy.locator('[data-profile-view-active]').click() + check('……「查看它的进度」:跟着进行中的那一个,写明是已有的任务', await until(async () => (await phase(page)) === 'running') + && (await inBox(page, '[data-profile-busy]')).includes('已有剖析任务在进行') && (await inBox(page, '[data-profile-progress]')) === '1 / 3 张表' + && (await inBox(page, '[data-profile-created-by]')) === `由「${OTHER}」发起`) + stopJob(other) + await until(async () => (await phase(page)) === 'done') + await page.locator('[data-profile-again]').click() + await until(async () => (await phase(page)) === 'setup') + // 不是 409 的失败:留在设置里写明,能再点开始 + replies.create.push(json({ detail: '服务端处理剖析时出错,请稍后重试' }, 500)) + await page.locator('[data-profile-start]').click() + check('其余失败:留在设置里写「未能开始剖析」和原因', await until(async () => (await inBox(page, '[data-profile-start-error]')).startsWith('未能开始剖析')) + && (await phase(page)) === 'setup' && !(await page.locator('[data-profile-start]').isDisabled())) + // 去数据源:停用、没有表结构、快照被改动时 + replies.create.push(json({ detail: cases[1][1], code: cases[1][4] }, 409)) + await page.locator('[data-profile-start]').click() + await box(page).locator('[data-profile-go-source]').click() + await page.waitForURL('**/data/databases', { timeout: 5000 }).catch(() => {}) + check('「前往数据源」回到数据源卡片', new URL(page.url()).pathname === '/data/databases', page.url()) + check('写请求只有建任务、停止和保存剖析设置', writes(sent, /./).every((s) => /profile-jobs(\/[^/]+\/cancel)?$|^PATCH \/datasources\/check-prof-shop$/.test(s.key)), + JSON.stringify(writes(sent, /./).map((s) => s.key))) + check('没有原生对话框', natives.length === 0, natives.join(' | ')) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 和起草排队', async () => { + const state = freshState() + // 起草任务在进行:剖析排在它后面 + const draft = newDraftJob(state, S1, ['orders', 'customers', 'visits']) + Object.assign(draft, { status: 'running', started_at: isoNow() }) + Object.assign(draft.tasks[0], { status: 'ok', finished_at: isoNow() }) + const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) }) + await page.locator('[data-catalog-row]').first().waitFor() + check('页头的起草入口写进度「起草 1/3」', await until(async () => (await page.locator('[data-catalog-draft-head="running"]').getAttribute('aria-label')) === '起草 1/3')) + await openDialog(page) + check('同源有起草任务:按钮写「排队剖析」,下面写起草任务结束后自动开始', (await textOf(page.locator('[data-profile-start]'))).includes('排队剖析') + && (await page.locator('[data-profile-start]').getAttribute('data-profile-start')) === 'queue' + && (await inBox(page, '[data-profile-queue]')).includes('起草任务结束后自动开始')) + await page.locator('[data-profile-start]').click() + await until(async () => (await phase(page)) === 'running') + const job = state.pjobs.at(-1) + check('……建出来是排队,写「排在起草任务后面,起草结束后自动开始」', job.waiting_for === 'draft' && (await inBox(page, '[data-profile-title]')).startsWith('排队中') + && (await inBox(page, '[data-profile-waiting="draft"]')) === '排在起草任务后面,起草结束后自动开始') + await page.locator('[data-profile-hide]').click() + check('……页头写「剖析 排队中」,悬停写排在起草任务后面', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === 'queued') + && (await textOf(head(page))) === '剖析 排队中' && (await head(page).getAttribute('title')).includes('排在起草任务后面')) + await shot(page, 'profile-queued') + // 起草结束、剖析开始 + for (const t of draft.tasks) Object.assign(t, { status: 'ok', finished_at: isoNow() }) + Object.assign(draft, { status: 'succeeded', finished_at: isoNow() }) + settle(state, job, job.tasks[0].table_name, { status: 'running', queries: 2 }) + await goto(page, `/data/catalog/${S1}`) + check('起草结束后剖析开始:页头写「剖析 0/5」', await until(async () => (await head(page).getAttribute('aria-label')) === `剖析 0/${job.tasks.length}`), + await head(page).getAttribute('aria-label').catch(() => '')) + + // 反过来:剖析在进行时起草,排在剖析后面 + await page.locator('[data-catalog-draft]').first().click() + const draftBox = page.locator('[data-draft-dialog]') + await draftBox.waitFor() + check('起草设置:写明有剖析任务在进行,起草会排在它后面', (await textOf(draftBox.locator('[data-draft-queue]'))).includes('起草会排在它后面,剖析结束后自动开始')) + await page.locator('[data-draft-start]').click() + check('……建出来的起草任务排在剖析后面:写「排在剖析任务后面,剖析结束后自动开始」', await until(async () => + (await textOf(draftBox.locator('[data-draft-waiting="profile"]'))) === '排在剖析任务后面,剖析结束后自动开始')) + await page.locator('[data-draft-hide]').click() + check('……目录页上方的起草进度也这么写', await until(async () => (await textOf(page.locator('[data-catalog-draft-progress]'))).includes('起草任务排在剖析任务后面,剖析结束后自动开始')), + await textOf(page.locator('[data-catalog-draft-progress]'))) + check('……页头的起草入口写「起草 排队中」,悬停写排在剖析任务后面', (await textOf(page.locator('[data-catalog-draft-head="queued"]'))) === '起草 排队中' + && (await page.locator('[data-catalog-draft-head="queued"]').getAttribute('title')).includes('排在剖析任务后面')) + stopJob(job) + check('写请求只有建剖析任务、建起草任务', writes(sent, /./).every((s) => /(profile|draft)-jobs$/.test(s.key)), JSON.stringify(writes(sent, /./).map((s) => s.key))) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 表清单的「剖析」一列与筛选', async () => { + const state = freshState() + const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state) }) + await page.locator('[data-catalog-row]').first().waitFor() + const cell = (n) => rowProfile(page, n) + const tone = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-profile]`).getAttribute('data-row-profile').catch(() => null) + check('从没剖析过:「未剖析」,用提醒色', (await cell('orders')) === '未剖析' && (await tone('orders')) === 'due', await cell('orders')) + check('剖析过、做完了:「3 天前剖析」', (await cell('customers')) === '3 天前剖析' && (await tone('customers')) === 'quiet', await cell('customers')) + check('上次没做完:「剖析部分完成」,用提醒色', (await cell('visits')) === '剖析部分完成' && (await tone('visits')) === 'due') + check('剖析之后出现了新的待核实关系:「有新关系待剖析」', (await cell('products')) === '有新关系待剖析' && (await tone('products')) === 'due') + check('上次失败:「剖析失败」,用错误色', (await cell('refunds')) === '剖析失败' && (await tone('refunds')) === 'bad') + check('没有可剖析的项:「无可剖析项」;视图、只剩目录的表不写', (await cell('settings_kv')) === '无可剖析项' + && await page.locator('[data-catalog-row="order_summary"] [data-row-profile]').count() === 0 + && await page.locator('[data-catalog-row="legacy_coupons"] [data-row-profile]').count() === 0) + const title = await page.locator('[data-catalog-row="visits"] [data-row-profile]').getAttribute('title') + check('……悬停写上次剖析的时间、结果和为什么需要剖析', title.startsWith('上次剖析(') && title.includes('部分完成') && title.includes('上次剖析没做完'), title) + const due = page.locator('[data-catalog-profile-due]') + check('「需要剖析」开关带数量 5,审阅进度的四项筛选照旧', (await textOf(due)).replace(/\s+/g, '') === '需要剖析5' + && (await page.locator('[data-catalog-filter] [data-filter]').count()) === 4, await textOf(due)) + await due.click() + const names = await page.locator('[data-catalog-row]').evaluateAll((els) => els.map((e) => e.getAttribute('data-catalog-row'))) + check('打开:只列需要剖析的 5 张', names.join(',') === 'orders,visits,products,refunds,tmp_log', names.join(',')) + check('……写进地址栏(profile=due),按钮标为按下', await until(async () => new URL(page.url()).searchParams.get('profile') === 'due') + && (await due.getAttribute('aria-pressed')) === 'true') + await page.reload({ waitUntil: 'networkidle' }) + await page.locator('[data-catalog-row]').first().waitFor() + check('……刷新之后还在', (await page.locator('[data-catalog-row]').count()) === 5 && (await page.locator('[data-catalog-profile-due]').getAttribute('aria-pressed')) === 'true') + // 筛出来一键剖析:当前筛选结果就是这 5 张 + await openDialog(page) + check('……打开剖析:多一项「当前筛选结果中的 5 张表」', await box(page).locator('[data-profile-scope="filtered"]').count() === 1 + && (await inBox(page, '[data-profile-scope="filtered"]')).startsWith('当前筛选结果中的 5 张表'), await inBox(page, '[data-profile-scope="filtered"]')) + await page.keyboard.press('Escape') + await page.locator('[data-catalog-profile-due]').click() + check('关掉:回到全部 9 张,地址里不留', await until(async () => (await page.locator('[data-catalog-row]').count()) === 9 && new URL(page.url()).search === '')) + await shot(page, 'profile-list') + // 没开剖析的源:每行不写「剖析」,没有开关 + await goto(page, `/data/catalog/${S2}`) + await page.locator('[data-catalog-row]').first().waitFor() + check('没开剖析的源(也没剖析过):不写「剖析」一格,没有「需要剖析」开关', await page.locator('[data-row-profile]').count() === 0 + && await page.locator('[data-catalog-profile-due]').count() === 0) + check('只读、不发写请求', writes(sent, /./).length === 0) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 表详情的「数据剖析」一节', async () => { + const state = freshState() + const replies = { create: [] } + const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}/customers`, { handlers: handlers(state, replies) }) + const sec = page.locator('[data-catalog-section="profile"]') + await sec.waitFor() + const old = state.pjobs[0] + check('上次剖析:结果、时间、发起人、发了几条查询,给「查看这次剖析」', await until(async () => (await textOf(sec.locator('[data-catalog-profile-last]'))).includes('完成')) + && (await textOf(sec.locator('[data-catalog-profile-last]'))).includes('上次剖析:') && (await textOf(sec.locator('[data-catalog-profile-last]'))).includes(`${OTHER} 发起`) + && (await textOf(sec.locator('[data-catalog-profile-last]'))).includes('4 条查询') && await sec.locator(`[data-catalog-profile-job="${old.id}"]`).count() === 1, + await textOf(sec.locator('[data-catalog-profile-last]'))) + check('……行数估算和来源', (await textOf(sec.locator('[data-profile-rows]'))) === '行数:约 12,000 行(来自数据库统计信息)', await textOf(sec.locator('[data-profile-rows]'))) + check('……发现摘要、码值候选', (await textOf(sec.locator('[data-catalog-profile-findings]'))) === '取到 1 列码值候选(2 个含义待填写)' + && await sec.locator('[data-profile-finding="codes"][data-column="level"]').count() === 1) + check('……日期范围', (await textOf(sec.locator('[data-profile-date-range="joined_at"]'))).includes('取值从 2021-03-01 08:00:00 到 2026-10-01 21:30:00')) + check('……跳过的项(按规则不查的遮罩列)', (await textOf(sec.locator('[data-profile-skip="masked"]'))).includes('列已遮罩')) + check('……发过的查询(折着)', (await textOf(sec.locator('[data-profile-queries-log] summary'))) === '看发过的查询(3 条)') + await sec.locator(`[data-catalog-profile-job="${old.id}"]`).click() + check('「查看这次剖析」:取来那个任务,打开它的结果', await until(async () => (await phase(page)) === 'done') + && (await inBox(page, '[data-profile-counts]')).startsWith('4 张表') && count(sent, new RegExp(`^GET .*profile-jobs/${old.id}$`)) >= 1) + await page.locator('[data-profile-close]').click() + await shot(page, 'profile-detail') + + // 剖析这张表:建只含这张表的任务;这张表在任务里时写「正在剖析这张表」和正在做哪一项 + await sec.locator('[data-catalog-profile-table]').click() + check('「剖析这张表」:建只含这张表的任务,提示已开始、可以查看进度', await until(async () => JSON.stringify(lastBody(sent, /profile-jobs$/)) === JSON.stringify({ tables: ['customers'] })) + && await until(async () => (await page.locator('[data-toast]').filter({ hasText: '已开始剖析 customers' }).count()) > 0), await toasts(page)) + const job = state.pjobs.at(-1) + settle(state, job, 'customers', { status: 'running', done: { codes: 1 }, queries: 2, current: '正在取 customers.joined_at 的最小值和最大值' }) + check('……这一节写「正在剖析这张表」和正在做哪一项,按钮不能再点', await until(async () => (await textOf(sec.locator('[data-catalog-profile-live="running"]'))).includes('正在剖析这张表')) + && (await textOf(sec.locator('[data-catalog-profile-live]'))).includes('正在取 customers.joined_at 的最小值和最大值') + && await sec.locator('[data-catalog-profile-table]').isDisabled()) + // 正有剖析任务时剖析别的表(409 正忙):写明进行中的那个到哪了、这张表在不在里面 + await page.locator('[data-catalog-row="products"] [data-catalog-open-row]').click() + await until(async () => (await page.locator('[data-catalog-detail="products"][data-version]').count()) === 1) + await sec.locator('[data-catalog-profile-table]').click() + const blocked = sec.locator('[data-catalog-profile-blocked="busy"]') + check('正忙(409):写服务端原话、进行中的那个到哪了、这张表不在其中', await until(async () => (await blocked.count()) === 1) + && (await textOf(blocked)).includes('正在进行数据剖析') && (await textOf(sec.locator('[data-catalog-profile-busy-where]'))).includes('这张表不在其中,等它结束后再剖析这张表'), + await textOf(blocked)) + await blocked.locator('[data-catalog-profile-progress]').click() + check('……「查看进度」打开进行中的那一个', await until(async () => (await phase(page)) === 'running') && (await inBox(page, '[data-profile-progress]')) === '0 / 1 张表') + await page.locator('[data-profile-hide]').click() + settle(state, job, 'customers', { queries: 4, elapsed_ms: 2000, row_estimate: { rows: 12500, method: 'stats', at_least: null } }) + finish(job) + await page.locator('[data-catalog-row="customers"] [data-catalog-open-row]').click() + check('剖析完:这一节换成这次的结果(行数更新),发起人是本机', await until(async () => (await textOf(sec.locator('[data-profile-rows]'))).includes('约 12,500 行')) + && (await textOf(sec.locator('[data-catalog-profile-last]'))).includes(`${ACTOR} 发起`)) + // 没剖析过、视图、没有可剖析项 + await page.locator('[data-catalog-row="orders"] [data-catalog-open-row]').click() + check('从没剖析过的表:写「还没有剖析过」,给「剖析这张表」', await until(async () => (await textOf(sec.locator('[data-catalog-profile-never]'))) === '这张表还没有剖析过。') + && !(await sec.locator('[data-catalog-profile-table]').isDisabled())) + await page.locator('[data-catalog-row="order_summary"] [data-catalog-open-row]').click() + check('视图:写「视图不剖析」,不给按钮', await until(async () => (await textOf(sec.locator('[data-catalog-profile-never]'))) === '视图不剖析。') + && await sec.locator('[data-catalog-profile-table]').count() === 0) + await page.locator('[data-catalog-row="settings_kv"] [data-catalog-open-row]').click() + check('没有可剖析的项:写明,不给按钮', await until(async () => (await textOf(sec.locator('[data-catalog-profile-never]'))).includes('没有可剖析的项')) + && await sec.locator('[data-catalog-profile-table]').count() === 0) + // 没开剖析的源:引到剖析设置 + await goto(page, `/data/catalog/${S2}/orders`) + await sec.waitFor() + check('没开剖析的源:写明未开启,「剖析这张表」不能点,给「开启数据剖析」', (await textOf(sec.locator('[data-catalog-profile-disabled]'))).includes('未开启数据剖析') + && await sec.locator('[data-catalog-profile-table]').isDisabled()) + await sec.locator('[data-catalog-profile-enable]').click() + check('……点开剖析设置', await until(async () => (await page.locator('[data-profile-settings-dialog]').count()) === 1)) + await page.keyboard.press('Escape') + check('写请求只有建任务', writes(sent, /./).every((s) => /profile-jobs$/.test(s.key)), JSON.stringify(writes(sent, /./).map((s) => s.key))) + check('没有原生对话框', natives.length === 0, natives.join(' | ')) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await section('剖析 · 窄屏', async () => { + const state = freshState() + const { page, errors, close } = await open(`/data/catalog/${S1}`, { handlers: handlers(state), viewport: { width: 390, height: 844 } }) + await page.locator('[data-catalog-row]').first().waitFor() + const overflow = () => page.evaluate(() => { + const root = document.querySelector('[data-catalog-page]') + return root ? root.scrollWidth - root.clientWidth : 99 + }) + check('390 宽:表清单带「剖析」一格,不横向滚动', await page.locator('[data-row-profile]').count() > 0 && await overflow() <= 1, String(await overflow())) + await openDialog(page) + await page.locator('[data-profile-start]').click() + await until(async () => (await phase(page)) === 'running') + const job = state.pjobs.at(-1) + settle(state, job, 'orders', { status: 'running', done: { relation: 3, codes: 5, date: 1 }, queries: 23, elapsed_ms: 18000, + current: '正在核对 orders.coupon_id → coupons.id,这一条要先抽样再到被指向表里核对' }) + await until(async () => (await inBox(page, '[data-profile-task="orders"] [data-profile-task-checks]')) !== '') + await taskLine(page, 'orders').locator('[data-profile-task-toggle]').click() + await until(async () => (await box(page).locator('[data-profile-checks]').count()) === 1) + const d = await page.locator('[role="dialog"]').last().boundingBox() + const inner = await box(page).evaluate((el) => el.scrollWidth - el.clientWidth) + check('……进行中、展开一张表:对话框在视口里,正文不横向滚动', !!d && d.x >= 0 && d.x + d.width <= 390 && inner <= 1, `${JSON.stringify(d)} ${inner}`) + await shot(page, 'profile-narrow') + stopJob(job) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + +await browser.close() + +const after = await realCounts() +check('真库里的数据源和目录数量没有变化', JSON.stringify(after) === JSON.stringify(before), `${JSON.stringify(before)} → ${JSON.stringify(after)}`) +check('检查用的假数据源没有写进真库', !(await (await fetch(`${API}/datasources`)).json()).some((s) => NAMES.includes(s.name))) + +console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 数据剖析全部通过') +process.exit(failed ? 1 : 0) diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index 2e762729..7bfa38f5 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -6,8 +6,7 @@ // 起草是后台任务:查的是「起草要求和补充说明有没有随请求发出」「关掉对话框任务照常进行、页面上有进度、再打开就是它」 // 「能停」「哪张表没写全、为什么写得清」「能只重试失败的」「已有任务、模型用不了这两种 409 各有去处」「清单上标出上次 // 起草失败的表、能单独重起草」。任务怎么往前走由检查自己推,页面每 1.5 秒取一次。 -// 数据剖析(阶段 4B)会对业务库发查询:剖析接口、保存数据源一律在浏览器层答掉,查的是「确认前写没写预算」「进行中是否 -// 明说停不下来」「报告说没说清用了几条查询、为什么停、跳过了什么」「各种 409 有没有下一步」「设置被拒时报错落没落到那一项」。 +// 数据剖析(后台任务、逐表进行)另有 check-catalog-profile;这里只在窄屏一节看剖析对话框放不放得下,剖析的建任务一律拦成 503。 // // 写法同 check-versions:沙箱里的数据源多半没探查过结构,目录也是空的,所以数据源列表、目录清单、单表目录都用 // page.route 伪造(虚构的景区业务库和一个导入表格的源);写请求(整份提交、单项审阅、起草)也在浏览器层答掉, @@ -646,8 +645,9 @@ function catalogHandlers(state, replies = {}) { const name = parts(key)[4] return json({ table: name, templates: state.impact?.[name] ?? [] })(route) })], - // 剖析没配答复的一律 503:检查脚本不能让剖析真的发到业务库 - [/^POST \/datasources\/check-cat-[a-z]+\/catalog\/profile$/, reply('profile', json({ detail: '检查脚本没有为这次剖析准备答复' }, 503))], + // 剖析任务(数据剖析的检查在 check-catalog-profile):这里没有剖析任务,建任务一律 503,不让剖析真的发到业务库 + [/^GET \/datasources\/check-cat-[a-z]+\/catalog\/profile-jobs\/(active|latest)$/, json({ job: null })], + [/^POST \/datasources\/check-cat-[a-z]+\/catalog\/profile-jobs(\/.*)?$/, json({ detail: '检查脚本没有为这次剖析准备答复' }, 503)], // 保存数据源(剖析设置):照提交改假状态,回整条数据源 [/^PATCH \/datasources\/check-cat-[a-z]+$/, reply('patch', (route, { key, body }) => { const id = parts(key)[2] @@ -2259,91 +2259,8 @@ await section('数据目录 · 空状态与导入表格', async () => { await close() }) -// --------------------------------------------------------------------------- -// 数据剖析(阶段 4B):剖析会对业务库发查询,这里一条都不能真的发出去——剖析接口和保存数据源都在浏览器层答掉, -// 照服务端(data/catalog_profile.py)的结论改假状态 -// --------------------------------------------------------------------------- -const GATE_REL = relId(['gate_id'], 'gates', ['id']) -const MEMBER_REL = relId(['member_id'], 'members', ['id']) -const PROFILE_DAY = '2026-10-03' -const PROFILE_ON = { enabled: true, max_queries: 60, query_timeout_s: 10 } -const withProfile = (state, src, value) => { - state.sources = state.sources.map((s) => (s.id === src ? { ...s, options: { ...s.options, catalog_profile: value } } : s)) -} -const BUDGET_DETAIL = '本次剖析的查询次数已用完(上限 60 条),未检查' - -/** - * 剖析的默认答复:入园记录的闸机关系核实成已验证(覆盖率 98.7%),会员关系覆盖率不够保持推断,状态列的码值候选多出一个 - * 没有含义的取值 2;订单只剖析了一半就用完了查询次数,订单明细一条没查。ms 是服务端「执行」多久 - */ -function profileReply(state, { ms = 0, extraCodes = false } = {}) { - return async (route) => { - if (ms) await sleep(ms) - const visits = state.tables[S1].find((t) => t.name === 'visits') - const notes = clone(visits.notes) - const at = `${PROFILE_DAY}T03:00:00+00:00` - // 剖析之前这两条关系的状态(服务端 previous_status):第一次闸机关系是命名推断,再剖析一次时已经是已验证 - const before = (id) => visits.notes.relations.find((r) => r.id === id)?.status ?? null - const previous = { gate: before(GATE_REL), member: before(MEMBER_REL) } - notes.relations = notes.relations.map((r) => (r.id === GATE_REL ? { - ...r, source: 'profile', status: 'verified', coverage: 0.987, cardinality: 'many_to_one', cardinality_checked: true, updated_at: at, - note: '数据剖析:子表抽样 300 个不同键值,父表对上 296 个,覆盖率 98.7%;被指向列是主键;子表一侧有重复值,多对一。', - } : r)) - notes.relations.push(rel(['member_id'], 'members', ['id'], 'profile', { coverage: 0.62, updated_at: at, - note: '数据剖析:抽样覆盖率 62%,可能不是这条关系;子表抽样 200 个不同键值,父表对上 124 个;被指向列是主键。' })) - notes.columns.status.codes = { ...item({ 1: '已入园', 9: '已作废', 2: '' }, 'profile', 'proposed'), updated_at: at } - notes.columns.status.codes.note = '数据剖析:统计全表,182000 行非空值共 3 个取值:1(94%)、9(5%)、2(1%)。' - // 统计的是全表:服务端给码值候选标「已列全」 - notes.columns.status.codes.complete = true - // extraCodes:票种列也取到码值候选(两个取值,含义都空着)——报告里有两列要填,填完一列回到报告接着填下一列 - if (extraCodes) notes.columns.ticket_type_id = { codes: item({ 11: '', 12: '' }, 'profile', 'proposed') } - writeEntry(visits, notes) - const tables = [ - { - table_name: 'visits', queries: 38, row_estimate: { rows: 182000, method: 'stats', at_least: null }, - findings: [ - { kind: 'relation', path: `relations.${GATE_REL}`, target: 'gate_id → gates.id', columns: ['gate_id'], to_table: 'gates', to_columns: ['id'], - status: 'verified', confirmed: false, previous_status: previous.gate, coverage: 0.987, cardinality: 'many_to_one', cardinality_checked: true, - sample: 300, matched: 296, - summary: previous.gate === 'verified' ? '覆盖率 98.7%,仍为有确证' : '覆盖率 98.7%,升为有确证' }, - { kind: 'relation', path: `relations.${MEMBER_REL}`, target: 'member_id → members.id', columns: ['member_id'], to_table: 'members', - to_columns: ['id'], status: 'proposed', confirmed: false, previous_status: previous.member, coverage: 0.62, cardinality: 'many_to_one', - cardinality_checked: false, sample: 200, matched: 124, - summary: '抽样覆盖率 62%,保持推断' }, - { kind: 'codes', path: 'columns.status.codes', column: 'status', values: [{ value: '1', rows: 171080 }, { value: '9', rows: 9100 }, - { value: '2', rows: 1820 }], rows: 182000, status: 'proposed', pending: 1, summary: '3 个取值' }, - ...(extraCodes ? [{ kind: 'codes', path: 'columns.ticket_type_id.codes', column: 'ticket_type_id', values: [{ value: '11', rows: 120000 }, - { value: '12', rows: 62000 }], rows: 182000, status: 'proposed', pending: 2, summary: '2 个取值' }] : []), - ], - skipped: [ - { kind: 'codes', target: 'member_level', path: 'columns.member_level.codes', reason: 'masked', detail: 'visits.member_level 在数据源设置中被遮罩,不取值' }, - { kind: 'date', target: 'visit_time', path: 'business_date', reason: 'too_large', - detail: 'visits 的行数超过整表统计的行数上限(100000 行),不取日期列的最小值和最大值' }, - ], - date_ranges: [], added: 1, updated: 2, removed: 0, version: visits.version, error: null, - }, - { - table_name: 'orders', queries: 22, row_estimate: { rows: null, method: 'count', at_least: 100001 }, findings: [], - skipped: [{ kind: 'relation', target: 'member_id → members.id', path: 'relations.x', reason: 'budget', detail: BUDGET_DETAIL }], - date_ranges: [], added: 0, updated: 0, removed: 0, version: 1, error: null, - }, - { - table_name: 'order_items', queries: 0, row_estimate: null, findings: [], - skipped: [{ kind: 'table', target: 'order_items', path: null, reason: 'budget', detail: BUDGET_DETAIL }], - date_ranges: [], added: 0, updated: 0, removed: 0, version: 1, error: null, - }, - ] - return json({ - profiled_at: at, actor: ACTOR, settings: { ...PROFILE_ON, sample_size: 2000, max_scan_rows: 100000, max_total_s: 120 }, - queries_used: 60, stopped: 'budget', tables, total: { added: 1, updated: 2, removed: 0 }, note: null, - empty_tables: ['ext_log_001', 'ext_log_002'], - })(route) - } -} +/** 数据剖析对话框(窄屏一节看它放不放得下) */ const profileBox = (page) => page.locator('[data-profile-dialog]') -const profilePhase = (page) => profileBox(page).getAttribute('data-profile-dialog').catch(() => null) -/** 报告里不该露的机读码:停止原因、跳过原因、状态、基数的英文值 */ -const PROFILE_CODE_RE = /\b(?:budget|deadline|failed|masked|too_large|high_cardinality|no_data|unsupported|verified|proposed|many_to_one|row_estimate)\b/ await section('数据目录 · 只探查了一部分表', async () => { // 探查结构每个数据源最多取 200 张表:多出来的不在清单里,助手也看不到。顶部要说出来,并给出下一步 @@ -2377,405 +2294,6 @@ await section('数据目录 · 只探查了一部分表', async () => { await close() }) -await section('数据目录 · 数据剖析设置', async () => { - const state = freshState() - const replies = { patch: [] } - const { page, sent, errors, natives, close } = await open('/data/databases', { handlers: catalogHandlers(state, replies) }) - // 手工登记的库:编辑框里有「数据剖析」一段,默认关闭,预算收着 - await page.locator('[data-source="zzcatscenic"]').getByRole('button', { name: '编辑', exact: true }).click() - const box = page.locator('[data-profile-settings]') - await box.waitFor() - check('编辑框里有「数据剖析」,默认关闭、预算收着', (await box.getAttribute('data-profile-settings')) === 'off' - && await box.locator('[data-profile-field]').count() === 0 && (await box.innerText()).includes('默认关闭')) - await box.locator('[data-profile-enabled]').check() - const labels = await box.locator('label.label').allInnerTexts() - check('开启后五项预算展开,中文标签', labels.join('、') === '查询次数上限、单条查询时限、抽样键值数、整表统计行数上限、总时长上限', labels.join('、')) - const units = await box.locator('[data-profile-field] span').allInnerTexts() - check('……每项带单位', units.join(',') === '条,秒,个,行,秒', units.join(',')) - const hints = await box.locator('[id$="-hint"]').allInnerTexts() - check('……写明范围和默认值,整表统计写明 0 的含义', hints.some((h) => h.startsWith('范围 1–500 条,默认 60')) - && hints.some((h) => h.includes('范围 0–10,000,000 行') && h.includes('填 0 表示一律不做整表统计')), hints.join(' | ')) - const risk = () => box.locator('[data-profile-risk]').innerText().catch(() => '') - check('……风险说明:会发出只读查询,每次最多 60 条、单条 10 秒(缺省)', (await risk()).includes('只读查询:最多 60 条,单条不超过 10 秒'), await risk()) - await page.locator('#ds-profile-max_queries').fill('80') - check('……风险说明跟着填的数走', (await risk()).includes('最多 80 条'), await risk()) - await page.locator('#ds-profile-max_queries').fill('0') - const err = await page.locator('#ds-profile-max_queries-error').innerText().catch(() => '') - check('填错(0 条):就地写明范围,保存按钮禁用', err === '请填写 1 到 500 之间的整数' && await page.locator('[data-source-save]').isDisabled(), err) - check('……输入框标出错(aria-invalid)', (await page.locator('#ds-profile-max_queries').getAttribute('aria-invalid')) === 'true') - await page.locator('#ds-profile-max_queries').fill('80') - await page.locator('#ds-profile-query_timeout_s').fill('20') - // 服务端按字段拒收(422):报错落在那一项下面,光标放进去,不只弹提示 - const REJECT = '数据剖析的「单条查询时限(秒)」需要填写 1 到 60 之间的数;当前为「20」' - // 服务端带机读码和出错的那一项(api/coded.py:code + field),界面按它落到那一格,不从原话里认 - replies.patch.push(json({ detail: REJECT, code: 'profile_settings_invalid', field: 'query_timeout_s' }, 422)) - await page.locator('[data-source-save]').click() - check('保存被拒(422):服务端原话写在「单条查询时限」下面', await until(async () => - (await page.locator('#ds-profile-query_timeout_s-error').innerText().catch(() => '')) === REJECT), - await page.locator('#ds-profile-query_timeout_s-error').innerText().catch(() => '')) - check('……光标落在那一项上,框标出错', await until(async () => page.evaluate(() => document.activeElement?.id === 'ds-profile-query_timeout_s')) - && (await page.locator('#ds-profile-query_timeout_s').getAttribute('aria-invalid')) === 'true') - check('……没有落到「高级连接参数」的查询时限上', await page.locator('#ds-adv-query_timeout_s-error').count() === 0) - if (SHOTS) await sleep(350) - await shot(page, 'catalog-profile-settings-422') - await page.locator('#ds-profile-query_timeout_s').fill('15') - check('改了这一项,报错收起', await page.locator('#ds-profile-query_timeout_s-error').count() === 0) - await page.locator('[data-source-save]').click() - await until(async () => (await page.locator('[data-profile-settings]').count()) === 0) - const saved = writes(sent, /^PATCH /).at(-1)?.body - check('保存:options 里写剖析设置(开关和填了的项,数字)', JSON.stringify(saved?.options?.catalog_profile) - === JSON.stringify({ enabled: true, max_queries: 80, query_timeout_s: 15 }), JSON.stringify(saved?.options)) - check('……卡片上标「数据剖析已开启」,悬停写明预算', await until(async () => (await page.locator('[data-source="zzcatscenic"] [data-profile-on]').count()) === 1) - && (await page.locator('[data-source="zzcatscenic"] [data-profile-on]').getAttribute('title')) === '数据剖析已开启:每次最多 80 条只读查询,单条不超过 15 秒') - // 再打开:剖析设置回显,没有摊进「高级连接参数」变成一串文字 - await page.locator('[data-source="zzcatscenic"]').getByRole('button', { name: '编辑', exact: true }).click() - await page.locator('[data-profile-settings="on"]').waitFor() - check('再打开编辑框:开关和填的数回显', await page.locator('#ds-profile-max_queries').inputValue() === '80' - && await page.locator('#ds-profile-query_timeout_s').inputValue() === '15') - const advanced = await page.locator('[data-profile-settings] ~ details, details').allInnerTexts() - check('……剖析设置没有摊进「高级连接参数」', !advanced.join(' ').includes('catalog_profile') && !advanced.join(' ').includes('[object Object]')) - await page.getByRole('button', { name: '取消', exact: true }).click() - - // 导入表格的源没有编辑框:卡片上单独设置,options 的其余键原样带上 - withProfile(state, S2, undefined) - state.sources = state.sources.map((s) => (s.id === S2 ? { ...s, options: { mask_columns: ['联系电话'] } } : s)) - await goto(page, '/data/tables') - await page.locator('[data-source="zzcatupload"] [data-edit-profile]').click() - const dialog = page.locator('[data-profile-settings-dialog]') - await dialog.waitFor() - check('导入表格的源:卡片上「设置数据剖析」打开只改这一项的弹窗', (await page.locator('[role="dialog"]').last().innerText()).includes('「zzcatupload」的数据剖析设置')) - check('……没改时保存禁用', await page.locator('[data-profile-save]').isDisabled()) - await dialog.locator('[data-profile-enabled]').check() - await page.locator('[data-profile-save]').click() - await until(async () => (await dialog.count()) === 0) - const body = writes(sent, /^PATCH \/datasources\/check-cat-upload$/).at(-1)?.body - check('……保存只换剖析设置,遮罩列原样带上', JSON.stringify(body) === JSON.stringify({ options: { mask_columns: ['联系电话'], catalog_profile: { enabled: true } } }), - JSON.stringify(body)) - check('……卡片上标「数据剖析已开启」', await until(async () => (await page.locator('[data-source="zzcatupload"] [data-profile-on]').count()) === 1)) - check('写请求只有保存数据源', writes(sent, /./).every((s) => /^PATCH \/datasources\/check-cat-/.test(s.key)), - JSON.stringify(writes(sent, /./).map((s) => s.key))) - check('没有原生对话框', natives.length === 0, natives.join(' | ')) - check('没有运行时报错', errors.length === 0, errors[0] ?? '') - await close() -}) - -await section('数据目录 · 数据剖析', async () => { - const state = freshState() - withProfile(state, S1, PROFILE_ON) - const replies = { profile: [] } - const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) }) - await page.locator('[data-catalog-row]').first().waitFor() - const head = page.locator('[data-catalog-profile]') - check('页头有「数据剖析」', (await head.getAttribute('aria-label')) === '数据剖析') - await head.click() - await profileBox(page).waitFor() - const setup = await profileBox(page).innerText() - check('没选表、没筛选:范围只有「使用次数最多、有待核实关联关系的表」,写明至多 10 张', - (await profileBox(page).locator('[data-profile-scope]').evaluateAll((els) => els.map((e) => e.getAttribute('data-profile-scope')))).join(',') === 'default' - && setup.includes('使用次数最多、有待核实关联关系的表') && setup.includes('至多 10 张')) - const budget = await profileBox(page).locator('[data-profile-budget]').innerText() - check('确认前写明预算:查询条数、单条和总时长、抽样规模、整表统计门槛,按哪个源的设置', - ['最多 60 条只读查询,单条不超过 10 秒', '总时长不超过 120 秒', '抽样 2,000 个键值', '行数超过 100,000 的表不做整表统计', '按「zzcatscenic」的数据剖析设置'] - .every((s) => budget.includes(s)), budget.replace(/\s+/g, ' ')) - check('……写明开始后无法中途停止', setup.includes('开始后无法中途停止')) - check('……还没开始就没有写请求', writes(sent, /./).length === 0) - if (SHOTS) await sleep(350) - await shot(page, 'catalog-profile-setup') - await page.keyboard.press('Escape') - await until(async () => (await profileBox(page).count()) === 0) - - // 打开入园记录的详情,选两张表剖析:完成后清单和正在看的详情都要重新载入 - await openDetail(page, S1, 'visits') - const v0 = await version(page) - check('剖析前:闸机关系是「推断 · 命名推断」,没有覆盖率', (await markStatus(page, `relations.${GATE_REL}`)) === 'proposed' - && (await page.locator(`[data-relation="${GATE_REL}"]`).innerText()).includes('—')) - await page.locator('[data-catalog-select="visits"]').click() - await page.locator('[data-catalog-select="orders"]').click() - await page.locator('[data-catalog-profile-selected]').click() - await profileBox(page).waitFor() - check('剖析所选:范围默认是「已选的 2 张表」', await profileBox(page).locator('[data-profile-scope="selected"] input').isChecked() - && (await profileBox(page).locator('[data-profile-scope="selected"]').innerText()).includes('已选的 2 张表')) - replies.profile.push(profileReply(state, { ms: 2600 })) - const lists0 = count(sent, /^GET \/datasources\/check-cat-scenic\/catalog$/) - await page.locator('[data-profile-start]').click() - check('进行中:写「正在剖析…」和已用时长、总时长上限', await until(async () => (await profilePhase(page)) === 'running') - && (await profileBox(page).locator('[data-profile-title]').innerText()) === '正在剖析…' - && (await profileBox(page).locator('[data-profile-elapsed]').innerText()).includes('(总时长上限 2 分 00 秒)')) - check('……时长在走(不是一直「不到 1 秒」)', await until(async () => Number(await profileBox(page).locator('[data-profile-elapsed]').getAttribute('data-profile-elapsed')) >= 1, 2500)) - check('……不给「停止」,明说无法中途停止', await profileBox(page).getByRole('button', { name: /停止|取消/ }).count() === 0 - && (await profileBox(page).locator('[data-profile-no-cancel]').innerText()).includes('无法中途停止')) - const req = writes(sent, /catalog\/profile$/).at(-1) - check('……请求带所选的两张表和本机署名', JSON.stringify([...(req?.body?.tables ?? [])].sort()) === JSON.stringify(['orders', 'visits']) - && decodeURIComponent(req?.actor ?? '') === ACTOR, JSON.stringify(req?.body)) - if (SHOTS) await sleep(350) - await shot(page, 'catalog-profile-running') - // 关掉窗口剖析照常进行:页头换成计时,完成后提示并可以打开报告 - await page.locator('[data-profile-background]').click() - check('「在后台继续」:窗口关掉,页头换成「正在剖析」', await until(async () => (await profileBox(page).count()) === 0) - && (await page.locator('[data-catalog-profile="running"]').innerText()).includes('正在剖析')) - const toastBox = page.locator('[data-toast]').filter({ hasText: '数据剖析已停止' }) - check('完成:提示「数据剖析已停止:查询次数已用完」,带「查看报告」', await until(async () => (await toastBox.count()) > 0, 6000), - await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => '')) - check('……页头回到「数据剖析」', await page.locator('[data-catalog-profile="running"]').count() === 0) - check('……写进去的表重新载入:清单重取、正在看的详情版本更新', await until(async () => count(sent, /^GET \/datasources\/check-cat-scenic\/catalog$/) > lists0) - && await until(async () => (await version(page)) === v0 + 1)) - await toastBox.getByRole('button', { name: '查看报告' }).click() - await profileBox(page).waitFor() - const report = profileBox(page).locator('[data-profile-report]') - check('报告:停止原因说中文', (await report.locator('[data-profile-title]').innerText()) === '剖析已停止:查询次数已用完') - check('……用了多少条查询、剖析几张表', (await report.locator('[data-profile-summary]').innerText()).startsWith('用了 60 / 60 条查询,剖析 3 张表')) - check('……目录的变化', (await report.locator('[data-profile-changes]').innerText()) === '新增 1 项、更新 2 项') - check('……停下之后怎么办:调高查询次数上限或缩小范围,给「修改设置」', (await report.locator('[data-profile-stopped="budget"]').innerText()).includes('调高查询次数上限') - && await report.locator('[data-profile-stopped] [data-profile-settings-open]').count() === 1) - const visitsCard = report.locator('[data-profile-table="visits"]') - const gate = visitsCard.locator(`[data-profile-finding="relation"][data-path="relations.${GATE_REL}"]`) - check('关系核实:覆盖率、基数、抽样规模,升为已验证', (await gate.locator('[data-profile-coverage]').innerText()) === '覆盖率 98.7%' - && (await gate.locator('[data-profile-cardinality]').innerText()) === '多对一(已用数据核实)' && (await gate.innerText()).includes('抽样 300 个键值,对上 296 个') - && (await gate.locator('[data-profile-outcome]').innerText()) === '升为已验证' && (await gate.getAttribute('data-status')) === 'verified', - (await gate.innerText()).replace(/\s+/g, ' ')) - // 结论是状态说明,不是链接:带框的小标签,文字用次要文字色(不是强调蓝),不能点 - const outcomeLook = await gate.locator('[data-profile-outcome]').evaluate((el) => { - const probe = document.createElement('span') - probe.style.color = 'var(--accent)' - document.body.append(probe) - const accent = getComputedStyle(probe).color - probe.style.color = 'var(--text-dim)' - const dim = getComputedStyle(probe).color - probe.remove() - const cs = getComputedStyle(el) - return { tag: el.tagName, color: cs.color, accent, dim, border: cs.borderTopStyle, cursor: cs.cursor, kind: el.getAttribute('data-profile-outcome'), - clickable: !!el.closest('a, button') } - }) - check('……结论用状态标签的样子:带框、次要文字色,不是蓝色正文、不像链接', outcomeLook.kind === 'raised' && outcomeLook.color === outcomeLook.dim - && outcomeLook.color !== outcomeLook.accent && outcomeLook.border === 'solid' && outcomeLook.cursor !== 'pointer' && !outcomeLook.clickable, - JSON.stringify(outcomeLook)) - // 结构化的读数:基数核实读 cardinality_checked,几个含义待填写读 pending(假答复的 summary 里故意不写),空表读 empty_tables - check('……基数用数据核实过没有,读 cardinality_checked', (await gate.locator('[data-profile-cardinality]').getAttribute('data-checked')) === 'true' - && (await visitsCard.locator(`[data-path="relations.${MEMBER_REL}"] [data-profile-cardinality]`).innerText()) === '多对一(子表一侧未核实,按表结构推断)') - check('……挑表时跳过的空表读 empty_tables', (await report.locator('[data-profile-empty-tables="2"]').innerText().catch(() => '')) - === '挑表时跳过 2 张空表:ext_log_001、ext_log_002') - const member = visitsCard.locator(`[data-profile-finding="relation"][data-path="relations.${MEMBER_REL}"]`) - check('……覆盖率不够的保持推断,写明原因', (await member.locator('[data-profile-outcome]').innerText()) === '保持推断:覆盖率不足,可能不是这条关系' - && (await member.locator('[data-profile-coverage]').innerText()) === '覆盖率 62%') - const codes = visitsCard.locator('[data-profile-finding="codes"]') - check('码值候选:列、各取值的行数、几个含义待填写,给「填写含义」', (await codes.getAttribute('data-column')) === 'status' - && (await codes.innerText()).includes('3 个取值,共 182,000 行') && (await codes.innerText()).includes('171,080 行') - && (await codes.locator('[data-codes-pending]').innerText()) === '1 个含义待填写' && await codes.locator('[data-profile-fill="status"]').count() === 1, - (await codes.innerText()).replace(/\s+/g, ' ')) - const skipped = visitsCard.locator('[data-profile-skipped]') - check('跳过的项:原因说中文,附服务端的整句', (await skipped.innerText()).includes('列已遮罩') && (await skipped.innerText()).includes('visits.member_level 在数据源设置中被遮罩,不取值') - && (await skipped.innerText()).includes('表太大'), (await skipped.innerText()).replace(/\s+/g, ' ')) - check('……行数估算写成人话:统计信息约 182,000 行、数到上限的超过 100,000 行', - (await visitsCard.locator('header').innerText()).includes('约 182,000 行') && (await report.locator('[data-profile-table="orders"] header').innerText()).includes('超过 100,000 行')) - check('……一条没查的表写明「查询次数用完」', (await report.locator('[data-profile-table="order_items"] [data-profile-skip="budget"]').innerText()).includes('查询次数用完')) - const reportText = await report.innerText() - check('报告不露机读码', !PROFILE_CODE_RE.test(reportText), reportText.match(PROFILE_CODE_RE)?.[0] ?? '') - if (SHOTS) await sleep(350) - await shot(page, 'catalog-profile-report') - await page.locator('[data-profile-close]').click() - await until(async () => (await profileBox(page).count()) === 0) - - // 表详情:关系的来源写「数据剖析」,带覆盖率和基数;码值的含义空着写「含义待填写」 - const gateRow = page.locator(`[data-relation="${GATE_REL}"]`) - check('表详情:闸机关系「已验证 · 数据剖析」,覆盖率 98.7%、多对一', (await markStatus(page, `relations.${GATE_REL}`)) === 'verified' - && (await gateRow.innerText()).includes('数据剖析') && (await gateRow.innerText()).includes('98.7%') && (await gateRow.innerText()).includes('多对一'), - (await gateRow.innerText().catch(() => '')).replace(/\s+/g, ' ')) - await openMark(page, `relations.${GATE_REL}`) - check('……点开来源:写明抽样规模和覆盖率的剖析说明', (await panel(page).innerText()).includes('子表抽样 300 个不同键值,父表对上 296 个')) - // 说明里不带日期:时间看这一项的 updated_at(按本地时间,2026-10-03 03:00 UTC 是 11:00) - check('……说明不带日期,时间按这一项的 updated_at 写', !/\d{4}-\d{2}-\d{2}/.test(await panel(page).locator('.whitespace-pre-wrap').innerText()) - && /更新于 (2026\/)?10\/3 11:00/.test(await panel(page).innerText()), (await panel(page).innerText()).replace(/\s+/g, ' ')) - check('……关系表上写基数已用数据核实', await page.locator(`[data-relation="${GATE_REL}"] [data-relation-cardinality][data-checked="true"]`).count() === 1 - && (await page.locator(`[data-relation="${GATE_REL}"] [data-relation-cardinality]`).innerText()).includes('已核实')) - await page.keyboard.press('Escape') - const statusCodes = page.locator('[data-column="status"] [data-cell="codes"]') - check('……码值的含义空着写「含义待填写」,给「填写含义」', (await statusCodes.locator('[data-code="2"] [data-code-pending]').innerText()) === '含义待填写' - && (await statusCodes.locator('[data-codes-fill]').innerText()).includes('填写含义')) - // 从报告点「填写含义」:窗口关掉,打开这张表那一列的码值弹窗,光标在空着的那一个上 - await page.locator('[data-catalog-profile]').click() - await profileBox(page).waitFor() - check('页头再点:上一次的报告看过了就回到选范围', (await profilePhase(page)) === 'setup') - await page.keyboard.press('Escape') - await statusCodes.locator('[data-codes-fill]').click() - const codesBox = page.locator('[data-codes-dialog="status"]') - await codesBox.waitFor() - check('码值弹窗:每个码值一个输入框,空着的写「含义待填写」,光标在它上面', await codesBox.locator('input[data-code]').count() === 3 - && (await codesBox.locator('input[data-code="2"]').getAttribute('placeholder')) === '含义待填写' - && await until(async () => page.evaluate(() => document.activeElement?.getAttribute('data-code') === '2'))) - check('……写明剖析说明(各取值的占比),时间按这一项的 updated_at', (await codesBox.innerText()).includes('1(94%)、9(5%)、2(1%)') - && /^((2026\/)?10\/3 11:00)$/.test(await codesBox.locator('[data-codes-note-at]').innerText().catch(() => '')), - await codesBox.locator('[data-codes-note]').innerText().catch(() => '')) - check('……没填时保存禁用', await page.locator('[data-codes-save]').isDisabled()) - check('……写明当前已列出全部取值(剖析统计了全表),可以取消', await codesBox.locator('[data-codes-complete]').isChecked() - && (await codesBox.innerText()).includes('已列出全部取值') && (await statusCodes.locator('[data-codes-complete-mark]').count()) === 1) - await codesBox.locator('[data-codes-complete]').uncheck() - check('……只改勾选也能保存', !(await page.locator('[data-codes-save]').isDisabled())) - await codesBox.locator('[data-codes-complete]').check() - await codesBox.locator('input[data-code="2"]').fill('已退票') - if (SHOTS) await sleep(350) - await shot(page, 'catalog-codes-fill') - const v1 = await version(page) - await page.keyboard.press('Enter') - check('回车保存:整份提交,只换这一列码值的含义,带读到的版本', await until(async () => (await codesBox.count()) === 0) - && JSON.stringify(lastBody(sent, /^PUT .*catalog\/visits$/)?.notes?.columns?.status?.codes?.value) === JSON.stringify({ 1: '已入园', 9: '已作废', 2: '已退票' }) - && lastBody(sent, /^PUT .*catalog\/visits$/)?.notes?.columns?.status?.codes?.complete === true - && lastBody(sent, /^PUT .*catalog\/visits$/)?.if_version === v1, JSON.stringify(lastBody(sent, /^PUT .*catalog\/visits$/)?.notes?.columns?.status)) - check('……保存后不再有「含义待填写」,这一项记为人工确认', await until(async () => (await statusCodes.locator('[data-code-pending]').count()) === 0) - && (await statusCodes.innerText()).includes('已退票') && (await markStatus(page, 'columns.status.codes')) === 'confirmed') - // 报告里的「填写含义」直达 - await page.locator('[data-catalog-profile]').click() - replies.profile.push(profileReply(state, { extraCodes: true })) - await page.locator('[data-profile-start]').click() - await until(async () => (await profilePhase(page)) === 'done') - check('窗口开着时完成:直接看报告,不另弹提示', (await toastBox.count()) <= 1) - // 重新剖析:闸机关系上一次已经核实成已验证,这次照样核实——写「仍为已验证」,不说「升为已验证」 - const gateAgain = profileBox(page).locator(`[data-profile-finding="relation"][data-path="relations.${GATE_REL}"] [data-profile-outcome]`) - check('重新剖析:原本就是已验证的关系写「仍为已验证,覆盖率 98.7%」,不说「升为」', (await gateAgain.innerText()) === '仍为已验证,覆盖率 98.7%' - && (await gateAgain.getAttribute('data-profile-outcome')) === 'same', await gateAgain.innerText()) - const memberAgain = profileBox(page).locator(`[data-profile-finding="relation"][data-path="relations.${MEMBER_REL}"] [data-profile-outcome]`) - check('……原本就是推断、这次仍不够的写「保持推断」', (await memberAgain.innerText()) === '保持推断:覆盖率不足,可能不是这条关系' - && (await memberAgain.getAttribute('data-profile-outcome')) === 'kept') - // 点「填写含义」:报告收起、弹出那一列的码值;保存或取消之后回到报告,这一列改写成「含义已填写」,焦点落在下一列上 - const fillLine = (col) => profileBox(page).locator(`[data-profile-finding="codes"][data-column="${col}"]`) - await fillLine('status').locator('[data-profile-fill]').click() - const fillBox = page.locator('[data-codes-dialog="status"]') - check('报告里点「填写含义」:报告收起,弹出这一列的码值', await until(async () => (await fillBox.count()) === 1) - && (await profileBox(page).count()) === 0) - await fillBox.locator('input[data-code="2"]').fill('已退票') - await page.keyboard.press('Enter') - check('填完保存:回到报告(不用重新剖析)', await until(async () => (await profilePhase(page)) === 'done') && (await fillBox.count()) === 0 - && count(sent, /catalog\/profile$/) === 2, String(await profilePhase(page))) - const statusLine = fillLine('status') - check('……刚填的这一列写「含义已填写」,按钮改成「修改含义」', (await statusLine.locator('[data-codes-filled]').innerText().catch(() => '')).includes('含义已填写') - && await statusLine.locator('[data-codes-pending]').count() === 0 && (await statusLine.locator('[data-profile-fill]').innerText()) === '修改含义', - (await statusLine.innerText().catch(() => '')).replace(/\s+/g, ' ')) - check('……还没填的下一列照旧写待填写,焦点落在它的「填写含义」上', (await fillLine('ticket_type_id').locator('[data-codes-pending]').innerText()) === '2 个含义待填写' - && await until(async () => page.evaluate(() => document.activeElement?.getAttribute('data-profile-fill') === 'ticket_type_id'), 2000), - await page.evaluate(() => document.activeElement?.outerHTML?.slice(0, 120) ?? '')) - if (SHOTS) await sleep(350) - await shot(page, 'catalog-profile-report-filled') - await page.keyboard.press('Enter') - const nextBox = page.locator('[data-codes-dialog="ticket_type_id"]') - check('……回车接着填下一列', await until(async () => (await nextBox.count()) === 1)) - await page.keyboard.press('Escape') - check('码值弹窗取消:同样回到报告,那一列仍写待填写', await until(async () => (await profilePhase(page)) === 'done') && (await nextBox.count()) === 0 - && (await fillLine('ticket_type_id').locator('[data-codes-pending]').innerText()) === '2 个含义待填写') - await page.locator('[data-profile-close]').click() - await until(async () => (await profileBox(page).count()) === 0) - await openDetail(page, S1, 'orders') - await page.locator('[data-catalog-profile]').click() - await profileBox(page).waitFor() - check('换了一张表、再点页头:回到选范围(报告已看过)', (await profilePhase(page)) === 'setup') - await page.keyboard.press('Escape') - - check('写请求只有剖析和整份提交', writes(sent, /./).every((s) => /catalog\/profile$|^PUT .*catalog\/visits$/.test(s.key)), - JSON.stringify(writes(sent, /./).map((s) => s.key))) - check('没有原生对话框', natives.length === 0, natives.join(' | ')) - check('没有运行时报错', errors.length === 0, errors[0] ?? '') - await close() -}) - -await section('数据目录 · 数据剖析开始不了(409)', async () => { - const state = freshState() - const replies = { profile: [], patch: [] } - const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) }) - await page.locator('[data-catalog-row]').first().waitFor() - // 数据源列表里就没开:不让开始,就地开启 - await page.locator('[data-catalog-profile]').click() - await profileBox(page).waitFor() - check('没开启:写明需要先开启,「开始剖析」禁用,没有预算', (await profileBox(page).locator('[data-profile-disabled]').innerText()).includes('未开启数据剖析') - && await page.locator('[data-profile-start]').isDisabled() && await profileBox(page).locator('[data-profile-budget]').count() === 0) - await profileBox(page).locator('[data-profile-disabled] [data-profile-enable]').click() - const settings = page.locator('[data-profile-settings-dialog]') - await settings.waitFor() - await settings.locator('[data-profile-enabled]').check() - await page.locator(`#profile-${S1}-max_queries`).fill('40') - // 保存被拒:只认机读码和 field。没带机读码的(老服务端)不从原话里猜是哪一项,只弹通用提示,弹窗留着 - const QUERIES_REJECT = '数据剖析的「查询次数上限」需要填写 1 到 500 之间的整数;当前为「40」' - replies.patch.push(json({ detail: QUERIES_REJECT }, 422)) - await page.locator('[data-profile-save]').click() - check('422 没带机读码:不从原话里猜是哪一项,只弹通用提示,弹窗留着', - await until(async () => (await page.locator('[data-toast]').filter({ hasText: '查询次数上限' }).count()) > 0) - && await settings.locator('[aria-invalid="true"]').count() === 0 && await settings.count() === 1) - replies.patch.push(json({ detail: QUERIES_REJECT, code: 'profile_settings_invalid', field: 'max_queries' }, 422)) - await page.locator('[data-profile-save]').click() - check('422 带机读码和 field:报错落在「查询次数上限」那一格,光标放进去', - await until(async () => (await page.locator(`#profile-${S1}-max_queries`).getAttribute('aria-invalid')) === 'true') - && await until(async () => page.evaluate((id) => document.activeElement?.id === id, `profile-${S1}-max_queries`))) - replies.patch.push(json({ detail: '数据剖析设置中有无法识别的项「rows」,请删除后重试', code: 'profile_settings_invalid' }, 422)) - await page.locator('[data-profile-save]').click() - check('422 带机读码、没有 field:报错写在剖析设置这一节上', await until(async () => - (await settings.locator('[data-profile-error="section"]').innerText().catch(() => '')).includes('无法识别的项'))) - await page.locator('[data-profile-save]').click() - check('就地开启:保存后回到剖析,预算按刚存的设置', await until(async () => (await settings.count()) === 0) - && await until(async () => (await profileBox(page).locator('[data-profile-budget]').innerText().catch(() => '')).includes('最多 40 条只读查询')) - && !(await page.locator('[data-profile-start]').isDisabled())) - check('……保存的是这个源的剖析设置', JSON.stringify(writes(sent, /^PATCH /).at(-1)?.body) === JSON.stringify({ options: { catalog_profile: { enabled: true, max_queries: 40 } } }), - JSON.stringify(writes(sent, /^PATCH /).at(-1)?.body)) - - // 服务端的 409 都带机读码(api/coded.py),界面按它分五种情况,不从原话里认 - const cases = [ - ['disabled', '数据源「zzcatscenic」未开启数据剖析。剖析会对业务库发查询,请先在数据源设置中开启数据剖析', '未开启数据剖析', 'data-profile-enable', 'profile_disabled'], - ['inactive', '数据源「zzcatscenic」已停用,无法进行数据剖析。请先在数据源设置中启用', '数据源已停用', 'data-profile-go-source', 'datasource_inactive'], - ['noSchema', '尚未探查结构,无法进行数据剖析。请先在数据源卡片上点「探查结构」', '还没有表结构', 'data-profile-go-source', 'schema_missing'], - ['busy', '数据源「zzcatscenic」正在进行数据剖析,请等待完成后再试', '这个数据源正在剖析', 'data-profile-retry', 'profile_busy'], - ['tampered', '数据文件与登记的版本不一致,可能被修改过,已拒绝查询', '数据文件核对未通过', 'data-profile-go-source', 'snapshot_tampered'], - ] - // 没带机读码的 409(老服务端):原话里明明写着「正在进行数据剖析」,也不去猜,按「无法开始剖析」给重试 - replies.profile.push(json({ detail: cases[3][1] }, 409)) - await page.locator('[data-profile-start]').click() - check('409 没带机读码:不从原话里猜,按「无法开始剖析」说,给重试', await until(async () => - (await profileBox(page).locator('[data-profile-blocked="other"]').count()) === 1) - && await profileBox(page).locator('[data-profile-blocked="other"] [data-profile-retry]').count() === 1) - await page.locator('[data-profile-back]').click() - await until(async () => (await profilePhase(page)) === 'setup') - for (const [kind, detail, title, action, code] of cases) { - replies.profile.push(json({ detail, code }, 409)) - await page.locator('[data-profile-start]').click() - const blocked = profileBox(page).locator(`[data-profile-blocked="${kind}"]`) - const ok = await until(async () => (await blocked.count()) === 1) - const text = ok ? await blocked.innerText() : '' - check(`409(${title}):写明是哪种情况、服务端原话和下一步,给对应的按钮`, ok && text.includes(title) && text.includes(detail) - && await blocked.locator(`[${action}]`).count() === 1, text.replace(/\s+/g, ' ').slice(0, 140)) - if (kind === 'busy') { - // 稍后重试:同一个范围再发一次,这次成功 - replies.profile.push(profileReply(state)) - await blocked.locator('[data-profile-retry]').click() - check('……「重试」再发一次,成功后看到报告', await until(async () => (await profilePhase(page)) === 'done')) - } - if (kind === 'disabled') { - // 服务端说没开(列表里的设置过时了):同样就地开启,开了回到选范围 - await blocked.locator('[data-profile-enable]').click() - await settings.waitFor() - await page.locator(`#profile-${S1}-max_queries`).fill('45') - await page.locator('[data-profile-save]').click() - check('……就地开启后回到选范围', await until(async () => (await profilePhase(page)) === 'setup')) - continue - } - // 底栏的按钮不在弹窗正文里 - await page.locator(kind === 'busy' ? '[data-profile-again]' : '[data-profile-back]').click() - await until(async () => (await profilePhase(page)) === 'setup') - } - if (SHOTS) await sleep(350) - await shot(page, 'catalog-profile-blocked') - // 不是 409 的失败:照常的报错,能重试 - replies.profile.push(json({ detail: '服务端处理剖析时出错,请稍后重试' }, 500)) - await page.locator('[data-profile-start]').click() - check('其余失败:写「剖析未能完成」和原因,能重试', await until(async () => (await profileBox(page).locator('[data-profile-failed]').count()) === 1) - && (await profileBox(page).locator('[data-profile-failed]').innerText()).includes('剖析未能完成')) - // 去数据源:停用、没有表结构、快照被改动时 - replies.profile.push(json({ detail: cases[1][1], code: cases[1][4] }, 409)) - await profileBox(page).locator('[data-profile-failed]').getByRole('button', { name: /重试/ }).click() - await profileBox(page).locator('[data-profile-go-source]').click() - await page.waitForURL('**/data/databases', { timeout: 5000 }).catch(() => {}) - check('「前往数据源」回到数据源卡片', new URL(page.url()).pathname === '/data/databases', page.url()) - check('写请求只有剖析和保存剖析设置', writes(sent, /./).every((s) => /catalog\/profile$|^PATCH \/datasources\/check-cat-scenic$/.test(s.key)), - JSON.stringify(writes(sent, /./).map((s) => s.key))) - check('没有原生对话框', natives.length === 0, natives.join(' | ')) - check('没有运行时报错', errors.length === 0, errors[0] ?? '') - await close() -}) - await section('数据目录 · 窄屏', async () => { const state = freshState() const { page, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state), viewport: { width: 390, height: 844 } })