t18-monitoring-panels-walkthrough.mjs 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. /**
  2. * T18 真机走查:运行监测模式四块面板按真实端点画出来、三块各自成败、筛选真的联动地图与台账(票 18)。
  3. *
  4. * 逻辑脚本(t18-monitoring-panels.mjs,63/63)已经把契约适配、视图计划、三块独立成败、
  5. * 筛选映射与台账渐进加载/徽标测透了;这里只补**只有在真机才成立**的部分:
  6. * 1. 进运行监测模式后,**界面真的**把四块面板画出来(DOM 实证,不是读 store);
  7. * 2. 一块端点坏掉时,坏的那块显示失败措辞,**好的两块仍然画出真实内容**;
  8. * 3. 点监测分布里的状态分布 → 面板与台账真的按状态变化,且**地图不变**(后端不收该入参);
  9. * 4. 改行业筛选 → 面板数字变化 + **一张图上的点位真的变少**(票 25 第 3 节的落图路径);
  10. * 5. 台账卡片真的按端点分页追加,卡片进视口后徽标真的补上(N+1);
  11. * 6. 点台账卡片 → 打开与地图**相同**的两级详情卡,且**不移动相机**。
  12. *
  13. * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由维护者按需执行):
  14. * node .scratch/cockpit-shell-panel-layout/verify/t18-monitoring-panels-walkthrough.mjs
  15. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  16. *
  17. * **场景注入的关键**(与票 11/17 同一套手法):用 `__COCKPIT_MOCK_PREFIX__` 把异常样例
  18. * **只**施加在运行监测的三个端点上,地图与其它分区保持 success —— 否则全局 empty 会把地图清空,
  19. * 「筛选联动地图」就退化成"地图本来就没点"的假失败。注意 `MOCK_ONLY` 是精确路径、
  20. * `MOCK_PREFIX` 才按前缀选中一族端点,写错键的后果是异常样例根本没生效而走查仍绿。
  21. *
  22. * 演示开关(宿主全局键注入,不改代码重新构建):
  23. * `__COCKPIT_MOCK_PREFIX__='/monitoring/'` + `__COCKPIT_MOCK_SCENARIO__='failed'`
  24. * —— 运行监测三块全失败、地图与其它分区照常
  25. * `...SCENARIO__='empty'` —— 三块都是"该面板当前没有数据"
  26. * `...SCENARIO__='monitoring-no-devices'` —— 在线率分母为 0(必须显示占位符,不是 0.0%)
  27. */
  28. import { join } from 'node:path'
  29. import { tmpdir } from 'node:os'
  30. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  31. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  32. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  33. /**
  34. * puppeteer-core 的位置:默认用 DSH 桌面 profile 里那一份,可用 `PUPPETEER_CORE_PATH` 覆盖。
  35. *
  36. * 为什么要留这个口子:profile 里 `puppeteer-core` 的**兄弟依赖** `@puppeteer/browsers`
  37. * 可能被装坏(实测遇到过:那个目录变成空目录,`Cannot find package .../@puppeteer/browsers`),
  38. * 而走查用的是系统已装的 Chrome、并不需要 profile 的整体依赖树 ——
  39. * 有一份独立的 puppeteer-core 就能继续跑,不必等 profile 修好。
  40. */
  41. const PUPPETEER =
  42. process.env.PUPPETEER_CORE_PATH ||
  43. 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  44. /** 异常样例只施加在运行监测族端点上(地图与详情保持 success,否则"联动地图"测不出来)。 */
  45. const MONITORING_ONLY = '/monitoring/'
  46. const results = []
  47. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
  48. function check(id, pass, evidence) {
  49. results.push({ id, pass })
  50. const rest = evidence === undefined ? '' : String(evidence)
  51. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  52. }
  53. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  54. const puppeteer = (await import(PUPPETEER)).default
  55. const browser = await puppeteer.launch({
  56. executablePath: CHROME,
  57. headless: true,
  58. userDataDir: join(tmpdir(), 'cockpit-t18-profile'),
  59. defaultViewport: { width: 1680, height: 950 },
  60. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  61. })
  62. console.log(`Chrome: ${await browser.version()}`)
  63. /**
  64. * 一次读全:四块面板的 DOM(状态标签 / 块类型 / KPI / 分布 / 状态筛选行 / 台账卡片)
  65. * + 地图侧口径(点位数、相机、详情卡)+ 顶栏筛选按钮的选中态。
  66. *
  67. * 面板内容是**从 DOM 读**的 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。
  68. */
  69. const readState = (page) =>
  70. page.evaluate(() => {
  71. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  72. const panels = Array.from(document.querySelectorAll('[data-panel-blocks]'))
  73. const card = document.querySelector('[data-point-detail]')
  74. const dev = window.__cockpitMap
  75. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  76. const camera = dev && dev.getCameraState ? dev.getCameraState() : null
  77. const panelView = (el) => ({
  78. id: el.dataset.panelId || '',
  79. state: (el.querySelector('[data-section-state]') || {}).dataset?.sectionState || '',
  80. stateText: ((el.querySelector('.section-state__label') || {}).textContent || '').trim(),
  81. blocks: Array.from(el.querySelectorAll('[data-panel-block]')).map((b) => b.dataset.panelBlock || ''),
  82. // 块的**标题**(h4)另收一份:验收点名的「覆盖情况 · 区域」是块标题,
  83. // 不是分布行的标签 —— 第一版只读 `[data-bar]` 的行标签,于是 A5 假红(断言找错了地方)。
  84. blockTitles: Array.from(el.querySelectorAll('.panel-block__title')).map((t) => (t.textContent || '').trim()),
  85. kpis: Array.from(el.querySelectorAll('[data-kpi]')).map((k) => ({
  86. key: k.dataset.kpi || '',
  87. label: ((k.querySelector('.panel-kpi__label') || {}).textContent || '').trim(),
  88. value: ((k.querySelector('.panel-kpi__value') || {}).textContent || '').trim(),
  89. })),
  90. bars: Array.from(el.querySelectorAll('[data-bar]')).map((b) => ({
  91. key: b.dataset.bar || '',
  92. label: ((b.querySelector('.panel-bar__label') || {}).textContent || '').trim(),
  93. count: ((b.querySelector('[data-bar-count]') || {}).textContent || '').trim(),
  94. })),
  95. // 可点状态分布行(票 18 的状态筛选入口)
  96. statusRows: Array.from(el.querySelectorAll('[data-point-status]')).map((b) => ({
  97. code: b.dataset.pointStatus || '',
  98. label: ((b.querySelector('.panel-bar__label') || {}).textContent || '').trim(),
  99. count: ((b.querySelector('[data-bar-count]') || {}).textContent || '').trim(),
  100. pressed: b.getAttribute('aria-pressed') === 'true',
  101. })),
  102. trends: Array.from(el.querySelectorAll('[data-trend]')).map((t) => ({
  103. key: t.dataset.trend || '',
  104. count: ((t.querySelector('[data-trend-count]') || {}).textContent || '').trim(),
  105. label: ((t.querySelector('.panel-trend__label') || {}).textContent || '').trim(),
  106. })),
  107. // 台账卡片
  108. cards: Array.from(el.querySelectorAll('[data-ledger-card]')).map((c) => ({
  109. code: c.dataset.ledgerCard || '',
  110. title: ((c.querySelector('.panel-item__title') || {}).textContent || '').trim(),
  111. badge: ((c.querySelector('[data-ledger-badge]') || {}).textContent || '').trim(),
  112. badgePending: !!c.querySelector('[data-ledger-badge-pending]'),
  113. })),
  114. items: Array.from(el.querySelectorAll('[data-item]')).map((i) => ({
  115. key: i.dataset.item || '',
  116. title: ((i.querySelector('.panel-item__title') || {}).textContent || '').trim(),
  117. meta: ((i.querySelector('.panel-item__meta') || {}).textContent || '').trim(),
  118. })),
  119. locatePoints: Array.from(el.querySelectorAll('[data-locate-point]')).map((b) => b.dataset.locatePoint || ''),
  120. gap: ((el.querySelector('[data-panel-gap]') || {}).textContent || '').trim(),
  121. pageNote: ((el.querySelector('[data-panel-page-note]') || {}).textContent || '').trim(),
  122. offlineNote: ((el.querySelector('[data-panel-offline-note]') || {}).textContent || '').trim(),
  123. rangeNote: ((el.querySelector('[data-panel-range-note]') || {}).textContent || '').trim(),
  124. placeholder: ((el.querySelector('[data-panel-placeholder]') || {}).textContent || '').trim(),
  125. hasMore: !!el.querySelector('[data-ledger-more]'),
  126. height: Math.round(el.getBoundingClientRect().height),
  127. })
  128. return {
  129. mode: (document.querySelector('.cockpit-shell__mode.is-active') || {}).textContent?.trim() || '',
  130. panelCount: panels.length,
  131. panels: panels.map(panelView),
  132. placeholders: document.querySelectorAll('.cockpit-placeholder').length,
  133. industryActive: (document.querySelector('.cockpit-shell__industry-btn.is-active') || {}).textContent?.trim() || '',
  134. scrollChips: Array.from(document.querySelectorAll('.cockpit-shell__scope-btn.is-active')).map((b) => b.textContent.trim()),
  135. engine: (host && host.dataset.mapEngine) || '',
  136. markers: markers ? markers.onMap : -1,
  137. markerCodes: markers ? (markers.markers || []).map((m) => m.code).sort() : [],
  138. camera: camera ? camera.pivot : null,
  139. hasCard: !!card,
  140. cardPointCode: card ? card.dataset.pointCode : '',
  141. detailLevel: card ? card.dataset.detailLevel : '',
  142. visibleText: document.querySelector('.cockpit-shell__column') ? document.querySelector('.cockpit-shell__column').innerText : '',
  143. panelWidth: panels.length ? Math.round(panels[0].getBoundingClientRect().width) : 0,
  144. }
  145. })
  146. /** 打开页面 → 切到运行监测模式 → 走真实的进入引导。 */
  147. async function openPage(scenario, onlyPath = MONITORING_ONLY) {
  148. const page = await browser.newPage()
  149. const errors = []
  150. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  151. page.on('console', (m) => {
  152. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  153. })
  154. await page.evaluateOnNewDocument(
  155. ({ name, only }) => {
  156. window.__COCKPIT_MOCK_SCENARIO__ = name
  157. // 两个键语义不同(见 `mocks/index.js`):`MOCK_ONLY` 是**精确**路径(且比的是注册模板),
  158. // `MOCK_PREFIX` 才按前缀选中一族端点。写错键的后果是"异常样例根本没生效"而走查仍绿。
  159. if (only.endsWith('/')) window.__COCKPIT_MOCK_PREFIX__ = only
  160. else window.__COCKPIT_MOCK_ONLY__ = only
  161. // 台账页大小给小一点,这样「加载下一页」真的会出现(否则后端默认 500,样例 8 条一页就到底)。
  162. window.__COCKPIT_MOCK_MAP__ = { pageSize: 10 }
  163. window.__COCKPIT_MOCK_MONITORING__ = { pageSize: 3 }
  164. },
  165. { name: scenario, only: onlyPath },
  166. )
  167. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  168. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  169. await page
  170. .waitForFunction(
  171. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  172. { timeout: 40000 },
  173. )
  174. .catch(() => {})
  175. if (await page.$('[data-cockpit-intro]')) {
  176. await page.click('[data-cockpit-intro]')
  177. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  178. }
  179. await sleep(1200)
  180. // 切到运行监测模式(一级模式切换不重建地图,票 01)。
  181. await page.evaluate(() => {
  182. const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '运行监测')
  183. if (button) button.click()
  184. })
  185. await page
  186. .waitForFunction(
  187. () => document.querySelectorAll('[data-panel-blocks]').length === 4 &&
  188. Array.from(document.querySelectorAll('[data-panel-blocks] [data-section-state]')).length >= 4,
  189. { timeout: 20000 },
  190. )
  191. .catch(() => {})
  192. await sleep(800)
  193. return { page, errors }
  194. }
  195. const panelOf = (state, id) => state.panels.find((p) => p.id === id) || null
  196. // ================================================================ A 正常场景:四块都画出来
  197. {
  198. const { page, errors } = await openPage('success')
  199. const state = await readState(page)
  200. check('A1-monitoring-mode-active', state.mode === '运行监测', `当前一级模式:${state.mode}`)
  201. check('A2-four-panels-rendered', state.panelCount === 4, `画出的面板数:${state.panelCount}`)
  202. check(
  203. 'A3-all-four-panels-succeeded',
  204. state.panels.every((p) => p.state === 'success'),
  205. state.panels.map((p) => `${p.id}=${p.state}`).join(' '),
  206. )
  207. const overview = panelOf(state, 'monitoringOverview')
  208. check(
  209. 'A4-overview-shows-scale-rate-and-types',
  210. overview.kpis.map((k) => k.label).join(',') === '监测点,监测设备,在线率,数据时间' &&
  211. overview.bars.some((b) => b.label === '燃气浓度计') &&
  212. overview.kpis.every((k) => k.value && k.value !== '—'),
  213. `概览 KPI ${overview.kpis.map((k) => `${k.label}=${k.value}`).join(' / ')};类型分布 ${overview.bars.length} 条`,
  214. )
  215. const distribution = panelOf(state, 'monitoringDistribution')
  216. // 「覆盖情况 · 区域/行业/设备类型」是**块标题**(h4);`bars` 收的是行(城区A/B/C…)。
  217. // 第一版拿行标签去比块标题,于是恒假 —— 断言找错了地方(真机走查逮到)。
  218. const coverage = distribution.blockTitles
  219. check(
  220. 'A5-distribution-shows-coverage-and-status',
  221. coverage.includes('覆盖情况 · 区域') &&
  222. coverage.includes('覆盖情况 · 行业') &&
  223. coverage.includes('覆盖情况 · 设备类型') &&
  224. distribution.bars.length > 0 &&
  225. distribution.statusRows.length === 6,
  226. `覆盖三块标题 ${coverage.filter((l) => l.includes('覆盖')).length} 个(${coverage.filter((l) => l.includes('覆盖')).join(' / ')});行 ${distribution.bars.length} 条;状态分布 ${distribution.statusRows.length} 行:${distribution.statusRows.map((r) => `${r.label}=${r.count}`).join(' ')}`,
  227. )
  228. // 状态词必须是**点位状态**的词(无设备),不是新鲜度哨兵的词(无数据)。
  229. const emptyRow = distribution.statusRows.find((r) => r.code === 'EMPTY')
  230. check(
  231. 'A5b-status-words-are-point-status-words',
  232. !!emptyRow && emptyRow.label.includes('无设备'),
  233. `EMPTY 行的词是「${emptyRow ? emptyRow.label : '(缺)'}」(应为「无设备」;若显示「无数据」说明用错了词表)`,
  234. )
  235. check(
  236. 'A5c-distribution-declares-the-map-gap',
  237. distribution.gap.includes('一张图'),
  238. `如实说明状态筛选不作用于一张图:${distribution.gap.slice(0, 60)}…`,
  239. )
  240. const situation = panelOf(state, 'operationOverview')
  241. check(
  242. 'A6-situation-shows-trends-and-abnormal',
  243. situation.blocks.filter((b) => b === 'trend').length === 2 &&
  244. situation.items.length > 0 &&
  245. !!situation.offlineNote,
  246. `两条趋势(预警/报警各一)+ 异常清单 ${situation.items.length} 条;${situation.offlineNote}`,
  247. )
  248. const ledger = panelOf(state, 'monitoringPointLedger')
  249. check(
  250. 'A7-ledger-renders-cards',
  251. ledger.cards.length > 0 && ledger.cards.every((c) => c.code && c.title),
  252. `台账卡片 ${ledger.cards.length} 张:${ledger.cards.map((c) => c.code).join(', ')}`,
  253. )
  254. // 其余模式是否还有占位 —— **不钉某个模式的固定数字**。
  255. //
  256. // 原判据是「切到预警处置 → 恰好 4 块占位」,但那条断言**锁死了另一个票的进度**:
  257. // 票 19(预警处置)接进真面板之后它就假红了(走查实测:预警处置 0 占位)。
  258. // 那个数字会随**别人**的票变化,而本票的走查不该因为它变红 ——
  259. // 那是"断言钉在别人的实现细节上"(票 17 的 A5 栽过同一个坑)。
  260. //
  261. // 改法:把四个模式各切一遍、记下占位块数,**只对运行监测断言 0**(那是本票的交付面),
  262. // 其余模式的数量**只作汇报**。这样别的票接进真面板时本走查不会假红。
  263. check(
  264. 'A8-monitoring-mode-has-no-placeholders',
  265. state.placeholders === 0,
  266. `运行监测模式下占位面板:${state.placeholders} 块(该模式 4 块全是真实面板)`,
  267. )
  268. const placeholderByMode = await page.evaluate(async () => {
  269. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  270. const result = []
  271. for (const button of Array.from(document.querySelectorAll('.cockpit-shell__mode'))) {
  272. const label = button.textContent.trim()
  273. button.click()
  274. await sleep(450)
  275. result.push({ label, placeholders: document.querySelectorAll('.cockpit-placeholder').length })
  276. }
  277. return result
  278. })
  279. const stillPending = placeholderByMode.filter((row) => row.placeholders > 0)
  280. check(
  281. 'A8b-placeholder-message-still-tracks-unimplemented-panels',
  282. // 运行监测自己必须 0 块;其余模式**有多少就报多少**(不写死数字,避免随别的票假红)。
  283. (placeholderByMode.find((row) => row.label === '运行监测') || {}).placeholders === 0,
  284. `各模式占位块数:${placeholderByMode.map((row) => `${row.label}=${row.placeholders}`).join(' · ')}${stillPending.length ? `(尚未接数据:${stillPending.map((r) => r.label).join('、')})` : '(16 块已全部接数据)'}`,
  285. )
  286. await page.evaluate(() => {
  287. const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '运行监测')
  288. if (button) button.click()
  289. })
  290. await sleep(600)
  291. // 接口词汇不许漏到大屏上(票 05/11/17 各逮到过一次;票 18 的枚举面更宽)。
  292. const leaks = [
  293. // 状态与原因码
  294. 'READY', 'NO_RESOLVED_DEVICES', 'AVAILABLE', 'SUCCESS', 'FAILED', 'UNAVAILABLE', 'EMPTY', 'STALE',
  295. // 枚举值
  296. 'GAS_SENSOR', 'WATER_SUPPLY_SENSOR', 'SEWAGE_ENV', 'SMART_DEVICE', 'TREND_WINDOW', 'CURRENT_BASELINE',
  297. 'NORMAL', 'WARNING', 'ALARM', 'EXPIRED', 'OFFLINE', 'UNKNOWN',
  298. 'GAS', 'WATER_SUPPLY', 'DRAINAGE',
  299. // 趋势桶必须是给人读的短标签(原样 ISO 串就是泄漏)
  300. 'bucketStart',
  301. ].filter((token) => new RegExp(`\\b${token}\\b`).test(state.visibleText))
  302. check(
  303. 'A9-no-backend-vocabulary-on-screen',
  304. leaks.length === 0,
  305. leaks.length ? `界面出现后端代号:${leaks.join(', ')}` : `两栏可见文本零后端代号(查了 ${26} 个状态码与枚举值)`,
  306. )
  307. // 趋势桶标签必须是短标签而不是整条 ISO 串(票 18 的 `bucketLabel`)。
  308. check(
  309. 'A9b-trend-buckets-are-human-labels',
  310. situation.trends.length > 0 && situation.trends.every((t) => !t.label.includes('T') && !t.label.includes('+08:00')),
  311. `趋势桶标签样例:${situation.trends.slice(0, 4).map((t) => t.label).join(' ')}(不是 ISO 串)`,
  312. )
  313. check('A10-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误')
  314. // ---- B 台账:渐进加载 + 徽标 N+1 ----
  315. const beforeMore = await readState(page)
  316. const ledgerBefore = panelOf(beforeMore, 'monitoringPointLedger')
  317. if (ledgerBefore.hasMore) {
  318. await page.evaluate(() => {
  319. const button = document.querySelector('[data-ledger-more]')
  320. if (button) button.click()
  321. })
  322. await sleep(900)
  323. const afterMore = await readState(page)
  324. const ledgerAfter = panelOf(afterMore, 'monitoringPointLedger')
  325. check(
  326. 'B1-ledger-loads-next-page-in-the-ui',
  327. ledgerAfter.cards.length > ledgerBefore.cards.length,
  328. `台账卡片 ${ledgerBefore.cards.length} → ${ledgerAfter.cards.length} 张(翻页真的追加)`,
  329. )
  330. } else {
  331. check('B1-ledger-loads-next-page-in-the-ui', false, '台账一页就到底了,"加载下一页"按钮没出现 —— 走查无法验证渐进加载')
  332. }
  333. // 徽标 N+1:卡片进视口后才补上(`data-ledger-badge` 从"取数中"变成真实计数)。
  334. const ledgerScrolled = await page.evaluate(async () => {
  335. const card = document.querySelector('[data-ledger-card]')
  336. if (card) card.scrollIntoView({ block: 'center' })
  337. await new Promise((r) => setTimeout(r, 1200))
  338. const el = document.querySelector('[data-ledger-card]')
  339. return {
  340. code: el ? el.dataset.ledgerCard : '',
  341. badge: el ? ((el.querySelector('[data-ledger-badge]') || {}).textContent || '').trim() : '',
  342. pending: el ? !!el.querySelector('[data-ledger-badge-pending]') : false,
  343. }
  344. })
  345. check(
  346. 'B2-ledger-badge-arrives-for-visible-cards',
  347. !!ledgerScrolled.badge && ledgerScrolled.badge.includes('设备'),
  348. `卡片 ${ledgerScrolled.code} 的徽标:「${ledgerScrolled.badge || '(还没到)'}」`,
  349. )
  350. // ---- C 点台账卡片 → 与地图相同的两级详情,并按票 41 触发点位级完整定位 ----
  351. //
  352. // **断言在票 41 被改写**(成例:t01 的 A5 在票 17 被改写)。原文是「点卡片不动相机」,
  353. // 那是票 18 当时的语义;票 41 的验收第 1、2 条明确要求「地图点击和台账选择使用同一监测点
  354. // 选择入口」且「有效点位坐标触发点位级完整视角定位」—— 台账不再是不动相机的那条旁路。
  355. // 新断言仍然有牙:它要求相机**真的**落到该点位的定位视角(range 换成点位档),
  356. // 而不是只把中心挪过去(旧的 `setCameraFocus` 只换中心、保持县域 range 12000)。
  357. const cameraBefore = (await readState(page)).camera
  358. const opened = await page.evaluate(async () => {
  359. const button = document.querySelector('[data-ledger-card] .panel-item__title--button')
  360. if (button) button.click()
  361. await new Promise((r) => setTimeout(r, 2500))
  362. const card = document.querySelector('[data-point-detail]')
  363. const camera = window.__cockpitMap && window.__cockpitMap.getCameraState ? window.__cockpitMap.getCameraState() : null
  364. return {
  365. code: card ? card.dataset.pointCode : '',
  366. level: card ? card.dataset.detailLevel : '',
  367. camera: camera ? camera.pivot : null,
  368. hpr: camera ? camera.hpr : null,
  369. settled: camera ? camera.settled : null,
  370. }
  371. })
  372. check(
  373. 'C1-ledger-card-opens-the-same-two-level-detail',
  374. !!opened.code && opened.level === 'point',
  375. `点台账卡片打开了详情卡:level=${opened.level} pointCode=${opened.code}`,
  376. )
  377. check(
  378. 'C2-ledger-card-locates-the-point',
  379. !!opened.hpr && opened.hpr.range < 2000 && !!opened.code,
  380. `点卡片触发点位级定位(预期 range≈500,实测 ${opened.hpr ? Math.round(opened.hpr.range) : 'n/a'} m;`
  381. + `点击前 range=${cameraBefore && cameraBefore.range ? Math.round(cameraBefore.range) : 'n/a'} m,`
  382. + '旧语义是只换中心、range 保持县域 12000)',
  383. )
  384. // ---- D 筛选联动:行业改 → 面板 + 地图都变;状态改 → 面板变、地图**不变** ----
  385. const beforeFilter = await readState(page)
  386. const markersBeforeIndustry = beforeFilter.markerCodes.join(',')
  387. const overviewBefore = (panelOf(beforeFilter, 'monitoringOverview') || {}).kpis || []
  388. await page.evaluate(() => {
  389. const button = Array.from(document.querySelectorAll('.cockpit-shell__industry-btn')).find((b) => b.textContent.trim() === '燃气')
  390. if (button) button.click()
  391. })
  392. await sleep(1800)
  393. const afterIndustry = await readState(page)
  394. const overviewAfter = (panelOf(afterIndustry, 'monitoringOverview') || {}).kpis || []
  395. const markersAfterIndustry = afterIndustry.markerCodes.join(',')
  396. check(
  397. 'D1-industry-filter-changes-panel-numbers',
  398. JSON.stringify(overviewBefore) !== JSON.stringify(overviewAfter) && afterIndustry.industryActive === '燃气',
  399. `行业切到「燃气」后面板数字变化:${overviewBefore.map((k) => `${k.label}=${k.value}`).join(' ')} → ${overviewAfter.map((k) => `${k.label}=${k.value}`).join(' ')}`,
  400. )
  401. check(
  402. 'D2-industry-filter-changes-the-map-points',
  403. markersBeforeIndustry !== markersAfterIndustry,
  404. `一张图上的点位真的变了:${markersBeforeIndustry || '(空)'} → ${markersAfterIndustry || '(空)'}(票 25 第 3 节的落图路径)`,
  405. )
  406. check(
  407. 'D2b-map-summary-follows-the-filter',
  408. afterIndustry.markers >= 0,
  409. `地图标记数 ${afterIndustry.markers}(筛选后仍能画出点位,不是被清空)`,
  410. )
  411. // ---- D2c 井盖(`MANHOLE`):后端第四个正式行业,界面必须真的筛出东西 ----
  412. //
  413. // 这条是**票 18 更正**补的:原来按「井盖在后端正式行业枚举里没有契约位」不传该筛选
  414. // (票 17 立、票 25 第八轮记的旧事实),后端后来把 `MANHOLE` 定为第四个正式行业
  415. // 且全部 11 个服务都收。不接的话,全局筛选条上那个「井盖」按钮对面板与地图完全不生效 ——
  416. // 而它正是验收第 5 条的一个维度。
  417. await page.evaluate(() => {
  418. const button = Array.from(document.querySelectorAll('.cockpit-shell__industry-btn')).find((b) => b.textContent.trim() === '井盖')
  419. if (button) button.click()
  420. })
  421. await sleep(1800)
  422. const afterManhole = await readState(page)
  423. const manholeLedger = panelOf(afterManhole, 'monitoringPointLedger')
  424. check(
  425. 'D2c-manhole-filter-actually-selects-and-shows-data',
  426. afterManhole.industryActive === '井盖' && (manholeLedger?.cards.length || 0) > 0,
  427. `切到「井盖」后:按钮=${afterManhole.industryActive}、台账 ${manholeLedger ? manholeLedger.cards.length : 0} 张卡片(${manholeLedger ? manholeLedger.cards.map((c) => c.code).join(',') : ''})—— 筛得出东西,不是空画面`,
  428. )
  429. check(
  430. 'D2d-manhole-filter-changes-the-map-points',
  431. afterManhole.markerCodes.join(',') !== markersAfterIndustry,
  432. `井盖筛选后一张图也变了:${markersAfterIndustry || '(空)'} → ${afterManhole.markerCodes.join(',') || '(空)'}`,
  433. )
  434. // 切回「全部」,后面的状态筛选组在未筛的画面上跑。
  435. await page.evaluate(() => {
  436. const button = Array.from(document.querySelectorAll('.cockpit-shell__industry-btn')).find((b) => b.textContent.trim() === '全部')
  437. if (button) button.click()
  438. })
  439. await sleep(1600)
  440. // 状态筛选:面板与台账变,**地图不变**(后端不收 pointStatus)。
  441. const beforeStatus = await readState(page)
  442. const markersBeforeStatus = beforeStatus.markerCodes.join(',')
  443. const distributionBefore = panelOf(beforeStatus, 'monitoringDistribution')
  444. await page.evaluate(() => {
  445. const row = document.querySelector('[data-point-status="ALARM"]')
  446. if (row) row.click()
  447. })
  448. await sleep(1600)
  449. const afterStatus = await readState(page)
  450. const distributionAfter = panelOf(afterStatus, 'monitoringDistribution')
  451. const ledgerAfterStatus = panelOf(afterStatus, 'monitoringPointLedger')
  452. check(
  453. 'D3-status-filter-changes-panel-and-ledger',
  454. distributionAfter.statusRows.some((r) => r.code === 'ALARM' && r.pressed) &&
  455. distributionBefore.kpis !== distributionAfter.kpis,
  456. `状态筛选生效:ALARM 行已选中(aria-pressed=${(distributionAfter.statusRows.find((r) => r.code === 'ALARM') || {}).pressed});生效状态读数为「${(distributionAfter.bars.find((b) => b.label.includes('生效')) || {}).count || '—'}」`,
  457. )
  458. check(
  459. 'D3b-status-filter-does-not-change-the-map',
  460. beforeStatus.markerCodes.join(',') === afterStatus.markerCodes.join(','),
  461. `状态筛选后一张图**没变**:${markersBeforeStatus} → ${afterStatus.markerCodes.join(',')}(后端 map-snapshot 不收 pointStatus,缺口已记票 25)`,
  462. )
  463. check(
  464. 'D3c-status-filter-narrows-the-ledger',
  465. ledgerAfterStatus.cards.length > 0 && ledgerAfterStatus.cards.length <= (panelOf(beforeStatus, 'monitoringPointLedger') || { cards: [] }).cards.length,
  466. `台账按状态收窄:${(panelOf(beforeStatus, 'monitoringPointLedger') || { cards: [] }).cards.length} → ${ledgerAfterStatus.cards.length} 张卡片`,
  467. )
  468. // 再点一次同一状态=退回全部(状态是**可清除**的条件)。
  469. await page.evaluate(() => {
  470. const row = document.querySelector('[data-point-status="ALARM"]')
  471. if (row) row.click()
  472. })
  473. await sleep(1400)
  474. const cleared = await readState(page)
  475. const clearedRow = (panelOf(cleared, 'monitoringDistribution') || { statusRows: [] }).statusRows.find((r) => r.code === 'ALARM')
  476. check(
  477. 'D4-clicking-the-active-status-clears-it',
  478. !!clearedRow && clearedRow.pressed === false,
  479. `再点一次已选中的状态 → 退回全部(aria-pressed=${clearedRow ? clearedRow.pressed : '(行缺失)'})`,
  480. )
  481. // 图例与地图标记仍一致(票 09 的语义不回退)。
  482. const legendConsistent = await page.evaluate(() => {
  483. const rows = Array.from(document.querySelectorAll('[data-legend-key]'))
  484. const markerTotal = window.__cockpitMap && window.__cockpitMap.getPointMarkers ? window.__cockpitMap.getPointMarkers().onMap : -1
  485. const legendTotal = rows.reduce((sum, row) => {
  486. const match = /(\d+)\s*$/.exec(row.textContent.trim())
  487. return sum + (match ? Number(match[1]) : 0)
  488. }, 0)
  489. return { legendTotal, markerTotal }
  490. })
  491. check(
  492. 'D5-legend-still-matches-markers-after-filtering',
  493. legendConsistent.legendTotal === legendConsistent.markerTotal,
  494. `图例合计 ${legendConsistent.legendTotal} = 地图标记 ${legendConsistent.markerTotal}(筛选后仍一致)`,
  495. )
  496. check('D6-filter-run-has-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误')
  497. await page.close()
  498. }
  499. // ================================================================ E 一块坏掉不遮其它两块
  500. {
  501. // E1/E2:三个端点全坏 —— 三块都该说清楚,而不是白屏。
  502. const { page, errors } = await openPage('failed')
  503. const state = await readState(page)
  504. check(
  505. 'E1-all-monitoring-panels-failed',
  506. state.panels.length === 4 && state.panels.every((p) => p.state !== 'success'),
  507. state.panels.map((p) => `${p.id}=${p.state}`).join(' '),
  508. )
  509. check(
  510. 'E2-failed-panels-say-why-not-blank',
  511. state.panels.every((p) => p.placeholder.includes('加载失败') || p.placeholder.includes('不可用')),
  512. `失败措辞:${state.panels.map((p) => p.placeholder).join(' / ')}`,
  513. )
  514. check('E2b-failure-run-has-no-page-errors', errors.length === 0, errors.length ? errors.join(' | ') : '无页面错误')
  515. await page.close()
  516. // E3/E4:只让**监测概览**这一个端点坏,其余两块必须照常画出真实内容。
  517. // 注意 `MOCK_ONLY` 比的是**注册模板**(精确路径),所以这里给准确路径。
  518. const { page: isolatedPage, errors: isolatedErrors } = await openPage('failed', '/monitoring/overview')
  519. const isolated = await readState(isolatedPage)
  520. const bad = isolated.panels.filter((p) => p.state !== 'success' && p.id !== 'monitoringPointLedger')
  521. check(
  522. 'E3-one-panel-failure-does-not-cover-others',
  523. bad.length === 1 && bad[0].id === 'monitoringOverview',
  524. `${bad.map((p) => p.id).join(',') || '无'} 失败;其余面板仍是 success`,
  525. )
  526. check(
  527. 'E4-good-panels-still-render-real-content',
  528. (panelOf(isolated, 'monitoringDistribution')?.statusRows.length || 0) > 0 &&
  529. (panelOf(isolated, 'operationOverview')?.items.length || 0) > 0,
  530. `分布状态行 ${panelOf(isolated, 'monitoringDistribution')?.statusRows.length} 行、运行概况条目 ${panelOf(isolated, 'operationOverview')?.items.length} 条照常画出`,
  531. )
  532. check(
  533. 'E5-isolated-run-has-no-page-errors',
  534. isolatedErrors.length === 0,
  535. isolatedErrors.length ? isolatedErrors.join(' | ') : '无页面错误',
  536. )
  537. await isolatedPage.close()
  538. }
  539. // ================================================================ F 空场景与分母为 0
  540. {
  541. const { page } = await openPage('empty')
  542. const state = await readState(page)
  543. check(
  544. 'F1-empty-scenario-explicit-placeholders',
  545. state.panels.length === 4 && state.panels.every((p) => p.placeholder === '该面板当前没有数据'),
  546. `四块占位说明:${state.panels.map((p) => p.placeholder).join(' / ')}`,
  547. )
  548. check(
  549. 'F2-empty-does-not-fake-zeros',
  550. state.panels.every((p) => !p.kpis.some((k) => k.value === '0')),
  551. '空场景不显示伪造的 0 读数(缺值一律占位符)',
  552. )
  553. await page.close()
  554. // 分母为 0:在线率必须是占位符 + 说明,**不能**是 0.0%。
  555. const { page: noDevicesPage } = await openPage('monitoring-no-devices')
  556. const noDevices = await readState(noDevicesPage)
  557. const rateKpi = ((panelOf(noDevices, 'monitoringOverview') || {}).kpis || []).find((k) => k.label === '在线率')
  558. check(
  559. 'F3-zero-denominator-online-rate-is-not-zero-percent',
  560. !!rateKpi && !rateKpi.value.includes('0.0%') && !rateKpi.value.includes('0%'),
  561. `分母为 0 时在线率读数:「${rateKpi ? rateKpi.value : '(缺 KPI)'}」(**不得**是 0.0%)`,
  562. )
  563. await noDevicesPage.close()
  564. }
  565. await browser.close()
  566. const failed = results.filter((item) => !item.pass)
  567. console.log('')
  568. console.log(`T18 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  569. if (failed.length) {
  570. console.log('未通过:')
  571. for (const item of failed) console.log(` · ${item.id}`)
  572. process.exit(1)
  573. }
  574. console.log('T18 运行监测模式面板走查全部通过。')