mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:31:58 +00:00
9252e6bb0e
- check-catalog-changes(骨架里的占位换掉):假接口加夹具(虚构的网店库),写请求在浏览器层拦下、断言请求体。守的是「变化」 视图的分组(要处理 / 提醒 / 说明,组内照服务端的先后)、两种筛选进请求和地址、刷新后还在;一条变化的依据、前后、影响面 (目录项现在的状态、「这条 SQL 现在会报列不存在」、模板链到编排页那个节点、有效记录条件);动作照服务端给的 actions 画、做不了 的写原因;清理先确认、加入码值带含义、知道了、误报的请求体,误报没写理由(含只填空白)按钮不能点;处理完写目录版本、建了的 起草任务点得进进度;表详情一节(变化、撤销过的「已列全」、四种观察和口径、口径换过的提醒、断档的日子也在小柱图里);数据源 角标(读屏写全、点了到「变化」);定时维护缺省关、开启时提示低峰时段、建(target_kind=source_maintenance)和改(PATCH 只带 改了的和版本)、步骤走 PUT、最近一次建了哪个任务和哪几步没执行;后台任务条按服务端的两种先后、页头读屏不写数字、提醒在条上 写全文、「查看」打开对应的对话框、探查的到数据源卡片;窄屏不撑宽页面。去掉排序、去掉「没写理由不能点」回跑,这几项都会报出来。 - check-catalog、check-catalog-profile:DraftStrip 的断言换成后台任务条(data-catalog-job-strip、data-catalog-job),页头的 「起草」「数据剖析」只断言读屏写状态、不写数字;剖析的「最近查询多数超时」改在任务条上断言全文;check-catalog-profile 的假接口 按建的先后给出队列,两种排法(起草在前 / 剖析在前)的文字都断言;窄屏下任务条看得见。三个目录检查都给变化、观察、后台任务、 upkeep 配上空的假接口,不把假的源放行到服务端去查。 - check-catalog-loop.mjs 的 PROFILE_ON 换成 table_max_queries: 40、table_max_s: 60(和 check-catalog-profile 的 DEFAULTS 一致, 05 (g))。 - check-capabilities:全局渠道可订阅的事件里有了「数据源有变化要处理」(第 4 波打开了订阅),登记了还没推送的只剩召回。 本包相关的检查(catalog-changes、catalog、catalog-profile、catalog-loop、capabilities、manage)在另起的一套后端 + vite 上都过了。
2510 lines
192 KiB
JavaScript
2510 lines
192 KiB
JavaScript
// 数据目录页(数据源卡片上的「数据目录」,/data/catalog/<源>)的交互回归。
|
||
//
|
||
// 数据目录的写操作都会改变助手和将来的 SQL 检查读到的东西:确认一项、驳回一项、整份提交都不是随手可以撤回的。
|
||
// 所以这里查的是「请求里带没带读到的版本」「批量确认只动推断项、来源不变」「编辑时没动的项原样交回(被驳回的照旧
|
||
// 驳回)」「别人刚改过时有没有停下来让人重新载入,而不是悄悄覆盖」——正常路径的页面检查一个字都不会说。
|
||
// 起草是后台任务:查的是「起草要求和补充说明有没有随请求发出」「关掉对话框任务照常进行、页面上有进度、再打开就是它」
|
||
// 「能停」「哪张表没写全、为什么写得清」「能只重试失败的」「已有任务、模型用不了这两种 409 各有去处」「清单上标出上次
|
||
// 起草失败的表、能单独重起草」。任务怎么往前走由检查自己推,页面每 1.5 秒取一次。
|
||
// 数据剖析(后台任务、逐表进行)另有 check-catalog-profile;这里只在窄屏一节看剖析对话框放不放得下,剖析的建任务一律拦成 503。
|
||
//
|
||
// 写法同 check-versions:沙箱里的数据源多半没探查过结构,目录也是空的,所以数据源列表、目录清单、单表目录都用
|
||
// page.route 伪造(虚构的景区业务库和一个导入表格的源);写请求(整份提交、单项审阅、起草)也在浏览器层答掉,
|
||
// 照服务端的规则改这里的假状态。没配的写请求一律拦成 503,不写库;最后核对真库里的数据源和目录数量没有变化。
|
||
// 跑之前前后端都得起着(./scripts/dev.sh),默认连 5273 / 8000;对别的实例跑时带上地址:
|
||
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-catalog.mjs
|
||
// 只跑其中几节:CHECK_ONLY=清单,编辑 node scripts/check-catalog.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 = '检查脚本'
|
||
|
||
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)
|
||
// 关之前先撤掉拦截、忽略还在路上的那几条:满负荷并行时,取真实返回的拦截可能在页面关掉之后才回来,
|
||
// 没人接住的报错会把整个检查进程带崩(「Request context disposed」)
|
||
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()
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 夹具:虚构的景区业务库(入园记录、订单、票种、门店销售……共 152 张表,其中一张只剩目录、表结构里已经没有)、
|
||
// 一个导入表格的源(说明由系统生成)、一个还没探查结构的库、一个有表结构但还没有目录的库。名字、数字全是假的
|
||
// ---------------------------------------------------------------------------
|
||
const T0 = '2026-09-28T02:00:00+00:00'
|
||
const INIT = { fk: 'verified', human: 'confirmed' }
|
||
const item = (value, source = 'llm', status) => ({ value, source, status: status ?? INIT[source] ?? 'proposed', updated_at: T0 })
|
||
const rel = (columns, to_table, to_columns, source, extra = {}) => ({
|
||
id: relId(columns, to_table, to_columns), columns, to_table, to_columns, cardinality: 'many_to_one', coverage: null,
|
||
source, status: INIT[source] ?? 'proposed', updated_at: T0, ...extra,
|
||
})
|
||
/** 关系编号:服务端按两端的表和列算哈希,这里只要稳定、不含句点 */
|
||
const relId = (columns, to_table, to_columns) => `r${[...columns, to_table, ...to_columns].join('-').toLowerCase().replace(/[^a-z0-9-]/g, '')}`
|
||
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-cat-scenic'
|
||
const S2 = 'check-cat-upload'
|
||
const S3 = 'check-cat-empty'
|
||
const S4 = 'check-cat-blank'
|
||
|
||
const VISITS_COLUMNS = [pk(), col('ticket_no', 'TEXT'), col('park_id'), col('gate_id'), col('ticket_type_id'), col('member_id'),
|
||
col('visit_time', 'TEXT'), col('visitor_count'), col('status')]
|
||
const visitsNotes = () => ({
|
||
label: item('入园记录'),
|
||
description: item('每张门票每次通过闸机入园记一行'),
|
||
grain: item('一张门票的一次入园'),
|
||
keys: item(['ticket_no']),
|
||
kind: item('fact'),
|
||
business_date: item({ column: 'visit_time', rule: '按入园时间的日期', timezone: 'Asia/Shanghai' }),
|
||
valid_filter: item('status <> 9', 'human'),
|
||
dedup: item('按 ticket_no 去重', 'llm', 'rejected'),
|
||
columns: {
|
||
ticket_no: { label: item('票号'), measure: item('identifier', 'name') },
|
||
park_id: { measure: item('identifier', 'fk') },
|
||
visitor_count: { label: item('入园人数'), unit: item('人'), measure: item('flow') },
|
||
status: { label: item('状态'), measure: item('status', 'human'), codes: item({ 1: '已入园', 9: '已作废' }) },
|
||
},
|
||
relations: [
|
||
rel(['park_id'], 'parks', ['id'], 'fk'),
|
||
rel(['gate_id'], 'gates', ['id'], 'name'),
|
||
rel(['ticket_type_id'], 'ticket_types', ['id'], 'fk'),
|
||
],
|
||
})
|
||
const WIDE_COLUMNS = [pk(), col('store_id'), col('productId'), col('member_id'), col('sold_at', 'TEXT'), col('qty'), col('amount', 'REAL'),
|
||
...Array.from({ length: 43 }, (_, i) => col(`promo_attr_${String(i + 1).padStart(2, '0')}`, 'TEXT'))]
|
||
const wideNotes = () => ({
|
||
label: item('门店销售'),
|
||
kind: item('fact'),
|
||
columns: {
|
||
qty: { label: item('数量'), measure: item('flow') },
|
||
amount: { label: item('销售金额'), unit: item('元'), measure: item('flow') },
|
||
...Object.fromEntries(Array.from({ length: 6 }, (_, i) => [`promo_attr_${String(i + 1).padStart(2, '0')}`, { label: item(`促销属性 ${i + 1}`) }])),
|
||
},
|
||
relations: [rel(['store_id'], 'stores', ['id'], 'fk')],
|
||
})
|
||
|
||
/** 一张表:structure 为 null 表示表结构里已经没有(目录还留着) */
|
||
const table = (name, usage, columns, notes, extra = {}) => ({
|
||
name, usage, columns, notes, version: notes ? (extra.version ?? 2) : 0, comment: null, is_view: false,
|
||
updated_at: notes ? T0 : null, updated_by: notes ? '审核甲' : null, missing: false, ...extra,
|
||
})
|
||
|
||
function scenicTables() {
|
||
const list = [
|
||
table('visits', 42, VISITS_COLUMNS, visitsNotes()),
|
||
table('orders', 30, [pk(), col('order_no', 'TEXT'), col('member_id'), col('total_amount', 'REAL')], {
|
||
label: item('订单', 'human'), kind: item('fact', 'human'), relations: [rel(['member_id'], 'members', ['id'], 'fk')],
|
||
}),
|
||
table('order_items', 25, [pk(), col('order_id'), col('ticket_type_id'), col('qty'), col('amount', 'REAL')], {
|
||
label: item('订单明细'), grain: item('一个订单里的一个票种'), kind: item('fact'),
|
||
columns: { qty: { measure: item('flow') }, amount: { unit: item('元'), measure: item('flow') } },
|
||
relations: [rel(['order_id'], 'orders', ['id'], 'fk'), rel(['ticket_type_id'], 'ticket_types', ['id'], 'name')],
|
||
}),
|
||
table('parks', 18, [pk(), col('park_code', 'TEXT'), col('name', 'TEXT')], {
|
||
label: item('景区', 'human'), kind: item('dimension', 'human'), keys: item(['park_code'], 'human'),
|
||
}),
|
||
table('ticket_types', 12, [pk(), col('park_id'), col('name', 'TEXT'), col('base_price', 'REAL')], {
|
||
label: item('票种'), kind: item('dimension'), relations: [rel(['park_id'], 'parks', ['id'], 'fk')],
|
||
}),
|
||
table('gates', 12, [pk(), col('park_id'), col('gate_code', 'TEXT')], null),
|
||
table('store_sales', 9, WIDE_COLUMNS, wideNotes()),
|
||
table('members', 7, [pk(), col('member_no', 'TEXT'), col('joined_on', 'TEXT')], null),
|
||
table('channels', 5, [pk(), col('channel_code', 'TEXT')], null),
|
||
table('stores', 4, [pk(), col('store_code', 'TEXT'), col('park_id')], null),
|
||
table('inventory_snapshots', 3, [pk(), col('storeID'), col('snapshot_date', 'TEXT'), col('on_hand')], {
|
||
label: item('库存快照'), kind: item('snapshot'), columns: { on_hand: { measure: item('stock') } },
|
||
}),
|
||
]
|
||
for (let i = 1; i <= 140; i++) list.push(table(`ext_log_${String(i).padStart(3, '0')}`, 0, [pk(), col('event_at', 'TEXT'), col('payload', 'TEXT')], null))
|
||
list.push(table('legacy_coupons', 0, null, { label: item('旧版优惠券', 'human') }, { version: 3, missing: true }))
|
||
return list
|
||
}
|
||
|
||
function uploadTables() {
|
||
return [
|
||
table('日客流', 6, [col('统计日期', 'DATE', { comment: '统计日期,与核对结果一致' }), col('入园人数', 'INTEGER', { comment: '当日入园人数合计,已与表内合计核对' })], {
|
||
kind: item('fact'), columns: { 入园人数: { measure: item('flow') } },
|
||
}, { comment: '每日入园人数汇总,由导入时的核对结果生成' }),
|
||
table('时段客流', 2, [col('统计日期', 'DATE'), col('时段', 'TEXT'), col('入园人数', 'INTEGER')], null, { comment: '按时段汇总的入园人数' }),
|
||
]
|
||
}
|
||
|
||
const blank = {
|
||
kind: 'sqlite', host: null, port: null, username: null, options: {}, 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) => ({
|
||
...blank, id, name, origin: 'manual', database: `/tmp/check/${name}.db`, table_count: count, tools: [`db_query__${name}`],
|
||
schema_synced_at: count ? T0 : null,
|
||
})
|
||
/** 景区库的数据源说明:起草时连同起草要求一起交给模型,起草对话框里写明 */
|
||
const S1_DESCRIPTION = '虚构的景区票务与客流库,供检查使用'
|
||
const SOURCES = [
|
||
{ ...manual(S1, 'zzcatscenic', 151), description: S1_DESCRIPTION },
|
||
{
|
||
...blank, id: S2, name: 'zzcatupload', origin: 'upload', table_count: 2, tools: ['db_query__zzcatupload'], schema_synced_at: T0,
|
||
database: `/tmp/check/uploads/${S2}.db`, import_mode: 'simple', current_recipe: null, open_staging: null,
|
||
current_snapshot: { id: 'snap-check-cat', created_at: T0, file_name: '客流日报.xlsx', raw_state: 'kept' },
|
||
},
|
||
manual(S3, 'zzcatempty', 0),
|
||
manual(S4, 'zzcatblank', 3),
|
||
]
|
||
const NAMES = SOURCES.map((s) => s.name)
|
||
|
||
/** 景区库存着的起草要求(数据源级,PUT /catalog/guidance 改它) */
|
||
const S1_GUIDANCE = '景区票务库:「核销」指闸机检票入园;金额单位是元,人数单位是人'
|
||
|
||
const freshState = () => ({
|
||
sources: SOURCES,
|
||
tables: {
|
||
[S1]: scenicTables(),
|
||
[S2]: uploadTables(),
|
||
[S3]: [],
|
||
[S4]: ['parking_lots', 'parking_records', 'shuttle_trips'].map((n, i) => table(n, 3 - i, [pk(), col('name', 'TEXT')], null)),
|
||
},
|
||
// 起草任务(后台任务):建的先后就是数组的先后
|
||
jobs: [],
|
||
jobSeq: 0,
|
||
guidance: { [S1]: S1_GUIDANCE },
|
||
// 设置里的起草模型(没配:跟随助手的模型)和助手当前用的模型
|
||
draftModel: { provider: null, model: null },
|
||
assistantModel: { configured: true, provider: null, model: null, effective_provider: 'check-gateway', effective_model: 'check-assistant-model', error: null },
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 假服务端:照 backend/app/data/catalog.py 的规则答整份提交、单项审阅、起草(只做界面看得出来的部分)
|
||
// ---------------------------------------------------------------------------
|
||
const TABLE_FIELDS = ['label', 'description', 'grain', 'keys', 'kind', 'business_date', 'valid_filter', 'dedup']
|
||
const COLUMN_FIELDS = ['label', 'meaning', 'unit', 'measure', 'codes']
|
||
const clone = (x) => JSON.parse(JSON.stringify(x))
|
||
|
||
function slots(notes) {
|
||
const out = new Map()
|
||
for (const f of TABLE_FIELDS) if (notes?.[f]) out.set(f, notes[f])
|
||
for (const [c, items] of Object.entries(notes?.columns ?? {})) for (const f of COLUMN_FIELDS) if (items?.[f]) out.set(`columns.${c}.${f}`, items[f])
|
||
for (const r of notes?.relations ?? []) out.set(`relations.${r.id}`, r)
|
||
return out
|
||
}
|
||
function assemble(map) {
|
||
const notes = {}
|
||
for (const [path, it] of map) {
|
||
if (path.startsWith('columns.')) {
|
||
const rest = path.slice('columns.'.length)
|
||
const cut = rest.lastIndexOf('.')
|
||
const c = rest.slice(0, cut)
|
||
;((notes.columns ??= {})[c] ??= {})[rest.slice(cut + 1)] = it
|
||
} else if (path.startsWith('relations.')) (notes.relations ??= []).push(it)
|
||
else notes[path] = it
|
||
}
|
||
return notes
|
||
}
|
||
function countsOf(notes) {
|
||
const c = { proposed: 0, verified: 0, confirmed: 0, rejected: 0 }
|
||
for (const it of slots(notes).values()) c[it.status]++
|
||
return c
|
||
}
|
||
const valueOf = (path, it) => JSON.stringify(path.startsWith('relations.')
|
||
? [it.columns, it.to_table, it.to_columns, it.cardinality ?? null, it.coverage ?? null] : it.value)
|
||
|
||
/**
|
||
* 整份提交(apply_human_edit):值改过的、新填的记为人工确认;值没动的按提交的状态(只认确认、驳回、初始状态);
|
||
* 提交里没有的关联关系,人工添加的删掉,其余的转为驳回(删掉会被起草和现推带回来)
|
||
*/
|
||
function applyPut(old, submitted) {
|
||
const prev = slots(old)
|
||
const sub = clone(submitted)
|
||
for (const r of sub.relations ?? []) { r.id = relId(r.columns, r.to_table, r.to_columns); r.source ??= 'human'; r.status ??= 'confirmed' }
|
||
const out = new Map()
|
||
const given = slots(sub)
|
||
for (const [path, it] of given) {
|
||
const p = prev.get(path)
|
||
if (!p || valueOf(path, it) !== valueOf(path, p)) {
|
||
out.set(path, { ...it, source: 'human', status: 'confirmed', updated_at: new Date().toISOString() })
|
||
} else {
|
||
const ok = ['confirmed', 'rejected', INIT[p.source] ?? 'proposed'].includes(it.status)
|
||
out.set(path, { ...p, status: ok ? it.status : p.status })
|
||
}
|
||
}
|
||
for (const [path, p] of prev) {
|
||
if (path.startsWith('relations.') && !given.has(path) && p.source !== 'human') out.set(path, { ...p, status: 'rejected' })
|
||
}
|
||
return assemble(out)
|
||
}
|
||
function applyReview(notes, path, action) {
|
||
const map = slots(clone(notes))
|
||
const it = map.get(path)
|
||
if (!it) return null
|
||
if (action === 'reset' && it.source === 'human') map.delete(path)
|
||
else it.status = action === 'confirm' ? 'confirmed' : action === 'reject' ? 'rejected' : INIT[it.source] ?? 'proposed'
|
||
return assemble(map)
|
||
}
|
||
|
||
const isUpload = (src) => src === S2
|
||
function rowOf(t) {
|
||
const n = t.notes ?? {}
|
||
const live = (it) => (it && it.status !== 'rejected' ? it : null)
|
||
return {
|
||
table_name: t.name, qualified: t.name, is_view: t.is_view, in_schema: !t.missing,
|
||
// 为什么不在(next-round 03 A1):夹具给了 absent 才带,不给时同老服务端(界面照旧说表结构中已没有)
|
||
...(t.absent ? { absent_reason: t.absent } : {}),
|
||
label: live(n.label)?.value ?? null, label_status: live(n.label)?.status ?? null, kind: live(n.kind)?.value ?? null,
|
||
counts: countsOf(n), relations: (n.relations ?? []).filter((r) => r.status !== 'rejected').length,
|
||
usage: t.usage, version: t.version, updated_at: t.updated_at, updated_by: t.updated_by,
|
||
}
|
||
}
|
||
function listOf(state, src) {
|
||
const all = state.tables[src]
|
||
const rows = all.filter((t) => !t.missing).sort((a, b) => b.usage - a.usage).map(rowOf)
|
||
rows.push(...all.filter((t) => t.missing).map(rowOf))
|
||
// 每行带这张表最近一次起草任务里的那一项(夹具可以直接给 lastDraft,任务里有这张表时以任务为准)
|
||
for (const r of rows) r.last_draft = lastDraftOf(state, src, r.table_name)
|
||
// 探查结构截断了的源(state.schemaTotal 里给了数据库里的总数):清单只有探查到的那部分
|
||
const explored = all.filter((t) => !t.missing).length
|
||
const total = state.schemaTotal?.[src] ?? explored
|
||
const active = state.jobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status))
|
||
return { tables: rows, system_notes: isUpload(src), schema_note: all.length ? null : '尚未探查结构',
|
||
schema_truncated: total > explored, schema_total: total, guidance: state.guidance[src] ?? null,
|
||
// 有对象索引的源才有探查范围的摘要(state.scope 里给了);没给是升级前探查的源
|
||
...(state.scope?.[src] ? { scope: state.scope[src] } : {}),
|
||
active_job: active ? (({ id, status, total: n, done }) => ({ id, status, total: n, done }))(jobOut(active)) : null }
|
||
}
|
||
const detailOf = (src, t) => ({
|
||
table_name: t.name, in_schema: !t.missing, ...(t.absent ? { absent_reason: t.absent } : {}), 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: t.comment, columns: t.columns,
|
||
primary_key: t.columns.filter((c) => c.pk).map((c) => c.name), foreign_keys: [], unique: [],
|
||
},
|
||
system_notes: isUpload(src), usage: t.usage,
|
||
})
|
||
const conflictDetail = (name) => ({ detail: `表「${name}」的数据目录刚被修改过,请重新载入后再提交` })
|
||
const parts = (key) => key.split(' ')[1].split('/') // ['', 'datasources', id, 'catalog', table?, 'review'?]
|
||
const findTable = (state, key) => {
|
||
const [, , src, , name] = parts(key)
|
||
return { src, t: state.tables[src]?.find((x) => x.name === name) }
|
||
}
|
||
const writeEntry = (t, notes) => {
|
||
t.notes = notes
|
||
t.version += 1
|
||
t.updated_at = new Date().toISOString()
|
||
t.updated_by = ACTOR
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 起草任务(后台任务,/api/datasources/{id}/catalog/draft-jobs)。任务怎么往前走由检查自己推(settle、finishJob、
|
||
// stopJob):页面每 1.5 秒取一次,看到的就是推到的那一步。不按时间自己走,结果不受机器快慢影响
|
||
// ---------------------------------------------------------------------------
|
||
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 isoNow = () => new Date().toISOString()
|
||
/**
|
||
* 服务端的几种答复(机读码 + 一句中文),说法照 api/catalog.py、data/catalog_jobs.py、data/draft_model.py 的原话。
|
||
* 起草模型用不了有长短两句:没有接入时短(界面原样显示),设置里的起草模型所在接入停用时长(带句号,也要原样显示)
|
||
*/
|
||
const busyOf = (state, src) => ({
|
||
detail: `数据源「${state.sources.find((s) => s.id === src)?.name ?? src}」正在起草数据目录,请等这次起草结束(或先停止它)再开始新的起草`,
|
||
code: 'catalog_draft_busy',
|
||
})
|
||
const MODEL_DOWN = { detail: '未配置模型接入,无法由模型起草数据目录。请到「设置 → 模型接入」添加', code: 'catalog_draft_model_unavailable' }
|
||
const MODEL_OFF = {
|
||
detail: '起草模型使用的模型接入「check-gw」已停用。请在「设置 → 模型接入」中启用它,或在「设置 → 偏好设置」的「起草模型」中重新选择',
|
||
code: 'catalog_draft_model_unavailable',
|
||
}
|
||
/** 任务整体出错时服务端写的原因(catalog_jobs.JOB_ERROR:已经带着「起草任务出错:」开头) */
|
||
const JOB_ERR = '起草任务出错:database is locked'
|
||
/** 起草时每段最多几列(服务端的常量):超过的宽表拆成「表级一段 + 列分段」 */
|
||
const PART_COLUMNS = 40
|
||
|
||
/**
|
||
* 建一个排队的任务:表结构里没有的表照样建进任务、记 skipped。重试建的记着原任务(retry_of,界面不用)。
|
||
* 超过 40 列的宽表均匀分段:列分段数 = ceil(列数 / 40),段进度是「表级一段 + 列分段」。
|
||
* 用模型的任务记着当前每段最多几列(part_columns,额度用完、超时多了服务端会减半)、调用接连没写出来时的提醒(notice),
|
||
* 用量里另记超时和没写出来的调用次数;逐表记模型拿不准、留空的列数和前几个列名(blank_columns、blank_sample)
|
||
*/
|
||
function newJob(state, src, { tables, use_model, note = null, retry_of = null }) {
|
||
const id = `check-job-${++state.jobSeq}`
|
||
const tasks = tables.map((name) => {
|
||
const t = state.tables[src].find((x) => x.name === name && !x.missing)
|
||
const cols = t?.columns?.length ?? 0
|
||
const partsTotal = t && use_model ? (cols > PART_COLUMNS ? 1 + Math.ceil(cols / PART_COLUMNS) : 1) : null
|
||
return {
|
||
table_name: name, status: t ? 'pending' : 'skipped', columns: cols,
|
||
parts_total: partsTotal, parts_done: 0,
|
||
// 逐段写进目录:已写进的段数;段被缩小、拆半后实际的块数和已结束的块数(不用模型时为 null)
|
||
parts_written: 0, chunks_total: partsTotal, chunks_done: partsTotal == null ? null : 0,
|
||
added: 0, updated: 0, removed: 0, version: t?.version ?? 0, model_items: null, blank_columns: 0, blank_sample: [],
|
||
error: t ? null : `表结构里没有 ${name},请先重新探查结构`, model_error: null,
|
||
started_at: null, finished_at: t ? null : isoNow(),
|
||
}
|
||
})
|
||
const job = {
|
||
id, source_id: src, status: 'queued', use_model: !!use_model, model: use_model ? 'check-draft-model' : null,
|
||
guidance: state.guidance[src] ?? null, note, created_at: isoNow(), started_at: null, finished_at: null, created_by: ACTOR,
|
||
retry_of, part_columns: use_model ? PART_COLUMNS : null, notice: null,
|
||
usage: { calls: 0, input_tokens: 0, output_tokens: 0, timeouts: 0, failed_calls: 0 }, error: null, tasks,
|
||
}
|
||
state.jobs.push(job)
|
||
return job
|
||
}
|
||
|
||
/** 接口返回的形状:计数、合计(含留空的列数)由逐表算出来 */
|
||
function jobOut(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]++
|
||
const sum = (k) => job.tasks.reduce((n, t) => n + t[k], 0)
|
||
const { stopAsked, ...rest } = job
|
||
return clone({ ...rest, total: job.tasks.length, done: job.tasks.filter((t) => TASK_END.has(t.status)).length, counts,
|
||
added: sum('added'), updated: sum('updated'), removed: sum('removed'), blank_columns: sum('blank_columns') })
|
||
}
|
||
|
||
/**
|
||
* 推进一张表:outcome.status 默认 ok(写完),给 running 就是还在起草(可带 parts_done)。写进了东西的表给它
|
||
* 一个起草出的中文名,清单重取后看得出来。用模型的每次推进记一次调用
|
||
*/
|
||
function settle(state, job, name, outcome = {}) {
|
||
const task = job.tasks.find((t) => t.table_name === name)
|
||
if (job.status === 'queued') Object.assign(job, { status: 'running', started_at: isoNow() })
|
||
const status = outcome.status ?? 'ok'
|
||
Object.assign(task, { started_at: task.started_at ?? isoNow() }, outcome, { status })
|
||
if (TASK_END.has(status)) {
|
||
task.finished_at = isoNow()
|
||
if (status === 'ok' && task.parts_total != null && outcome.parts_done == null) task.parts_done = task.parts_total
|
||
if (task.added > 0) {
|
||
const t = state.tables[job.source_id].find((x) => x.name === name)
|
||
if (t) {
|
||
writeEntry(t, { ...(t.notes ?? {}), label: item(`${name} 的起草名`) })
|
||
task.version = t.version
|
||
}
|
||
}
|
||
}
|
||
if (job.use_model) {
|
||
job.usage.calls += 1
|
||
job.usage.input_tokens += 1200
|
||
job.usage.output_tokens += 2400
|
||
}
|
||
}
|
||
|
||
/** 排队的任务开始起草(服务端轮到它了) */
|
||
const runJob = (job) => Object.assign(job, { status: 'running', started_at: isoNow() })
|
||
|
||
/** 收尾:有失败、部分完成、停止的表就是 partial */
|
||
function finishJob(job, status) {
|
||
const bad = job.tasks.some((t) => RETRYABLE.has(t.status))
|
||
Object.assign(job, { status: status ?? (bad ? 'partial' : 'succeeded'), finished_at: isoNow() })
|
||
job.started_at ??= job.finished_at
|
||
}
|
||
|
||
/**
|
||
* 停下:没开始和没写完的表记 cancelled,已经写进目录的不动。带原因的(服务重启中断)任务和表记同一句(服务端 _close 的
|
||
* 做法);使用者点的停止不带原因
|
||
*/
|
||
function stopJob(job, status = 'cancelled', error = null) {
|
||
for (const t of job.tasks) {
|
||
if (t.status !== 'pending' && t.status !== 'running') continue
|
||
// 写过段的表照旧记已停止,原因写一句已写进目录几段、剩下几段没起草(服务端停止、中断时的说法:catalog_jobs 的
|
||
// STOPPED_WRITTEN、INTERRUPTED_WRITTEN)
|
||
const written = t.parts_written ?? 0
|
||
const modelError = written > 0 && t.parts_total
|
||
? `${status === 'interrupted' ? '已中断' : '已停止'}:已写进目录 ${written} 段,剩下 ${t.parts_total - written} 段没起草` : t.model_error
|
||
Object.assign(t, { status: 'cancelled', error, model_error: modelError, finished_at: isoNow() })
|
||
}
|
||
if (error) job.error = error
|
||
delete job.stopAsked
|
||
finishJob(job, status)
|
||
}
|
||
|
||
/** 任务整体出错(数据源没了、开始时起草模型已经用不了、意外出错):没结束的表也记失败,原因同任务的(服务端 _end 的做法) */
|
||
function failJob(job, error) {
|
||
for (const t of job.tasks) {
|
||
if (t.status === 'pending' || t.status === 'running') Object.assign(t, { status: 'failed', error, finished_at: isoNow() })
|
||
}
|
||
job.error = error
|
||
finishJob(job, 'failed')
|
||
}
|
||
|
||
function lastDraftOf(state, src, name) {
|
||
const jobs = [...state.jobs].reverse().filter((j) => j.source_id === src)
|
||
const fixture = state.tables[src]?.find((x) => x.name === name)?.lastDraft ?? null
|
||
// 最近一次这张表结束于完成、部分完成的起草记下的留空列数(服务端 last_drafts 的 blank_columns_from)
|
||
const ended = jobs.map((j) => j.tasks.find((x) => x.table_name === name)).find((t) => t && (t.status === 'ok' || t.status === 'partial'))
|
||
const from = ended ? ended.blank_columns : fixture?.blank_columns_from ?? null
|
||
for (const job of jobs) {
|
||
const t = job.tasks.find((x) => x.table_name === name)
|
||
if (t) {
|
||
return { status: t.status, job_id: job.id, finished_at: t.finished_at, error: t.error, model_error: t.model_error,
|
||
blank_columns: t.blank_columns, blank_columns_from: from, parts_written: t.parts_written ?? 0 }
|
||
}
|
||
}
|
||
return fixture
|
||
}
|
||
|
||
const jobOf = (state, key) => {
|
||
const [, , src, , , id] = parts(key)
|
||
return state.jobs.find((j) => j.id === id && j.source_id === src) ?? null
|
||
}
|
||
const activeOf = (state, src) => state.jobs.find((j) => j.source_id === src && JOB_ACTIVE.has(j.status)) ?? null
|
||
|
||
function jobHandlers(state, reply) {
|
||
const NO_JOB = json({ detail: '起草任务不存在,可能属于别的数据源,或者已随数据源删除' }, 404)
|
||
return [
|
||
// 起草要求(数据源级):空串清除,超长 422。请求里必须有 guidance 这一项(服务端同样:没有就是 pydantic 的英文 422)
|
||
[/^PUT \/datasources\/check-cat-[a-z]+\/catalog\/guidance$/, reply('guidance', (route, { key, body }) => {
|
||
if (!body || typeof body !== 'object' || !('guidance' in body)) {
|
||
return json({ detail: [{ type: 'missing', loc: ['guidance'], msg: 'Field required', input: body }] }, 422)(route)
|
||
}
|
||
const g = String(body.guidance ?? '').trim()
|
||
if ([...g].length > 2000) return json({ detail: `起草要求最多 2000 字,当前 ${[...g].length} 字,请删减后再保存` }, 422)(route)
|
||
state.guidance[parts(key)[2]] = g || null
|
||
return json({ guidance: g || null })(route)
|
||
})],
|
||
// 建任务:同一个源已有排队、进行中的 409;不给 tables 取使用次数前 20 张
|
||
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs$/, reply('createJob', (route, { key, body }) => {
|
||
const src = parts(key)[2]
|
||
if (activeOf(state, src)) return json(busyOf(state, src), 409)(route)
|
||
const tables = body?.tables?.length ? body.tables
|
||
: state.tables[src].filter((t) => !t.missing).sort((a, b) => b.usage - a.usage).slice(0, 20).map((t) => t.name)
|
||
const job = newJob(state, src, { tables, use_model: !!body?.use_model, note: body?.note?.trim() || null })
|
||
return json({ job: jobOut(job) }, 202)(route)
|
||
})],
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/active$/, (route, { key }) => {
|
||
const job = activeOf(state, parts(key)[2])
|
||
return json({ job: job && jobOut(job) })(route)
|
||
}],
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/latest$/, (route, { key }) => {
|
||
const job = state.jobs.filter((j) => j.source_id === parts(key)[2]).at(-1) ?? null
|
||
return json({ job: job && jobOut(job) })(route)
|
||
}],
|
||
// 停止:排队的直接停;进行中的记下来,等检查用 stopJob 推到底(这期间页面应当写「正在停止」)
|
||
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/cancel$/, reply('cancel', (route, { key }) => {
|
||
const job = jobOf(state, key)
|
||
if (!job) return NO_JOB(route)
|
||
if (!JOB_ACTIVE.has(job.status)) return json({ detail: '这次起草已经结束了,不需要停止', code: 'catalog_draft_finished' }, 409)(route)
|
||
if (job.status === 'queued') stopJob(job)
|
||
else job.stopAsked = true
|
||
return json({ job: jobOut(job) })(route)
|
||
})],
|
||
// 重试:新任务只含失败、部分完成、停止的表,沿用用模型和补充说明。起草模型用不了的 409 由检查用 replies.retry 给
|
||
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+\/retry$/, reply('retry', (route, { key }) => {
|
||
const old = jobOf(state, key)
|
||
if (!old) return NO_JOB(route)
|
||
if (activeOf(state, old.source_id)) return json(busyOf(state, old.source_id), 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 = newJob(state, old.source_id, { tables, use_model: old.use_model, note: old.note, retry_of: old.id })
|
||
return json({ job: jobOut(job) }, 202)(route)
|
||
})],
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/draft-jobs\/[^/]+$/, (route, { key }) => {
|
||
const job = jobOf(state, key)
|
||
return job ? json({ job: jobOut(job) })(route) : NO_JOB(route)
|
||
}],
|
||
// 起草对话框写明用哪个起草模型:设置里的 catalog_draft(其余设置照真服务端的)、助手当前用的模型
|
||
[/^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: state.draftModel } }).catch(() => {})
|
||
}],
|
||
[/^GET \/copilot\/model$/, (route) => json(state.assistantModel)(route)],
|
||
]
|
||
}
|
||
|
||
function catalogHandlers(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)
|
||
}
|
||
return [
|
||
[/^GET \/datasources$/, (route) => json(state.sources)(route)],
|
||
// 数据源的变化、取值观察、后台任务条(next-round 第 4 波):这里没有,给空的(不放行到服务端去查一个假的源)
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/changes$/, json({ items: [], total: 0, counts: { pending: 0, open: 0, by_severity: { action: 0, warning: 0, info: 0 } } })],
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/observations$/, json({ table: '', series: [] })],
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/background-jobs$/, json({ items: [] })],
|
||
[/^GET \/datasources\/upkeep$/, json({ items: [] })],
|
||
// 卡片展开时取的表清单(导入表格的源默认展开)
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/schema$/, (route, { key }) =>
|
||
json({ tables: state.tables[parts(key)[2]].filter((t) => !t.missing).map((t) => t.name), summary: '', synced_at: T0 })(route)],
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog$/, (route, { key }) => json(listOf(state, parts(key)[2]))(route)],
|
||
// 起草任务、起草要求:放在单表的 GET / PUT 前面(catalog/guidance 也对得上单表 PUT 的写法)
|
||
...jobHandlers(state, reply),
|
||
// 旧的同步起草接口:前端不该再用它,调了就答 410,下面的检查会数它
|
||
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft$/, json({ detail: '检查脚本:前端不该再调用同步起草接口' }, 410)],
|
||
// 影响面:引用这张表的已发布、受管模板(只读)。没配的表答空
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/[^/]+\/impact$/, reply('impact', (route, { key }) => {
|
||
const name = parts(key)[4]
|
||
return json({ table: name, templates: state.impact?.[name] ?? [] })(route)
|
||
})],
|
||
// 剖析任务(数据剖析的检查在 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]
|
||
state.sources = state.sources.map((s) => (s.id === id ? { ...s, ...body, password: undefined } : s))
|
||
return json(state.sources.find((s) => s.id === id))(route)
|
||
})],
|
||
// 在对象索引里找表(范围外的)、加入范围:要放在单表的 GET 前面(search 也对得上单表的写法)
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/search$/, (route, { key, url }) => {
|
||
const q = (url.searchParams.get('q') ?? '').toLowerCase()
|
||
const items = (state.outside?.[parts(key)[2]] ?? []).filter((o) => o.name.includes(q) || (o.comment ?? '').includes(q))
|
||
return json({ items: items.map((o) => ({ qualified: o.name, kind: 'table', est_rows: null, in_scope: false,
|
||
scope_reason: null, absent_reason: 'out_of_scope', ...o })), total: items.length, indexed: true })(route)
|
||
}],
|
||
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/scope\/add$/, (route, { key, body }) => {
|
||
const src = parts(key)[2]
|
||
const added = []
|
||
for (const name of body?.tables ?? []) {
|
||
const i = (state.outside?.[src] ?? []).findIndex((o) => o.name === name)
|
||
if (i < 0) continue
|
||
state.outside[src].splice(i, 1)
|
||
state.tables[src].push(table(name, 0, [pk(), col('opened_at', 'TEXT')], null))
|
||
added.push(name)
|
||
}
|
||
return json({ added, already: [], failed: [], refused: [], scope: state.scope?.[src] ?? null })(route)
|
||
}],
|
||
[/^GET \/datasources\/check-cat-[a-z]+\/catalog\/[^/]+$/, reply('get', (route, { key }) => {
|
||
const { src, t } = findTable(state, key)
|
||
return t ? json(detailOf(src, t))(route) : json({ detail: `数据源中没有表 ${parts(key)[4]},可能已被删除或尚未探查结构` }, 404)(route)
|
||
})],
|
||
[/^PUT \/datasources\/check-cat-[a-z]+\/catalog\/[^/]+$/, reply('put', (route, { key, body }) => {
|
||
const { src, t } = findTable(state, key)
|
||
if (body.if_version !== t.version) return json(conflictDetail(t.name), 409)(route)
|
||
writeEntry(t, applyPut(t.notes ?? {}, body.notes))
|
||
return json(detailOf(src, t))(route)
|
||
})],
|
||
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/[^/]+\/review$/, reply('review', (route, { key, body }) => {
|
||
const { src, t } = findTable(state, key)
|
||
if (body.if_version !== t.version) return json(conflictDetail(t.name), 409)(route)
|
||
const notes = applyReview(t.notes ?? {}, body.path, body.action)
|
||
if (!notes) return json({ detail: '找不到这一项,可能已被修改,请重新载入' }, 422)(route)
|
||
writeEntry(t, notes)
|
||
return json(detailOf(src, t))(route)
|
||
})],
|
||
]
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 页面上的定位
|
||
// ---------------------------------------------------------------------------
|
||
const rowNames = (page) => page.locator('[data-catalog-row]').evaluateAll((els) => els.map((e) => e.getAttribute('data-catalog-row')))
|
||
const confirmBox = (page) => page.locator('[role="dialog"]').filter({ hasNot: page.locator('[data-draft-dialog]') })
|
||
.filter({ hasNot: page.locator('[data-item-panel]') }).last()
|
||
const consequences = async (page) => (await confirmBox(page).locator('ul li').allInnerTexts()).map((t) => t.trim())
|
||
const draftBox = (page) => page.locator('[data-draft-dialog]')
|
||
/** 起草对话框在哪一步:setup / running / done;没开着为 null */
|
||
const draftPhase = (page) => draftBox(page).getAttribute('data-draft-dialog', { timeout: 500 }).catch(() => null)
|
||
/** 对话框里某一处的文字(空白压成一个空格);没有这一处为空串 */
|
||
const draftText = async (page, sel) => (await draftBox(page).locator(sel).first().innerText({ timeout: 500 }).catch(() => '')).replace(/\s+/g, ' ').trim()
|
||
|
||
/** 选几张表(第一张点、最后一张 Shift 点)或者不选,打开起草对话框,按需勾用模型、写补充说明,开始;等到进入进行中 */
|
||
async function startDraft(page, { select = null, model = false, note = '' } = {}) {
|
||
if (select) {
|
||
await page.locator(`[data-catalog-select="${select[0]}"]`).click()
|
||
if (select.length > 1) await page.locator(`[data-catalog-select="${select.at(-1)}"]`).click({ modifiers: ['Shift'] })
|
||
await page.locator('[data-catalog-draft-selected]').click()
|
||
} else {
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
}
|
||
await draftBox(page).waitFor()
|
||
if (model) await draftBox(page).locator('[data-draft-model] input').check()
|
||
if (note) await draftBox(page).locator('[data-draft-note] textarea').fill(note)
|
||
await page.locator('[data-draft-start]').click()
|
||
return until(async () => (await draftPhase(page)) === 'running')
|
||
}
|
||
|
||
/** 起草用的几句话(虚构) */
|
||
const GUIDANCE2 = '景区票务库:「核销」指闸机检票入园;金额单位是元。会员相关的表统一写「会员」,不写「用户」'
|
||
const DRAFT_NOTE = '这一批是闸机和会员相关的表'
|
||
// 宽表没写出来的那一段写明列的范围(列名照表结构原样,50 列均分成表级一段加两段各 25 列);只有真拆过才写「已拆小
|
||
// 重试仍没写出」。写法照服务端 catalog_draft._partial_text 拼出来的原样
|
||
const WIDE_ERR = '这张表 50 列,分 3 段起草,第 3 段(promo_attr_19 到 promo_attr_43,共 25 列)没写出来:'
|
||
+ '模型的思考和输出用完了额度(32768 个 token),这一段没写出来;已拆小重试仍没写出。可以只重试这张表'
|
||
const CALL_ERR = '调用模型出错:服务暂时不可用(已重试 2 次)'
|
||
/**
|
||
* 调用接连没写出来时服务端给的提醒:写法照服务端 catalog_draft.NOTICE 的原样(原因按额度用完、超时、只给空壳、出错的
|
||
* 顺序列,去处是 draft_model.DRAFT_MODEL_PLACE)
|
||
*/
|
||
const DRAFT_NOTICE = '最近 6 次调用有 4 次没写出来(额度用完 1 次、超时 3 次)。'
|
||
+ '可以在「设置 → 偏好设置」的「起草模型」换一个模型、减少这次起草的表,或者停止这次起草'
|
||
/** 宽表里模型拿不准、留空的列(服务端最多给 5 个列名) */
|
||
const BLANK_SAMPLE = ['promo_attr_31', 'promo_attr_32', 'promo_attr_33', 'promo_attr_34', 'promo_attr_35']
|
||
/** 补上缩写含义的起草要求(虚构) */
|
||
const GUIDANCE_ABBR = `${S1_GUIDANCE}。promo_attr 开头的列是促销活动的附加属性,按列名后面的编号区分`
|
||
const detail = (page) => page.locator('[data-catalog-detail]')
|
||
const version = async (page) => Number(await detail(page).getAttribute('data-version'))
|
||
const panel = (page) => page.locator('[data-item-panel]')
|
||
const openDetail = async (page, src, name) => {
|
||
await goto(page, `/data/catalog/${src}/${encodeURIComponent(name)}`)
|
||
await until(async () => (await page.locator(`[data-catalog-detail="${name}"][data-version]`).count()) > 0)
|
||
}
|
||
/** 点开一项的状态标识,等弹层定好位置 */
|
||
const openMark = async (page, path) => {
|
||
await page.locator(`[data-item-mark="${path}"]`).first().scrollIntoViewIfNeeded()
|
||
await page.locator(`[data-item-mark="${path}"]`).first().click()
|
||
await panel(page).waitFor({ timeout: 3000 })
|
||
await until(async () => (await panel(page).evaluate((el) => el.getBoundingClientRect().top)) > -1000, 2000)
|
||
}
|
||
const markStatus = (page, path) => page.locator(`[data-item-mark="${path}"]`).first().getAttribute('data-status').catch(() => null)
|
||
const rowCounts = (page, name) => page.locator(`[data-catalog-row="${name}"] [data-counts]`).getAttribute('data-counts').catch(() => '')
|
||
|
||
// 界面不露的机读码:状态、来源、表类型、度量类型、基数的英文值
|
||
const CODE_RE = /\b(?:proposed|verified|confirmed|rejected|many_to_one|one_to_many|one_to_one|flow|stock|ratio|identifier|fact|dimension|llm|fk|if_version|notes)\b/
|
||
const KNOWN = ['zzcatscenic', 'zzcatupload', 'zzcatempty', 'zzcatblank']
|
||
async function cleanCopy(page, where) {
|
||
let t = await page.locator('[data-catalog-page]').innerText()
|
||
for (const n of KNOWN) t = t.replaceAll(n, '')
|
||
// 表名、列名是库里的标识,照原样显示,先去掉再查
|
||
t = t.replace(/[A-Za-z_][A-Za-z0-9_]*_[A-Za-z0-9_]+/g, '')
|
||
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 { page, sent, errors, natives, close } = await open('/data/databases', { handlers: catalogHandlers(state) })
|
||
const entry = page.locator('[data-source="zzcatscenic"] [data-catalog-open]')
|
||
check('手工登记的库:卡片上有「数据目录」', await entry.count() === 1 && (await entry.getAttribute('aria-label')) === '数据目录')
|
||
await entry.click()
|
||
await page.waitForURL(`**/data/catalog/${S1}`, { timeout: 5000 }).catch(() => {})
|
||
check('……点了进入 /data/catalog/<源>(独立页面)', new URL(page.url()).pathname === `/data/catalog/${S1}`, page.url())
|
||
check('……页面标题写明是哪个源的数据目录', (await page.locator('[data-catalog-page] h1').innerText()).includes('「zzcatscenic」的数据目录'))
|
||
await page.locator('[data-catalog-back]').click()
|
||
await page.waitForURL('**/data/databases', { timeout: 5000 }).catch(() => {})
|
||
check('「返回数据源」回到数据库标签', new URL(page.url()).pathname === '/data/databases', page.url())
|
||
await goto(page, '/data/tables')
|
||
check('导入表格的源:卡片上也有「数据目录」', await page.locator('[data-source="zzcatupload"] [data-catalog-open]').count() === 1)
|
||
await page.locator('[data-source="zzcatupload"] [data-catalog-open]').click()
|
||
await page.waitForURL(`**/data/catalog/${S2}`, { timeout: 5000 }).catch(() => {})
|
||
await page.locator('[data-catalog-back]').click()
|
||
await page.waitForURL('**/data/tables', { timeout: 5000 }).catch(() => {})
|
||
check('……导入表格的源「返回数据源」回到表格标签', new URL(page.url()).pathname === '/data/tables', page.url())
|
||
check('没有发出写请求', writes(sent, /./).length === 0, 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, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
let names = await rowNames(page)
|
||
check('列出全部 152 张表', names.length === 152, String(names.length))
|
||
check('默认按使用次数排序:入园记录在最前', names.slice(0, 5).join(',') === 'visits,orders,order_items,parks,ticket_types', names.slice(0, 5).join(','))
|
||
check('表结构里已经没有的表排在最后,并标出来', names.at(-1) === 'legacy_coupons'
|
||
&& (await page.locator('[data-catalog-row="legacy_coupons"] [data-row-missing]').innerText()).includes('表结构中已没有这张表'))
|
||
const visits = await page.locator('[data-catalog-row="visits"]').innerText()
|
||
check('每行写中文名、表名、类型、关联数、使用次数', ['入园记录', 'visits', '明细表', '3 个关联', '使用 42 次'].every((s) => visits.includes(s)),
|
||
visits.replace(/\s+/g, ' '))
|
||
const c = countsOf(visitsNotes())
|
||
check('……各状态的项数(推断、已验证、已确认、已驳回)', (await rowCounts(page, 'visits')) === `${c.proposed},${c.verified},${c.confirmed},${c.rejected}`,
|
||
await rowCounts(page, 'visits'))
|
||
check('……读屏念得出各状态的项数', (await page.locator('[data-catalog-row="visits"] .sr-only').allInnerTexts()).join('|').includes(`推断 ${c.proposed} 项`))
|
||
check('没有目录的表写「没有目录」、中文名写「未填写中文名」', (await page.locator('[data-catalog-row="gates"]').innerText()).includes('没有目录')
|
||
&& (await page.locator('[data-catalog-row="gates"] [data-row-label]').innerText()) === '未填写中文名')
|
||
// 没填中文名的表:标题就是表名,「未填写中文名」降为下一行的淡色提示;填了的照旧中文名作标题、表名在下一行
|
||
const titleOf = (name) => page.locator(`[data-catalog-row="${name}"] [data-row-title]`).evaluate((el) => ({
|
||
text: el.innerText, weight: Number(getComputedStyle(el).fontWeight), color: getComputedStyle(el).color,
|
||
hint: getComputedStyle(el.closest('[data-catalog-row]').querySelector('[data-row-no-label]') ?? el).color,
|
||
}))
|
||
const gatesTitle = await titleOf('gates')
|
||
const visitsTitle = await titleOf('visits')
|
||
check('没填中文名:标题写表名、用正文的颜色和字重,「未填写中文名」是淡色的次要提示', gatesTitle.text === 'gates' && gatesTitle.weight >= 500
|
||
&& gatesTitle.color === visitsTitle.color && gatesTitle.hint !== gatesTitle.color, JSON.stringify(gatesTitle))
|
||
check('填了中文名:标题写中文名', visitsTitle.text === '入园记录')
|
||
await cleanCopy(page, '表清单')
|
||
await shot(page, 'catalog-list')
|
||
|
||
// 搜索:表名、中文名都认
|
||
const search = page.locator('[data-catalog-search]')
|
||
await search.fill('订单')
|
||
names = await rowNames(page)
|
||
check('搜索中文名「订单」:订单、订单明细', names.join(',') === 'orders,order_items', names.join(','))
|
||
check('……计数写「2 / 152 张表」', (await page.locator('[data-catalog-count]').innerText()) === '2 / 152 张表')
|
||
await search.fill('ext_log_01')
|
||
check('搜索表名「ext_log_01」:10 张', (await rowNames(page)).length === 10)
|
||
await search.fill('')
|
||
|
||
// 按状态筛选
|
||
const counts = await page.locator('[data-catalog-filter] [data-filter]').evaluateAll((els) => els.map((e) => e.innerText.replace(/\s+/g, '')))
|
||
check('筛选按钮带数量:全部 152、有待确认项 5、没有待确认项 3、没有目录 144', counts.join('|') === '全部152|有待确认项5|没有待确认项3|没有目录144', counts.join('|'))
|
||
// 「已确认」专指人工确认:只有已验证项的表不说「全部已确认」,说「没有待确认项」,悬停写明已验证的不需要逐项确认
|
||
check('筛选不说「全部已确认」(只有已验证项的表也在这一组)', !counts.join('|').includes('全部已确认')
|
||
&& (await page.locator('[data-filter="done"]').getAttribute('title')).includes('已验证的项由外键约束或数据剖析核实'))
|
||
await page.locator('[data-filter="pending"]').click()
|
||
names = await rowNames(page)
|
||
check('「有待确认项」:只有还有推断项的表', names.join(',') === 'visits,order_items,ticket_types,store_sales,inventory_snapshots', names.join(','))
|
||
await page.locator('[data-filter="done"]').click()
|
||
names = await rowNames(page)
|
||
check('「没有待确认项」:订单、景区、只剩目录的旧表', names.join(',') === 'orders,parks,legacy_coupons', names.join(','))
|
||
await page.locator('[data-filter="none"]').click()
|
||
check('「没有目录」:144 张', (await rowNames(page)).length === 144)
|
||
// 筛选组是单选组:方向键在组内移动并选中
|
||
await page.locator('[data-filter="none"]').focus()
|
||
await page.keyboard.press('ArrowLeft')
|
||
check('筛选组:← 选中前一项「没有待确认项」', (await page.locator('[data-catalog-filter]').getAttribute('data-catalog-filter')) === 'done')
|
||
await page.locator('[data-filter="all"]').click()
|
||
|
||
// 排序
|
||
await page.locator('[data-catalog-sort]').selectOption('pending')
|
||
names = await rowNames(page)
|
||
const proposedOf = (n) => countsOf(state.tables[S1].find((t) => t.name === n)?.notes).proposed
|
||
check('按待确认项排序:推断项最多的在前', proposedOf(names[0]) >= proposedOf(names[1]) && proposedOf(names[1]) >= proposedOf(names[2])
|
||
&& names.at(-1) === 'legacy_coupons', names.slice(0, 3).join(','))
|
||
await page.locator('[data-catalog-sort]').selectOption('name')
|
||
names = await rowNames(page)
|
||
const inSchema = names.slice(0, -1)
|
||
check('按表名排序:字母序,只剩目录的表仍在最后', inSchema.join(',') === [...inSchema].sort((a, b) => a.localeCompare(b, 'en')).join(',')
|
||
&& names.at(-1) === 'legacy_coupons', names.slice(0, 3).join(','))
|
||
await page.locator('[data-catalog-sort]').selectOption('usage')
|
||
|
||
// 筛选、搜索、排序写进地址栏:刷新不丢,打开一张表、再回到清单都带着
|
||
await page.locator('[data-filter="pending"]').click()
|
||
await page.locator('[data-catalog-sort]').selectOption('name')
|
||
await search.fill('order')
|
||
await until(async () => new URL(page.url()).searchParams.get('q') === 'order', 3000)
|
||
const listUrl = new URL(page.url())
|
||
check('筛选、搜索、排序写进地址栏的查询参数', listUrl.searchParams.get('filter') === 'pending' && listUrl.searchParams.get('sort') === 'name'
|
||
&& listUrl.searchParams.get('q') === 'order', listUrl.search)
|
||
await page.reload({ waitUntil: 'networkidle' })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
names = await rowNames(page)
|
||
check('刷新之后筛选、搜索、排序都还在', (await page.locator('[data-catalog-filter]').getAttribute('data-catalog-filter')) === 'pending'
|
||
&& await page.locator('[data-catalog-sort]').inputValue() === 'name' && await search.inputValue() === 'order'
|
||
&& names.join(',') === 'order_items', `${names.join(',')} ${page.url()}`)
|
||
await page.locator('[data-catalog-row="order_items"] [data-catalog-open-row]').click()
|
||
await page.waitForURL(`**/data/catalog/${S1}/order_items?**`, { timeout: 5000 }).catch(() => {})
|
||
const tableUrl = new URL(page.url())
|
||
check('打开一张表:地址带着清单的查询参数,清单照旧筛着', tableUrl.pathname === `/data/catalog/${S1}/order_items`
|
||
&& tableUrl.search === listUrl.search && (await rowNames(page)).join(',') === 'order_items', page.url())
|
||
await page.goBack({ waitUntil: 'networkidle' })
|
||
check('后退回到清单:筛选照旧', new URL(page.url()).search === listUrl.search
|
||
&& (await page.locator('[data-catalog-filter]').getAttribute('data-catalog-filter')) === 'pending', page.url())
|
||
await page.locator('[data-filter="all"]').click()
|
||
await page.locator('[data-catalog-sort]').selectOption('usage')
|
||
await search.fill('')
|
||
check('回到默认(全部、按使用次数、不搜索):地址里不留这几个参数', await until(async () => new URL(page.url()).search === '', 3000), page.url())
|
||
|
||
// 键盘:↑↓ 在行之间移动,Enter 打开
|
||
await page.locator('[data-catalog-row="visits"] [data-catalog-open-row]').focus()
|
||
await page.keyboard.press('ArrowDown')
|
||
check('键盘:↓ 移到下一行', await page.evaluate(() => document.activeElement?.closest('[data-catalog-row]')?.getAttribute('data-catalog-row')) === 'orders')
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForURL(`**/data/catalog/${S1}/orders`, { timeout: 5000 }).catch(() => {})
|
||
check('……Enter 打开这张表', new URL(page.url()).pathname === `/data/catalog/${S1}/orders`, page.url())
|
||
check('……清单上标出正在看的表', await until(async () => (await page.locator('[data-catalog-row="orders"] [data-catalog-open-row]').getAttribute('aria-current')) === 'true'),
|
||
String(await page.locator('[data-catalog-row="orders"] [data-catalog-open-row]').getAttribute('aria-current')))
|
||
const focusVisible = await page.evaluate(() => {
|
||
const el = document.querySelector('[data-catalog-row="orders"] [data-catalog-open-row]')
|
||
el.focus()
|
||
return getComputedStyle(el).boxShadow !== 'none' || getComputedStyle(el).outlineStyle !== 'none'
|
||
})
|
||
check('……行按钮有可见的焦点样式', focusVisible)
|
||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||
await close()
|
||
})
|
||
|
||
await section('数据目录 · 用到但没确认(顶部摘要)', async () => {
|
||
const state = freshState()
|
||
// 期望值照假数据现算:使用次数大于 0、还在表结构里的表,按推断项 / 没有目录分
|
||
const used = state.tables[S1].filter((t) => !t.missing && t.usage > 0)
|
||
const progress = (t) => { const c = countsOf(t.notes ?? {}); return c.proposed ? 'pending' : c.verified + c.confirmed + c.rejected ? 'done' : 'none' }
|
||
const pending = used.filter((t) => progress(t) === 'pending').length
|
||
const none = used.filter((t) => progress(t) === 'none').length
|
||
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
const bar = page.locator('[data-catalog-usage-summary]')
|
||
await bar.waitFor({ timeout: 3000 })
|
||
check('数字照清单算:查询过的表、有推断项的、没有目录的', await bar.getAttribute('data-catalog-usage-summary') === `${used.length},${pending},${none}`,
|
||
`${await bar.getAttribute('data-catalog-usage-summary')} ≠ ${used.length},${pending},${none}`)
|
||
const text = await bar.innerText()
|
||
check('写成一句话', text.includes(`运行中查询过的 ${used.length} 张表里,有 ${pending} 张还有推断项未确认,${none} 张还没有目录`), text)
|
||
await bar.locator('[data-usage-focus="pending"]').click()
|
||
check('点「还有推断项未确认」:切到「有待确认项」筛选',
|
||
(await page.locator('[data-catalog-filter]').getAttribute('data-catalog-filter')) === 'pending')
|
||
check('……排序是按使用次数', await page.locator('[data-catalog-sort]').inputValue() === 'usage')
|
||
const shown = await rowNames(page)
|
||
const expectTop = used.filter((t) => progress(t) === 'pending').sort((a, b) => b.usage - a.usage).map((t) => t.name)
|
||
check('……只列查询过的、有推断项的表,按使用次数排', shown.join(',') === expectTop.join(','), `${shown.join(',')} ≠ ${expectTop.join(',')}`)
|
||
await bar.locator('[data-usage-focus="none"]').click()
|
||
check('点「还没有目录」:切到「没有目录」筛选', (await page.locator('[data-catalog-filter]').getAttribute('data-catalog-filter')) === 'none'
|
||
&& (await rowNames(page))[0] === used.filter((t) => progress(t) === 'none').sort((a, b) => b.usage - a.usage)[0].name)
|
||
// 摘要说「N 张还没有目录」,点进去就是那 N 张:只看运行中查询过的表,不是全部没有目录的表
|
||
const expectNone = used.filter((t) => progress(t) === 'none').sort((a, b) => b.usage - a.usage).map((t) => t.name)
|
||
const noneShown = await rowNames(page)
|
||
check('……只列运行中查询过、还没有目录的表,张数和摘要一致', noneShown.join(',') === expectNone.join(',') && noneShown.length === none,
|
||
`${noneShown.length} 张:${noneShown.slice(0, 6).join(',')}`)
|
||
check('……清单上写明「只看运行中查询过的表」', (await page.locator('[data-catalog-used-only]').innerText().catch(() => '')).includes('只看运行中查询过的表'))
|
||
const usedCounts = await page.locator('[data-catalog-filter] [data-filter]').evaluateAll((els) => els.map((e) => e.innerText.replace(/\s+/g, '')))
|
||
check('……筛选按钮上的数字按同一个口径算', usedCounts.includes(`没有目录${none}`) && usedCounts.includes(`全部${used.length}`), usedCounts.join('|'))
|
||
check('……摘要上点中的那一段标为按下', await bar.locator('[data-usage-focus="none"]').getAttribute('aria-pressed') === 'true')
|
||
await shot(page, 'catalog-used-only')
|
||
await page.locator('[data-catalog-used-only-clear]').click()
|
||
const allNone = state.tables[S1].filter((t) => progress(t) === 'none').length
|
||
check('取消「只看运行中查询过的表」:回到全部没有目录的表,摘要不再标按下', (await rowNames(page)).length === allNone
|
||
&& await page.locator('[data-catalog-used-only]').count() === 0
|
||
&& await bar.locator('[data-usage-focus="none"]').getAttribute('aria-pressed') === 'false', String((await rowNames(page)).length))
|
||
check('摘要只读、不发请求', writes(sent, /./).length === 0)
|
||
// 都审完了:只写一句,不给按钮
|
||
for (const t of state.tables[S1]) if (t.notes) t.notes = applyPut({}, { label: { value: t.notes.label?.value ?? t.name } })
|
||
for (const t of state.tables[S1]) if (!t.notes && t.usage > 0) t.notes = { label: item(t.name, 'human') }
|
||
await goto(page, `/data/catalog/${S1}`)
|
||
await bar.waitFor({ timeout: 3000 })
|
||
check('都确认了:写「都没有待确认项」,没有按钮', (await bar.innerText()).includes(`运行中查询过的 ${used.length} 张表都没有待确认项`)
|
||
&& await bar.locator('[data-usage-focus]').count() === 0, await bar.innerText())
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
await close()
|
||
})
|
||
|
||
await section('数据目录 · 起草:设置与开始', async () => {
|
||
const state = freshState()
|
||
const replies = { createJob: [] }
|
||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
|
||
// 多选:Shift 点选连选一段
|
||
await page.locator('[data-catalog-select="gates"]').click()
|
||
await page.locator('[data-catalog-select="members"]').click({ modifiers: ['Shift'] })
|
||
check('Shift 点选连选一段:闸机到会员共 3 张', (await page.locator('[data-catalog-count]').innerText()) === '已选 3 张')
|
||
await page.locator('[data-catalog-draft-selected]').click()
|
||
await draftBox(page).waitFor()
|
||
check('起草范围默认是「已选的 3 张表」', await draftBox(page).locator('[data-draft-scope="selected"] input').isChecked()
|
||
&& (await draftBox(page).locator('[data-draft-scope="selected"]').innerText()).includes('已选的 3 张表'))
|
||
check('……说明只发送表结构、不发送数据', (await draftBox(page).locator('[data-draft-model]').innerText()).includes('不发送数据'))
|
||
check('没勾「用模型起草」:不显示起草要求和补充说明(只对模型有用)',
|
||
await draftBox(page).locator('[data-draft-guidance], [data-draft-note]').count() === 0)
|
||
await draftBox(page).locator('[data-draft-model] input').check()
|
||
const guidance = draftBox(page).locator('[data-draft-guidance] textarea')
|
||
const note = draftBox(page).locator('[data-draft-note] textarea')
|
||
check('勾上以后:起草要求显示数据源现在存的那一份,可以改', await guidance.inputValue().catch(() => '') === S1_GUIDANCE
|
||
&& await guidance.isEditable().catch(() => false), await guidance.inputValue().catch(() => '—'))
|
||
check('……本次补充说明是空的', await note.inputValue().catch(() => '—') === '')
|
||
check('……写明用的是哪个起草模型:设置里没配,跟随助手的模型(写出当前是哪个)',
|
||
(await draftText(page, '[data-draft-model-name]')).includes('跟随助手的模型(当前:check-assistant-model)'), await draftText(page, '[data-draft-model-name]'))
|
||
check('……数据源说明也一并交给模型:写出来', (await draftText(page, '[data-draft-source-description]')).includes(S1_DESCRIPTION),
|
||
await draftText(page, '[data-draft-source-description]'))
|
||
await shot(page, 'catalog-draft-setup')
|
||
|
||
// 超长:写明上限,不能开始
|
||
await guidance.fill('长'.repeat(2001))
|
||
check('起草要求超过 2,000 字:写明上限,「起草」不可用', (await draftText(page, '[data-draft-guidance]')).includes('不超过 2,000 字')
|
||
&& await page.locator('[data-draft-start]').isDisabled(), await draftText(page, '[data-draft-guidance]'))
|
||
await guidance.fill(GUIDANCE2)
|
||
await note.fill(DRAFT_NOTE)
|
||
|
||
// 起草模型用不了:409 带机读码,留在设置阶段写明原因,不进入进行中、不去取任务
|
||
replies.createJob.push(json(MODEL_DOWN, 409))
|
||
await page.locator('[data-draft-start]').click()
|
||
check('起草模型用不了(409):留在设置阶段,照原样写服务端那句话', await until(async () => (await draftText(page, '[data-draft-start-error]')) === MODEL_DOWN.detail)
|
||
&& (await draftPhase(page)) === 'setup', await draftText(page, '[data-draft-start-error]'))
|
||
const keys = writes(sent, /./).map((s) => s.key.replace(/^(\w+) .*\/catalog\//, '$1 '))
|
||
check('……起草要求改过:先存起草要求、再建任务', keys.join(',') === 'PUT guidance,POST draft-jobs', keys.join(','))
|
||
check('……存的是改过的那一份', JSON.stringify(lastBody(sent, /catalog\/guidance$/)) === JSON.stringify({ guidance: GUIDANCE2 }),
|
||
JSON.stringify(lastBody(sent, /catalog\/guidance$/)))
|
||
check('……没有去取任务进度', count(sent, /draft-jobs\/check-job/) === 0)
|
||
|
||
// 再点一次:这回建得起来;起草要求已经存过、没再改,不再存一次
|
||
await page.locator('[data-draft-start]').click()
|
||
check('建起来以后进入进行中', await until(async () => (await draftPhase(page)) === 'running'))
|
||
const body = lastBody(sent, /draft-jobs$/)
|
||
check('建任务的请求:所选的表(清单顺序)、用模型、本次补充说明',
|
||
JSON.stringify(body) === JSON.stringify({ tables: ['gates', 'store_sales', 'members'], use_model: true, note: DRAFT_NOTE }), JSON.stringify(body))
|
||
check('……起草要求只存了一次', count(sent, /^PUT .*catalog\/guidance$/) === 1)
|
||
check('……写请求都带本机署名', writes(sent, /catalog\/(draft-jobs|guidance)/).every((s) => decodeURIComponent(s.actor ?? '') === ACTOR))
|
||
check('进行中:逐表列出 3 张,都在等待', (await draftBox(page).locator('[data-draft-task]').count()) === 3
|
||
&& (await draftText(page, '[data-draft-task="gates"] [data-draft-task-status]')) === '等待', await draftText(page, '[data-draft-tasks]'))
|
||
check('……进度写「已完成 0 / 3 张表」', (await draftText(page, '[data-draft-progress]')) === '已完成 0 / 3 张表', await draftText(page, '[data-draft-progress]'))
|
||
check('……清单上的选择清空(这几张已经交给起草任务)', (await page.locator('[data-catalog-count]').innerText()).endsWith('张表'))
|
||
await shot(page, 'catalog-draft-running')
|
||
stopJob(state.jobs.at(-1))
|
||
check('没有调用旧的同步起草接口', count(sent, /catalog\/draft$/) === 0)
|
||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||
await close()
|
||
})
|
||
|
||
await section('数据目录 · 起草:进度、结果与重试', async () => {
|
||
const state = freshState()
|
||
const replies = { retry: [] }
|
||
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
check('开始起草(闸机到会员 3 张,用模型,带补充说明)', await startDraft(page, { select: ['gates', 'members'], model: true, note: DRAFT_NOTE }))
|
||
const job = state.jobs.at(-1)
|
||
|
||
// 一张写完、宽表起草到第 1 段
|
||
settle(state, job, 'gates', { added: 3, model_items: 6 })
|
||
settle(state, job, 'store_sales', { status: 'running', parts_done: 1 })
|
||
check('进度:已完成 1 / 3 张表', await until(async () => (await draftBox(page).locator('[data-draft-progress]').getAttribute('data-draft-progress', { timeout: 500 }).catch(() => '')) === '1/3'),
|
||
await draftText(page, '[data-draft-progress]'))
|
||
const bar = draftBox(page).locator('[role="progressbar"]').first()
|
||
check('……进度条读屏可读:名称、当前值、上限', (await bar.getAttribute('aria-valuenow')) === '1' && (await bar.getAttribute('aria-valuemax')) === '3'
|
||
&& !!(await bar.getAttribute('aria-label')))
|
||
check('……宽表写出段进度「1 / 3 段」,状态是「起草中」', (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-parts]')) === '1 / 3 段'
|
||
&& (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-status]')) === '起草中', await draftText(page, '[data-draft-task="store_sales"]'))
|
||
check('……写完的那张写「已完成」', (await draftText(page, '[data-draft-task="gates"] [data-draft-task-status]')) === '已完成')
|
||
check('……合计写「新增 3 项、更新 0 项」', (await draftText(page, '[data-draft-summary]')) === '新增 3 项、更新 0 项', await draftText(page, '[data-draft-summary]'))
|
||
check('……写明用的模型', (await draftText(page, '[data-draft-model-used]')).includes('check-draft-model'), await draftText(page, '[data-draft-model-used]'))
|
||
check('写完的表马上并入目录:清单重取,起草出的中文名出现',
|
||
await until(async () => (await page.locator('[data-catalog-row="gates"] [data-row-label]').innerText({ timeout: 500 }).catch(() => '')) === 'gates 的起草名'))
|
||
|
||
// 宽表有一段没写出来;会员表一段都没写出来
|
||
settle(state, job, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_items: 8, model_error: WIDE_ERR })
|
||
settle(state, job, 'members', { status: 'failed', model_items: 0, model_error: CALL_ERR })
|
||
finishJob(job)
|
||
check('结束:标题写「起草完成,部分表未写全」', await until(async () => (await draftPhase(page)) === 'done')
|
||
&& (await draftText(page, '[data-draft-title]')) === '起草完成,部分表未写全', await draftText(page, '[data-draft-title]'))
|
||
const problems = await draftText(page, '[data-draft-problems]')
|
||
check('……写明哪些没写全:失败 1 张、部分完成 1 张', problems.includes('失败 1 张') && problems.includes('部分完成 1 张'), problems)
|
||
const shown = await draftBox(page).locator('[data-draft-task]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-task')))
|
||
check('……逐表只列没写全的 2 张,各自写原因', shown.join(',') === 'store_sales,members'
|
||
&& (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 3 段(promo_attr_19 到 promo_attr_43,共 25 列)没写出来')
|
||
&& (await draftText(page, '[data-draft-task="members"] [data-draft-task-reason]')).includes('已重试 2 次'), shown.join(','))
|
||
await draftBox(page).locator('[data-draft-show-all]').click()
|
||
check('……点「显示全部」列出 3 张', (await draftBox(page).locator('[data-draft-task]').count()) === 3)
|
||
check('……合计、用量照实写', (await draftText(page, '[data-draft-summary]')) === '新增 7 项、更新 0 项'
|
||
&& (await draftText(page, '[data-draft-usage]')).includes('调用模型 4 次'), `${await draftText(page, '[data-draft-summary]')} · ${await draftText(page, '[data-draft-usage]')}`)
|
||
check('……清单上失败的表标「上次起草失败」', await until(async () => (await page.locator('[data-catalog-row="members"] [data-row-last-draft="failed"]').count()) === 1))
|
||
await shot(page, 'catalog-draft-partial')
|
||
|
||
// 结束以后不再取进度
|
||
const polls = () => count(sent, new RegExp(`^GET .*draft-jobs/${job.id}$`))
|
||
const n = polls()
|
||
await sleep(3200)
|
||
check('结束以后不再取进度', polls() === n, `${n} → ${polls()}`)
|
||
|
||
// 只重试失败的表
|
||
const retry = page.locator('[data-draft-retry]')
|
||
check('给「只重试失败的表(2 张)」', (await retry.innerText().catch(() => '')).includes('只重试失败的表(2 张)'), await retry.innerText().catch(() => ''))
|
||
// 重试时起草模型已经用不了(服务端和建任务时一样回 409 catalog_draft_model_unavailable):留在结果里,照原样写服务端
|
||
// 那句话(长句也不拆成「标题:原因」),不建新任务
|
||
replies.retry.push(json(MODEL_OFF, 409))
|
||
await retry.click()
|
||
check('重试时起草模型用不了(409):留在结果里,照原样写服务端那句话,不建新任务',
|
||
await until(async () => (await draftText(page, '[data-draft-action-error]')) === MODEL_OFF.detail)
|
||
&& (await draftPhase(page)) === 'done' && state.jobs.at(-1) === job, await draftText(page, '[data-draft-action-error]'))
|
||
await retry.click()
|
||
check('重试:调重试接口,进入新任务的进行中,只含没写全的 2 张', await until(async () => (await draftPhase(page)) === 'running'
|
||
&& (await draftBox(page).locator('[data-draft-task]').count()) === 2) && count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/retry$`)) === 2)
|
||
check('……上一次的报错收起', await draftBox(page).locator('[data-draft-action-error]').count() === 0)
|
||
const job2 = state.jobs.at(-1)
|
||
check('……新任务沿用用模型和补充说明', job2.id !== job.id && job2.use_model && job2.note === DRAFT_NOTE && job2.retry_of === job.id)
|
||
settle(state, job2, 'store_sales', { added: 1, model_items: 3 })
|
||
settle(state, job2, 'members', { added: 5, model_items: 5 })
|
||
finishJob(job2)
|
||
check('……都写完:「起草完成」,不再给重试', await until(async () => (await draftText(page, '[data-draft-title]')) === '起草完成')
|
||
&& await page.locator('[data-draft-retry]').count() === 0, await draftText(page, '[data-draft-title]'))
|
||
check('……没有没写全的表时逐表全部列出', (await draftBox(page).locator('[data-draft-task]').count()) === 2)
|
||
await page.locator('[data-draft-close]').click()
|
||
check('关掉以后清单上的标记跟着最新一次起草:会员表不再标失败', await until(async () => (await page.locator('[data-catalog-row="members"] [data-row-last-draft]').count()) === 0))
|
||
|
||
// 一项都没写进去:只有「模型给了内容、只是和现有目录一致」才说「没有变化」
|
||
const nothing = async (outcome) => {
|
||
await startDraft(page, { select: ['parks'], model: true })
|
||
const j = state.jobs.at(-1)
|
||
settle(state, j, 'parks', outcome)
|
||
finishJob(j)
|
||
await until(async () => (await draftPhase(page)) === 'done')
|
||
const out = await draftText(page, '[data-draft-summary]')
|
||
await page.locator('[data-draft-close]').click()
|
||
return out
|
||
}
|
||
check('模型一项可用内容都没给:不说「没有变化」,说「目录没有更新」', (await nothing({ status: 'failed', model_items: 0, model_error: '模型没有给出这张表的可用内容' }))
|
||
=== '目录没有更新:模型没有给出可用的内容,原因见下方')
|
||
check('……模型给了内容、只是和现有目录一致:才说「没有变化」', (await nothing({ model_items: 3 })) === '没有变化:起草出的内容与现有目录一致')
|
||
check('没有调用旧的同步起草接口', count(sent, /catalog\/draft$/) === 0)
|
||
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: catalogHandlers(state) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
// 页头「起草」:没选表,范围默认是使用次数最多的 20 张
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).waitFor()
|
||
const scopes = await draftBox(page).locator('[data-draft-scope]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-scope')))
|
||
check('没选表时:范围是「使用次数最多的 20 张」「全部 151 张」,默认前者', scopes.join(',') === 'top,all'
|
||
&& await draftBox(page).locator('[data-draft-scope="top"] input').isChecked()
|
||
&& (await draftBox(page).locator('[data-draft-scope="all"]').innerText()).includes('全部 151 张表'), scopes.join(','))
|
||
check('……没有上一次起草时不写「上一次」', await draftBox(page).locator('[data-draft-last]').count() === 0)
|
||
await draftBox(page).locator('[data-draft-model] input').check()
|
||
await page.locator('[data-draft-start]').click()
|
||
await until(async () => (await draftPhase(page)) === 'running')
|
||
const body = lastBody(sent, /draft-jobs$/) ?? {}
|
||
check('请求带使用次数最多的 20 张表(不含只剩目录的那张),补充说明空着就不带', body.tables?.length === 20 && !body.tables.includes('legacy_coupons')
|
||
&& body.use_model === true && !('note' in body), JSON.stringify(body).slice(0, 160))
|
||
const job = state.jobs.at(-1)
|
||
check('刚建好还在排队:标题写「排队中」', (await draftText(page, '[data-draft-title]')).startsWith('排队中'), await draftText(page, '[data-draft-title]'))
|
||
runJob(job)
|
||
check('……轮到它了:「正在起草…」', await until(async () => (await draftText(page, '[data-draft-title]')) === '正在起草…'), await draftText(page, '[data-draft-title]'))
|
||
|
||
// 关掉对话框:任务不停,进度在页头下方的后台任务条上(只有这一处,05 (g));页头的「起草」只换图标,读屏写状态、不写数字
|
||
await page.locator('[data-draft-hide]').click()
|
||
check('「在后台继续」:对话框关上,没有发停止', await until(async () => (await draftBox(page).count()) === 0) && count(sent, /\/cancel$/) === 0)
|
||
const strip = page.locator('[data-catalog-job-strip] [data-catalog-job="draft"]')
|
||
check('……后台任务条上写「起草 0/20」', await until(async () => (await strip.count()) === 1) && (await strip.innerText()).includes('起草 0/20'),
|
||
await strip.innerText({ timeout: 500 }).catch(() => ''))
|
||
const draftHead = page.locator('[data-catalog-draft-head]')
|
||
check('……进度只在任务条上一处:页头的「起草」读屏写「起草(进行中)」,不写数字', (await draftHead.getAttribute('aria-label')) === '起草(进行中)'
|
||
&& !/\d/.test(await draftHead.innerText()), await draftHead.getAttribute('aria-label'))
|
||
settle(state, job, 'visits', { added: 2 })
|
||
settle(state, job, 'orders', { added: 1 })
|
||
check('……进度跟着任务走', await until(async () => (await strip.getAttribute('data-progress', { timeout: 500 }).catch(() => '')) === '2/20'),
|
||
await strip.getAttribute('data-progress', { timeout: 500 }).catch(() => ''))
|
||
check('……进度条读屏可读', (await strip.locator('[role="progressbar"]').getAttribute('aria-valuenow').catch(() => '')) === '2')
|
||
await shot(page, 'catalog-draft-strip')
|
||
// 点「查看」回到对话框:直接是进行中的那个任务
|
||
await strip.locator('[data-catalog-job-open]').click()
|
||
check('点开:回到对话框,直接显示进行中的任务', await until(async () => (await draftPhase(page)) === 'running')
|
||
&& (await draftText(page, '[data-draft-progress]')) === '已完成 2 / 20 张表', await draftText(page, '[data-draft-progress]'))
|
||
// 停止:调停止接口;服务端马上取消进行中的模型调用,只等手上那张表写完(停止接口等它收尾才答)。这期间写「正在停止」,
|
||
// 说法照服务端的做法:不是「等当前这次调用结束」——一次调用可能要几分钟
|
||
await page.locator('[data-draft-stop]').click()
|
||
check('点「停止」:调停止接口,写「正在停止:取消进行中的模型调用…」,停止按钮不能再点',
|
||
await until(async () => (await draftText(page, '[data-draft-title]')).startsWith('正在停止:取消进行中的模型调用'))
|
||
&& count(sent, new RegExp(`^POST .*draft-jobs/${job.id}/cancel$`)) === 1 && await page.locator('[data-draft-stop]').isDisabled(),
|
||
await draftText(page, '[data-draft-title]'))
|
||
stopJob(job)
|
||
check('……停下来:标题写「已停止」,没开始的 18 张记已停止', await until(async () => (await draftPhase(page)) === 'done')
|
||
&& (await draftText(page, '[data-draft-title]')) === '已停止' && (await draftText(page, '[data-draft-problems]')).includes('已停止 18 张'),
|
||
`${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-problems]')}`)
|
||
check('……已经写进目录的不回滚:合计照实写', (await draftText(page, '[data-draft-summary]')) === '新增 3 项、更新 0 项', await draftText(page, '[data-draft-summary]'))
|
||
check('……可以只重试没写的 18 张', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(18 张)'))
|
||
await page.locator('[data-draft-close]').click()
|
||
|
||
// 再起一个(不用模型),按 Esc 关掉对话框,重新载入页面:清单带着进行中的任务,打开就是它
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).waitFor()
|
||
check('上一次停止了:设置阶段写明上一次的结果,可以点开看', (await draftText(page, '[data-draft-last]')).includes('已停止')
|
||
&& await draftBox(page).locator('[data-draft-last-open]').count() === 1, await draftText(page, '[data-draft-last]'))
|
||
await page.locator('[data-draft-start]').click()
|
||
await until(async () => (await draftPhase(page)) === 'running')
|
||
const job3 = state.jobs.at(-1)
|
||
check('不用模型:请求的用模型为假,逐表不写段进度', lastBody(sent, /draft-jobs$/)?.use_model === false
|
||
&& await draftBox(page).locator('[data-draft-task-parts]').count() === 0)
|
||
await page.keyboard.press('Escape')
|
||
check('按 Esc 关掉对话框也不停任务', await until(async () => (await draftBox(page).count()) === 0)
|
||
&& count(sent, new RegExp(`^POST .*draft-jobs/${job3.id}/cancel$`)) === 0)
|
||
await goto(page, `/data/catalog/${S1}`)
|
||
check('重新载入页面:清单带着进行中的任务,任务条上照样显示进度', await until(async () => (await page.locator('[data-catalog-job="draft"]').count()) === 1))
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
check('……点「起草」直接显示进行中的任务,不是重新选范围', await until(async () => (await draftPhase(page)) === 'running')
|
||
&& await draftBox(page).locator('[data-draft-scope]').count() === 0)
|
||
await page.locator('[data-draft-hide]').click()
|
||
// 关着对话框时结束:提示,带「查看结果」
|
||
for (const t of job3.tasks) if (t.status === 'pending') settle(state, job3, t.table_name, { added: 1 })
|
||
finishJob(job3)
|
||
const toastBox = page.locator('[data-toast]').filter({ hasText: '起草完成' })
|
||
check('关着对话框时结束:提示「数据目录起草完成」,带「查看结果」', await until(async () => (await toastBox.count()) > 0),
|
||
await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => ''))
|
||
check('……任务条收起', (await page.locator('[data-catalog-job-strip]').count()) === 0)
|
||
await toastBox.getByRole('button', { name: '查看结果' }).click()
|
||
check('……点「查看结果」打开这一次的结果', await until(async () => (await draftPhase(page)) === 'done')
|
||
&& (await draftText(page, '[data-draft-title]')) === '起草完成', await draftText(page, '[data-draft-title]'))
|
||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||
await close()
|
||
})
|
||
|
||
await section('数据目录 · 起草:已有任务(409)、服务重启中断、整体出错与离开页面', async () => {
|
||
const state = freshState()
|
||
// 上一次起草因服务重启中断:没结束的表记已停止,写明原因
|
||
const old = newJob(state, S1, { tables: ['visits', 'orders', 'parks'], use_model: true, note: DRAFT_NOTE })
|
||
settle(state, old, 'visits', { added: 2 })
|
||
stopJob(old, 'interrupted', '服务重启,起草中断')
|
||
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).waitFor()
|
||
check('上一次起草因服务重启中断:设置阶段写明,给「查看」', await until(async () => (await draftText(page, '[data-draft-last]')).includes('服务重启')),
|
||
await draftText(page, '[data-draft-last]'))
|
||
await draftBox(page).locator('[data-draft-last-open]').click()
|
||
// 重试不是续跑:服务端把这些表整张重新分段起草(不跳过已写进目录的段),不能说成「接着起草」剩下的部分
|
||
check('……点开:结果写明服务重启中断,可以只重试这些表、会重新起草整张表(不说接着起草)', await until(async () => (await draftPhase(page)) === 'done')
|
||
&& (await draftText(page, '[data-draft-title]')) === '服务重启,起草中断' && (await draftText(page, '[data-draft-interrupted]')).includes('会重新起草整张表')
|
||
&& (await draftText(page, '[data-draft-interrupted]')).includes('已写进目录的内容不会丢') && !(await draftText(page, '[data-draft-interrupted]')).includes('接着起草'),
|
||
`${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-interrupted]')}`)
|
||
// 服务端把任务和没写完的表都记「服务重启,起草中断」:标题、提示已经写了,逐表只写「已停止」,不再各写一遍
|
||
check('……没写的 2 张记「已停止」,原因不再逐张重复', (await draftText(page, '[data-draft-task="orders"] [data-draft-task-status]')) === '已停止'
|
||
&& (await draftText(page, '[data-draft-task="parks"] [data-draft-task-status]')) === '已停止'
|
||
&& await draftBox(page).locator('[data-draft-task-reason]').count() === 0, await draftText(page, '[data-draft-tasks]'))
|
||
await shot(page, 'catalog-draft-interrupted')
|
||
await page.locator('[data-draft-retry]').click()
|
||
check('……「只重试失败的表」接着起草没写的 2 张', await until(async () => (await draftPhase(page)) === 'running')
|
||
&& JSON.stringify(state.jobs.at(-1).tasks.map((t) => t.table_name)) === JSON.stringify(['orders', 'parks']))
|
||
const again = state.jobs.at(-1)
|
||
settle(state, again, 'orders', { added: 1 })
|
||
settle(state, again, 'parks', { added: 1 })
|
||
finishJob(again)
|
||
await until(async () => (await draftPhase(page)) === 'done')
|
||
await page.locator('[data-draft-close]').click()
|
||
|
||
// 任务整体出错(意外出错、开始时起草模型已经用不了……):服务端把没结束的表也记失败、原因同任务的。原因只在标题下面
|
||
// 写一次,照服务端的原话(它已经带着「起草任务出错:」开头,界面不再套一层);逐表不再重复同一句;这些表都能重试
|
||
check('开始起草(使用次数最多的 20 张,用模型)', await startDraft(page, { model: true }))
|
||
const broken = state.jobs.at(-1)
|
||
settle(state, broken, 'visits', { added: 2, model_items: 4 })
|
||
settle(state, broken, 'orders', { status: 'failed', model_items: 0, model_error: CALL_ERR })
|
||
failJob(broken, JOB_ERR)
|
||
check('任务整体出错:标题写「起草失败」,原因照服务端的原话写一次', await until(async () => (await draftPhase(page)) === 'done')
|
||
&& (await draftText(page, '[data-draft-title]')) === '起草失败' && (await draftText(page, '[data-draft-job-error]')) === JOB_ERR,
|
||
`${await draftText(page, '[data-draft-title]')} · ${await draftText(page, '[data-draft-job-error]')}`)
|
||
check('……逐表只列没写的 19 张;和任务相同的原因不再逐张重复,各有原因的照样写', (await draftBox(page).locator('[data-draft-task]').count()) === 19
|
||
&& await draftBox(page).locator('[data-draft-task-reason]').count() === 1
|
||
&& (await draftText(page, '[data-draft-task="orders"] [data-draft-task-reason]')) === CALL_ERR, await draftText(page, '[data-draft-tasks]'))
|
||
check('……这 19 张都能重试', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(19 张)'))
|
||
await shot(page, 'catalog-draft-failed')
|
||
await page.locator('[data-draft-close]').click()
|
||
|
||
// 别处(另一个标签页)又起了一个任务,这一页还不知道:点开始时服务端回 409,转去显示那一个。先等设置阶段把上一次起草
|
||
// 取回来(写出「上一次起草…起草失败」),免得它晚到、取回的已经是别处那个进行中的任务,就走不到 409 了
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).waitFor()
|
||
check('上一次整体出错:设置阶段写明「起草失败」', await until(async () => (await draftText(page, '[data-draft-last]')).includes('起草失败')),
|
||
await draftText(page, '[data-draft-last]'))
|
||
const other = newJob(state, S1, { tables: ['gates', 'members'], use_model: true })
|
||
settle(state, other, 'gates', { added: 1 })
|
||
const pollsOf = (id) => count(sent, new RegExp(`^GET .*draft-jobs/${id}$`))
|
||
await page.locator('[data-draft-start]').click()
|
||
check('已有进行中的任务(409):转去显示那一个,并写明', await until(async () => (await draftPhase(page)) === 'running')
|
||
&& (await draftText(page, '[data-draft-busy]')).includes('已有进行中的起草任务')
|
||
&& (await draftText(page, '[data-draft-progress]')) === '已完成 1 / 2 张表', `${await draftText(page, '[data-draft-busy]')} · ${await draftText(page, '[data-draft-progress]')}`)
|
||
check('……先问进行中的是哪一个,之后跟着它取进度', count(sent, /draft-jobs\/active$/) >= 1 && await until(async () => pollsOf(other.id) >= 1),
|
||
`active ${count(sent, /draft-jobs\/active$/)} 次,取进度 ${pollsOf(other.id)} 次`)
|
||
|
||
// 任务进行中离开数据目录页(页内跳转,页面卸下,不是重新载入):不再取进度。卸下以后稍停,让卸下前已经发出的那次
|
||
// 取进度落进记录(停得太久,没清掉的那一拍可能恰好落在这段里、数不出来),再数两个多间隔
|
||
await page.locator('[data-draft-hide]').click()
|
||
await until(async () => (await draftBox(page).count()) === 0)
|
||
await page.locator('[data-catalog-back]').click()
|
||
const left = await until(async () => new URL(page.url()).pathname === '/data/databases' && (await page.locator('[data-catalog-page]').count()) === 0)
|
||
await sleep(100)
|
||
const polled = pollsOf(other.id)
|
||
await sleep(3200)
|
||
check('任务进行中离开数据目录页:页面卸下以后不再取进度', left && pollsOf(other.id) === polled,
|
||
`${left ? '' : `没有离开(${new URL(page.url()).pathname});`}${polled} → ${pollsOf(other.id)}`)
|
||
stopJob(other)
|
||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||
await close()
|
||
})
|
||
|
||
await section('数据目录 · 起草:清单上的失败标记与单表重起草', async () => {
|
||
const state = freshState()
|
||
const replies = { createJob: [] }
|
||
const FAIL = '模型 420 秒内没有写完这一段'
|
||
const tOf = (n) => state.tables[S1].find((t) => t.name === n)
|
||
tOf('orders').lastDraft = { status: 'failed', job_id: 'check-job-old', finished_at: T0, error: null, model_error: FAIL }
|
||
tOf('order_items').lastDraft = { status: 'partial', job_id: 'check-job-old', finished_at: T0, error: null, model_error: WIDE_ERR }
|
||
tOf('parks').lastDraft = { status: 'ok', job_id: 'check-job-old', finished_at: T0, error: null, model_error: null }
|
||
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
const mark = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-last-draft]`)
|
||
check('上次起草失败的表:清单上标「上次起草失败」并写原因', (await mark('orders').getAttribute('data-row-last-draft', { timeout: 1000 }).catch(() => '')) === 'failed'
|
||
&& (await mark('orders').innerText()).includes('上次起草失败') && (await mark('orders').innerText()).includes(FAIL),
|
||
await mark('orders').innerText({ timeout: 500 }).catch(() => ''))
|
||
check('……部分完成的标「上次起草部分完成」', (await mark('order_items').getAttribute('data-row-last-draft', { timeout: 1000 }).catch(() => '')) === 'partial'
|
||
&& (await mark('order_items').innerText()).includes('上次起草部分完成'))
|
||
check('……原因全文在悬停里', (await mark('order_items').getAttribute('title', { timeout: 500 }).catch(() => '') ?? '').includes(WIDE_ERR))
|
||
check('……写完了的、没起草过的不标', await mark('parks').count() === 0 && await mark('visits').count() === 0)
|
||
await shot(page, 'catalog-last-draft-marks')
|
||
|
||
// 打开这张表:写明原因,给「重新起草这张表」
|
||
await page.locator('[data-catalog-row="orders"] [data-catalog-open-row]').click()
|
||
const note = page.locator('[data-catalog-last-draft]')
|
||
check('打开这张表:写明上次起草失败和原因', await until(async () => (await note.count()) === 1)
|
||
&& (await note.innerText()).includes('上次起草失败') && (await note.innerText()).includes(FAIL), await note.innerText({ timeout: 500 }).catch(() => ''))
|
||
await shot(page, 'catalog-last-draft-detail')
|
||
await note.locator('[data-catalog-redraft]').click()
|
||
check('「重新起草这张表」:建只含这张表、用模型、不带补充说明的任务', await until(async () => !!lastBody(sent, /draft-jobs$/))
|
||
&& JSON.stringify(lastBody(sent, /draft-jobs$/)) === JSON.stringify({ tables: ['orders'], use_model: true }), JSON.stringify(lastBody(sent, /draft-jobs$/)))
|
||
check('……打开对话框显示这个任务的进度', await until(async () => (await draftPhase(page)) === 'running')
|
||
&& (await draftBox(page).locator('[data-draft-task]').count()) === 1)
|
||
const job = state.jobs.at(-1)
|
||
settle(state, job, 'orders', { added: 2 })
|
||
finishJob(job)
|
||
await until(async () => (await draftPhase(page)) === 'done')
|
||
await page.locator('[data-draft-close]').click()
|
||
check('……写完以后:清单上的标记和详情里的说明都收起', await until(async () => (await mark('orders').count()) === 0
|
||
&& (await page.locator('[data-catalog-last-draft]').count()) === 0))
|
||
|
||
// 模型用不了:单表重起草直接提示原因,不打开对话框
|
||
await page.locator('[data-catalog-row="order_items"] [data-catalog-open-row]').click()
|
||
await until(async () => (await page.locator('[data-catalog-last-draft]').count()) === 1)
|
||
check('部分完成的表:详情里写「上次起草部分完成」', (await page.locator('[data-catalog-last-draft]').innerText()).includes('上次起草部分完成'))
|
||
// 正在编辑这张表:起草会写进它,编辑中的保存就会撞上版本冲突,先不给点
|
||
await page.locator('[data-catalog-edit]').click()
|
||
check('正在编辑这张表:「重新起草这张表」不能点,写明先保存或取消', await page.locator('[data-catalog-redraft]').isDisabled()
|
||
&& (await page.locator('[data-catalog-last-draft]').innerText()).includes('保存或取消后再重新起草'))
|
||
await page.locator('[data-catalog-cancel]').click()
|
||
replies.createJob.push(json(MODEL_DOWN, 409))
|
||
await page.locator('[data-catalog-redraft]').click()
|
||
check('模型用不了:提示原因,不打开对话框', await until(async () => (await page.locator('[data-toast]').filter({ hasText: '未配置模型接入' }).count()) > 0)
|
||
&& await draftBox(page).count() === 0, await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => ''))
|
||
check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.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, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
check('开始起草(闸机到会员 3 张,用模型)', await startDraft(page, { select: ['gates', 'members'], model: true }))
|
||
const job = state.jobs.at(-1)
|
||
const usageLine = () => draftText(page, '[data-draft-usage-line]')
|
||
settle(state, job, 'store_sales', { status: 'running', parts_done: 1 })
|
||
check('还没有超时、没写出来的调用:用量只写调用次数和 token,没有提醒、超时的说明和段大小的说明',
|
||
await until(async () => (await usageLine()).includes('调用模型 1 次')) && !(await usageLine()).includes('超时')
|
||
&& !(await usageLine()).includes('没写出来')
|
||
&& await draftBox(page).locator('[data-draft-notice], [data-draft-timeout-note], [data-draft-part-columns]').count() === 0, await usageLine())
|
||
|
||
// 宽表的段接连超时、用完额度:服务端记下超时和没写出来的次数,之后的段缩小到每段 20 列,并给出一句提醒
|
||
Object.assign(job.usage, { calls: 7, timeouts: 3, failed_calls: 4 })
|
||
Object.assign(job, { part_columns: 20, notice: DRAFT_NOTICE })
|
||
const notice = draftBox(page).locator('[data-draft-notice]')
|
||
check('接连没写出来:进行中醒目显示服务端的提醒,是提醒的样式(不是错误)',
|
||
await until(async () => (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
|
||
&& (await notice.getAttribute('data-notice')) === 'warn', await draftText(page, '[data-draft-notice]'))
|
||
const above = await page.evaluate(() => {
|
||
const n = document.querySelector('[data-draft-notice]')?.getBoundingClientRect()
|
||
const list = document.querySelector('[data-draft-tasks]')?.getBoundingClientRect()
|
||
return !!n && !!list && n.bottom <= list.top
|
||
})
|
||
check('……提醒在逐表进度上面,不用往下翻', above)
|
||
// 没写出来的次数里含超时的:写成「其中」,免得读成两个数相加
|
||
check('……用量写「没写出来 4 次(其中超时 3 次)」', (await usageLine()).includes('没写出来 4 次(其中超时 3 次)'), await usageLine())
|
||
check('……写明超时的调用不计用量', (await draftText(page, '[data-draft-timeout-note]')).includes('超时的调用不计用量'),
|
||
await draftText(page, '[data-draft-timeout-note]'))
|
||
check('……写明之后的段每段最多 20 列', (await draftText(page, '[data-draft-part-columns]')).includes('每段最多 20 列'),
|
||
await draftText(page, '[data-draft-part-columns]'))
|
||
await shot(page, 'catalog-draft-notice')
|
||
|
||
// 关掉对话框:任务条上起草那一行也带着提醒(写全文:窄屏、触屏没有悬停);好转后服务端清掉提醒,两处都收起
|
||
await page.locator('[data-draft-hide]').click()
|
||
const strip = page.locator('[data-catalog-job-strip] [data-catalog-job="draft"]')
|
||
const stripNotice = strip.locator('[data-catalog-job-notice]')
|
||
check('在后台继续:任务条上起草那一行带着提醒,写全文', await until(async () => (await stripNotice.count()) === 1)
|
||
&& (await stripNotice.innerText()).replace(/\s+/g, ' ').trim() === DRAFT_NOTICE,
|
||
await stripNotice.innerText({ timeout: 500 }).catch(() => ''))
|
||
await shot(page, 'catalog-draft-strip-notice')
|
||
job.notice = null
|
||
check('……好转后服务端清掉提醒:任务条上不再带着', await until(async () => (await stripNotice.count()) === 0))
|
||
await strip.locator('[data-catalog-job-open]').click()
|
||
check('……点开对话框:提醒也不再显示,超时和没写出来的次数照旧', await until(async () => (await draftPhase(page)) === 'running')
|
||
&& await notice.count() === 0 && (await usageLine()).includes('其中超时 3 次'), await usageLine())
|
||
|
||
// 又接连没写出来:提醒再出现;任务结束以后不再显示(停止、缩小范围的建议只对进行中的任务有用),用量里的次数和
|
||
// 超时不计用量的说明照旧,段缩小的说明收起
|
||
Object.assign(job.usage, { timeouts: 5, failed_calls: 6 })
|
||
Object.assign(job, { part_columns: 10, notice: DRAFT_NOTICE })
|
||
check('又接连没写出来:提醒再出现,段缩小到每段 10 列', await until(async () => (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
|
||
&& (await draftText(page, '[data-draft-part-columns]')).includes('每段最多 10 列'), await draftText(page, '[data-draft-part-columns]'))
|
||
for (const name of ['gates', 'store_sales', 'members']) settle(state, job, name, { added: 1, model_items: 1 })
|
||
finishJob(job)
|
||
check('结束:不再显示提醒和段缩小的说明,用量里的次数和超时不计用量的说明照旧', await until(async () => (await draftPhase(page)) === 'done')
|
||
&& await notice.count() === 0 && await draftBox(page).locator('[data-draft-part-columns]').count() === 0
|
||
&& (await usageLine()).includes('没写出来 6 次(其中超时 5 次)') && (await draftText(page, '[data-draft-timeout-note]')).includes('超时的调用不计用量'),
|
||
await usageLine())
|
||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||
await close()
|
||
})
|
||
|
||
await section('数据目录 · 起草:模型拿不准、留空的列', async () => {
|
||
const state = freshState()
|
||
// 不在这次起草里的一张表:上一次起草部分完成,又有留空的列
|
||
state.tables[S1].find((t) => t.name === 'channels').lastDraft = {
|
||
status: 'partial', job_id: 'check-job-old', finished_at: T0, error: null, model_error: CALL_ERR, blank_columns: 4,
|
||
}
|
||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
check('开始起草(闸机到会员 3 张,用模型)', await startDraft(page, { select: ['gates', 'members'], model: true }))
|
||
const job = state.jobs.at(-1)
|
||
|
||
// 留空的列:模型列出了、中文名和含义都没给。表的状态不因此变。闸机表写完了;宽表还在起草,已经有几列留空
|
||
settle(state, job, 'gates', { added: 2, model_items: 3, blank_columns: 1, blank_sample: ['gate_code'] })
|
||
settle(state, job, 'store_sales', { status: 'running', parts_done: 2, blank_columns: 5, blank_sample: BLANK_SAMPLE })
|
||
const blankOf = (n) => draftBox(page).locator(`[data-draft-task="${n}"] [data-draft-task-blank]`)
|
||
const rowBlank = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-blank]`)
|
||
check('起草中:逐表跟着写留空的列;清单上写完的表才提示「上次起草」,还在起草的不提示',
|
||
await until(async () => (await blankOf('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('5 列模型拿不准、留空了')
|
||
&& (await rowBlank('gates').count()) === 1)
|
||
&& (await rowBlank('store_sales').count()) === 0 && (await page.locator('[data-catalog-row="store_sales"] [data-row-last-draft]').count()) === 0,
|
||
`${await draftText(page, '[data-draft-task="store_sales"]')} · ${await rowBlank('gates').count()} · ${await rowBlank('store_sales').count()}`)
|
||
settle(state, job, 'store_sales', { added: 30, model_items: 38, blank_columns: 12, blank_sample: BLANK_SAMPLE })
|
||
check('逐表:写完的宽表写「12 列模型拿不准、留空了」,状态照旧是「已完成」',
|
||
await until(async () => (await blankOf('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('12 列模型拿不准、留空了'))
|
||
&& (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-status]')) === '已完成',
|
||
await draftText(page, '[data-draft-task="store_sales"]'))
|
||
const sample = blankOf('store_sales').locator('[data-draft-task-blank-sample]')
|
||
check('……悬停看得到是哪几列', ((await blankOf('store_sales').locator('summary').getAttribute('title')) ?? '').includes(BLANK_SAMPLE.join('、')),
|
||
await blankOf('store_sales').locator('summary').getAttribute('title').catch(() => ''))
|
||
check('……列名默认收起', !(await sample.isVisible()))
|
||
await blankOf('store_sales').locator('summary').click()
|
||
check('……展开写出列名,列不全时写一共几列', await until(async () => sample.isVisible())
|
||
&& (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-blank-sample]')) === `留空的列:${BLANK_SAMPLE.join('、')} 等 12 列`,
|
||
await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-blank-sample]'))
|
||
settle(state, job, 'members', { added: 3, model_items: 3 })
|
||
finishJob(job)
|
||
check('结束:标题照旧「起草完成」(留空的列不算没写全),不给「只重试失败的表」',
|
||
await until(async () => (await draftPhase(page)) === 'done') && (await draftText(page, '[data-draft-title]')) === '起草完成'
|
||
&& await page.locator('[data-draft-retry]').count() === 0, await draftText(page, '[data-draft-title]'))
|
||
const blankSum = await draftText(page, '[data-draft-blank-summary]')
|
||
check('……汇总写合计:共 13 列模型拿不准、留空了(2 张表)', blankSum.includes('共 13 列模型拿不准、留空了(2 张表)'), blankSum)
|
||
check('……并给出办法:在起草要求里补充缩写和术语的含义,再重新起草这几张表,已有的内容不会丢',
|
||
blankSum.includes('在起草要求里补充缩写和术语的含义') && blankSum.includes('重新起草这几张表') && blankSum.includes('已有的内容不会丢'), blankSum)
|
||
const shown = await draftBox(page).locator('[data-draft-task]').evaluateAll((els) => els.map((e) => e.getAttribute('data-draft-task')))
|
||
check('……逐表默认只列有留空列的 2 张,没有留空列的那张不写,可以显示全部', shown.join(',') === 'gates,store_sales'
|
||
&& await draftBox(page).locator('[data-draft-show-all]').count() === 1, shown.join(','))
|
||
await draftBox(page).locator('[data-draft-show-all]').click()
|
||
check('……显示全部:没有留空列的表不写留空', (await draftBox(page).locator('[data-draft-task]').count()) === 3 && await blankOf('members').count() === 0)
|
||
await shot(page, 'catalog-draft-blank')
|
||
|
||
// 清单:有留空列的表显示轻提示,不是失败标记
|
||
check('清单:有留空列的表显示轻提示「上次起草有 12 列模型拿不准、留空了」',
|
||
await until(async () => (await rowBlank('store_sales').innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 12 列模型拿不准、留空了')),
|
||
await rowBlank('store_sales').innerText({ timeout: 500 }).catch(() => ''))
|
||
const colors = await page.evaluate(() => {
|
||
const color = (q) => { const el = document.querySelector(q); return el ? getComputedStyle(el).color : null }
|
||
return { blank: color('[data-catalog-row="store_sales"] [data-row-blank]'), faint: color('[data-catalog-row="store_sales"] [data-row-usage]'),
|
||
partial: color('[data-catalog-row="channels"] [data-row-last-draft]') }
|
||
})
|
||
check('……不是失败标记:不写「上次起草失败 / 部分完成」,用次要信息的淡色,不用警告色', await page.locator('[data-catalog-row="store_sales"] [data-row-last-draft]').count() === 0
|
||
&& !!colors.blank && colors.blank === colors.faint && colors.blank !== colors.partial, JSON.stringify(colors))
|
||
check('……没有留空列的表不提示', await rowBlank('members').count() === 0)
|
||
check('……部分完成又有留空列的表:失败标记和留空提示都在', (await page.locator('[data-catalog-row="channels"] [data-row-last-draft="partial"]').count()) === 1
|
||
&& (await rowBlank('channels').innerText().catch(() => '')).includes('上次起草有 4 列模型拿不准、留空了'))
|
||
|
||
// 「重新起草这 2 张表」:回到设置,范围就是这 2 张、勾着用模型,焦点在起草要求;补上缩写的含义再开始
|
||
await draftBox(page).locator('[data-draft-blank-redraft]').click()
|
||
check('「重新起草这 2 张表」:回到设置,范围是有留空列的这 2 张,勾着用模型,焦点在起草要求',
|
||
await until(async () => (await draftPhase(page)) === 'setup')
|
||
&& await draftBox(page).locator('[data-draft-scope="preset"] input').isChecked()
|
||
&& (await draftText(page, '[data-draft-scope="preset"]')).includes('有留空列的 2 张表')
|
||
&& await draftBox(page).locator('[data-draft-model] input').isChecked()
|
||
&& await until(async () => page.evaluate(() => document.activeElement?.id === 'catalog-draft-guidance')),
|
||
`${await draftText(page, '[data-draft-scope="preset"]')} · ${await page.evaluate(() => document.activeElement?.id ?? '')}`)
|
||
check('……起草按钮写「起草 2 张表」', (await page.locator('[data-draft-start]').innerText()).includes('起草 2 张表'))
|
||
await draftBox(page).locator('[data-draft-guidance] textarea').fill(GUIDANCE_ABBR)
|
||
await page.locator('[data-draft-start]').click()
|
||
check('……先存起草要求,再建只含这 2 张表、用模型的任务', await until(async () => (await draftPhase(page)) === 'running')
|
||
&& JSON.stringify(lastBody(sent, /catalog\/guidance$/)) === JSON.stringify({ guidance: GUIDANCE_ABBR })
|
||
&& JSON.stringify(lastBody(sent, /draft-jobs$/)) === JSON.stringify({ tables: ['gates', 'store_sales'], use_model: true }),
|
||
JSON.stringify(lastBody(sent, /draft-jobs$/)))
|
||
// 这一回宽表仍有几列拿不准(已有的内容不丢由服务端保证)。关着对话框时结束:提示里也写留空的列
|
||
const again = state.jobs.at(-1)
|
||
await page.locator('[data-draft-hide]').click()
|
||
await until(async () => (await draftBox(page).count()) === 0)
|
||
settle(state, again, 'gates', { model_items: 3 })
|
||
settle(state, again, 'store_sales', { updated: 5, model_items: 40, blank_columns: 7, blank_sample: BLANK_SAMPLE })
|
||
finishJob(again)
|
||
const toastBox = page.locator('[data-toast]').filter({ hasText: '起草完成' })
|
||
check('关着对话框时结束:提示里写「7 列模型拿不准、留空了」', await until(async () => (await toastBox.count()) > 0)
|
||
&& (await toastBox.first().innerText()).includes('7 列模型拿不准、留空了'),
|
||
await page.locator('[data-toast]').allInnerTexts().then((t) => t.join(' | ')).catch(() => ''))
|
||
await toastBox.getByRole('button', { name: '查看结果' }).click()
|
||
check('……点「查看结果」:汇总写合计', await until(async () => (await draftText(page, '[data-draft-blank-summary]')).includes('共 7 列模型拿不准、留空了(1 张表)')),
|
||
await draftText(page, '[data-draft-blank-summary]'))
|
||
await page.locator('[data-draft-close]').click()
|
||
// 再打开起草:设置阶段写明上一次有留空的列,可以点开
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).waitFor()
|
||
check('再打开起草:设置阶段写明上一次起草有 7 列模型拿不准、留空了,可以点开', await until(async () => (await draftText(page, '[data-draft-last]')).includes('7 列模型拿不准、留空了'))
|
||
&& await draftBox(page).locator('[data-draft-last-open]').count() === 1, await draftText(page, '[data-draft-last]'))
|
||
await page.keyboard.press('Escape')
|
||
await until(async () => (await draftBox(page).count()) === 0)
|
||
|
||
// 表详情:顶部同样提示(不是失败),给「重新起草这张表」和「补充起草要求」
|
||
await page.locator('[data-catalog-row="store_sales"] [data-catalog-open-row]').click()
|
||
const tip = page.locator('[data-catalog-last-draft]')
|
||
check('表详情:顶部写「上次起草有 7 列模型拿不准、留空了」,是提示(不是失败)',
|
||
await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 7 列模型拿不准、留空了'))
|
||
&& (await tip.getAttribute('data-notice')) === 'info', await tip.innerText({ timeout: 500 }).catch(() => ''))
|
||
check('……写明办法:补充起草要求再重新起草,已有的内容不会丢;给「重新起草这张表」', (await tip.innerText()).includes('已有的内容不会丢')
|
||
&& await tip.locator('[data-catalog-redraft]').count() === 1, await tip.innerText())
|
||
await shot(page, 'catalog-blank-detail')
|
||
const jobsBefore = count(sent, /^POST .*draft-jobs$/)
|
||
await tip.locator('[data-catalog-draft-guidance]').click()
|
||
check('「补充起草要求」:打开起草设置,范围只有这张表,勾着用模型,焦点在起草要求',
|
||
await until(async () => (await draftPhase(page)) === 'setup')
|
||
&& await draftBox(page).locator('[data-draft-scope="preset"] input').isChecked()
|
||
&& (await draftText(page, '[data-draft-scope="preset"]')).includes('store_sales')
|
||
&& await draftBox(page).locator('[data-draft-model] input').isChecked()
|
||
&& (await page.locator('[data-draft-start]').innerText()).includes('起草 1 张表')
|
||
&& await until(async () => page.evaluate(() => document.activeElement?.id === 'catalog-draft-guidance')),
|
||
`${await draftText(page, '[data-draft-scope="preset"]')} · ${await page.evaluate(() => document.activeElement?.id ?? '')}`)
|
||
await page.keyboard.press('Escape')
|
||
check('……没改就关掉:不建任务', await until(async () => (await draftBox(page).count()) === 0) && count(sent, /^POST .*draft-jobs$/) === jobsBefore)
|
||
await tip.locator('[data-catalog-redraft]').click()
|
||
check('「重新起草这张表」:建只含这张表、用模型的任务', await until(async () => count(sent, /^POST .*draft-jobs$/) === jobsBefore + 1)
|
||
&& JSON.stringify(lastBody(sent, /draft-jobs$/)) === JSON.stringify({ tables: ['store_sales'], use_model: true }),
|
||
JSON.stringify(lastBody(sent, /draft-jobs$/)))
|
||
const third = state.jobs.at(-1)
|
||
await until(async () => (await draftPhase(page)) === 'running')
|
||
settle(state, third, 'store_sales', { model_items: 40 })
|
||
finishJob(third)
|
||
await until(async () => (await draftPhase(page)) === 'done')
|
||
await page.locator('[data-draft-close]').click()
|
||
check('……这回没有留空的列:清单的轻提示和详情的提示都收起', await until(async () => (await rowBlank('store_sales').count()) === 0 && (await tip.count()) === 0))
|
||
|
||
// 部分完成又有留空列的表:一条说明里写原因和留空的列,只有一个「重新起草这张表」
|
||
await page.locator('[data-catalog-row="channels"] [data-catalog-open-row]').click()
|
||
check('部分完成又有留空列的表:详情里一条说明写原因和留空的列,只有一个「重新起草这张表」',
|
||
await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草部分完成'))
|
||
&& (await tip.innerText()).includes(CALL_ERR) && (await tip.innerText()).includes('4 列模型拿不准、留空了')
|
||
&& (await tip.getAttribute('data-notice')) === 'warn' && await tip.locator('[data-catalog-redraft]').count() === 1,
|
||
await tip.innerText({ timeout: 500 }).catch(() => ''))
|
||
await cleanCopy(page, '留空列的提示')
|
||
check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.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 old = newJob(state, S1, { tables: ['store_sales', 'members'], use_model: true })
|
||
settle(state, old, 'store_sales', { added: 10, model_items: 40, blank_columns: 6, blank_sample: BLANK_SAMPLE })
|
||
settle(state, old, 'members', { added: 2, model_items: 3, blank_columns: 3, blank_sample: ['member_no', 'joined_on'] })
|
||
finishJob(old)
|
||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
check('开始起草(门店销售、会员 2 张,用模型)', await startDraft(page, { select: ['store_sales', 'members'], model: true }))
|
||
const job = state.jobs.at(-1)
|
||
const task = (n) => draftBox(page).locator(`[data-draft-task="${n}"]`)
|
||
const text = (n, sel) => task(n).locator(sel).first().innerText({ timeout: 500 }).then((t) => t.replace(/\s+/g, ' ').trim()).catch(() => '')
|
||
|
||
// 宽表第 1 段写完就写进目录:逐表写「已写进目录 1 段」;没拆过的段不写块进度(段进度已经说清了)
|
||
settle(state, job, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1, chunks_total: 3, chunks_done: 1 })
|
||
check('宽表写完一段:逐表写「已写进目录 1 段」,段进度「1 / 3 段」,没拆过不写块进度',
|
||
await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 1 段')
|
||
&& (await text('store_sales', '[data-draft-task-parts]')) === '1 / 3 段' && await task('store_sales').locator('[data-draft-task-chunks]').count() === 0,
|
||
`${await text('store_sales', '[data-draft-task-written]')} · ${await text('store_sales', '[data-draft-task-parts]')}`)
|
||
check('……「已写进目录」悬停写明停止、服务重启时保留', ((await task('store_sales').locator('[data-draft-task-written]').getAttribute('title')) ?? '').includes('停止或服务重启时已写进的段保留'))
|
||
// 额度用完、超时后段被拆半:块比段多,写「已结束几块 / 共几块」,悬停说明为什么变多
|
||
settle(state, job, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1, chunks_total: 5, chunks_done: 2 })
|
||
const chunks = task('store_sales').locator('[data-draft-task-chunks]')
|
||
check('段被拆小:写块进度「2 / 5 块」,悬停说明实际分成 5 块', await until(async () => (await text('store_sales', '[data-draft-task-chunks]')) === '2 / 5 块')
|
||
&& ((await chunks.getAttribute('title')) ?? '').includes('实际分成 5 块'), await text('store_sales', '[data-draft-task-chunks]'))
|
||
settle(state, job, 'store_sales', { status: 'running', parts_done: 2, parts_written: 2, chunks_total: 5, chunks_done: 4 })
|
||
check('……接着写:「已写进目录 2 段」「2 / 3 段」「4 / 5 块」', await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 2 段')
|
||
&& (await text('store_sales', '[data-draft-task-parts]')) === '2 / 3 段' && (await text('store_sales', '[data-draft-task-chunks]')) === '4 / 5 块')
|
||
check('……还没开始的表不写段进度、块进度、已写进目录', await task('members').locator('[data-draft-task-parts], [data-draft-task-chunks], [data-draft-task-written]').count() === 0)
|
||
// 起草时记下的几句话(服务端原话):没收下的有效记录条件和原因,列在这张表下面,不算失败
|
||
settle(state, job, 'store_sales', { status: 'running', parts_done: 2, parts_written: 2, chunks_total: 5, chunks_done: 4,
|
||
log: ['有效记录条件按剖析核对排除了全部行(共 1,200 行),模型提议的这一条没有收下:status <> 9'] })
|
||
check('……起草时记下的说明(没收下的有效记录条件)照原话列在这张表下面', await until(async () => (await text('store_sales', '[data-draft-task-log]')).includes('模型提议的这一条没有收下:status <> 9')),
|
||
await text('store_sales', '[data-draft-task-log]'))
|
||
check('……没有说明的表不写这一行', await task('members').locator('[data-draft-task-log]').count() === 0)
|
||
await shot(page, 'catalog-draft-chunks')
|
||
|
||
// 停止:已写进的段保留。说法照服务端:写过段的表记已停止,原因写已写进几段、剩下几段没起草
|
||
await page.locator('[data-draft-stop]').click()
|
||
check('点「停止」:写明正在停止、已写进目录的段保留', await until(async () => (await draftText(page, '[data-draft-title]')).includes('已写进目录的段保留')),
|
||
await draftText(page, '[data-draft-title]'))
|
||
stopJob(job)
|
||
check('停下来:宽表记已停止,原因写「已写进目录 2 段,剩下 1 段没起草」', await until(async () => (await draftPhase(page)) === 'done')
|
||
&& (await text('store_sales', '[data-draft-task-status]')) === '已停止'
|
||
&& (await text('store_sales', '[data-draft-task-reason]')).includes('已写进目录 2 段,剩下 1 段没起草'),
|
||
`${await text('store_sales', '[data-draft-task-status]')} · ${await text('store_sales', '[data-draft-task-reason]')}`)
|
||
check('……服务端的原因已经写了,界面不再重复一行「已写进目录」', await task('store_sales').locator('[data-draft-task-written]').count() === 0)
|
||
const cancelled = await draftText(page, '[data-draft-cancelled]')
|
||
// 重试会把这张宽表整张重新起草(服务端不跳过已写进目录的段),不能说成「接着起草剩下的段」
|
||
check('……汇总写明已写进的段保留:其中 1 张表已写进目录 2 段,只重试这些表时会重新起草整张表', cancelled.includes('每写完一段就写进目录')
|
||
&& cancelled.includes('其中 1 张表已写进目录 2 段') && cancelled.includes('只重试这些表时会重新起草整张表,已写进的不会丢')
|
||
&& !cancelled.includes('接着起草'), cancelled)
|
||
check('……可以只重试这 2 张', (await page.locator('[data-draft-retry]').innerText().catch(() => '')).includes('(2 张)'))
|
||
await page.locator('[data-draft-close]').click()
|
||
|
||
// 老服务端停下来时原因里没写:界面自己补一句。部分完成的表段进度是「都结束了」,看不出写进了几段:也写一句
|
||
const legacy = newJob(state, S1, { tables: ['store_sales', 'members'], use_model: true })
|
||
settle(state, legacy, 'members', { status: 'partial', parts_total: 2, parts_done: 2, parts_written: 1, model_error: CALL_ERR })
|
||
settle(state, legacy, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1 })
|
||
stopJob(legacy)
|
||
legacy.tasks[0].model_error = null
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).waitFor()
|
||
await until(async () => (await draftBox(page).locator('[data-draft-last-open]').count()) === 1)
|
||
await draftBox(page).locator('[data-draft-last-open]').click()
|
||
check('服务端的原因里没写已写进几段:界面补「已写进目录 1 段,剩下 2 段没起草」', await until(async () => (await text('store_sales', '[data-draft-task-written]')) === '已写进目录 1 段,剩下 2 段没起草'),
|
||
await text('store_sales', '[data-draft-task-written]'))
|
||
check('……部分完成的表写「已写进目录 1 段」,原因照旧', (await text('members', '[data-draft-task-written]')) === '已写进目录 1 段'
|
||
&& (await text('members', '[data-draft-task-reason]')).includes(CALL_ERR), await text('members', '[data-draft-task-written]'))
|
||
await page.locator('[data-draft-close]').click()
|
||
state.jobs.pop()
|
||
|
||
// 服务重启中断:写过段的宽表原因写「已中断:已写进目录 1 段,剩下 2 段没起草」(服务端中断时的说法),界面不再补一行;
|
||
// 提示写明重试会重新起草整张表,不说接着起草
|
||
const crashed = newJob(state, S1, { tables: ['store_sales'], use_model: true })
|
||
settle(state, crashed, 'store_sales', { status: 'running', parts_done: 1, parts_written: 1 })
|
||
stopJob(crashed, 'interrupted', '服务重启,起草中断')
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).waitFor()
|
||
await until(async () => (await draftBox(page).locator('[data-draft-last-open]').count()) === 1)
|
||
await draftBox(page).locator('[data-draft-last-open]').click()
|
||
check('服务重启中断:宽表的原因写「已中断:已写进目录 1 段,剩下 2 段没起草」,不再补一行「已写进目录」',
|
||
await until(async () => (await text('store_sales', '[data-draft-task-reason]')) === '已中断:已写进目录 1 段,剩下 2 段没起草')
|
||
&& await task('store_sales').locator('[data-draft-task-written]').count() === 0, await text('store_sales', '[data-draft-task-reason]'))
|
||
check('……提示写明只重试这些表会重新起草整张表,已写进目录的内容不会丢', (await draftText(page, '[data-draft-interrupted]')).includes('会重新起草整张表,已写进目录的内容不会丢'),
|
||
await draftText(page, '[data-draft-interrupted]'))
|
||
await page.locator('[data-draft-close]').click()
|
||
state.jobs.pop()
|
||
|
||
// 清单:会员表这次还没开始就停了(没有新写入):接着用上一次写完时记下的留空列数提示;门店销售这次写进了 2 段,不再拿旧数提示
|
||
const rowBlank = (n) => page.locator(`[data-catalog-row="${n}"] [data-row-blank]`)
|
||
await goto(page, `/data/catalog/${S1}`)
|
||
check('清单:停止、没有新写入的表接着提示「上次起草有 3 列模型拿不准、留空了」', await until(async () => (await rowBlank('members').innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了')),
|
||
await rowBlank('members').innerText({ timeout: 500 }).catch(() => ''))
|
||
check('……这次写进了段的宽表不拿旧的留空列数提示', await rowBlank('store_sales').count() === 0)
|
||
await page.locator('[data-catalog-row="members"] [data-catalog-open-row]').click()
|
||
const tip = page.locator('[data-catalog-last-draft]')
|
||
check('表详情:顶部照旧提示 3 列留空,写明是再之前那次起草记下的(最近一次已停止)',
|
||
await until(async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了'))
|
||
&& (await tip.getAttribute('data-catalog-last-draft')) === 'cancelled'
|
||
&& (await tip.locator('[data-catalog-last-draft-earlier]').innerText().catch(() => '')).includes('最近一次起草已停止'),
|
||
await tip.innerText({ timeout: 500 }).catch(() => ''))
|
||
check('……给「重新起草这张表」「补充起草要求」,旁边是「和助手一起完善」', await tip.locator('[data-catalog-redraft]').count() === 1
|
||
&& await tip.locator('[data-catalog-draft-guidance]').count() === 1 && await tip.locator('[data-catalog-assistant-open="notice"]').count() === 1)
|
||
await cleanCopy(page, '逐段写进目录的提示')
|
||
check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.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 tOf = (n) => state.tables[S1].find((t) => t.name === n)
|
||
// 起草过(部分完成、留了几列空)的表后来重新探查结构时没了,只剩目录;另一张有留空列的这时还在表结构里
|
||
tOf('legacy_coupons').lastDraft = {
|
||
status: 'partial', job_id: 'check-job-old', finished_at: T0, error: null, model_error: CALL_ERR, blank_columns: 3,
|
||
}
|
||
tOf('channels').lastDraft = { status: 'ok', job_id: 'check-job-old', finished_at: T0, error: null, model_error: null, blank_columns: 4 }
|
||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
const tip = page.locator('[data-catalog-last-draft]')
|
||
const tipText = async () => (await tip.innerText({ timeout: 500 }).catch(() => '')).replace(/\s+/g, ' ').trim()
|
||
const buttons = () => tip.locator('[data-catalog-redraft], [data-catalog-draft-guidance]').count()
|
||
|
||
// 清单:只剩目录的表照样写上次起草留空了几列(是历史),悬停里不再建议重新起草(起草不了)
|
||
const goneBlank = page.locator('[data-catalog-row="legacy_coupons"] [data-row-blank]')
|
||
const goneTitle = (await goneBlank.getAttribute('title', { timeout: 1000 }).catch(() => '')) ?? ''
|
||
check('清单:只剩目录的表照样提示上次起草留空的列,悬停里不建议重新起草',
|
||
(await goneBlank.innerText({ timeout: 500 }).catch(() => '')).includes('上次起草有 3 列模型拿不准、留空了')
|
||
&& !!goneTitle && !goneTitle.includes('重新起草'), goneTitle)
|
||
|
||
// 表详情:写明上次起草的结果,不给「重新起草这张表」「补充起草要求」(服务端只会把它记成已跳过),写明为什么
|
||
await page.locator('[data-catalog-row="legacy_coupons"] [data-catalog-open-row]').click()
|
||
check('只剩目录的表:详情顶部照样写上次起草部分完成、原因和留空的列',
|
||
await until(async () => (await tipText()).includes('上次起草部分完成'))
|
||
&& (await tipText()).includes(CALL_ERR) && (await tipText()).includes('3 列模型拿不准、留空了'), await tipText())
|
||
check('……不给「重新起草这张表」「补充起草要求」,写明表结构中已没有这张表、不能重新起草',
|
||
await buttons() === 0
|
||
&& (await tip.locator('[data-catalog-redraft-missing]').innerText({ timeout: 500 }).catch(() => '')).includes('表结构中已没有这张表,不能重新起草'),
|
||
await tipText())
|
||
check('……也不建议补充起草要求再重新起草', !(await tipText()).includes('再重新起草'), await tipText())
|
||
await shot(page, 'catalog-missing-last-draft')
|
||
|
||
// 打开详情以后表才没了(别处重新探查了结构):清单重取了、详情还没有重新载入。这时点「补充起草要求」,起草设置
|
||
// 不能退回别的范围(以前退到「使用次数最多的 20 张表」还勾着用模型,以为只起草这一张,实际按别的范围起草了)
|
||
await page.locator('[data-catalog-row="channels"] [data-catalog-open-row]').click()
|
||
check('还在表结构里的表:详情给「重新起草这张表」和「补充起草要求」',
|
||
await until(async () => (await tip.locator('[data-catalog-draft-guidance]').count()) === 1)
|
||
&& await tip.locator('[data-catalog-redraft]').count() === 1, await tipText())
|
||
tOf('channels').missing = true
|
||
// 起草另一张表:每张表起草完页面就重取清单;正在看的这张不在任务里,详情不重新载入
|
||
check('起草另一张表(不用模型)', await startDraft(page, { select: ['gates'] }))
|
||
const job = state.jobs.at(-1)
|
||
settle(state, job, 'gates', { added: 1 })
|
||
finishJob(job)
|
||
await until(async () => (await draftPhase(page)) === 'done')
|
||
await page.locator('[data-draft-close]').click()
|
||
check('……清单重取回来:这张表标成表结构中已没有,详情还是打开时的那份',
|
||
await until(async () => (await page.locator('[data-catalog-row="channels"] [data-row-missing]').count()) === 1)
|
||
&& await tip.locator('[data-catalog-draft-guidance]').count() === 1)
|
||
const jobsBefore = count(sent, /^POST .*draft-jobs$/)
|
||
await tip.locator('[data-catalog-draft-guidance]').click()
|
||
const presetInput = draftBox(page).locator('[data-draft-scope="preset"] input')
|
||
check('「补充起草要求」:范围仍是这张表、选着它,写明表结构中已没有这张表、不能起草',
|
||
await until(async () => (await draftPhase(page)) === 'setup')
|
||
&& await presetInput.isChecked({ timeout: 1000 }).catch(() => false)
|
||
&& (await draftText(page, '[data-draft-scope="preset"]')).includes('channels')
|
||
&& (await draftText(page, '[data-draft-preset-missing]')).includes('表结构中已没有这张表,不能起草'),
|
||
`${await draftText(page, '[data-draft-scope="preset"]')} · 选中:${await draftBox(page).locator('[data-draft-scope]:has(input:checked)').getAttribute('data-draft-scope', { timeout: 500 }).catch(() => '')}`)
|
||
check('……不能开始,也不默认勾用模型,焦点不放进起草要求', await page.locator('[data-draft-start]').isDisabled()
|
||
&& !(await draftBox(page).locator('[data-draft-model] input').isChecked())
|
||
&& await page.evaluate(() => document.activeElement?.id !== 'catalog-draft-guidance'),
|
||
`${await page.locator('[data-draft-start]').innerText()} · 用模型:${await draftBox(page).locator('[data-draft-model] input').isChecked()}`)
|
||
// 截图等弹窗的打开动效播完
|
||
if (SHOTS) await sleep(400)
|
||
await shot(page, 'catalog-missing-preset')
|
||
await page.keyboard.press('Escape')
|
||
check('……关掉:没有建任务', await until(async () => (await draftBox(page).count()) === 0) && count(sent, /^POST .*draft-jobs$/) === jobsBefore)
|
||
|
||
// 换一张再回来:详情重新载入,也认得表结构中已没有,不再给两个按钮
|
||
await page.locator('[data-catalog-row="gates"] [data-catalog-open-row]').click()
|
||
await until(async () => (await page.locator('[data-catalog-detail="gates"][data-version]').count()) === 1)
|
||
await page.locator('[data-catalog-row="channels"] [data-catalog-open-row]').click()
|
||
check('重新打开这张表:详情也写明表结构中已没有,不再给两个按钮',
|
||
await until(async () => (await page.locator('[data-catalog-detail="channels"] [data-catalog-missing]').count()) === 1)
|
||
&& await buttons() === 0 && (await tipText()).includes('不能重新起草'), await tipText())
|
||
await cleanCopy(page, '表结构里已经没有的表')
|
||
check('只发过起草任务相关的写请求', writes(sent, /./).every((s) => /catalog\/(draft-jobs|guidance)/.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: catalogHandlers(state) })
|
||
await openDetail(page, S1, 'visits')
|
||
const head = await detail(page).locator('header').innerText()
|
||
check('表头:中文名、表类型、表名、使用次数、谁改过', ['入园记录', '明细表', 'visits', '使用 42 次', '审核甲'].every((s) => head.includes(s)), head.replace(/\s+/g, ' '))
|
||
// 时间用全站统一的短格式(formatTime:今年写「9/28 10:00」),完整时间(带秒和时区)放在悬停里
|
||
const updated = detail(page).locator('[data-detail-updated]')
|
||
check('……修改时间写全站统一的短格式,完整时间放在悬停里', /^审核甲 于 (2026\/)?9\/28 10:00 修改$/.test(await updated.innerText())
|
||
&& !head.includes('UTC') && (await updated.getAttribute('title')) === '2026-09-28 10:00:00 (UTC+08:00)', `${await updated.innerText()} / ${await updated.getAttribute('title')}`)
|
||
const grain = page.locator('[data-field="grain"]')
|
||
check('表级项:粒度的值和「推断 · 模型起草」', (await grain.innerText()).includes('一张门票的一次入园') && (await grain.innerText()).includes('推断')
|
||
&& (await grain.innerText()).includes('模型起草'))
|
||
check('……有效记录条件是人工填写、已确认', (await page.locator('[data-field="valid_filter"]').innerText()).includes('status <> 9')
|
||
&& await markStatus(page, 'valid_filter') === 'confirmed')
|
||
check('……被驳回的去重规则划掉', await markStatus(page, 'dedup') === 'rejected'
|
||
&& await page.locator('[data-field="dedup"] .line-through').count() === 1)
|
||
const bd = await page.locator('[data-field="business_date"]').innerText()
|
||
check('……业务日期写列、规则、时区', ['visit_time', '按入园时间的日期', 'Asia/Shanghai'].every((x) => bd.includes(x)), bd.replace(/\s+/g, ' '))
|
||
check('列表格:9 列,写列名、类型、主键', await page.locator('[data-column]').count() === 9
|
||
&& (await page.locator('[data-column="id"]').innerText()).includes('主键') && (await page.locator('[data-column="ticket_no"]').innerText()).includes('TEXT'))
|
||
check('……码值写成「码值 含义」', (await page.locator('[data-column="status"] [data-cell="codes"]').innerText()).replace(/\s+/g, ' ').includes('1 已入园'))
|
||
check('……度量类型写中文(可累加),外键列是「已验证」', (await page.locator('[data-column="visitor_count"] [data-cell="measure"]').innerText()).includes('可累加')
|
||
&& await markStatus(page, 'columns.park_id.measure') === 'verified')
|
||
check('关联关系:3 条,写目标表、字段、基数、来源', await page.locator('[data-relation]').count() === 3
|
||
&& (await page.locator(`[data-relation="${relId(['gate_id'], 'gates', ['id'])}"]`).innerText()).replace(/\s+/g, ' ').includes('gates id 多对一'))
|
||
check('四种状态用同一套标识,带图例', await page.locator('[data-status-legend] li').count() === 4)
|
||
await cleanCopy(page, '表详情')
|
||
await shot(page, 'catalog-detail')
|
||
|
||
// 弹层:键盘打开、Esc 收起、焦点回到触发按钮
|
||
await openMark(page, 'grain')
|
||
const pt = await panel(page).innerText()
|
||
check('点状态标识:弹层写位置、状态说明、来源', pt.includes('表的粒度的来源和状态') && pt.includes('只作提示,确认后才参与 SQL 检查') && pt.includes('来源:模型起草'), pt.replace(/\s+/g, ' '))
|
||
check('……更新时间也写短格式,完整时间放在悬停里', /更新于 (2026\/)?9\/28 10:00/.test(pt) && !pt.includes('UTC')
|
||
&& (await panel(page).locator('[data-item-updated]').getAttribute('title')) === '2026-09-28 10:00:00 (UTC+08:00)', pt.replace(/\s+/g, ' '))
|
||
check('……焦点落在「确认」上', await page.evaluate(() => document.activeElement?.getAttribute('data-review-action')) === 'confirm')
|
||
check('……推断项已是初始状态,「恢复」不可点', await panel(page).locator('[data-review-action="reset"]').isDisabled())
|
||
await page.keyboard.press('Escape')
|
||
check('……Esc 收起,焦点回到状态标识', await panel(page).count() === 0
|
||
&& await page.evaluate(() => document.activeElement?.getAttribute('data-item-mark')) === 'grain')
|
||
|
||
// 确认
|
||
let v = await version(page)
|
||
await openMark(page, 'grain')
|
||
await panel(page).locator('[data-review-action="confirm"]').click()
|
||
await until(async () => await markStatus(page, 'grain') === 'confirmed')
|
||
let body = lastBody(sent, /\/review$/)
|
||
check('确认:POST …/review,带路径、操作和读到的版本', JSON.stringify(body) === JSON.stringify({ path: 'grain', action: 'confirm', if_version: v }), JSON.stringify(body))
|
||
check('……标识变成「已确认」,读屏播报', await markStatus(page, 'grain') === 'confirmed'
|
||
&& (await page.locator('[data-catalog-live]').innerText()) === '已确认表的粒度')
|
||
const c = countsOf(state.tables[S1][0].notes)
|
||
check('……清单上这一行的计数跟着变', (await rowCounts(page, 'visits')) === `${c.proposed},${c.verified},${c.confirmed},${c.rejected}`, await rowCounts(page, 'visits'))
|
||
|
||
// 驳回列级项
|
||
v = await version(page)
|
||
await openMark(page, 'columns.ticket_no.label')
|
||
await panel(page).locator('[data-review-action="reject"]').click()
|
||
await until(async () => await markStatus(page, 'columns.ticket_no.label') === 'rejected')
|
||
body = lastBody(sent, /\/review$/)
|
||
check('驳回列级项:路径写 columns.<列名>.<字段>', JSON.stringify(body) === JSON.stringify({ path: 'columns.ticket_no.label', action: 'reject', if_version: v }), JSON.stringify(body))
|
||
check('……值划掉', await page.locator('[data-column="ticket_no"] [data-cell="label"] .line-through').count() === 1)
|
||
|
||
// 恢复:推断项回到初始状态
|
||
await openMark(page, 'columns.ticket_no.label')
|
||
check('被驳回的推断项「恢复」可点', !(await panel(page).locator('[data-review-action="reset"]').isDisabled())
|
||
&& (await panel(page).locator('[data-review-action="reset"]').innerText()).includes('恢复'))
|
||
await panel(page).locator('[data-review-action="reset"]').click()
|
||
await until(async () => await markStatus(page, 'columns.ticket_no.label') === 'proposed')
|
||
check('……恢复后回到「推断」', (lastBody(sent, /\/review$/))?.action === 'reset' && await markStatus(page, 'columns.ticket_no.label') === 'proposed')
|
||
|
||
// 人工填写的项:恢复即删除
|
||
await openMark(page, 'valid_filter')
|
||
check('人工填写的项:按钮写「删除」,说明恢复即删除', (await panel(page).locator('[data-review-action="reset"]').innerText()).includes('删除')
|
||
&& (await panel(page).innerText()).includes('恢复即删除'))
|
||
await panel(page).locator('[data-review-action="reset"]').click()
|
||
await until(async () => (await page.locator('[data-item-mark="valid_filter"]').count()) === 0)
|
||
check('……删除后这一项显示「—」', (await page.locator('[data-field="valid_filter"]').innerText()).includes('—'))
|
||
|
||
// 关系的审阅路径
|
||
const gid = relId(['gate_id'], 'gates', ['id'])
|
||
await openMark(page, `relations.${gid}`)
|
||
await panel(page).locator('[data-review-action="confirm"]').click()
|
||
await until(async () => await markStatus(page, `relations.${gid}`) === 'confirmed')
|
||
check('确认关联关系:路径写 relations.<编号>', lastBody(sent, /\/review$/)?.path === `relations.${gid}`)
|
||
|
||
// 目标表能点就跳过去
|
||
await page.locator('[data-relation-target="parks"]').click()
|
||
await page.waitForURL(`**/data/catalog/${S1}/parks`, { timeout: 5000 }).catch(() => {})
|
||
check('点关联关系的目标表:打开那张表', new URL(page.url()).pathname === `/data/catalog/${S1}/parks`
|
||
&& await until(async () => (await page.locator('[data-catalog-detail="parks"][data-version]').count()) === 1))
|
||
// 上一张 / 下一张按清单顺序
|
||
await page.locator('[data-catalog-next]').click()
|
||
await page.waitForURL(`**/data/catalog/${S1}/ticket_types`, { timeout: 5000 }).catch(() => {})
|
||
check('「下一张」按清单顺序:景区之后是票种', new URL(page.url()).pathname === `/data/catalog/${S1}/ticket_types`, page.url())
|
||
check('只发了审阅请求,没有别的写请求', writes(sent, /./).every((s) => /\/review$/.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()
|
||
state.impact = {
|
||
visits: [
|
||
{ workflow_id: 'wf-daily', name: '入园日报', version: 4, level: 'governed', impact: 'direct', nodes: [
|
||
{ node_id: 'q', label: '查询入园人数', type: 'tool', impact: 'direct' },
|
||
{ node_id: 'm', label: '合并入园和订单', type: 'merge', impact: 'direct', via: [{ node_id: 'q', label: '查询入园人数', alias: 'v' }] },
|
||
] },
|
||
{ workflow_id: 'wf-ask', name: '客流分析', version: 2, level: 'published', impact: 'possible', nodes: [
|
||
{ node_id: 'ask', label: '分析客流', type: 'agent', impact: 'possible' },
|
||
] },
|
||
],
|
||
}
|
||
const { page, sent, errors, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await openDetail(page, S1, 'visits')
|
||
const sec = page.locator('[data-catalog-section="impact"]')
|
||
await sec.locator('[data-catalog-impact="2"]').waitFor({ timeout: 3000 })
|
||
const text = await sec.innerText()
|
||
check('表详情有「引用这张表的模板」一栏,写明按已发布版本统计', text.includes('引用这张表的模板') && text.includes('按每个模板当前的已发布版本统计'))
|
||
const daily = sec.locator('[data-impact-template="wf-daily"]')
|
||
check('直接引用:模板名、版本、受管、「直接引用」', ['入园日报', 'v4', '受管', '直接引用'].every((x) => text.includes(x))
|
||
&& await daily.getAttribute('data-impact-level') === 'direct')
|
||
// 「直接引用」不是警示:用强调色,不用琥珀色(全站的「等待、要人处理」)
|
||
const directLook = await daily.locator('[data-impact="direct"]').first().evaluate((el) => {
|
||
const probe = document.createElement('span')
|
||
document.body.append(probe)
|
||
const tone = (v) => { probe.style.color = `var(${v})`; return getComputedStyle(probe).color }
|
||
const out = { color: getComputedStyle(el).color, accent: tone('--accent'), waiting: tone('--st-waiting') }
|
||
probe.remove()
|
||
return out
|
||
})
|
||
check('「直接引用」用强调色,不用警示的琥珀色', directLook.color === directLook.accent && directLook.color !== directLook.waiting, JSON.stringify(directLook))
|
||
check('合并查询写明经由哪个输入', (await daily.locator('[data-impact-node="m"]').innerText()).includes('经由输入「查询入园人数」'))
|
||
check('Agent 写「可能涉及」,悬停说明原因', (await sec.locator('[data-impact-template="wf-ask"] [data-impact="possible"]').getAttribute('title')).includes('运行时生成'))
|
||
check('模板名点进去是画布,定位到那个节点', (await daily.locator('a').first().getAttribute('href')) === '/studio/wf-daily?focus=q')
|
||
// 开发模式下 StrictMode 会把挂载时的取数做两遍,只查有没有取、没有写
|
||
check('统计影响面只读', writes(sent, /impact/).length === 0 && count(sent, /\/visits\/impact$/) >= 1,
|
||
`${count(sent, /\/visits\/impact$/)} 次`)
|
||
// 在目录页改过(这里是编辑保存):表头说有几个已发布模板引用这张表,和助手卡片保存后同一句
|
||
const savedHint = page.locator('[data-catalog-saved-impact]')
|
||
check('没改过:表头不说影响面', await savedHint.count() === 0)
|
||
await page.locator('[data-catalog-edit]').click()
|
||
await page.locator('[data-input="grain"]').fill('一张门票的一次入园(检查)')
|
||
await page.locator('[data-catalog-save]').click()
|
||
check('在目录页编辑保存后:表头说「2 个已发布模板引用这张表,下次正式运行时会提示数据目录有变化」',
|
||
await until(async () => (await savedHint.count()) === 1)
|
||
&& (await savedHint.innerText()).includes('2 个已发布模板引用这张表,下次正式运行时会提示数据目录有变化'),
|
||
await savedHint.innerText().catch(() => ''))
|
||
await shot(page, 'catalog-saved-impact')
|
||
await savedHint.locator('[data-catalog-saved-impact-show]').click()
|
||
check('……点「查看模板」:焦点落到「引用这张表的模板」一栏', await until(async () => page.evaluate(() => document.activeElement?.id === 'catalog-sec-impact'), 2000))
|
||
await savedHint.locator('[data-catalog-saved-impact-dismiss]').click()
|
||
check('……可以收起', await savedHint.count() === 0)
|
||
// 单项确认也升版本:同样说
|
||
await openMark(page, 'description')
|
||
await page.locator('[data-item-panel] [data-review-action="confirm"]').click()
|
||
check('单项确认之后同样说', await until(async () => (await savedHint.count()) === 1))
|
||
// 没有模板引用的表:直说没有
|
||
await openDetail(page, S1, 'orders')
|
||
check('换一张表:上一张的提示不带过来', await savedHint.count() === 0)
|
||
check('没有模板引用:直说没有', (await page.locator('[data-catalog-section="impact"] [data-catalog-impact="0"]').innerText())
|
||
.includes('没有已发布或受管的模板引用这张表'))
|
||
// 表详情保存之后(版本变了)重新统计
|
||
const before = count(sent, /\/orders\/impact$/)
|
||
await page.locator('[data-catalog-edit]').click()
|
||
await page.locator('[data-input="grain"]').fill('一笔订单一行(检查)')
|
||
await page.locator('[data-catalog-save]').click()
|
||
await until(async () => count(sent, /\/orders\/impact$/) > before, 3000)
|
||
check('保存之后重新统计影响面', count(sent, /\/orders\/impact$/) > before, `${before} → ${count(sent, /\/orders\/impact$/)}`)
|
||
await sleep(300)
|
||
check('……没有模板引用的表保存后不说影响面', await savedHint.count() === 0)
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
await close()
|
||
})
|
||
|
||
await section('数据目录 · 批量确认', async () => {
|
||
const state = freshState()
|
||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await openDetail(page, S1, 'order_items')
|
||
const old = clone(state.tables[S1][2].notes)
|
||
const n = countsOf(old).proposed
|
||
const btn = page.locator('[data-catalog-confirm-all]')
|
||
check(`「确认本表全部推断(${n})」`, (await btn.innerText()).includes(`确认本表全部推断(${n})`))
|
||
const v = await version(page)
|
||
await btn.click()
|
||
await confirmBox(page).waitFor()
|
||
const cons = await consequences(page)
|
||
check('……先确认:标题写表和项数,后果写参与 SQL 检查、来源不变', (await confirmBox(page).innerText()).includes(`确认「订单明细」的 ${n} 项推断?`)
|
||
&& cons.includes('确认后这些项参与 SQL 检查') && cons.includes('来源保持不变,状态改为已确认'), cons.join(' | '))
|
||
await confirmBox(page).getByRole('button', { name: `确认 ${n} 项` }).click()
|
||
await until(async () => count(sent, /^PUT /) === 1)
|
||
const body = lastBody(sent, /^PUT /)
|
||
const before = slots(old)
|
||
const after = slots(body?.notes)
|
||
const okItems = [...before].every(([p, it]) => {
|
||
const x = after.get(p)
|
||
return x && x.source === it.source && x.status === (it.status === 'proposed' ? 'confirmed' : it.status) && valueOf(p, x) === valueOf(p, it)
|
||
})
|
||
check('……整份提交:推断项改成已确认,来源和值不变,其余项原样', body?.if_version === v && okItems && after.size === before.size, JSON.stringify(body).slice(0, 200))
|
||
check('……提交后按钮消失、表头计数没有推断', await until(async () => (await btn.count()) === 0)
|
||
&& (await detail(page).locator('header [data-counts]').getAttribute('data-counts')).startsWith('0,'))
|
||
await until(async () => (await rowCounts(page, 'order_items')).startsWith('0,'))
|
||
check('……清单上这张表移到「没有待确认项」', (await page.locator('[data-catalog-row="order_items"]').getAttribute('data-progress')) === 'done')
|
||
|
||
// 确认本列推断:只动这一列
|
||
await openDetail(page, S1, 'store_sales')
|
||
check('一张表 50 列:全部列出', await page.locator('[data-column]').count() === 50)
|
||
await page.locator('[data-column-filter]').fill('promo')
|
||
check('……按列名筛选:43 列', await page.locator('[data-column]').count() === 43 && (await page.locator('[data-column-count]').innerText()) === '43 / 50 列')
|
||
await page.locator('[data-column-filter]').fill('')
|
||
await page.locator('[data-only-pending]').check()
|
||
check('……只看有推断项的列:8 列', await page.locator('[data-column]').count() === 8)
|
||
await page.locator('[data-only-pending]').uncheck()
|
||
const wideOld = clone(state.tables[S1].find((t) => t.name === 'store_sales').notes)
|
||
await page.locator('[data-confirm-column="amount"]').click()
|
||
await until(async () => count(sent, /^PUT /) === 2)
|
||
const cb = lastBody(sent, /^PUT /)
|
||
check('「确认本列推断」:只有这一列的推断项改成已确认', cb?.notes?.columns?.amount?.unit?.status === 'confirmed'
|
||
&& cb.notes.columns.amount.measure.status === 'confirmed' && cb.notes.columns.qty.label.status === 'proposed'
|
||
&& cb.notes.label.status === 'proposed' && JSON.stringify(cb.notes.columns.amount.unit.value) === JSON.stringify(wideOld.columns.amount.unit.value))
|
||
check('……这一行的按钮消失', await until(async () => (await page.locator('[data-confirm-column="amount"]').count()) === 0))
|
||
|
||
// 码值的含义还空着(数据剖析给的候选只有取值):各种确认都先说清确认的是取值清单,确认后照旧写「含义待填写」
|
||
state.tables[S1].find((t) => t.name === 'visits').notes.columns.status.codes = item({ 1: '已入园', 9: '', 2: '' }, 'profile', 'proposed')
|
||
await openDetail(page, S1, 'visits')
|
||
const puts = () => count(sent, /^PUT |\/review$/)
|
||
const put0 = puts()
|
||
await page.locator('[data-catalog-confirm-all]').click()
|
||
await confirmBox(page).waitFor()
|
||
check('「确认本表全部推断」:码值含义还空着时后果里写明确认的是取值清单', (await consequences(page)).includes('有 2 个码值的含义待填写,确认的是取值清单'),
|
||
(await consequences(page)).join(' | '))
|
||
await confirmBox(page).getByRole('button', { name: '取消' }).click()
|
||
await openMark(page, 'columns.status.codes')
|
||
await panel(page).locator('[data-review-action="confirm"]').click()
|
||
await confirmBox(page).waitFor()
|
||
check('单项「确认」码值:同样先问', (await confirmBox(page).innerText()).includes('确认列 status 的码值?')
|
||
&& (await consequences(page)).includes('有 2 个码值的含义待填写,确认的是取值清单'), (await confirmBox(page).innerText()).replace(/\s+/g, ' '))
|
||
await confirmBox(page).getByRole('button', { name: '取消' }).click()
|
||
await page.locator('[data-confirm-column="status"]').click()
|
||
await confirmBox(page).waitFor()
|
||
const blankCons = await consequences(page)
|
||
check('「确认本列推断」原本一键确认,码值含义还空着时先问:有 2 个码值的含义待填写,确认的是取值清单',
|
||
(await confirmBox(page).innerText()).includes('确认列 status 的 2 项推断?') && blankCons.includes('有 2 个码值的含义待填写,确认的是取值清单')
|
||
&& blankCons.some((c) => c.includes('仍显示「含义待填写」')), blankCons.join(' | '))
|
||
await confirmBox(page).getByRole('button', { name: '取消' }).click()
|
||
check('……三处取消都没有提交', puts() === put0, String(puts() - put0))
|
||
await page.locator('[data-confirm-column="status"]').click()
|
||
await confirmBox(page).waitFor()
|
||
await confirmBox(page).getByRole('button', { name: '确认 2 项' }).click()
|
||
await until(async () => puts() === put0 + 1)
|
||
const blankCell = page.locator('[data-column="status"] [data-cell="codes"]')
|
||
check('……确认后码值记为已确认,空着的仍写「含义待填写」,还能填写', await until(async () => (await markStatus(page, 'columns.status.codes')) === 'confirmed')
|
||
&& await blankCell.locator('[data-code-pending]').count() === 2 && await blankCell.locator('[data-codes-fill]').count() === 1
|
||
&& JSON.stringify(lastBody(sent, /^PUT .*catalog\/visits$/)?.notes?.columns?.status?.codes?.value) === JSON.stringify({ 1: '已入园', 9: '', 2: '' }),
|
||
(await blankCell.innerText()).replace(/\s+/g, ' '))
|
||
check('只发了整份提交,没有别的写请求', writes(sent, /./).every((s) => /^PUT /.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 replies = {}
|
||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state, replies) })
|
||
await openDetail(page, S1, 'visits')
|
||
const v = await version(page)
|
||
await page.locator('[data-catalog-edit]').click()
|
||
const bar = page.locator('[data-catalog-edit-bar]')
|
||
check('进入编辑:底部有保存栏,未修改时「保存」不可点', (await detail(page).getAttribute('data-editing')) === 'true'
|
||
&& (await bar.innerText()).includes('尚未修改') && await page.locator('[data-catalog-save]').isDisabled())
|
||
check('……写明保存后改动过的项记为人工确认、清空即删除', (await detail(page).innerText()).includes('清空一项即删除'))
|
||
check('……被驳回的项输入框留空,原值写在占位里', (await page.locator('[data-field="dedup"] input').inputValue()) === ''
|
||
&& (await page.locator('[data-field="dedup"] input').getAttribute('placeholder')) === '已驳回:按 ticket_no 去重')
|
||
await page.locator('[data-field="label"] input').fill('入园流水')
|
||
await page.locator('[data-field="grain"] input').fill('')
|
||
check('改两处:写「2 处修改」,改过的项标「已修改」', (await page.locator('[data-catalog-changes]').innerText()) === '2 处修改'
|
||
&& await page.locator('[data-field="label"] [data-changed]').count() === 1)
|
||
await page.locator('[data-field="keys"] input').fill('ticket_no、no_such_col')
|
||
check('业务主键写了表结构里没有的列:提醒,不拦', (await page.locator('[data-keys-unknown]').innerText()).includes('no_such_col')
|
||
&& !(await page.locator('[data-catalog-save]').isDisabled()))
|
||
await page.locator('[data-field="keys"] input').fill('ticket_no、park_id')
|
||
const codes = page.locator('[data-column="status"] [data-input="codes"]')
|
||
await codes.fill('1=已入园\n9=已作废\n坏行')
|
||
check('码值格式不对:就地写第几行、底部写几处格式不正确、不能保存', (await page.locator('[data-column="status"] [role="alert"]').innerText()) === '第 3 行应写成「码值=含义」'
|
||
&& (await page.locator('[data-catalog-problems]').innerText()).includes('1 处格式不正确') && await page.locator('[data-catalog-save]').isDisabled())
|
||
await codes.fill('1=已入园\n2=已退票\n9=已作废')
|
||
const complete = page.locator('[data-column="status"] [data-input="codes-complete"]')
|
||
check('码值下有「已列出全部取值」:原来没标的不勾,没有码值的列勾不了', !(await complete.isChecked())
|
||
&& (await page.locator('[data-column="status"] [data-cell="codes"]').innerText()).includes('已列出全部取值')
|
||
&& await page.locator('[data-column="visitor_count"] [data-input="codes-complete"]').isDisabled())
|
||
await complete.check()
|
||
await page.locator('[data-column="visitor_count"] [data-input="unit"]').fill('人次')
|
||
await page.locator('[data-add-relation]').click()
|
||
const draft = page.locator('[data-relation-draft^="new-"]')
|
||
check('新加的关联关系没填完:写明要填哪些、不能保存', (await draft.innerText()).includes('本表字段、目标表和目标字段都要填写') && await page.locator('[data-catalog-save]').isDisabled())
|
||
await draft.locator('[data-input="columns"]').fill('member_id')
|
||
await draft.locator('[data-input="to_table"]').fill('members')
|
||
await draft.locator('[data-input="to_columns"]').fill('id, member_no')
|
||
check('……两端字段数不一致:写明', (await draft.innerText()).includes('两端的字段数不一致'))
|
||
await draft.locator('[data-input="to_columns"]').fill('id')
|
||
await draft.locator('[data-input="cardinality"]').selectOption('many_to_one')
|
||
// 关联关系:人工添加的才能删除;外键约束、命名推断得出的只能驳回(删掉会被起草和现推带回来)
|
||
const PARK = relId(['park_id'], 'parks', ['id'])
|
||
const GATE = relId(['gate_id'], 'gates', ['id'])
|
||
const parkDraft = page.locator(`[data-relation-draft="${PARK}"]`)
|
||
check('关联关系:外键约束、命名推断得出的不给「删除」只给「驳回」,新加的给「删除」,写明为什么',
|
||
await parkDraft.locator('[data-relation-reject]').count() === 1 && await parkDraft.locator('[aria-label="删除这条关联关系"]').count() === 0
|
||
&& await page.locator(`[data-relation-draft="${GATE}"] [data-relation-reject]`).count() === 1
|
||
&& await draft.locator('[aria-label="删除这条关联关系"]').count() === 1 && await draft.locator('[data-relation-reject]').count() === 0
|
||
&& (await page.locator('[data-relation-remove-hint]').innerText()).includes('只能驳回'))
|
||
await parkDraft.locator('[data-input="cardinality"]').selectOption('one_to_one')
|
||
await parkDraft.locator('[data-relation-reject]').click()
|
||
check('……点「驳回」:标「保存后驳回」,两端和基数回到原样、锁住不能改,可以撤销', (await parkDraft.getAttribute('data-rejecting')) === 'true'
|
||
&& (await parkDraft.innerText()).includes('保存后驳回') && (await parkDraft.locator('[data-input="cardinality"]').inputValue()) === 'many_to_one'
|
||
&& await parkDraft.locator('[data-input="columns"]').isDisabled() && await parkDraft.locator('[data-relation-undo-reject]').count() === 1)
|
||
await parkDraft.locator('[data-relation-undo-reject]').click()
|
||
check('……撤销驳回:回到原样,不算修改', (await parkDraft.getAttribute('data-rejecting')) === null
|
||
&& (await page.locator('[data-catalog-changes]').innerText()) === '7 处修改', await page.locator('[data-catalog-changes]').innerText())
|
||
await page.locator(`[data-relation-draft="${GATE}"] [data-relation-reject]`).click()
|
||
await shot(page, 'catalog-edit')
|
||
check('保存栏写「8 处修改」(勾「已列出全部取值」、驳回一条关系各算一处)', (await page.locator('[data-catalog-changes]').innerText()) === '8 处修改', await page.locator('[data-catalog-changes]').innerText())
|
||
await page.locator('[data-catalog-save]').click()
|
||
await until(async () => count(sent, /^PUT /) === 1)
|
||
const body = lastBody(sent, /^PUT /)
|
||
const n = body?.notes ?? {}
|
||
const old = visitsNotes()
|
||
check('PUT 带读到的版本', body?.if_version === v, String(body?.if_version))
|
||
check('……改过的项交新值:中文名、业务主键、码值、单位', n.label?.value === '入园流水' && JSON.stringify(n.keys?.value) === '["ticket_no","park_id"]'
|
||
&& JSON.stringify(n.columns?.status?.codes?.value) === JSON.stringify({ 1: '已入园', 2: '已退票', 9: '已作废' })
|
||
&& n.columns?.visitor_count?.unit?.value === '人次', JSON.stringify({ label: n.label, keys: n.keys }))
|
||
check('……码值带上「已列出全部取值」', n.columns?.status?.codes?.complete === true, JSON.stringify(n.columns?.status?.codes))
|
||
check('……清空的粒度不交(服务端删掉这一项)', !('grain' in n))
|
||
check('……没动的项原样交回:来源、状态都不变(被驳回的照旧驳回)', JSON.stringify(n.dedup) === JSON.stringify(old.dedup)
|
||
&& JSON.stringify(n.description) === JSON.stringify(old.description) && JSON.stringify(n.columns?.park_id) === JSON.stringify(old.columns.park_id))
|
||
const gateOld = old.relations.find((r) => r.id === GATE)
|
||
check('……原有的关系原样交回,新的一条带两端和基数', old.relations.filter((r) => r.id !== GATE).every((r) => n.relations?.some((x) => JSON.stringify(x) === JSON.stringify(r)))
|
||
&& n.relations?.some((r) => r.to_table === 'members' && r.columns.join() === 'member_id' && r.to_columns.join() === 'id' && r.cardinality === 'many_to_one'))
|
||
check('……驳回的那条原样交回、状态改为已驳回(等同单项驳回,不是删掉)',
|
||
JSON.stringify(n.relations?.find((r) => r.id === GATE)) === JSON.stringify({ ...gateOld, status: 'rejected' }))
|
||
check('保存后退出编辑,改过的项显示「已确认 · 人工填写」', await until(async () => (await detail(page).getAttribute('data-editing')) === 'false')
|
||
&& await markStatus(page, 'label') === 'confirmed' && (await page.locator('[data-field="label"]').innerText()).includes('人工填写'))
|
||
check('……驳回的关系在列表里划掉、标已驳回', (await page.locator(`[data-relation="${GATE}"]`).getAttribute('data-status')) === 'rejected')
|
||
check('……码值标「已列全」', await page.locator('[data-column="status"] [data-codes-complete-mark]').count() === 1
|
||
&& (await page.locator('[data-column="status"] [data-codes-complete-mark]').innerText()).includes('已列全'))
|
||
check('……清单上的中文名跟着变', await until(async () => (await page.locator('[data-catalog-row="visits"] [data-row-label]').innerText()) === '入园流水'))
|
||
check('……提示已保存', await until(async () => (await page.locator('body').innerText()).includes('已保存,改动过的项已记为人工确认')))
|
||
|
||
// 服务端说格式不对(422):显示原话,留在编辑里
|
||
await page.locator('[data-catalog-edit]').click()
|
||
await page.locator('[data-field="dedup"] input').fill('按票号保留最后一条')
|
||
replies.put = [json({ detail: '数据目录的格式不正确:表的去重规则应为不超过 500 字的文字' }, 422)]
|
||
await page.locator('[data-catalog-save]').click()
|
||
check('服务端回 422:显示原话,留在编辑里', await until(async () => (await page.locator('[data-catalog-write-error]').innerText().catch(() => '')).includes('表的去重规则应为不超过 500 字的文字'))
|
||
&& (await detail(page).getAttribute('data-editing')) === 'true')
|
||
check('没有被拦下的写请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked).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 { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await openDetail(page, S1, 'ticket_types')
|
||
// 页面载入之后,别人确认了票种的中文名
|
||
const tt = state.tables[S1].find((t) => t.name === 'ticket_types')
|
||
writeEntry(tt, applyReview(tt.notes, 'label', 'confirm'))
|
||
const stale = await version(page)
|
||
await openMark(page, 'kind')
|
||
await panel(page).locator('[data-review-action="confirm"]').click()
|
||
const banner = page.locator('[data-catalog-conflict]')
|
||
await banner.waitFor({ timeout: 4000 }).catch(() => {})
|
||
check('审阅撞上别人的修改:横幅写「刚被其他人修改过」、操作未生效', (await banner.getAttribute('data-catalog-conflict').catch(() => '')) === 'review'
|
||
&& (await banner.innerText()).includes('这张表的数据目录刚被其他人修改过') && (await banner.innerText()).includes('刚才的操作未生效'))
|
||
check('……没有自动重试、没有改动页面上的状态', count(sent, /\/review$/) === 1 && await markStatus(page, 'kind') === 'proposed'
|
||
&& lastBody(sent, /\/review$/)?.if_version === stale)
|
||
await shot(page, 'catalog-conflict')
|
||
const gets = count(sent, /^GET \/datasources\/check-cat-scenic\/catalog\/ticket_types$/)
|
||
await banner.locator('[data-catalog-reload]').click()
|
||
check('「重新载入」:重取这张表,横幅消失,显示别人确认过的中文名', await until(async () => (await banner.count()) === 0)
|
||
&& count(sent, /^GET \/datasources\/check-cat-scenic\/catalog\/ticket_types$/) === gets + 1
|
||
&& await version(page) === tt.version && await markStatus(page, 'label') === 'confirmed')
|
||
check('……重新载入后清单上的计数也是最新的', (await rowCounts(page, 'ticket_types')) === Object.values(countsOf(tt.notes)).join(','))
|
||
|
||
// 编辑中撞上:修改留着,重新载入前先问
|
||
await openDetail(page, S1, 'visits')
|
||
await page.locator('[data-catalog-edit]').click()
|
||
await page.locator('[data-field="label"] input').fill('入园明细')
|
||
const vt = state.tables[S1][0]
|
||
writeEntry(vt, applyReview(vt.notes, 'grain', 'confirm'))
|
||
await page.locator('[data-catalog-save]').click()
|
||
await banner.waitFor({ timeout: 4000 }).catch(() => {})
|
||
check('保存撞上别人的修改:横幅写明修改尚未保存,留在编辑里,填的内容还在', (await banner.getAttribute('data-catalog-conflict').catch(() => '')) === 'edit'
|
||
&& (await banner.innerText()).includes('你的修改尚未保存') && (await detail(page).getAttribute('data-editing')) === 'true'
|
||
&& (await page.locator('[data-field="label"] input').inputValue()) === '入园明细')
|
||
check('……只发了一次 PUT,没有拿新版本号悄悄重发', count(sent, /^PUT /) === 1)
|
||
await banner.locator('[data-catalog-reload]').click()
|
||
await confirmBox(page).waitFor()
|
||
check('……重新载入前先问:放弃未保存的修改', (await confirmBox(page).innerText()).includes('放弃未保存的修改并重新载入?'))
|
||
await confirmBox(page).getByRole('button', { name: '取消' }).click()
|
||
check('……取消则什么都不动', (await detail(page).getAttribute('data-editing')) === 'true' && (await page.locator('[data-field="label"] input').inputValue()) === '入园明细')
|
||
await banner.locator('[data-catalog-reload]').click()
|
||
await confirmBox(page).getByRole('button', { name: '放弃修改并重新载入' }).click()
|
||
check('……确认后退出编辑,显示最新内容', await until(async () => (await detail(page).getAttribute('data-editing')) === 'false')
|
||
&& await version(page) === vt.version && await markStatus(page, 'grain') === 'confirmed' && (await banner.count()) === 0)
|
||
check('只发了审阅和整份提交,没有被拦下的写请求', !sent.some((s) => s.blocked))
|
||
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: catalogHandlers(state) })
|
||
await openDetail(page, S1, 'orders')
|
||
await page.locator('[data-catalog-edit]').click()
|
||
await page.locator('[data-field="grain"] input').fill('一个订单一行')
|
||
await page.locator('[data-catalog-next]').click()
|
||
await confirmBox(page).waitFor()
|
||
check('有未保存的修改时换表:先问', (await confirmBox(page).innerText()).includes('数据目录有未保存的修改'))
|
||
await confirmBox(page).getByRole('button', { name: '取消' }).click()
|
||
check('……取消则留在这张表,修改还在', new URL(page.url()).pathname === `/data/catalog/${S1}/orders`
|
||
&& (await page.locator('[data-field="grain"] input').inputValue()) === '一个订单一行')
|
||
await page.locator('[data-catalog-next]').click()
|
||
await confirmBox(page).getByRole('button', { name: '放弃修改并离开' }).click()
|
||
await page.waitForURL(`**/data/catalog/${S1}/order_items`, { timeout: 5000 }).catch(() => {})
|
||
check('……确认后换到下一张,不在编辑', new URL(page.url()).pathname === `/data/catalog/${S1}/order_items`
|
||
&& await until(async () => (await page.locator('[data-catalog-detail="order_items"]').getAttribute('data-editing')) === 'false'))
|
||
check('没有写请求', writes(sent, /./).length === 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, natives, close } = await open(`/data/catalog/${S3}`, { handlers: catalogHandlers(state) })
|
||
const empty = page.locator('[data-catalog-no-schema]')
|
||
await empty.waitFor()
|
||
const et = await empty.innerText()
|
||
check('没有表结构:写「还没有表结构」、原因,引导去点「探查结构」', et.includes('还没有表结构') && et.includes('尚未探查结构') && et.includes('点击「探查结构」'), et.replace(/\s+/g, ' '))
|
||
check('……不给「起草」(没有表结构起草不了)', await page.locator('[data-catalog-draft]').count() === 0)
|
||
await shot(page, 'catalog-no-schema')
|
||
await page.locator('[data-catalog-go-introspect]').click()
|
||
await page.waitForURL('**/data/databases', { timeout: 5000 }).catch(() => {})
|
||
check('……按钮回到数据源卡片', new URL(page.url()).pathname === '/data/databases', page.url())
|
||
|
||
await goto(page, `/data/catalog/${S4}`)
|
||
const ov = page.locator('[data-catalog-overview]')
|
||
await ov.waitFor()
|
||
check('有表结构、还没有目录:写「还没有数据目录。先起草,再逐表确认」', (await ov.innerText()).includes('还没有数据目录') && (await ov.innerText()).includes('先起草,再逐表确认'))
|
||
check('……给出「起草」', await ov.locator('[data-catalog-draft]').count() === 1)
|
||
await shot(page, 'catalog-empty')
|
||
|
||
await goto(page, `/data/catalog/${S1}`)
|
||
const ovt = await page.locator('[data-catalog-overview]').innerText()
|
||
check('有目录、没选表时:写审阅进度,给出使用最多、还有推断项的表', ovt.includes('共 152 张表:5 张有待确认项,3 张没有待确认项,144 张没有目录')
|
||
&& (await page.locator('[data-catalog-review-next]').getAttribute('data-catalog-review-next')) === 'visits', ovt.replace(/\s+/g, ' ').slice(0, 120))
|
||
// 进度条的色段和那句话同一个先后(有待确认项、没有待确认项、没有目录),下面有图例说明每种颜色
|
||
const segs = await page.locator('[data-catalog-overview] [data-progress-seg]').evaluateAll((els) => els.map((e) => e.getAttribute('data-progress-seg')))
|
||
const legend = await page.locator('[data-catalog-progress-legend] [data-legend]').evaluateAll((els) => els.map((e) => [e.getAttribute('data-legend'), e.innerText.trim()]))
|
||
check('进度条的色段按「有待确认项、没有待确认项、没有目录」排,和文字的先后一致', segs.join(',') === 'pending,done,none', segs.join(','))
|
||
check('……下面有图例,先后和文字一致', legend.map(([k]) => k).join(',') === 'pending,done,none'
|
||
&& legend.map(([, t]) => t).join('|') === '有待确认项|没有待确认项|没有目录', JSON.stringify(legend))
|
||
const swatch = await page.evaluate(() => [...document.querySelectorAll('[data-catalog-progress-legend] [data-legend]')].map((li) => {
|
||
const key = li.getAttribute('data-legend')
|
||
const seg = document.querySelector(`[data-progress-seg="${key}"]`)
|
||
return getComputedStyle(li.querySelector('span')).backgroundColor === (seg ? getComputedStyle(seg).backgroundColor : '')
|
||
}))
|
||
check('……图例的色块和色段同色', swatch.length === 3 && swatch.every(Boolean), JSON.stringify(swatch))
|
||
|
||
// 导入表格的源:说明由系统生成,只读
|
||
await goto(page, `/data/catalog/${S2}`)
|
||
const sys = page.locator('[data-catalog-overview] [data-catalog-system-notes]')
|
||
check('导入表格的源:概览写明说明是导入时生成的、只读', (await sys.innerText()).includes('导入时生成的说明') && (await sys.innerText()).includes('只读'))
|
||
await openDetail(page, S2, '日客流')
|
||
const tn = page.locator('[data-system-table-note]')
|
||
check('……表说明只读显示,标「导入时生成」', (await tn.innerText()).includes('每日入园人数汇总,由导入时的核对结果生成') && (await tn.innerText()).includes('导入时生成'))
|
||
const cn = page.locator('[data-column="入园人数"] [data-system-note]')
|
||
check('……列的说明写在列名下面,标「导入时生成」', (await cn.innerText()).includes('当日入园人数合计,已与表内合计核对') && (await cn.innerText()).includes('导入时生成'))
|
||
await page.locator('[data-catalog-edit]').click()
|
||
const values = await page.locator('[data-catalog-detail] input, [data-catalog-detail] textarea').evaluateAll((els) => els.map((e) => e.value))
|
||
check('……编辑时说明仍是只读文字,没有任何输入框装着它', (await tn.count()) === 1 && (await cn.count()) === 1
|
||
&& !values.some((x) => x.includes('核对结果生成') || x.includes('已与表内合计核对')))
|
||
await shot(page, 'catalog-system-notes')
|
||
await page.locator('[data-catalog-cancel]').click()
|
||
await cleanCopy(page, '导入表格的表详情')
|
||
check('没有写请求', writes(sent, /./).length === 0)
|
||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||
await close()
|
||
})
|
||
|
||
/** 数据剖析对话框(窄屏一节看它放不放得下) */
|
||
const profileBox = (page) => page.locator('[data-profile-dialog]')
|
||
|
||
await section('数据目录 · 只探查了一部分表', async () => {
|
||
// 升级前探查的源(没有对象索引):老缓存只探查了前 200 张,顶部照旧说出来,下一步是重新探查(新的探查列出全部对象)
|
||
const state = freshState()
|
||
const explored = state.tables[S1].filter((t) => !t.missing).length
|
||
const total = explored + 57
|
||
state.schemaTotal = { [S1]: total }
|
||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
const bar = page.locator('[data-catalog-schema-partial]')
|
||
check('顶部有一条说明', await bar.waitFor({ timeout: 3000 }).then(() => true, () => false))
|
||
check('……数字:一共几张、探查了几张', (await bar.getAttribute('data-catalog-schema-partial')) === `${total},${explored}`,
|
||
await bar.getAttribute('data-catalog-schema-partial'))
|
||
const text = await bar.innerText().catch(() => '')
|
||
check('……写成「共有 N 张表,只探查了前 M 张」', text.includes(`这个数据源共有 ${total} 张表,只探查了前 ${explored} 张`), text)
|
||
check('……其余的助手也看不到', text.includes('其余 57 张不在数据目录中') && text.includes('助手也看不到'), text.replace(/\s+/g, ' '))
|
||
check('……给出下一步:回到数据源卡片重新探查', text.includes('重新「探查结构」') && text.includes('全部对象'), text.replace(/\s+/g, ' '))
|
||
await shot(page, 'catalog-schema-partial')
|
||
await cleanCopy(page, '只探查了一部分表')
|
||
await bar.locator('[data-catalog-schema-partial-go]').click()
|
||
await page.waitForURL('**/data/databases', { timeout: 5000 }).catch(() => {})
|
||
check('……按钮回到数据源卡片', new URL(page.url()).pathname === '/data/databases', page.url())
|
||
// 没截断的库不出这一条
|
||
await goto(page, `/data/catalog/${S4}`)
|
||
await page.locator('[data-catalog-overview]').waitFor()
|
||
check('没截断的库不出这一条', await page.locator('[data-catalog-schema-partial]').count() === 0)
|
||
check('只读、不发写请求', writes(sent, /./).length === 0)
|
||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
await close()
|
||
})
|
||
|
||
await section('数据目录 · 探查范围:三种不在、分批显示、加入范围', async () => {
|
||
// 几千张表的库(next-round 03 A1、A2):清单只列探查范围内的;表结构里没有的按原因分开说,只有真删除说「表已不在」;
|
||
// 一次画 200 行,「显示更多」再画;搜索词同时在对象索引里找范围外的表,「加入范围」之后清单里就有了
|
||
const state = freshState()
|
||
for (let i = 1; i <= 120; i++) state.tables[S1].push(table(`arch_evt_${String(i).padStart(3, '0')}`, 0, [pk()], null))
|
||
state.tables[S1].push(table('gone_promos', 0, null, { label: item('已删除的促销', 'human') }, { missing: true, absent: 'removed' }))
|
||
state.tables[S1].push(table('cold_ledger', 0, null, { label: item('冷数据台账', 'human') }, { missing: true, absent: 'out_of_scope' }))
|
||
state.tables[S1].push(table('locked_audit', 0, null, { label: item('权限受限的审计表', 'human') }, { missing: true, absent: 'reflect_failed' }))
|
||
const explored = state.tables[S1].filter((t) => !t.missing).length
|
||
state.schemaTotal = { [S1]: explored + 2600 }
|
||
state.scope = { [S1]: { in_scope: explored + 1, reflected: explored, failed: 1, out_of_scope: 2600, cap: 500, index_total: explored + 2600,
|
||
pinned: 4, explicit: 0, rule: explored - 3, on_demand: 0 } }
|
||
state.outside = { [S1]: [{ name: 'complaint_ticket', comment: '客户投诉工单' }, { name: 'complaint_reason', comment: '投诉原因字典' }] }
|
||
const { page, sent, errors, natives, close } = await open(`/data/catalog/${S1}`, { handlers: catalogHandlers(state) })
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
const rendered = await page.locator('[data-catalog-row]').count()
|
||
check('一次只画 200 行', rendered === 200, String(rendered))
|
||
const more = page.locator('[data-catalog-more]')
|
||
const all = state.tables[S1].length
|
||
check('……「显示更多」写明已显示几张、一共几张', (await more.getAttribute('data-catalog-more')) === `200/${all}`
|
||
&& (await more.innerText()).includes(`已显示 200 / ${all} 张`), await more.innerText().catch(() => ''))
|
||
await more.click()
|
||
await until(async () => (await page.locator('[data-catalog-row]').count()) === all)
|
||
check('……点了再画下一批,画完就不再给按钮', await page.locator('[data-catalog-row]').count() === all
|
||
&& await page.locator('[data-catalog-more]').count() === 0, String(await page.locator('[data-catalog-row]').count()))
|
||
const reason = async (name) => page.locator(`[data-catalog-row="${name}"] [data-row-missing]`)
|
||
.evaluate((el) => [el.getAttribute('data-row-missing'), el.innerText]).catch(() => ['', ''])
|
||
const [r1, t1] = await reason('gone_promos')
|
||
check('真删除的写「表已不在」', r1 === 'removed' && t1.includes('表已不在'), t1)
|
||
const [r2, t2] = await reason('cold_ledger')
|
||
check('探查范围外的写「在探查范围外」,不说表已不在', r2 === 'out_of_scope' && t2.includes('在探查范围外') && !t2.includes('已不在'), t2)
|
||
const [r3, t3] = await reason('locked_audit')
|
||
check('读不到结构的写「读不到结构」', r3 === 'reflect_failed' && t3.includes('读不到结构'), t3)
|
||
const [r4, t4] = await reason('legacy_coupons')
|
||
check('老服务端不给原因的照旧写「表结构中已没有这张表」', r4 === '' && t4.includes('表结构中已没有这张表'), t4)
|
||
const bar = page.locator('[data-catalog-schema-partial]')
|
||
const barText = await bar.innerText().catch(() => '')
|
||
check('顶部说明写探查范围:共几张、范围内几张、范围外的怎么找', barText.includes('探查范围内') && barText.includes('其余 2,600 张在探查范围外')
|
||
&& barText.includes('加入范围'), barText.replace(/\s+/g, ' '))
|
||
|
||
// 表详情:在范围外的那张说清怎么加进来
|
||
await page.locator('[data-catalog-row="cold_ledger"] [data-catalog-open-row]').click()
|
||
const notice = page.locator('[data-catalog-missing]')
|
||
await notice.waitFor({ timeout: 4000 }).catch(() => {})
|
||
check('表详情里按原因说:范围外的给加入范围的去处', (await notice.getAttribute('data-catalog-missing')) === 'out_of_scope'
|
||
&& (await notice.innerText()).includes('探查范围'), await notice.innerText().catch(() => ''))
|
||
|
||
// 搜索范围外的表并加入范围
|
||
const search = page.locator('[data-catalog-search]')
|
||
await search.fill('complaint')
|
||
const outsideRow = page.locator('[data-catalog-outside-row="complaint_ticket"]')
|
||
check('搜索词同时在对象索引里找:范围外的命中列在清单下面', await outsideRow.waitFor({ timeout: 4000 }).then(() => true, () => false))
|
||
check('……写明在探查范围外、带注释', (await page.locator('[data-catalog-outside]').innerText()).includes('探查范围外的表')
|
||
&& (await outsideRow.innerText()).includes('客户投诉工单'), await page.locator('[data-catalog-outside]').innerText().catch(() => ''))
|
||
await shot(page, 'catalog-outside-search')
|
||
await outsideRow.locator('[data-catalog-add-scope]').click()
|
||
await until(async () => (await page.locator('[data-catalog-row="complaint_ticket"]').count()) === 1)
|
||
const addReq = lastBody(sent, /^POST \/datasources\/check-cat-scenic\/catalog\/scope\/add$/)
|
||
check('……「加入范围」发的是这一张', JSON.stringify(addReq) === JSON.stringify({ tables: ['complaint_ticket'] }), JSON.stringify(addReq))
|
||
check('……加完清单重取,这张表在清单里了', await page.locator('[data-catalog-row="complaint_ticket"]').count() === 1)
|
||
check('……范围外的命中里不再有它', await page.locator('[data-catalog-outside-row="complaint_ticket"]').count() === 0)
|
||
await cleanCopy(page, '探查范围')
|
||
check('只有「加入范围」一种写请求', writes(sent, /./).every((s) => /scope\/add$/.test(s.key)),
|
||
writes(sent, /./).map((s) => s.key).join(' | '))
|
||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||
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 } })
|
||
// 横向溢出只量数据目录页自己(列表格、关联关系表在自己的框里横向滚动,不算)。不量整页:390 宽时应用外壳里收起的
|
||
// 导航提示本来就超出视口,与本页无关
|
||
const overflow = () => page.evaluate(() => {
|
||
const root = document.querySelector('[data-catalog-page]')
|
||
return root ? root.scrollWidth - root.clientWidth : 99
|
||
})
|
||
await page.locator('[data-catalog-row]').first().waitFor()
|
||
check('390 宽:只显示表清单', await page.locator('[data-catalog-list]').isVisible() && !(await page.locator('[data-catalog-overview]').isVisible()))
|
||
check('……清单没有横向滚动', await overflow() <= 1, String(await overflow()))
|
||
// 页头放不下说明:只留标题,不能被挤成一个字的宽度、在标题后面露出说明的头一个字
|
||
const headSub = await page.locator('[data-catalog-page] > header p').count()
|
||
check('……页头只有标题,不露说明的残字', headSub === 0 && !(await page.locator('[data-catalog-page] > header').innerText()).includes('先起草'),
|
||
await page.locator('[data-catalog-page] > header').innerText())
|
||
await page.locator('[data-catalog-row="visits"] [data-catalog-open-row]').click()
|
||
await until(async () => (await page.locator('[data-catalog-detail="visits"][data-version]').count()) === 1)
|
||
check('……打开一张表:只显示详情,有「返回表清单」', !(await page.locator('[data-catalog-list]').isVisible())
|
||
&& await page.locator('[data-catalog-detail-back]').isVisible())
|
||
check('……详情没有横向滚动', await overflow() <= 1, String(await overflow()))
|
||
// 列表格、关联关系不再是 860px / 640px 宽、要横着滚的表格:排成卡片,状态标识始终在框里看得到
|
||
const fits = (sel) => page.evaluate((q) => {
|
||
const box = document.querySelector(q)
|
||
if (!box) return null
|
||
const r = box.getBoundingClientRect()
|
||
const marks = [...box.querySelectorAll('[data-item-mark]')].map((m) => m.getBoundingClientRect())
|
||
return { scroll: box.scrollWidth - box.clientWidth, width: Math.round(r.width), marks: marks.length,
|
||
inside: marks.every((m) => m.width > 0 && m.left >= r.left - 1 && m.right <= r.right + 1),
|
||
head: getComputedStyle(box.querySelector('thead')).display }
|
||
}, sel)
|
||
const cols = await fits('[data-catalog-columns]')
|
||
check('……列表格排成卡片:不横向滚动,表头收起,每个状态标识都在框里', !!cols && cols.scroll <= 1 && cols.head === 'none' && cols.marks > 0 && cols.inside,
|
||
JSON.stringify(cols))
|
||
const codesBox = await page.locator('[data-column="status"] [data-cell="codes"]').boundingBox()
|
||
check('……码值那一项在屏幕里,前面写着字段名', !!codesBox && codesBox.x >= 0 && codesBox.x + codesBox.width <= 390
|
||
&& (await page.locator('[data-column="status"] [data-cell="codes"]').innerText()).startsWith('码值'), JSON.stringify(codesBox))
|
||
await page.locator('[data-column="visitor_count"]').scrollIntoViewIfNeeded()
|
||
if (SHOTS) await sleep(200)
|
||
await shot(page, 'catalog-narrow-columns')
|
||
const rels = await fits('[data-catalog-relations]')
|
||
check('……关联关系也排成卡片:不横向滚动,三条关系的状态都看得到', !!rels && rels.scroll <= 1 && rels.head === 'none' && rels.marks === 3 && rels.inside,
|
||
JSON.stringify(rels))
|
||
await page.locator('[data-catalog-section="relations"]').scrollIntoViewIfNeeded()
|
||
if (SHOTS) await sleep(200)
|
||
await shot(page, 'catalog-narrow-relations')
|
||
await openMark(page, 'grain')
|
||
const box = await panel(page).boundingBox()
|
||
check('……状态弹层在视口里', !!box && box.x >= 0 && box.x + box.width <= 390, JSON.stringify(box))
|
||
await page.keyboard.press('Escape')
|
||
await page.locator('[data-catalog-edit]').click()
|
||
check('……编辑时保存栏可见、没有横向滚动', await page.locator('[data-catalog-save]').isVisible() && await overflow() <= 1)
|
||
await page.locator('[data-catalog-cancel]').click()
|
||
await page.locator('[data-catalog-detail-back]').click()
|
||
check('……「返回表清单」回到清单', await until(async () => page.locator('[data-catalog-list]').isVisible()))
|
||
await shot(page, 'catalog-narrow')
|
||
// 数据剖析:页头只留图标,弹窗在视口里、不横向滚动
|
||
await page.locator('[data-catalog-profile]').click()
|
||
await profileBox(page).waitFor()
|
||
const dlg = await page.locator('[role="dialog"]').last().boundingBox()
|
||
const inner = await profileBox(page).evaluate((el) => el.scrollWidth - el.clientWidth)
|
||
check('……数据剖析弹窗在视口里,正文不横向滚动', !!dlg && dlg.x >= 0 && dlg.x + dlg.width <= 390 && inner <= 1, `${JSON.stringify(dlg)} ${inner}`)
|
||
await page.keyboard.press('Escape')
|
||
// 起草:设置阶段、进行中(宽表的段进度、长原因)弹窗都在视口里、不横向滚动;关掉以后上方那条进度也不撑宽页面
|
||
const draftFits = async () => {
|
||
const d = await page.locator('[role="dialog"]').last().boundingBox()
|
||
const scroll = await draftBox(page).evaluate((el) => el.scrollWidth - el.clientWidth)
|
||
return { ok: !!d && d.x >= 0 && d.x + d.width <= 390 && scroll <= 1, d, scroll }
|
||
}
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).waitFor()
|
||
await draftBox(page).locator('[data-draft-model] input').check()
|
||
const setupFit = await draftFits()
|
||
check('……起草弹窗(设置阶段)在视口里,正文不横向滚动', setupFit.ok, JSON.stringify(setupFit))
|
||
await page.locator('[data-draft-start]').click()
|
||
await until(async () => (await draftPhase(page)) === 'running')
|
||
const narrowJob = state.jobs.at(-1)
|
||
settle(state, narrowJob, 'visits', { added: 2 })
|
||
settle(state, narrowJob, 'store_sales', { status: 'partial', parts_done: 2, added: 4, model_error: WIDE_ERR, blank_columns: 12, blank_sample: BLANK_SAMPLE })
|
||
// 接连没写出来的提醒、超时和没写出来的次数、段缩小的说明也都在
|
||
Object.assign(narrowJob.usage, { timeouts: 3, failed_calls: 4 })
|
||
Object.assign(narrowJob, { part_columns: 20, notice: DRAFT_NOTICE })
|
||
await until(async () => (await draftText(page, '[data-draft-task="store_sales"] [data-draft-task-reason]')).includes('第 3 段')
|
||
&& (await draftText(page, '[data-draft-notice]')) === DRAFT_NOTICE)
|
||
await draftBox(page).locator('[data-draft-task="store_sales"] [data-draft-task-blank] summary').click().catch(() => {})
|
||
const runFit = await draftFits()
|
||
check('……起草进行中:段进度、长原因、提醒、展开的留空列名都折行,弹窗不横向滚动', runFit.ok
|
||
&& await draftBox(page).locator('[data-draft-task-blank-sample]').isVisible().catch(() => false), JSON.stringify(runFit))
|
||
await shot(page, 'catalog-narrow-draft')
|
||
await page.locator('[data-draft-hide]').click()
|
||
await until(async () => (await page.locator('[data-catalog-job="draft"]').count()) === 1)
|
||
check('……后台任务条(带着提醒)看得见、不撑宽页面', await overflow() <= 1 && !!(await page.locator('[data-catalog-job-strip]').boundingBox())
|
||
&& (await page.locator('[data-catalog-job-strip]').boundingBox()).width <= 390 && await page.locator('[data-catalog-job="draft"]').isVisible()
|
||
&& await page.locator('[data-catalog-job="draft"] [data-catalog-job-notice]').count() === 1, String(await overflow()))
|
||
stopJob(narrowJob)
|
||
await until(async () => (await page.locator('[data-catalog-job-strip]').count()) === 0)
|
||
// 宽屏照旧是表格(表头在,列表格 860px 起,框里横向滚动)
|
||
await page.setViewportSize({ width: 1440, height: 900 })
|
||
await openDetail(page, S1, 'visits')
|
||
const wide = await page.locator('[data-catalog-columns]').evaluate((el) => ({ head: getComputedStyle(el.querySelector('thead')).display,
|
||
table: getComputedStyle(el.querySelector('table')).display, width: el.querySelector('table').getBoundingClientRect().width }))
|
||
check('宽屏照旧是表格', wide.head !== 'none' && wide.table === 'table' && wide.width >= 860, JSON.stringify(wide))
|
||
check('……页头写着说明', (await page.locator('[data-catalog-page] > header').innerText()).includes('先起草'))
|
||
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)
|