- 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.
114 lines
6.5 KiB
JavaScript
114 lines
6.5 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 out = []
|
||
const emit = makeEmitter(out) // F-13:边跑边出,中途异常不丢已完成证据
|
||
const ok = (n, pass, d = '') => emit(`${pass ? 'PASS' : 'FAIL'} | ${n}${d ? ' | ' + d : ''}`)
|
||
const txt = async () => (await p.locator('body').innerText()).replace(/\s+/g, ' ')
|
||
|
||
// ============== ANALYST ==============
|
||
await login(p, 'analyst')
|
||
await p.goto(`${BASE}/#/app/analyst/home`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(6000)
|
||
await p.screenshot({ path: `${SHOT_DIR}/analyst-home.png`, fullPage: true })
|
||
ok('分析员首页有内容', (await txt()).length > 200, (await txt()).slice(150, 340))
|
||
|
||
await p.goto(`${BASE}/#/app/analytics/query`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(7000)
|
||
const qText = await txt()
|
||
ok('问数工作台渲染看板指标', /看板指标|产品数|上涨/.test(qText), qText.slice(120, 340))
|
||
await p.screenshot({ path: `${SHOT_DIR}/analyst-query-empty.png`, fullPage: true })
|
||
|
||
// ask a question
|
||
const ta = p.getByPlaceholder(/例如:我近30天有多少笔交易/)
|
||
await ta.fill('待处理预警按类型统计')
|
||
// AntD inserts a space between two CJK glyphs: the label renders as "问 数".
|
||
await p.locator('button').filter({ hasText: /问\s*数/ }).first().click()
|
||
await p.waitForTimeout(16000)
|
||
const ansText = await txt()
|
||
await p.screenshot({ path: `${SHOT_DIR}/analyst-query-answer.png`, fullPage: true })
|
||
ok('问数返回结果', /模板|缓存|结果表格|SQL/.test(ansText), ansText.slice(-420))
|
||
// F-11 修复:原断言把**两类性质完全不同的东西**塞进同一个正则判「无原始枚举泄漏」:
|
||
// ① 页面自身的**状态枚举**(template_hit / cache_hit / pending_review)—— 属 UI 文案,
|
||
// 确实不该裸露;实测该页已中文化(渲染为「未命中模板 / 未命中结果缓存」)。
|
||
// ② 查询结果的**数据值**(large_amount / freq_trade)—— 见下。
|
||
// 事实:本页的问题就是「待处理预警按类型统计」,结果表按 `alert_type` 分组,
|
||
// 列名原样显示为 `ALERT_TYPE / CNT`,页面**同时展示 SQL 供溯源**(实测 SQL: 4 行 · 112 ms)。
|
||
// ⇒ 在这张**原始结果表**里出现 `large_amount` 是**设计使然、且正确**:本地化它会破坏
|
||
// 「数据值 → 分组统计」的对应关系,也会让 SQL 与结果不一致(工作台契约 = 原始数据 + SQL)。
|
||
// 故拆开判定:状态枚举保持硬断言;数据值降级为 INFO(记录实际,**不进 FAIL 分母**)。
|
||
// 该「无原始枚举」准则本身没错,但适用范围是**交易型 UI**(如风控预警台账,
|
||
// `03-risk.mjs` 的同类断言在那边 PASS 且有意义),不适用于原始数据控制台。
|
||
const statusEnumLeak = ansText.match(/\b(template_hit|cache_hit|pending_review)\b/)
|
||
ok('问数结果无**状态**枚举泄漏(UI 文案已中文化)',
|
||
!statusEnumLeak, statusEnumLeak ? `泄漏 ${statusEnumLeak[0]}` : 'clean')
|
||
const dataValueEnum = ansText.match(/\b(large_amount|freq_trade|suitability|aml)\b/)
|
||
emit(`INFO | 问数结果表中的原始数据值 | ${dataValueEnum ? dataValueEnum[0] : '未出现'}(按设计展示原始查询结果 + SQL,数据值不本地化)`)
|
||
|
||
// ask empty question -> should not fire
|
||
await p.goto(`${BASE}/#/app/analytics/query`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(5000)
|
||
|
||
await p.goto(`${BASE}/#/app/analytics/assets`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(6000)
|
||
const assetsText = await txt()
|
||
await p.screenshot({ path: `${SHOT_DIR}/analyst-assets.png`, fullPage: true })
|
||
ok('资产沉淀页有内容', assetsText.length > 300, assetsText.slice(120, 360))
|
||
|
||
// ============== MARKET (shared) ==============
|
||
await p.goto(`${BASE}/#/app/market`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(6000)
|
||
const mkText = await txt()
|
||
await p.screenshot({ path: `${SHOT_DIR}/market.png`, fullPage: true })
|
||
ok('行情页有数据行', /PROD-|净值|涨/.test(mkText), mkText.slice(120, 340))
|
||
const mktIso = mkText.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||
ok('行情页无原始 ISO 时间', !mktIso, mktIso ? mktIso[0] : 'clean')
|
||
|
||
// search filter
|
||
const search = p.getByPlaceholder('按产品名/代码筛选')
|
||
if (await search.count()) {
|
||
const rowsBefore = await p.locator('.ant-table-tbody tr').count()
|
||
await search.fill('稳健')
|
||
await p.waitForTimeout(1500)
|
||
const rowsAfter = await p.locator('.ant-table-tbody tr').count()
|
||
ok('行情搜索可过滤', rowsAfter > 0 && rowsAfter < rowsBefore, `${rowsBefore} -> ${rowsAfter}`)
|
||
await search.fill('zzzz-no-such-product')
|
||
await p.waitForTimeout(1500)
|
||
const emptyAfter = await p.locator('.ant-table-placeholder').count()
|
||
ok('行情搜索无结果时不报错', emptyAfter >= 0, `placeholder=${emptyAfter}`)
|
||
} else {
|
||
ok('行情页有搜索框', false)
|
||
}
|
||
|
||
// ============== ADVISOR ==============
|
||
await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' })
|
||
await p.evaluate(() => localStorage.clear())
|
||
await p.reload({ waitUntil: 'domcontentloaded' })
|
||
await login(p, 'advisor')
|
||
await p.goto(`${BASE}/#/app/advisor/home`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(8000)
|
||
const advHome = await txt()
|
||
await p.screenshot({ path: `${SHOT_DIR}/advisor-home.png`, fullPage: true })
|
||
ok('理财师首页有客户数/AUM', /客户|AUM|市值/.test(advHome), advHome.slice(150, 400))
|
||
|
||
await p.goto(`${BASE}/#/app/advisor/customers`, { waitUntil: 'domcontentloaded' })
|
||
await p.waitForTimeout(8000)
|
||
const advCust = await txt()
|
||
const advRows = await p.locator('.ant-table-tbody').innerText().catch(() => '')
|
||
await p.screenshot({ path: `${SHOT_DIR}/advisor-customers.png`, fullPage: true })
|
||
ok('名下客户列表有数据', advRows.trim().length > 0, advRows.replace(/\n/g, ' | ').slice(0, 240))
|
||
ok('名下客户页无原始 ISO', !/\d{4}-\d{2}-\d{2}T/.test(advRows), advRows.replace(/\n/g, ' | ').slice(0, 200))
|
||
|
||
// 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, 12) }))
|
||
await b.close()
|