/** * 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 }