mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:41:58 +00:00
1060 lines
72 KiB
JavaScript
1060 lines
72 KiB
JavaScript
// 数据目录页(数据源卡片上的「数据目录」,/data/catalog/<源>)的交互回归。
|
||
//
|
||
// 数据目录的写操作都会改变助手和将来的 SQL 检查读到的东西:确认一项、驳回一项、整份提交都不是随手可以撤回的。
|
||
// 所以这里查的是「请求里带没带读到的版本」「批量确认只动推断项、来源不变」「编辑时没动的项原样交回(被驳回的照旧
|
||
// 驳回)」「别人刚改过时有没有停下来让人重新载入,而不是悄悄覆盖」「起草按批调用、能停、出错时说得清」——
|
||
// 正常路径的页面检查一个字都不会说。
|
||
//
|
||
// 写法同 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)
|
||
return { page, sent, errors, natives, close: () => { opened.delete(ctx); 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 SOURCES = [
|
||
manual(S1, 'zzcatscenic', 151),
|
||
{
|
||
...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)
|
||
|
||
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)),
|
||
},
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 假服务端:照 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()
|
||
for (const [path, it] of slots(sub)) {
|
||
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 })
|
||
}
|
||
}
|
||
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,
|
||
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))
|
||
return { tables: rows, system_notes: isUpload(src), schema_note: all.length ? null : '尚未探查结构' }
|
||
}
|
||
const detailOf = (src, t) => ({
|
||
table_name: t.name, in_schema: !t.missing, notes: t.notes ?? {}, version: t.version, updated_at: t.updated_at, updated_by: t.updated_by,
|
||
structure: t.missing ? null : {
|
||
qualified: t.name, is_view: t.is_view, comment: 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
|
||
}
|
||
|
||
/** 起草的默认回答:每张表新增 2 项;没有目录的表顺手填上一个推断的中文名,清单重取后看得出来 */
|
||
function draftReply(state) {
|
||
return (route, { key, body }) => {
|
||
const src = parts(key)[2]
|
||
const rows = (body.tables ?? []).map((name) => {
|
||
const t = state.tables[src].find((x) => x.name === name)
|
||
if (t && !t.notes) writeEntry(t, { label: item(`${name} 的推断名`, 'name') })
|
||
return { table_name: name, added: 2, updated: 0, removed: 0, version: t?.version ?? 0, error: null, model_error: null }
|
||
})
|
||
const total = { added: rows.length * 2, updated: 0, removed: 0 }
|
||
return json({ tables: rows, total, model_used: !!body.use_model, model: body.use_model ? 'check-model' : null, model_error: null })(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)],
|
||
// 卡片展开时取的表清单(导入表格的源默认展开)
|
||
[/^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)],
|
||
[/^POST \/datasources\/check-cat-[a-z]+\/catalog\/draft$/, reply('draft', draftReply(state))],
|
||
[/^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]')
|
||
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()) === '未填写中文名')
|
||
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('|'))
|
||
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')
|
||
|
||
// 键盘:↑↓ 在行之间移动,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()
|
||
const MODEL_ERR = '未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加'
|
||
const replies = { draft: [] }
|
||
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('不发送数据'))
|
||
await draftBox(page).locator('[data-draft-model] input').check()
|
||
// 模型整体用不了:第一批就知道,结果里写明原因
|
||
replies.draft.push((route, ctx) => {
|
||
const rows = ctx.body.tables.map((n) => ({ table_name: n, added: 4, updated: 1, removed: 0, version: 1, error: null, model_error: null }))
|
||
for (const n of ctx.body.tables) writeEntry(state.tables[S1].find((t) => t.name === n), { label: item(`${n} 的推断名`, 'name') })
|
||
return json({ tables: rows, total: { added: 12, updated: 3, removed: 0 }, model_used: false, model: null, model_error: MODEL_ERR })(route)
|
||
})
|
||
const listsBefore = count(sent, /^GET \/datasources\/check-cat-scenic\/catalog$/)
|
||
await page.locator('[data-draft-start]').click()
|
||
check('起草完成:写出「新增 12 项、更新 3 项」', await until(async () => (await draftBox(page).locator('[data-draft-summary]').innerText().catch(() => '')) === '新增 12 项、更新 3 项'),
|
||
await draftBox(page).locator('[data-draft-summary]').innerText().catch(() => ''))
|
||
const body = lastBody(sent, /catalog\/draft$/)
|
||
check('……请求带所选的表(清单顺序)和「用模型起草」', JSON.stringify(body) === JSON.stringify({ tables: ['gates', 'store_sales', 'members'], use_model: true }), JSON.stringify(body))
|
||
check('……模型用不了时写明原因', (await draftBox(page).locator('[data-draft-model-error]').innerText().catch(() => '')).includes('模型未参与起草:未配置模型接入'))
|
||
check('……进度写「已完成 3 / 3 张表」', (await draftBox(page).locator('[data-draft-progress]').innerText()) === '已完成 3 / 3 张表')
|
||
check('……写请求带本机署名', sent.filter((s) => /catalog\/draft$/.test(s.key)).every((s) => decodeURIComponent(s.actor ?? '') === ACTOR))
|
||
await shot(page, 'catalog-draft-done')
|
||
await page.locator('[data-draft-close]').click()
|
||
check('关掉后重取清单、清空选择,起草出的中文名出现在清单上', await until(async () => count(sent, /^GET \/datasources\/check-cat-scenic\/catalog$/) > listsBefore)
|
||
&& (await page.locator('[data-catalog-count]').innerText()).endsWith('张表')
|
||
&& await until(async () => (await page.locator('[data-catalog-row="gates"] [data-row-label]').innerText()) === 'gates 的推断名'))
|
||
|
||
// 全部 151 张(只剩目录的那张起草不了,不算进范围),不请模型时每批 40 张
|
||
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(','))
|
||
await draftBox(page).locator('[data-draft-scope="all"]').click()
|
||
const n0 = count(sent, /catalog\/draft$/)
|
||
await page.locator('[data-draft-start]').click()
|
||
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 8000)
|
||
const batches = sent.filter((s) => /catalog\/draft$/.test(s.key)).slice(n0).map((s) => s.body)
|
||
check('表多时分批调用:40、40、40、31 张,都不请模型', batches.map((b) => b.tables.length).join(',') === '40,40,40,31'
|
||
&& batches.every((b) => b.use_model === false) && !batches.flatMap((b) => b.tables).includes('legacy_coupons'),
|
||
batches.map((b) => b.tables.length).join(','))
|
||
check('……进度写满、合计各批的结果', (await draftBox(page).locator('[data-draft-progress]').innerText()) === '已完成 151 / 151 张表'
|
||
&& (await draftBox(page).locator('[data-draft-summary]').innerText()) === '新增 302 项、更新 0 项')
|
||
check('……进度条读屏可读(progressbar 带当前值)', (await draftBox(page).locator('[role="progressbar"]').getAttribute('aria-valuenow')) === '151')
|
||
await page.locator('[data-draft-close]').click()
|
||
|
||
// 请模型时每批 12 张
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).locator('[data-draft-model] input').check()
|
||
const n1 = count(sent, /catalog\/draft$/)
|
||
await page.locator('[data-draft-start]').click()
|
||
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 8000)
|
||
const modelBatches = sent.filter((s) => /catalog\/draft$/.test(s.key)).slice(n1).map((s) => s.body)
|
||
check('用模型起草前 20 张:每批 12 张(12、8),都请模型', modelBatches.map((b) => `${b.tables.length}:${b.use_model}`).join(',') === '12:true,8:true',
|
||
modelBatches.map((b) => `${b.tables.length}:${b.use_model}`).join(','))
|
||
check('……写出用的是哪个模型', (await draftBox(page).innerText()).includes('模型:check-model'))
|
||
await page.locator('[data-draft-close]').click()
|
||
|
||
// 中途停止:当前这一批做完后不再发下一批
|
||
replies.draft.push(async (route, ctx) => { await sleep(900); return draftReply(state)(route, ctx) })
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).locator('[data-draft-scope="all"]').click()
|
||
const n2 = count(sent, /catalog\/draft$/)
|
||
await page.locator('[data-draft-start]').click()
|
||
await page.locator('[data-draft-stop]').click()
|
||
check('点「停止」后写「当前这一批完成后停止…」', (await draftBox(page).locator('[data-draft-title]').innerText()) === '当前这一批完成后停止…')
|
||
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 5000)
|
||
check('……这一批做完就停:只发了 1 批,写明完成了多少', count(sent, /catalog\/draft$/) - n2 === 1
|
||
&& (await draftBox(page).locator('[data-draft-title]').innerText()) === '已停止:完成 40 / 151 张表,其余未起草')
|
||
await page.locator('[data-draft-close]').click()
|
||
|
||
// 第二批出错:停下来,写服务端原话;个别表没起草、模型只在个别表上失败的分开列
|
||
replies.draft.push((route, ctx) => {
|
||
const rows = ctx.body.tables.map((n, i) => ({ table_name: n, added: 1, updated: 0, removed: 0, version: 1,
|
||
error: i === 0 ? '这张表的数据目录正被其他人修改,请稍后再起草' : null, model_error: i === 1 ? '模型返回的内容无法解析' : null }))
|
||
return json({ tables: rows, total: { added: 39, updated: 0, removed: 0 }, model_used: true, model: 'check-model', model_error: null })(route)
|
||
})
|
||
replies.draft.push(json({ detail: '服务端处理起草时出错,请稍后重试' }, 500))
|
||
await page.locator('[data-catalog-draft]').first().click()
|
||
await draftBox(page).locator('[data-draft-scope="all"]').click()
|
||
await page.locator('[data-draft-start]').click()
|
||
await until(async () => (await draftBox(page).getAttribute('data-draft-dialog')) === 'done', 5000)
|
||
const text = await draftBox(page).innerText()
|
||
check('第二批出错:写「起草中断」和服务端原话,进度停在 40', text.includes('起草中断') && text.includes('服务端处理起草时出错')
|
||
&& (await draftBox(page).locator('[data-draft-progress]').innerText()) === '已完成 40 / 151 张表', text.replace(/\s+/g, ' ').slice(0, 160))
|
||
check('……没起草的表单独列出(1 张)', (await draftBox(page).locator('[data-draft-table-errors]').innerText()).includes('1 张表未能起草'))
|
||
check('……模型只在个别表上失败的另列(1 张),说明已按其余来源起草', (await draftBox(page).locator('[data-draft-table-model-errors]').innerText()).includes('1 张表的模型起草失败'))
|
||
await shot(page, 'catalog-draft-failed')
|
||
check('只发过起草请求,没有别的写请求', writes(sent, /./).every((s) => /catalog\/draft$/.test(s.key)), JSON.stringify(writes(sent, /./).filter((s) => !/catalog\/draft$/.test(s.key)).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, ' '))
|
||
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('……焦点落在「确认」上', 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()
|
||
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))
|
||
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=已作废')
|
||
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')
|
||
await shot(page, 'catalog-edit')
|
||
check('保存栏写「6 处修改」', (await page.locator('[data-catalog-changes]').innerText()) === '6 处修改', 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('……清空的粒度不交(服务端删掉这一项)', !('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))
|
||
check('……原有的关系原样交回,新的一条带两端和基数', old.relations.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('保存后退出编辑,改过的项显示「已确认 · 人工填写」', 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 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))
|
||
|
||
// 导入表格的源:说明由系统生成,只读
|
||
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()
|
||
})
|
||
|
||
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()))
|
||
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()))
|
||
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')
|
||
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)
|