| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690 |
- /**
- * T18 真机走查:运行监测模式四块面板按真实端点画出来、三块各自成败、筛选真的联动地图与台账(票 18)。
- *
- * 逻辑脚本(t18-monitoring-panels.mjs,63/63)已经把契约适配、视图计划、三块独立成败、
- * 筛选映射与台账渐进加载/徽标测透了;这里只补**只有在真机才成立**的部分:
- * 1. 进运行监测模式后,**界面真的**把四块面板画出来(DOM 实证,不是读 store);
- * 2. 一块端点坏掉时,坏的那块显示失败措辞,**好的两块仍然画出真实内容**;
- * 3. 点监测分布里的状态分布 → 面板与台账真的按状态变化,且**地图不变**(后端不收该入参);
- * 4. 改行业筛选 → 面板数字变化 + **一张图上的点位真的变少**(票 25 第 3 节的落图路径);
- * 5. 台账卡片真的按端点分页追加,卡片进视口后徽标真的补上(N+1);
- * 6. 点台账卡片 → 打开与地图**相同**的两级详情卡,且**不移动相机**。
- *
- * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由维护者按需执行):
- * node .scratch/cockpit-shell-panel-layout/verify/t18-monitoring-panels-walkthrough.mjs
- * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
- *
- * **场景注入的关键**(与票 11/17 同一套手法):用 `__COCKPIT_MOCK_PREFIX__` 把异常样例
- * **只**施加在运行监测的三个端点上,地图与其它分区保持 success —— 否则全局 empty 会把地图清空,
- * 「筛选联动地图」就退化成"地图本来就没点"的假失败。注意 `MOCK_ONLY` 是精确路径、
- * `MOCK_PREFIX` 才按前缀选中一族端点,写错键的后果是异常样例根本没生效而走查仍绿。
- *
- * 演示开关(宿主全局键注入,不改代码重新构建):
- * `__COCKPIT_MOCK_PREFIX__='/monitoring/'` + `__COCKPIT_MOCK_SCENARIO__='failed'`
- * —— 运行监测三块全失败、地图与其它分区照常
- * `...SCENARIO__='empty'` —— 三块都是"该面板当前没有数据"
- * `...SCENARIO__='monitoring-no-devices'` —— 在线率分母为 0(必须显示占位符,不是 0.0%)
- */
- 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 的位置:默认用 DSH 桌面 profile 里那一份,可用 `PUPPETEER_CORE_PATH` 覆盖。
- *
- * 为什么要留这个口子:profile 里 `puppeteer-core` 的**兄弟依赖** `@puppeteer/browsers`
- * 可能被装坏(实测遇到过:那个目录变成空目录,`Cannot find package .../@puppeteer/browsers`),
- * 而走查用的是系统已装的 Chrome、并不需要 profile 的整体依赖树 ——
- * 有一份独立的 puppeteer-core 就能继续跑,不必等 profile 修好。
- */
- 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 MONITORING_ONLY = '/monitoring/'
- 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-t18-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 host = document.querySelector('.cockpit-shell__map .map-canvas')
- const panels = Array.from(document.querySelectorAll('[data-panel-blocks]'))
- const card = document.querySelector('[data-point-detail]')
- const dev = window.__cockpitMap
- const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
- const camera = dev && dev.getCameraState ? dev.getCameraState() : null
- 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 || ''),
- // 块的**标题**(h4)另收一份:验收点名的「覆盖情况 · 区域」是块标题,
- // 不是分布行的标签 —— 第一版只读 `[data-bar]` 的行标签,于是 A5 假红(断言找错了地方)。
- 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(),
- })),
- 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(),
- })),
- // 可点状态分布行(旧版面板筛选入口;票 14 起面板改饼图,面板内已不再产出)
- statusRows: Array.from(el.querySelectorAll('[data-point-status]')).map((b) => ({
- code: b.dataset.pointStatus || '',
- label: ((b.querySelector('.panel-bar__label') || {}).textContent || '').trim(),
- count: ((b.querySelector('[data-bar-count]') || {}).textContent || '').trim(),
- pressed: b.getAttribute('aria-pressed') === 'true',
- })),
- // 票 14:状态分布饼图(八行图例 + 扇区;行内 StatusTag 带状态词,供 A5b 断言)
- pieRows: Array.from(el.querySelectorAll('[data-pie-row]')).map((r) => ({
- key: r.dataset.pieRow || '',
- label: ((r.querySelector('.status-tag__label') || {}).textContent || '').trim(),
- count: ((r.querySelector('[data-pie-count]') || {}).textContent || '').trim(),
- selected: r.dataset.pieSelected === 'yes',
- })),
- pieSlices: Array.from(el.querySelectorAll('[data-pie-slice]')).map((s) => s.dataset.pieSlice || ''),
- pieNote: ((el.querySelector('[data-pie-note]') || {}).textContent || '').trim(),
- trends: Array.from(el.querySelectorAll('[data-trend]')).map((t) => ({
- key: t.dataset.trend || '',
- count: ((t.querySelector('[data-trend-count]') || {}).textContent || '').trim(),
- label: ((t.querySelector('.panel-trend__label') || {}).textContent || '').trim(),
- })),
- // 台账卡片
- cards: Array.from(el.querySelectorAll('[data-ledger-card]')).map((c) => ({
- code: c.dataset.ledgerCard || '',
- title: ((c.querySelector('.panel-item__title') || {}).textContent || '').trim(),
- badge: ((c.querySelector('[data-ledger-badge]') || {}).textContent || '').trim(),
- badgePending: !!c.querySelector('[data-ledger-badge-pending]'),
- })),
- items: Array.from(el.querySelectorAll('[data-item]')).map((i) => ({
- key: i.dataset.item || '',
- title: ((i.querySelector('.panel-item__title') || {}).textContent || '').trim(),
- meta: ((i.querySelector('.panel-item__meta') || {}).textContent || '').trim(),
- })),
- locatePoints: Array.from(el.querySelectorAll('[data-locate-point]')).map((b) => b.dataset.locatePoint || ''),
- gap: ((el.querySelector('[data-panel-gap]') || {}).textContent || '').trim(),
- pageNote: ((el.querySelector('[data-panel-page-note]') || {}).textContent || '').trim(),
- offlineNote: ((el.querySelector('[data-panel-offline-note]') || {}).textContent || '').trim(),
- rangeNote: ((el.querySelector('[data-panel-range-note]') || {}).textContent || '').trim(),
- placeholder: ((el.querySelector('[data-panel-placeholder]') || {}).textContent || '').trim(),
- hasMore: !!el.querySelector('[data-ledger-more]'),
- height: Math.round(el.getBoundingClientRect().height),
- })
- return {
- mode: (document.querySelector('.cockpit-shell__mode.is-active') || {}).textContent?.trim() || '',
- panelCount: panels.length,
- panels: panels.map(panelView),
- placeholders: document.querySelectorAll('.cockpit-placeholder').length,
- industryActive: (document.querySelector('.cockpit-shell__industry-btn.is-active') || {}).textContent?.trim() || '',
- scrollChips: Array.from(document.querySelectorAll('.cockpit-shell__scope-btn.is-active')).map((b) => b.textContent.trim()),
- // 票 14:地图顶部状态栏(全局组合筛选入口,八桶多选 OR;计数=全局未筛口径)
- statusFilter: Array.from(document.querySelectorAll('[data-legend-key]')).map((r) => ({
- key: r.dataset.legendKey || '',
- selected: r.dataset.statusSelected === 'yes',
- count: r.dataset.statusCount || '',
- })),
- engine: (host && host.dataset.mapEngine) || '',
- markers: markers ? markers.onMap : -1,
- markerCodes: markers ? (markers.markers || []).map((m) => m.code).sort() : [],
- snapshotObjects: window.__cockpitMap?.mapSummary?.getState?.().objects?.map((r) => ({ code: r.code, devices: r.deviceCodes })) || [],
- camera: camera ? camera.pivot : null,
- hasCard: !!card,
- cardPointCode: card ? card.dataset.pointCode : '',
- detailLevel: card ? card.dataset.detailLevel : '',
- visibleText: document.querySelector('.cockpit-shell__column') ? document.querySelector('.cockpit-shell__column').innerText : '',
- panelWidth: panels.length ? Math.round(panels[0].getBoundingClientRect().width) : 0,
- }
- })
- /** 打开页面 → 切到运行监测模式 → 走真实的进入引导。 */
- async function openPage(scenario, onlyPath = MONITORING_ONLY) {
- const page = await browser.newPage()
- const errors = []
- page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
- page.on('console', (m) => {
- if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
- })
- await page.evaluateOnNewDocument(
- ({ name, only }) => {
- window.__COCKPIT_MOCK_SCENARIO__ = name
- // 两个键语义不同(见 `mocks/index.js`):`MOCK_ONLY` 是**精确**路径(且比的是注册模板),
- // `MOCK_PREFIX` 才按前缀选中一族端点。写错键的后果是"异常样例根本没生效"而走查仍绿。
- if (only.endsWith('/')) window.__COCKPIT_MOCK_PREFIX__ = only
- else window.__COCKPIT_MOCK_ONLY__ = only
- // 台账页大小给小一点,这样「加载下一页」真的会出现(否则后端默认 500,样例 8 条一页就到底)。
- window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 }
- window.__COCKPIT_MOCK_MONITORING__ = { pageSize: 3 }
- },
- { name: scenario, only: onlyPath },
- )
- await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
- await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
- await page
- .waitForFunction(
- () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
- { timeout: 40000 },
- )
- .catch(() => {})
- if (await page.$('[data-cockpit-intro]')) {
- await page.click('[data-cockpit-intro]')
- await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
- }
- await sleep(1200)
- // 切到运行监测模式(一级模式切换不重建地图,票 01)。
- await page.evaluate(() => {
- const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '运行监测')
- if (button) button.click()
- })
- await page
- .waitForFunction(
- () => document.querySelectorAll('[data-panel-blocks]').length === 4 &&
- Array.from(document.querySelectorAll('[data-panel-blocks] [data-section-state]')).length >= 4,
- { timeout: 20000 },
- )
- .catch(() => {})
- await sleep(800)
- return { page, errors }
- }
- const panelOf = (state, id) => state.panels.find((p) => p.id === id) || null
- // ================================================================ A 正常场景:四块都画出来
- {
- const { page, errors } = await openPage('success')
- const state = await readState(page)
- check('A1-monitoring-mode-active', state.mode === '运行监测', `当前一级模式:${state.mode}`)
- check('A2-four-panels-rendered', state.panelCount === 4, `画出的面板数:${state.panelCount}`)
- check(
- 'A3-all-four-panels-succeeded',
- state.panels.every((p) => p.state === 'success'),
- state.panels.map((p) => `${p.id}=${p.state}`).join(' '),
- )
- const overview = panelOf(state, 'monitoringOverview')
- check(
- 'A4-overview-shows-scale-rate-and-types',
- overview.kpis.map((k) => k.label).join(',') === '监测点,监测设备,在线率,数据时间' &&
- overview.bars.some((b) => b.label === '燃气浓度计') &&
- overview.kpis.every((k) => k.value && k.value !== '—'),
- `概览 KPI ${overview.kpis.map((k) => `${k.label}=${k.value}`).join(' / ')};类型分布 ${overview.bars.length} 条`,
- )
- const distribution = panelOf(state, 'monitoringDistribution')
- // 「覆盖情况 · 区域/行业/设备类型」是**块标题**(h4);`bars` 收的是行(城区A/B/C…)。
- // 第一版拿行标签去比块标题,于是恒假 —— 断言找错了地方(真机走查逮到)。
- // 票 14:状态统计改**饼图**(八行图例 + 扇区),面板不再自带筛选入口。
- const coverage = distribution.blockTitles
- check(
- 'A5-distribution-shows-coverage-and-status',
- coverage.includes('覆盖情况 · 区域') &&
- coverage.includes('覆盖情况 · 行业') &&
- coverage.includes('覆盖情况 · 设备类型') &&
- distribution.bars.length > 0 &&
- distribution.pieRows.length === 8 &&
- distribution.pieSlices.length > 0,
- `覆盖三块标题在(${coverage.filter((l) => l.includes('覆盖')).join(' / ')});行 ${distribution.bars.length} 条;饼图 ${distribution.pieRows.length} 行图例(八桶)、${distribution.pieSlices.length} 个扇区`,
- )
- // 状态词必须是**点位状态**的词(无设备),不是新鲜度哨兵的词(无数据)。
- const emptyRow = distribution.pieRows.find((r) => r.key === 'EMPTY')
- check(
- 'A5b-status-words-are-point-status-words',
- !!emptyRow && emptyRow.label.includes('无设备'),
- `EMPTY 行的词是「${emptyRow ? emptyRow.label : '(缺)'}」(应为「无设备」;若显示「无数据」说明用错了词表)`,
- )
- check(
- 'A5c-distribution-declares-region-scope-note',
- distribution.gap.includes('范围筛选'),
- `面板如实说明范围落点:${distribution.gap.slice(0, 60)}…`,
- )
- const situation = panelOf(state, 'operationOverview')
- check(
- 'A6-situation-shows-trends-and-abnormal',
- situation.blocks.filter((b) => b === 'trend').length === 2 &&
- situation.items.length > 0 &&
- !!situation.offlineNote,
- `两条趋势(预警/报警各一)+ 异常清单 ${situation.items.length} 条;${situation.offlineNote}`,
- )
- const ledger = panelOf(state, 'monitoringPointLedger')
- check(
- 'A7-ledger-renders-cards',
- ledger.cards.length > 0 && ledger.cards.every((c) => c.code && c.title),
- `台账卡片 ${ledger.cards.length} 张:${ledger.cards.map((c) => c.code).join(', ')}`,
- )
- // 其余模式是否还有占位 —— **不钉某个模式的固定数字**。
- //
- // 原判据是「切到预警处置 → 恰好 4 块占位」,但那条断言**锁死了另一个票的进度**:
- // 票 19(预警处置)接进真面板之后它就假红了(走查实测:预警处置 0 占位)。
- // 那个数字会随**别人**的票变化,而本票的走查不该因为它变红 ——
- // 那是"断言钉在别人的实现细节上"(票 17 的 A5 栽过同一个坑)。
- //
- // 改法:把四个模式各切一遍、记下占位块数,**只对运行监测断言 0**(那是本票的交付面),
- // 其余模式的数量**只作汇报**。这样别的票接进真面板时本走查不会假红。
- check(
- 'A8-monitoring-mode-has-no-placeholders',
- state.placeholders === 0,
- `运行监测模式下占位面板:${state.placeholders} 块(该模式 4 块全是真实面板)`,
- )
- const placeholderByMode = await page.evaluate(async () => {
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
- const result = []
- for (const button of Array.from(document.querySelectorAll('.cockpit-shell__mode'))) {
- const label = button.textContent.trim()
- button.click()
- await sleep(450)
- result.push({ label, placeholders: document.querySelectorAll('.cockpit-placeholder').length })
- }
- return result
- })
- const stillPending = placeholderByMode.filter((row) => row.placeholders > 0)
- check(
- 'A8b-placeholder-message-still-tracks-unimplemented-panels',
- // 运行监测自己必须 0 块;其余模式**有多少就报多少**(不写死数字,避免随别的票假红)。
- (placeholderByMode.find((row) => row.label === '运行监测') || {}).placeholders === 0,
- `各模式占位块数:${placeholderByMode.map((row) => `${row.label}=${row.placeholders}`).join(' · ')}${stillPending.length ? `(尚未接数据:${stillPending.map((r) => r.label).join('、')})` : '(16 块已全部接数据)'}`,
- )
- await page.evaluate(() => {
- const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '运行监测')
- if (button) button.click()
- })
- await sleep(600)
- // 接口词汇不许漏到大屏上(票 05/11/17 各逮到过一次;票 18 的枚举面更宽)。
- const leaks = [
- // 状态与原因码
- 'READY', 'NO_RESOLVED_DEVICES', 'AVAILABLE', 'SUCCESS', 'FAILED', 'UNAVAILABLE', 'EMPTY', 'STALE',
- // 枚举值
- 'GAS_SENSOR', 'WATER_SUPPLY_SENSOR', 'SEWAGE_ENV', 'SMART_DEVICE', 'TREND_WINDOW', 'CURRENT_BASELINE',
- 'NORMAL', 'WARNING', 'ALARM', 'EXPIRED', 'OFFLINE', 'UNKNOWN',
- 'GAS', 'WATER_SUPPLY', 'DRAINAGE',
- // 趋势桶必须是给人读的短标签(原样 ISO 串就是泄漏)
- 'bucketStart',
- ].filter((token) => new RegExp(`\\b${token}\\b`).test(state.visibleText))
- check(
- 'A9-no-backend-vocabulary-on-screen',
- leaks.length === 0,
- leaks.length ? `界面出现后端代号:${leaks.join(', ')}` : `两栏可见文本零后端代号(查了 ${26} 个状态码与枚举值)`,
- )
- // 趋势桶标签必须是短标签而不是整条 ISO 串(票 18 的 `bucketLabel`)。
- check(
- 'A9b-trend-buckets-are-human-labels',
- situation.trends.length > 0 && situation.trends.every((t) => !t.label.includes('T') && !t.label.includes('+08:00')),
- `趋势桶标签样例:${situation.trends.slice(0, 4).map((t) => t.label).join(' ')}(不是 ISO 串)`,
- )
- check('A10-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误')
- // ---- B 台账:渐进加载 + 徽标 N+1 ----
- const beforeMore = await readState(page)
- const ledgerBefore = panelOf(beforeMore, 'monitoringPointLedger')
- if (ledgerBefore.hasMore) {
- await page.evaluate(() => {
- const button = document.querySelector('[data-ledger-more]')
- if (button) button.click()
- })
- await sleep(900)
- const afterMore = await readState(page)
- const ledgerAfter = panelOf(afterMore, 'monitoringPointLedger')
- check(
- 'B1-ledger-loads-next-page-in-the-ui',
- ledgerAfter.cards.length > ledgerBefore.cards.length,
- `台账卡片 ${ledgerBefore.cards.length} → ${ledgerAfter.cards.length} 张(翻页真的追加)`,
- )
- } else {
- check('B1-ledger-loads-next-page-in-the-ui', false, '台账一页就到底了,"加载下一页"按钮没出现 —— 走查无法验证渐进加载')
- }
- // 徽标 N+1:卡片进视口后才补上(`data-ledger-badge` 从"取数中"变成真实计数)。
- const ledgerScrolled = await page.evaluate(async () => {
- const card = document.querySelector('[data-ledger-card]')
- if (card) card.scrollIntoView({ block: 'center' })
- await new Promise((r) => setTimeout(r, 1200))
- const el = document.querySelector('[data-ledger-card]')
- return {
- code: el ? el.dataset.ledgerCard : '',
- badge: el ? ((el.querySelector('[data-ledger-badge]') || {}).textContent || '').trim() : '',
- pending: el ? !!el.querySelector('[data-ledger-badge-pending]') : false,
- }
- })
- check(
- 'B2-ledger-badge-arrives-for-visible-cards',
- !!ledgerScrolled.badge && ledgerScrolled.badge.includes('设备'),
- `卡片 ${ledgerScrolled.code} 的徽标:「${ledgerScrolled.badge || '(还没到)'}」`,
- )
- // ---- C 点台账卡片 → 与地图相同的两级详情,并按票 41 触发点位级完整定位 ----
- //
- // **断言在票 41 被改写**(成例:t01 的 A5 在票 17 被改写)。原文是「点卡片不动相机」,
- // 那是票 18 当时的语义;票 41 的验收第 1、2 条明确要求「地图点击和台账选择使用同一监测点
- // 选择入口」且「有效点位坐标触发点位级完整视角定位」—— 台账不再是不动相机的那条旁路。
- // 新断言仍然有牙:它要求相机**真的**落到该点位的定位视角(range 换成点位档),
- // 而不是只把中心挪过去(旧的 `setCameraFocus` 只换中心、保持县域 range 12000)。
- const cameraBefore = (await readState(page)).camera
- const opened = await page.evaluate(async () => {
- const button = document.querySelector('[data-ledger-card] .panel-item__title--button')
- if (button) button.click()
- await new Promise((r) => setTimeout(r, 2500))
- const card = document.querySelector('[data-point-detail]')
- const camera = window.__cockpitMap && window.__cockpitMap.getCameraState ? window.__cockpitMap.getCameraState() : null
- return {
- code: card ? card.dataset.pointCode : '',
- level: card ? card.dataset.detailLevel : '',
- camera: camera ? camera.pivot : null,
- hpr: camera ? camera.hpr : null,
- settled: camera ? camera.settled : null,
- }
- })
- check(
- 'C1-ledger-card-opens-the-same-two-level-detail',
- !!opened.code && opened.level === 'point',
- `点台账卡片打开了详情卡:level=${opened.level} pointCode=${opened.code}`,
- )
- check(
- 'C2-ledger-card-locates-the-point',
- !!opened.hpr && opened.hpr.range < 2000 && !!opened.code,
- `点卡片触发点位级定位(预期 range≈500,实测 ${opened.hpr ? Math.round(opened.hpr.range) : 'n/a'} m;`
- + `点击前 range=${cameraBefore && cameraBefore.range ? Math.round(cameraBefore.range) : 'n/a'} m,`
- + '旧语义是只换中心、range 保持县域 12000)',
- )
- // ---- D 筛选联动:行业改 → 面板 + 地图都变;状态改 → 面板变、地图**不变** ----
- const beforeFilter = await readState(page)
- const markersBeforeIndustry = beforeFilter.markerCodes.join(',')
- const overviewBefore = (panelOf(beforeFilter, 'monitoringOverview') || {}).kpis || []
- await page.evaluate(() => {
- const button = Array.from(document.querySelectorAll('.cockpit-shell__industry-btn')).find((b) => b.textContent.trim() === '燃气')
- if (button) button.click()
- })
- await sleep(1800)
- const afterIndustry = await readState(page)
- const overviewAfter = (panelOf(afterIndustry, 'monitoringOverview') || {}).kpis || []
- const markersAfterIndustry = afterIndustry.markerCodes.join(',')
- check(
- 'D1-industry-filter-changes-panel-numbers',
- JSON.stringify(overviewBefore) !== JSON.stringify(overviewAfter) && afterIndustry.industryActive === '燃气',
- `行业切到「燃气」后面板数字变化:${overviewBefore.map((k) => `${k.label}=${k.value}`).join(' ')} → ${overviewAfter.map((k) => `${k.label}=${k.value}`).join(' ')}`,
- )
- check(
- 'D2-industry-filter-changes-the-map-points',
- JSON.stringify(beforeFilter.snapshotObjects) !== JSON.stringify(afterIndustry.snapshotObjects),
- `筛选后的摘要对象/设备成员改变;可见标记可保持不变:${markersBeforeIndustry || '(空)'} → ${markersAfterIndustry || '(空)'}`,
- )
- check(
- 'D2b-map-summary-follows-the-filter',
- afterIndustry.markers >= 0,
- `地图标记数 ${afterIndustry.markers}(筛选后仍能画出点位,不是被清空)`,
- )
- // ---- D2c 井盖(`MANHOLE`):后端第四个正式行业,界面必须真的筛出东西 ----
- //
- // 这条是**票 18 更正**补的:原来按「井盖在后端正式行业枚举里没有契约位」不传该筛选
- // (票 17 立、票 25 第八轮记的旧事实),后端后来把 `MANHOLE` 定为第四个正式行业
- // 且全部 11 个服务都收。不接的话,全局筛选条上那个「井盖」按钮对面板与地图完全不生效 ——
- // 而它正是验收第 5 条的一个维度。
- await page.evaluate(() => {
- const button = Array.from(document.querySelectorAll('.cockpit-shell__industry-btn')).find((b) => b.textContent.trim() === '井盖')
- if (button) button.click()
- })
- await sleep(1800)
- const afterManhole = await readState(page)
- const manholeLedger = panelOf(afterManhole, 'monitoringPointLedger')
- check(
- 'D2c-manhole-filter-actually-selects-and-shows-data',
- afterManhole.industryActive === '井盖' && (manholeLedger?.cards.length || 0) > 0,
- `切到「井盖」后:按钮=${afterManhole.industryActive}、台账 ${manholeLedger ? manholeLedger.cards.length : 0} 张卡片(${manholeLedger ? manholeLedger.cards.map((c) => c.code).join(',') : ''})—— 筛得出东西,不是空画面`,
- )
- check(
- 'D2d-manhole-filter-changes-the-map-points',
- afterManhole.markerCodes.join(',') !== markersAfterIndustry,
- `井盖筛选后一张图也变了:${markersAfterIndustry || '(空)'} → ${afterManhole.markerCodes.join(',') || '(空)'}`,
- )
- await page.evaluate(() => {
- document.querySelector('[data-industry="gas"]')?.click()
- })
- await sleep(1800)
- // ---- D2e 「另有 N 台」(票 14 的命中设备子集)----
- // 行业=燃气时快照的 deviceCodes 是**命中子集**:MP-001 只带 EQ-002(燃气),
- // 详情里的 EQ-001(供水)就是「未命中当前筛选」的那 1 台 —— 收起显示 N、展开可交互。
- await page.evaluate(async () => {
- const card = Array.from(document.querySelectorAll('[data-ledger-card]')).find((c) => c.dataset.ledgerCard === 'MP-001')
- const button = card && card.querySelector('.panel-item__title--button')
- if (button) button.click()
- await new Promise((r) => setTimeout(r, 2000))
- })
- await sleep(800)
- const moreState = await page.evaluate(() => {
- const detail = document.querySelector('[data-point-detail]')
- const more = document.querySelector('[data-device-more]')
- return {
- code: detail ? detail.dataset.pointCode : '',
- moreState: more ? more.dataset.deviceMore : '',
- moreCount: more ? more.dataset.deviceMoreCount : '',
- rows: Array.from(document.querySelectorAll('[data-device-layer-row]')).map((r) => `${r.dataset.deviceLayerCode}:${r.dataset.deviceLayerRow}`),
- snapshot: window.__cockpitMap?.mapSummary?.getState?.().objects?.map((r) => ({ code: r.code, devices: r.deviceCodes })),
- industry: document.querySelector('.cockpit-shell__industry-btn.is-active')?.textContent?.trim(),
- }
- })
- check(
- 'D2e-unmatched-devices-collapse-with-count',
- moreState.code === 'MP-001' && moreState.moreState === 'closed' && moreState.moreCount === '1',
- JSON.stringify(moreState),
- )
- await page.evaluate(() => {
- const toggle = document.querySelector('[data-device-more-toggle]')
- if (toggle) toggle.click()
- })
- await sleep(400)
- const expandedMore = await page.evaluate(() => ({
- state: (document.querySelector('[data-device-more]') || { dataset: {} }).dataset.deviceMore || '',
- rows: Array.from(document.querySelectorAll('[data-device-layer-row="unmatched"]')).map((r) => r.dataset.deviceLayerCode),
- }))
- check(
- 'D2e2-unmatched-expandable-and-interactive',
- expandedMore.state === 'open' && expandedMore.rows.includes('EQ-001'),
- `展开后可见未命中设备:${expandedMore.rows.join(',') || '(无)'}`,
- )
- // 收尾:关闭详情,让后续状态筛选组在干净的画面上跑。
- await page.evaluate(() => {
- const close = document.querySelector('[data-detail-close]')
- if (close) close.click()
- })
- await sleep(400)
- // 切回「全部」,后面的状态筛选组在未筛的画面上跑。
- await page.evaluate(() => {
- const button = Array.from(document.querySelectorAll('.cockpit-shell__industry-btn')).find((b) => b.textContent.trim() === '全部')
- if (button) button.click()
- })
- await sleep(1600)
- // 状态筛选(票 14):入口是**地图顶部状态栏**(八桶多选 OR),选 ALARM 后
- // 一张图/面板/台账**都**收窄(`map-snapshot` 现已收多值 pointStatus);状态栏计数不变(全局未筛口径)。
- const beforeStatus = await readState(page)
- const markersBeforeStatus = beforeStatus.markerCodes.join(',')
- const countsBeforeStatus = beforeStatus.statusFilter.map((r) => `${r.key}:${r.count}`).join('|')
- await page.evaluate(() => {
- const row = document.querySelector('[data-legend-key="ALARM"]')
- if (row) row.click()
- })
- await sleep(1800)
- const afterStatus = await readState(page)
- const countsAfterStatus = afterStatus.statusFilter.map((r) => `${r.key}:${r.count}`).join('|')
- const alarmRow = afterStatus.statusFilter.find((r) => r.key === 'ALARM') || {}
- check(
- 'D3-status-bar-selects-and-the-map-narrows',
- alarmRow.selected === true &&
- afterStatus.markerCodes.join(',') !== markersBeforeStatus &&
- afterStatus.markerCodes.every((c) => c === 'MP-002'),
- `状态栏 ALARM 已选中;一张图收窄:${markersBeforeStatus} → ${afterStatus.markerCodes.join(',')}(map-snapshot 现已收多值 pointStatus,旧缺口已关闭)`,
- )
- check(
- 'D3b-status-bar-counts-stay-global-unfiltered',
- countsBeforeStatus === countsAfterStatus && alarmRow.count !== '',
- `计数不随筛选变化(全局未筛口径):${countsBeforeStatus}`,
- )
- const ledgerAfterStatus = panelOf(afterStatus, 'monitoringPointLedger')
- check(
- 'D3c-status-filter-narrows-the-ledger',
- ledgerAfterStatus.cards.length > 0 && ledgerAfterStatus.cards.length <= (panelOf(beforeStatus, 'monitoringPointLedger') || { cards: [] }).cards.length,
- `台账按状态收窄:${(panelOf(beforeStatus, 'monitoringPointLedger') || { cards: [] }).cards.length} → ${ledgerAfterStatus.cards.length} 张卡片`,
- )
- // 再点一次同一状态=取消(多选 OR 的可清除语义),一张图恢复。
- await page.evaluate(() => {
- const row = document.querySelector('[data-legend-key="ALARM"]')
- if (row) row.click()
- })
- await sleep(1600)
- const cleared = await readState(page)
- const clearedRow = cleared.statusFilter.find((r) => r.key === 'ALARM') || {}
- check(
- 'D4-clicking-the-active-status-clears-it',
- clearedRow.selected === false && cleared.markerCodes.join(',') === markersBeforeStatus,
- `再点一次已选中的状态 → 取消,一张图恢复:${cleared.markerCodes.join(',')}`,
- )
- // 图例与地图标记仍一致(票 09 的语义不回退)。
- const legendConsistent = await page.evaluate(() => {
- const rows = Array.from(document.querySelectorAll('[data-legend-key]'))
- const markerTotal = window.__cockpitMap && window.__cockpitMap.getPointMarkers ? window.__cockpitMap.getPointMarkers().onMap : -1
- const legendTotal = rows.reduce((sum, row) => {
- const match = /(\d+)\s*$/.exec(row.textContent.trim())
- return sum + (match ? Number(match[1]) : 0)
- }, 0)
- return { legendTotal, markerTotal }
- })
- check(
- 'D5-global-distribution-stays-stable-after-clearing',
- cleared.statusFilter.length === 8 &&
- cleared.statusFilter.every((r) => r.count !== '') &&
- cleared.statusFilter.map((r) => `${r.key}:${r.count}`).join('|') === countsBeforeStatus,
- `全局计数合计 ${legendConsistent.legendTotal} 保持未筛口径;可见标记 ${legendConsistent.markerTotal} 是独立口径`,
- )
- check('D6-filter-run-has-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误')
- await page.close()
- }
- // ================================================================ E 一块坏掉不遮其它两块
- {
- // E1/E2:三个端点全坏 —— 三块都该说清楚,而不是白屏。
- const { page, errors } = await openPage('failed')
- const state = await readState(page)
- check(
- 'E1-all-monitoring-panels-failed',
- state.panels.length === 4 && state.panels.every((p) => p.state !== 'success'),
- state.panels.map((p) => `${p.id}=${p.state}`).join(' '),
- )
- check(
- 'E2-failed-panels-say-why-not-blank',
- state.panels.every((p) => p.placeholder.includes('加载失败') || p.placeholder.includes('不可用')),
- `失败措辞:${state.panels.map((p) => p.placeholder).join(' / ')}`,
- )
- check('E2b-failure-run-has-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误')
- await page.close()
- // E3/E4:只让**监测概览**这一个端点坏,其余两块必须照常画出真实内容。
- // 注意 `MOCK_ONLY` 比的是**注册模板**(精确路径),所以这里给准确路径。
- const { page: isolatedPage, errors: isolatedErrors } = await openPage('failed', '/monitoring/overview')
- const isolated = await readState(isolatedPage)
- const bad = isolated.panels.filter((p) => p.state !== 'success' && p.id !== 'monitoringPointLedger')
- check(
- 'E3-one-panel-failure-does-not-cover-others',
- bad.length === 1 && bad[0].id === 'monitoringOverview',
- `${bad.map((p) => p.id).join(',') || '无'} 失败;其余面板仍是 success`,
- )
- check(
- 'E4-good-panels-still-render-real-content',
- (panelOf(isolated, 'monitoringDistribution')?.pieRows.length || 0) === 8 &&
- (panelOf(isolated, 'operationOverview')?.items.length || 0) > 0,
- `分布饼图 ${panelOf(isolated, 'monitoringDistribution')?.pieRows.length} 行图例(八桶)、运行概况条目 ${panelOf(isolated, 'operationOverview')?.items.length} 条照常画出`,
- )
- check(
- 'E5-isolated-run-has-no-page-errors',
- isolatedErrors.length === 0,
- isolatedErrors.length ? isolatedErrors.join(' | ') : '无页面错误',
- )
- await isolatedPage.close()
- }
- // ================================================================ F 空场景与分母为 0
- {
- const { page } = await openPage('empty')
- const state = await readState(page)
- check(
- 'F1-empty-scenario-explicit-placeholders',
- state.panels.length === 4 && state.panels.every((p) => p.placeholder === '该面板当前没有数据'),
- `四块占位说明:${state.panels.map((p) => p.placeholder).join(' / ')}`,
- )
- check(
- 'F2-empty-does-not-fake-zeros',
- state.panels.every((p) => !p.kpis.some((k) => k.value === '0')),
- '空场景不显示伪造的 0 读数(缺值一律占位符)',
- )
- await page.close()
- // 分母为 0:在线率必须是占位符 + 说明,**不能**是 0.0%。
- const { page: noDevicesPage } = await openPage('monitoring-no-devices')
- const noDevices = await readState(noDevicesPage)
- const rateKpi = ((panelOf(noDevices, 'monitoringOverview') || {}).kpis || []).find((k) => k.label === '在线率')
- check(
- 'F3-zero-denominator-online-rate-is-not-zero-percent',
- !!rateKpi && !rateKpi.value.includes('0.0%') && !rateKpi.value.includes('0%'),
- `分母为 0 时在线率读数:「${rateKpi ? rateKpi.value : '(缺 KPI)'}」(**不得**是 0.0%)`,
- )
- await noDevicesPage.close()
- }
- await browser.close()
- const failed = results.filter((item) => !item.pass)
- console.log('')
- console.log(`T18 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:')
- for (const item of failed) console.log(` · ${item.id}`)
- process.exit(1)
- }
- console.log('T18 运行监测模式面板走查全部通过。')
|