| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- /**
- * T20 真机走查:总体概览模式四块面板按真实端点画出来、三块各自成败、两条联动真的生效(票 20)。
- *
- * 逻辑脚本(t20-overview-panels.mjs,59/59)已经把契约适配、视图计划、三块独立成败、
- * 联动入口与静态守卫测透了;这里只补**只有在真机才成立**的部分:
- * 1. 进总体概览模式后,界面**真的**把四块面板画出来(DOM 实证,不是读 store);
- * 2. 预警/报警两族统计同屏;工单状态分布是 **0–7 共 8 个码**(不是旧的七态);
- * 3. 「故障」缺口与「区域分布来自兄弟端点」两条说明**真的印在界面上**;
- * 4. 点行业构成行 → **全局行业筛选真的变了**、运行监测那块数字真的重算;
- * 5. 点当前预警/处置率读数 → **一级模式真的切到预警处置**;
- * 6. 只坏一块端点 → 坏的那块说失败、**好的两块仍画出真实内容**;
- * 7. 两栏可见文本里**零后端代号**(含 `CURRENT_BASELINE`、`LAST_7_DAYS`、`stale` 这类)。
- *
- * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由维护者按需执行):
- * node .scratch/cockpit-shell-panel-layout/verify/t20-overview-panels-walkthrough.mjs
- * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
- *
- * ── 2026-09-29 执行记录:本脚本**未能以 puppeteer 跑**,改由 DSH 自带浏览器真实驱动 ──
- *
- * 本机 `@puppeteer/browsers` 是**空目录**(票 01 记录的既有环境问题,非本票引入):
- * `Cannot find package '.../@puppeteer/browsers/index.js' imported from .../ChromeLauncher.js`
- * 这与票 36 的处境完全相同,故沿用其办法:**用 DSH 自带浏览器真实驱动同一份页面**,
- * 逐项跑下面同一批断言(A/B/C/D/E 五组),证据见
- * `verify/walkthrough/t20-overview-panels.png`。走查**逮到一个真缺陷**(见下),已修并复跑。
- *
- * **走查逮到的真缺陷(只有真机才发现,逻辑闸门当时全绿)**:
- * `monitoringSummaryPanel` 与 `alertSettingsPanel` 以为 `data` 是 `{monitoring}` / `{warningSettings}`
- * 包装对象,而外壳传的是**裸 `section.data`** → 两块面板的读数**全是占位符 `—`**;
- * 而 t20 逻辑闸门当时 59/59 全绿,因为**测试喂的正是那个错误的包装形状**(测试与实现犯了同一个错)。
- * 已修(两块改为直读 `data`),并补三条护栏:C3/C4(按真实形状断言读数**是数字**)与
- * **D8(经 `panelView()` 端到端逐块读数字)**、**F6c(钉住外壳逐块的数据源分派)**。
- * 教训:手喂 data 的单元断言证明不了"外壳喂进来的形状是对的"——**必须有一条走外壳真实入口的断言**。
- *
- * **场景注入的关键**(与票 11/17/18 同一套手法):用 `__COCKPIT_MOCK_PREFIX__='/overview/'`
- * 把异常样例**只**施加在概览端点上,地图与其它分区保持 success。
- * 注意 `MOCK_ONLY` 是精确路径、`MOCK_PREFIX` 才按前缀选中一族端点。
- *
- * ⚠️ DSH 浏览器**没有"文档创建前注入全局"的口子**(puppeteer 的 `evaluateOnNewDocument` 对应物),
- * 所以场景注入要用「设好 `window.__COCKPIT_MOCK_*__` → `location.hash='#/'` → 再回 `#/cockpit`」
- * 触发组件重挂载(`onMounted` 里会重新注册 mock),全局因此得以生效。
- *
- * ⚠️ 复跑前先重启 dev server:长跑的实例可能仍给浏览器发旧模块
- * (懒加载模块尤其明显,票 18 因此把 A15 误判红过一次)。
- */
- import { join } from 'node:path'
- import { tmpdir } from 'node:os'
- const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
- const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
- const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
- /** puppeteer-core 的位置(可用 `PUPPETEER_CORE_PATH` 覆盖,理由见 t18 的说明)。 */
- const PUPPETEER =
- process.env.PUPPETEER_CORE_PATH ||
- 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
- /** 异常样例只施加在概览族端点上(地图与其它分区保持 success)。 */
- const OVERVIEW_ONLY = '/overview/'
- const results = []
- /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
- function check(id, pass, evidence) {
- results.push({ id, pass })
- const rest = evidence === undefined ? '' : String(evidence)
- console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
- }
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
- const puppeteer = (await import(PUPPETEER)).default
- const browser = await puppeteer.launch({
- executablePath: CHROME,
- headless: true,
- userDataDir: join(tmpdir(), 'cockpit-t20-profile'),
- defaultViewport: { width: 1680, height: 950 },
- args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
- })
- console.log(`Chrome: ${await browser.version()}`)
- /**
- * 一次读全:四块面板的 DOM(状态标签 / 块 / KPI / 分布 / 趋势 / 联动出口)
- * + 顶栏筛选选中态 + 一级模式。
- *
- * 面板内容是**从 DOM 读**的 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。
- */
- const readState = (page) =>
- page.evaluate(() => {
- const panels = Array.from(document.querySelectorAll('[data-panel-blocks]'))
- const panelView = (el) => ({
- id: el.dataset.panelId || '',
- state: (el.querySelector('[data-section-state]') || {}).dataset?.sectionState || '',
- stateText: ((el.querySelector('.section-state__label') || {}).textContent || '').trim(),
- blocks: Array.from(el.querySelectorAll('[data-panel-block]')).map((b) => b.dataset.panelBlock || ''),
- blockTitles: Array.from(el.querySelectorAll('.panel-block__title')).map((t) => (t.textContent || '').trim()),
- kpis: Array.from(el.querySelectorAll('[data-kpi]')).map((k) => ({
- key: k.dataset.kpi || '',
- label: ((k.querySelector('.panel-kpi__label') || {}).textContent || '').trim(),
- value: ((k.querySelector('.panel-kpi__value') || {}).textContent || '').trim(),
- // 联动出口:带 `data-kpi-link` 的读数才可点
- link: (k.querySelector('[data-kpi-link]') || {}).dataset?.kpiLink || '',
- })),
- bars: Array.from(el.querySelectorAll('[data-bar]')).map((b) => ({
- key: b.dataset.bar || '',
- label: ((b.querySelector('.panel-bar__label') || {}).textContent || '').trim(),
- count: ((b.querySelector('[data-bar-count]') || {}).textContent || '').trim(),
- action: b.dataset.barAction || '',
- tag: b.tagName.toLowerCase(),
- })),
- trends: Array.from(el.querySelectorAll('[data-trend]')).map((t) => ({
- key: t.dataset.trend || '',
- count: ((t.querySelector('[data-trend-count]') || {}).textContent || '').trim(),
- })),
- gap: ((el.querySelector('[data-panel-gap]') || {}).textContent || '').trim(),
- rangeNote: ((el.querySelector('[data-panel-range-note]') || {}).textContent || '').trim(),
- note: ((el.querySelector('[data-panel-page-note]') || {}).textContent || '').trim(),
- missing: ((el.querySelector('[data-panel-missing]') || {}).textContent || '').trim(),
- text: (el.textContent || '').replace(/\s+/g, ' ').trim(),
- })
- const activeMode = (document.querySelector('.cockpit-shell__mode.is-active') || {}).textContent || ''
- const industryPressed = Array.from(document.querySelectorAll('[data-industry]'))
- .filter((b) => b.getAttribute('aria-pressed') === 'true')
- .map((b) => b.dataset.industry || '')
- return {
- mode: activeMode.trim(),
- panels: panels.map(panelView),
- // 两栏(左右 aside)的可见文本 —— 泄漏守卫的扫描面
- columnsText: Array.from(document.querySelectorAll('.cockpit-shell__column'))
- .map((c) => (c.textContent || '').replace(/\s+/g, ' '))
- .join(' '),
- placeholders: document.querySelectorAll('.cockpit-placeholder').length,
- industryPressed,
- errors: window.__t20Errors || [],
- }
- })
- /** 切到指定一级模式并等面板渲染。 */
- async function switchMode(page, label) {
- await page.evaluate((text) => {
- const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === text)
- if (button) button.click()
- }, label)
- await sleep(900)
- }
- /** 打开页面并进入系统(引导页开着时要先点进去)。 */
- async function openCockpit(globals = {}) {
- const page = await browser.newPage()
- page.on('pageerror', (error) => {
- page.evaluate((message) => {
- window.__t20Errors = window.__t20Errors || []
- window.__t20Errors.push(message)
- }, String(error && error.message ? error.message : error)).catch(() => {})
- })
- await page.evaluateOnNewDocument((injected) => {
- Object.assign(window, injected)
- }, globals)
- await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
- await sleep(1200)
- // 引导层(票 30)默认开着:点「进入系统」才到工作画面。
- await page.evaluate(() => {
- const intro = document.querySelector('.cockpit-intro, [data-cockpit-intro]')
- if (intro) {
- const clickable = intro.querySelector('button') || intro
- clickable.click()
- }
- })
- await sleep(1500)
- return page
- }
- // ================================================================ A 正常场景
- {
- const page = await openCockpit()
- await switchMode(page, '总体概览')
- const state = await readState(page)
- const byId = Object.fromEntries(state.panels.map((p) => [p.id, p]))
- check(
- 'A1-four-panels-are-rendered-as-real-content',
- state.panels.length === 4 && state.placeholders === 0,
- `四块面板 + ${state.placeholders} 块占位(panelId:${state.panels.map((p) => p.id).join(', ')})`,
- )
- check(
- 'A2-all-four-panels-report-ready',
- ['situationSummary', 'infrastructureSummary', 'monitoringSummary', 'alertSettings'].every(
- (id) => byId[id] && byId[id].state === 'success',
- ),
- `四块状态:${state.panels.map((p) => `${p.id}=${p.state}`).join(' / ')}`,
- )
- const situation = byId.situationSummary || { kpis: [], blockTitles: [], trends: [] }
- check(
- 'A3-situation-kpis-drawn-from-dom',
- situation.kpis.length >= 8 &&
- situation.kpis.some((k) => k.key === 'facilityCount') &&
- situation.kpis.some((k) => k.key === 'onlineRate') &&
- situation.kpis.some((k) => k.key === 'handlingRate'),
- `态势总览 KPI:${situation.kpis.map((k) => `${k.label}=${k.value}`).join(' · ')}`,
- )
- check(
- 'A4-situation-has-two-trend-series',
- situation.trends.length > 0 &&
- situation.blockTitles.filter((t) => t.includes('趋势')).length === 2 &&
- situation.blockTitles.some((t) => t.includes('近 7 日')),
- `两条趋势:${situation.blockTitles.filter((t) => t.includes('趋势')).join(' / ')}(趋势点 ${situation.trends.length} 个)`,
- )
- const infra = byId.infrastructureSummary || { blockTitles: [], bars: [] }
- check(
- 'A5-infrastructure-shows-industry-and-region',
- infra.blockTitles.some((t) => t.includes('行业构成')) && infra.blockTitles.some((t) => t.includes('区域分布')),
- `基础设施块:${infra.blockTitles.join(' / ')}`,
- )
- check(
- 'A5b-industry-rows-are-clickable-buttons',
- infra.bars.some((b) => b.action === 'industry' && b.tag === 'button'),
- `行业行是可点按钮(${infra.bars.filter((b) => b.action === 'industry').length} 行带 action)`,
- )
- const monitoring = byId.monitoringSummary || { blockTitles: [], gap: '', kpis: [] }
- check(
- 'A6-monitoring-shows-online-rate-and-four-buckets',
- ['onlineRate', 'online', 'offline', 'stale', 'unknownOrMissing'].every((key) => monitoring.kpis.some((k) => k.key === key)),
- `运行监测 KPI:${monitoring.kpis.map((k) => `${k.label}=${k.value}`).join(' · ')}`,
- )
- check(
- 'A7-fault-gap-is-printed-on-screen',
- monitoring.gap.includes('故障') && monitoring.gap.includes('没有独立'),
- `「故障」缺口说明真的印在界面上:${monitoring.gap.slice(0, 60)}…`,
- )
- check(
- 'A7b-metadata-freshness-row-present',
- monitoring.blockTitles.some((t) => t.includes('最近采集时间')),
- `逐设备最近采集时间有值时才画那一行:${monitoring.blockTitles.join(' / ')}`,
- )
- const alert = byId.alertSettings || { blockTitles: [], bars: [] }
- const stateBars = alert.bars.filter((b) => /待派单|待接单|已接单|处理中|待验收|已结案|延期中|已取消/.test(b.label))
- check(
- 'A8-alert-settings-shows-both-families-and-eight-codes',
- alert.blockTitles.some((t) => t.includes('预警')) &&
- alert.blockTitles.some((t) => t.includes('报警')) &&
- // 工单状态分布必须是 0–7 共 8 个码(不是旧的七态)
- ['待派单', '待接单', '已接单', '处理中', '待验收', '已结案', '延期中', '已取消'].every((name) =>
- stateBars.some((b) => b.label.includes(name)),
- ),
- `预警/报警两族 + 0–7 共 8 个状态码:${stateBars.map((b) => `${b.label}${b.count}`).join(' ')}`,
- )
- check(
- 'A8b-delay-state-is-labelled-as-bypass-not-hidden',
- stateBars.some((b) => b.label.includes('延期中')),
- '「延期中」是正式状态并画出来了(旧口径「不展示延期」已按后端枚举作废)',
- )
- // 接口词汇不许漏到大屏上(票 05/11/17 各逮到过一次真泄漏)。
- // 守卫面要覆盖**两类**词:状态/原因码与**枚举值**(含本票新增的窗口类型)。
- const leaks = [
- 'READY', 'EMPTY', 'STALE', 'SUCCESS', 'FAILED', 'UNAVAILABLE', 'CURRENT', 'NO_SNAPSHOT',
- 'GAS', 'WATER_SUPPLY', 'DRAINAGE', 'MANHOLE',
- 'CURRENT_BASELINE', 'LAST_7_DAYS', 'LAST_24_HOURS', 'LAST_30_DAYS',
- 'large', 'medium', 'small', 'NO_RESOLVED_DEVICES', 'AVAILABLE',
- ].filter((token) => new RegExp(`\\b${token}\\b`).test(state.columnsText))
- check('A9-no-interface-vocabulary-on-screen', leaks.length === 0, leaks.length ? `泄漏:${leaks.join(', ')}` : '两栏可见文本零后端代号')
- check('A10-no-page-errors', state.errors.length === 0, state.errors.length ? state.errors.join(' | ') : '走查期间无页面级报错')
- // ---- B 联动:行业行 → 全局筛选 ----
- const before = await readState(page)
- const gasRow = await page.evaluate(() => {
- const panel = document.querySelector('[data-panel-id="infrastructureSummary"]')
- const row = panel && panel.querySelector('[data-bar-action="industry"][data-bar="GAS"]')
- if (!row) return false
- row.click()
- return true
- })
- await sleep(1500)
- const afterIndustry = await readState(page)
- check(
- 'B1-industry-row-writes-the-global-filter',
- gasRow && afterIndustry.industryPressed.includes('gas'),
- `点燃气行 → 全局行业筛选变成 [${afterIndustry.industryPressed.join(', ')}](点到了:${gasRow})`,
- )
- const beforeDevices = (before.panels.find((p) => p.id === 'monitoringSummary') || { kpis: [] }).kpis.find((k) => k.key === 'deviceCount')
- const afterDevices = (afterIndustry.panels.find((p) => p.id === 'monitoringSummary') || { kpis: [] }).kpis.find((k) => k.key === 'deviceCount')
- check(
- 'B1b-monitoring-numbers-really-recalculated',
- !!beforeDevices && !!afterDevices && beforeDevices.value !== afterDevices.value,
- `运行监测「智能设备」真的重算:${beforeDevices && beforeDevices.value} → ${afterDevices && afterDevices.value}`,
- )
- check(
- 'B1c-unfilterable-panels-keep-their-numbers',
- (before.panels.find((p) => p.id === 'infrastructureSummary') || { kpis: [] }).kpis.find((k) => k.key === 'facilityCount').value ===
- (afterIndustry.panels.find((p) => p.id === 'infrastructureSummary') || { kpis: [] }).kpis.find((k) => k.key === 'facilityCount').value,
- '基础设施那块不收行业入参,故读数不变(不假装生效)',
- )
- // ---- C 联动:指标读数 → 切到预警处置模式 ----
- const clicked = await page.evaluate(() => {
- const link = document.querySelector('[data-kpi-link="WARNING"]')
- if (!link) return false
- link.click()
- return true
- })
- await sleep(1200)
- const afterLink = await readState(page)
- check(
- 'C1-metric-link-switches-to-warning-response',
- clicked && afterLink.mode === '预警处置',
- `点「当前预警」读数 → 一级模式 = ${afterLink.mode}`,
- )
- await page.close()
- }
- // ================================================================ D 一块坏不遮其它
- {
- const page = await openCockpit({
- __COCKPIT_MOCK_SCENARIO__: 'failed',
- __COCKPIT_MOCK_PREFIX__: '/overview/monitoring',
- })
- await switchMode(page, '总体概览')
- const state = await readState(page)
- const byId = Object.fromEntries(state.panels.map((p) => [p.id, p]))
- check(
- 'D1-one-broken-endpoint-does-not-cover-the-others',
- byId.monitoringSummary.state !== 'success' &&
- byId.infrastructureSummary.state === 'success' &&
- byId.alertSettings.state === 'success',
- `只坏运行监测端点:监测 ${byId.monitoringSummary.state} / 基础设施 ${byId.infrastructureSummary.state} / 预警设置 ${byId.alertSettings.state}`,
- )
- check(
- 'D1b-broken-panel-says-so-in-words',
- /失败|不可用/.test(byId.monitoringSummary.text),
- `坏的那块措辞:「${byId.monitoringSummary.stateText || byId.monitoringSummary.gap || byId.monitoringSummary.text.slice(0, 40)}」`,
- )
- check(
- 'D1c-healthy-panels-still-draw-real-content',
- (byId.infrastructureSummary.bars || []).length > 0 && (byId.alertSettings.bars || []).length > 0,
- `好块照常画出内容:基础设施 ${byId.infrastructureSummary.bars.length} 行分布 · 预警设置 ${byId.alertSettings.bars.length} 行分布`,
- )
- await page.close()
- }
- // ================================================================ E 空场景与分母为 0
- {
- const page = await openCockpit({ __COCKPIT_MOCK_SCENARIO__: 'empty', __COCKPIT_MOCK_PREFIX__: '/overview/' })
- await switchMode(page, '总体概览')
- const state = await readState(page)
- const byId = Object.fromEntries(state.panels.map((p) => [p.id, p]))
- check(
- 'E1-empty-is-not-failure',
- ['monitoringSummary', 'alertSettings'].every((id) => byId[id] && byId[id].state === 'empty'),
- `空场景状态:${state.panels.map((p) => `${p.id}=${p.state}`).join(' / ')}`,
- )
- // 分母为 0 的在线率必须是占位符 + 说明,**不能**是 0.0%(票 18 的 M3/M14 同一族)。
- const rate = (byId.monitoringSummary || { kpis: [] }).kpis.find((k) => k.key === 'onlineRate')
- check(
- 'E2-zero-denominator-is-a-placeholder-not-zero-percent',
- !!rate && rate.value === '—' && !rate.value.includes('0.0%'),
- `空场景在线率显示「${rate && rate.value}」(不是 0.0%)`,
- )
- check(
- 'E3-no-fake-zero-readings',
- !(byId.alertSettings || { text: '' }).text.includes('处置率 0.0%'),
- '空场景不显示伪造的 0 读数(缺值一律占位符)',
- )
- await page.close()
- }
- await browser.close()
- const failed = results.filter((r) => !r.pass)
- console.log('')
- console.log(`T20 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:')
- for (const item of failed) console.log(` · ${item.id}`)
- process.exitCode = 1
- }
|