- Introduced `_recent_days_series_hint` to handle queries related to "最近N天" for time series data aggregation. - Added `_cn_num_to_int` function to convert Chinese numerals to integers for better query parsing. - Updated `_nl_sql_hints` to incorporate the new hint generation logic, ensuring accurate SQL output for recent days queries. - Enhanced documentation to reflect these changes and improve clarity on the new functionalities.
192 lines
11 KiB
JavaScript
192 lines
11 KiB
JavaScript
import { launch, instrument, login, BASE, SHOT_DIR, makeEmitter } from './harness.mjs'
|
||
|
||
const b = await launch()
|
||
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } })
|
||
const p = await ctx.newPage()
|
||
const rec = instrument(p)
|
||
const api = []
|
||
p.on('request', (r) => {
|
||
const u = r.url()
|
||
if (u.includes('/api/')) api.push(`${r.method()} ${u.replace(BASE, '')}`)
|
||
})
|
||
const out = []
|
||
const emit = makeEmitter(out) // F-13:边跑边出,中途异常不丢已完成证据
|
||
const ok = (n, pass, d = '') => emit(`${pass ? 'PASS' : 'FAIL'} | ${n}${d ? ' | ' + d : ''}`)
|
||
|
||
await login(p, 'risk')
|
||
|
||
// ---------- home ----------
|
||
await p.goto(`${BASE}/#/app/risk/home`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(6000)
|
||
const homeText = (await p.locator('body').innerText()).replace(/\s+/g, ' ')
|
||
await p.screenshot({ path: `${SHOT_DIR}/risk-home.png`, fullPage: true })
|
||
ok('风控首页出现免责声明', /最终判定需经|不构成|自动生成/.test(homeText), homeText.slice(180, 330))
|
||
|
||
// ---------- home KPI:把原恒真断言 `ok(..., true, ...)` 换成**对实测值的真实断言** ----------
|
||
// 修复前:`ok('风控首页无 0 值假图表', true, homeText.slice(...))` —— 第二个参数是字面量
|
||
// `true`,恒过,不构成任何证据(2026-09-13 记录为资产腐化 F-01)。
|
||
// 修复依据:2026-09-13 实测该页渲染出的 KPI 文本为
|
||
// 「待审预警 43 / 待审核预警总数 43 / 今日新增 43 / 涉及客户 5」,另有 2 个 canvas、
|
||
// 10 行预警明细表、以及 2 处 `— —` 占位。
|
||
// 断言策略:**判"数据源是否活着"而非判固定数字** —— 预警总数会随处置动作变化,
|
||
// 写死 43 会让本用例在 Part B(08-edge 会提交处置)后假 FAIL。
|
||
// 若后端数据源死掉:KPI 会变 0 或 `—`、表格会空 → 本用例 FAIL,正是原作者的意图。
|
||
const kpi = await p.evaluate(() => {
|
||
const t = (document.body.innerText || '').replace(/\s+/g, ' ')
|
||
const grab = (label) => {
|
||
const m = t.match(new RegExp(label + '\\s*([0-9]+)'))
|
||
return m ? Number(m[1]) : null
|
||
}
|
||
// ⚠️ 主指标的 DOM 顺序是「标题 → 数值 → 标签」,**标签在数值后面**
|
||
// (`web/src/components/dashboard/DashboardHero.tsx:41-51`:title → mainDisplay → mainLabel)。
|
||
// 所以 hero 数值**不能用标签去抓** —— `待审核预警总数` 后面跟的是下一个区块的首个数字,
|
||
// 用标签抓恒为 null(本轮实测踩到:pendingTitle:null 导致假 FAIL)。
|
||
// 正确抓法是**按位置**匹配「待审预警 <数值> 待审核预警总数」这三元组。
|
||
const hero = t.match(new RegExp('待审预警\\s*([0-9]+)\\s*待审核预警总数'))
|
||
return {
|
||
heroTotal: hero ? Number(hero[1]) : null,
|
||
heroMatch: !!hero,
|
||
// MetricCard 是「标签 → 数值」,与 hero 相反,可直接 grab
|
||
todayNew: grab('今日新增'),
|
||
customers: grab('涉及客户'),
|
||
canvases: document.querySelectorAll('canvas').length,
|
||
tableRows: document.querySelectorAll('.ant-table-tbody tr').length,
|
||
dashes: (t.match(/—\s*—/g) || []).length,
|
||
}
|
||
})
|
||
const kpiDump = JSON.stringify(kpi)
|
||
ok('风控首页 KPI 为真实正整数(非 0/非占位)',
|
||
Number.isInteger(kpi.heroTotal) && kpi.heroTotal > 0 &&
|
||
Number.isInteger(kpi.todayNew) && kpi.todayNew > 0 &&
|
||
Number.isInteger(kpi.customers) && kpi.customers > 0,
|
||
kpiDump)
|
||
ok('风控首页图表已渲染(canvas ≥ 2)', kpi.canvases >= 2, `canvas=${kpi.canvases}`)
|
||
ok('风控首页预警明细表有行', kpi.tableRows >= 1, `rows=${kpi.tableRows}`)
|
||
// 「今日新增」与「待审预警」实测同为 43(因演示库预警皆为当日生成)—— 只记录不判定:
|
||
// 两者语义上可以相等,无独立证据表明是缺陷。
|
||
emit(`INFO | 风控首页 KPI 原始读数 | ${kpiDump}`)
|
||
|
||
// ---------- alerts: filters ----------
|
||
await p.goto(`${BASE}/#/app/risk/alerts`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(4500)
|
||
|
||
api.length = 0
|
||
const custInput = p.getByPlaceholder('CUST-9527')
|
||
await custInput.click()
|
||
for (const ch of 'CUST-1002') await custInput.press(ch) // type one char at a time, like a human
|
||
await p.waitForTimeout(3000)
|
||
const listCalls = api.filter((c) => c.includes('/api/risk/alerts') && c.includes('customer_id')).length
|
||
// F-07a 修复:原用例**名与语义相反** —— 名字写「未做防抖」,判定式 `listCalls <= 1` 与实测
|
||
// (9 次击键 → 1 次请求)判的却是「**已做**防抖」。名字是错的,条件是对的。
|
||
// 实测根因:`web/src/pages/risk/RiskAlertsPage.tsx:72`
|
||
// `const t = window.setTimeout(() => setCustomerId(customerIdInput), 300)` —— 300ms 防抖。
|
||
// 订正为「已做防抖」,并把期望写进 detail(>1 即防抖失效 → FAIL)。
|
||
ok('客户ID 输入已做 300ms 防抖(连敲 9 个字符只发 ≤1 次请求)',
|
||
listCalls <= 1, `输入 9 个字符 -> ${listCalls} 次 /api/risk/alerts 请求(期望 ≤1)`)
|
||
await p.screenshot({ path: `${SHOT_DIR}/risk-alerts-filtered.png`, fullPage: true })
|
||
|
||
// status filter
|
||
// F-07b 修复:原用例 `p.getByText('全部状态').click()` 点的是 Select 的 **placeholder 文案**。
|
||
// 但 `web/src/pages/risk/RiskAlertsPage.tsx:31` 把 `status` 初值设为 `'pending_review'`
|
||
// → Select **有值** → placeholder 不渲染(实测 DOM:`ant-select-content-has-value` +
|
||
// `title="待审核"`,全页无「全部状态」文本节点)。
|
||
// ⇒ getByText 必然 30s 超时 → `.catch(()=>{})` 吞掉 → **下拉从未打开** →
|
||
// `.ant-select-item-option` 找不到 → 恒记 `FAIL | 状态筛选下拉可选 | option not found`。
|
||
// **这不是选择器过期,是点击目标选错**(AntD 6 的 `.ant-select-item-option` 实测存在,见下)。
|
||
// 改为点 Select 本体:过滤区第 1 个 `.ant-select` = 状态筛选。
|
||
// 另记一条真实行为:状态筛选**默认不是「全部」而是「待审核」**,故首屏「全部状态」不可达,
|
||
// 须先点「清空筛选」才看得到全量 —— 记为 INFO,不判缺陷(页面标题即「待审预警一览」)。
|
||
const statusSel = p.locator('.ant-select').first()
|
||
await statusSel.click()
|
||
const handled = p.locator('.ant-select-item-option', { hasText: '已处置' }).first()
|
||
const openedOk = await handled.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false)
|
||
if (openedOk) {
|
||
api.length = 0
|
||
await handled.click()
|
||
await p.waitForTimeout(4000)
|
||
// 强断言:选中「已处置」后,列表请求必须**带上 `status=handled`** —— 这才证明筛选真的接上了,
|
||
// 而不是「表格还有行」这种在「暂无数据」时也会为真的弱断言。
|
||
const handledCalls = api.filter((c) => c.includes('/api/risk/alerts') && /status=handled/i.test(c)).length
|
||
ok('状态筛选「已处置」真的下发了 status=handled',
|
||
handledCalls >= 1, `${handledCalls} 次 /api/risk/alerts?...status=handled`)
|
||
const rows = await p.locator('.ant-table-tbody').innerText()
|
||
emit(`INFO | 状态筛选「已处置」表格读数 | ${rows.replace(/\n/g, ' | ').slice(0, 160)}`)
|
||
} else {
|
||
const dd = await p.locator('.ant-select-dropdown').innerText().catch(() => '(无 dropdown)')
|
||
ok('状态筛选下拉可选', false, `dropdown=${dd.replace(/\s+/g, ' ').slice(0, 120)}`)
|
||
}
|
||
|
||
// clear filters
|
||
const clearBtn = p.getByRole('button', { name: '清空筛选' })
|
||
if (await clearBtn.count()) {
|
||
await clearBtn.click()
|
||
await p.waitForTimeout(4000)
|
||
const afterClear = await p.locator('.ant-table-tbody').innerText()
|
||
ok('清空筛选后回到全量', afterClear.trim().length > 0, afterClear.replace(/\n/g, ' | ').slice(0, 200))
|
||
ok(
|
||
'清空筛选真的清空了输入框',
|
||
(await p.getByPlaceholder('CUST-9527').inputValue()) === '',
|
||
`客户ID框=${JSON.stringify(await p.getByPlaceholder('CUST-9527').inputValue())}`,
|
||
)
|
||
}
|
||
|
||
// raw enums / timestamps in the table
|
||
const tableText = await p.locator('.ant-table-tbody').innerText().catch(() => '')
|
||
const iso = tableText.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||
ok('时间列显示为原始 ISO 字符串(未本地化)', !iso, iso ? `例如 ${iso[0]}` : 'no iso found')
|
||
const rawEnum = tableText.match(/\b(aml|large_amount|freq_trade|suitability|pattern|pending_review|confirmed_normal)\b/)
|
||
ok('枚举列显示为英文原始值(未中文化)', !rawEnum, rawEnum ? `例如 ${rawEnum[0]}` : 'no raw enum found')
|
||
|
||
// ---------- suitability ----------
|
||
await p.goto(`${BASE}/#/app/risk/suitability`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(3000)
|
||
await p.getByRole('button', { name: '校验适当性' }).click()
|
||
await p.waitForTimeout(6000)
|
||
const suitText = (await p.locator('body').innerText()).replace(/\s+/g, ' ')
|
||
ok('适当性校验 A-1 预设返回结果', /阻断|通过|match_result/.test(suitText), suitText.slice(250, 480))
|
||
await p.screenshot({ path: `${SHOT_DIR}/risk-suitability.png`, fullPage: true })
|
||
|
||
// empty-submit validation
|
||
await p.goto(`${BASE}/#/app/risk/suitability`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(2500)
|
||
await p.getByPlaceholder('CUST-1001').fill('')
|
||
await p.getByPlaceholder('PROD-161725').fill('')
|
||
await p.getByRole('button', { name: '校验适当性' }).click()
|
||
await p.waitForTimeout(1200)
|
||
const validation = (await p.locator('body').innerText()).replace(/\s+/g, ' ')
|
||
ok('必填校验阻止空提交', /必填|请输入|required/.test(validation), validation.slice(-160))
|
||
|
||
// ---------- AML scan ----------
|
||
await p.goto(`${BASE}/#/app/risk/aml-scan`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(2500)
|
||
await p.getByRole('button', { name: '执行扫描' }).click()
|
||
await p.waitForTimeout(9000)
|
||
const amlText = (await p.locator('body').innerText()).replace(/\s+/g, ' ')
|
||
ok('AML 扫描返回结果', /扫描客户|scanned/.test(amlText), amlText.slice(250, 460))
|
||
await p.screenshot({ path: `${SHOT_DIR}/risk-aml.png`, fullPage: true })
|
||
|
||
// ---------- simulate ----------
|
||
await p.goto(`${BASE}/#/app/risk/simulate`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(2500)
|
||
const simBody = (await p.locator('body').innerText()).replace(/\s+/g, ' ')
|
||
const simBtn = p.locator('button', { hasText: /模拟|提交|交易/ }).first()
|
||
if (await simBtn.count()) {
|
||
await simBtn.click()
|
||
await p.waitForTimeout(8000)
|
||
const after = (await p.locator('body').innerText()).replace(/\s+/g, ' ')
|
||
ok('模拟交易有反馈', after !== simBody, after.slice(200, 420))
|
||
} else {
|
||
ok('模拟交易页有提交按钮', false, simBody.slice(150, 320))
|
||
}
|
||
await p.screenshot({ path: `${SHOT_DIR}/risk-simulate.png`, fullPage: true })
|
||
|
||
// F-13:明细已在产生时即时输出;此处只给汇总(即使中断也不丢已完成证据)
|
||
const _nP = out.filter((l) => l.startsWith('PASS')).length
|
||
const _nF = out.filter((l) => l.startsWith('FAIL')).length
|
||
const _nI = out.filter((l) => l.startsWith('INFO')).length
|
||
console.log(`
|
||
===== ${_nP} PASS / ${_nF} FAIL / ${_nI} INFO =====`)
|
||
console.log('\n--- errors ---')
|
||
console.log(JSON.stringify({ pageErrors: [...new Set(rec.pageErrors)], httpErrors: rec.httpErrors.slice(0, 8) }))
|
||
await b.close()
|