Files
group_xinghuo_jinrong/scripts/e2e/03-risk.mjs
T
zhanghongyu_0626 188bf6a438 feat(analyst_agent): Add support for recent days queries and enhance SQL hint generation
- 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.
2026-09-13 22:36:19 +08:00

192 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()