t20-overview-panels-walkthrough.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. /**
  2. * T20 真机走查:总体概览模式四块面板按真实端点画出来、三块各自成败、两条联动真的生效(票 20)。
  3. *
  4. * 逻辑脚本(t20-overview-panels.mjs,59/59)已经把契约适配、视图计划、三块独立成败、
  5. * 联动入口与静态守卫测透了;这里只补**只有在真机才成立**的部分:
  6. * 1. 进总体概览模式后,界面**真的**把四块面板画出来(DOM 实证,不是读 store);
  7. * 2. 预警/报警两族统计同屏;工单状态分布是 **0–7 共 8 个码**(不是旧的七态);
  8. * 3. 「故障」缺口与「区域分布来自兄弟端点」两条说明**真的印在界面上**;
  9. * 4. 点行业构成行 → **全局行业筛选真的变了**、运行监测那块数字真的重算;
  10. * 5. 点当前预警/处置率读数 → **一级模式真的切到预警处置**;
  11. * 6. 只坏一块端点 → 坏的那块说失败、**好的两块仍画出真实内容**;
  12. * 7. 两栏可见文本里**零后端代号**(含 `CURRENT_BASELINE`、`LAST_7_DAYS`、`stale` 这类)。
  13. *
  14. * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由维护者按需执行):
  15. * node .scratch/cockpit-shell-panel-layout/verify/t20-overview-panels-walkthrough.mjs
  16. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  17. *
  18. * ── 2026-09-29 执行记录:本脚本**未能以 puppeteer 跑**,改由 DSH 自带浏览器真实驱动 ──
  19. *
  20. * 本机 `@puppeteer/browsers` 是**空目录**(票 01 记录的既有环境问题,非本票引入):
  21. * `Cannot find package '.../@puppeteer/browsers/index.js' imported from .../ChromeLauncher.js`
  22. * 这与票 36 的处境完全相同,故沿用其办法:**用 DSH 自带浏览器真实驱动同一份页面**,
  23. * 逐项跑下面同一批断言(A/B/C/D/E 五组),证据见
  24. * `verify/walkthrough/t20-overview-panels.png`。走查**逮到一个真缺陷**(见下),已修并复跑。
  25. *
  26. * **走查逮到的真缺陷(只有真机才发现,逻辑闸门当时全绿)**:
  27. * `monitoringSummaryPanel` 与 `alertSettingsPanel` 以为 `data` 是 `{monitoring}` / `{warningSettings}`
  28. * 包装对象,而外壳传的是**裸 `section.data`** → 两块面板的读数**全是占位符 `—`**;
  29. * 而 t20 逻辑闸门当时 59/59 全绿,因为**测试喂的正是那个错误的包装形状**(测试与实现犯了同一个错)。
  30. * 已修(两块改为直读 `data`),并补三条护栏:C3/C4(按真实形状断言读数**是数字**)与
  31. * **D8(经 `panelView()` 端到端逐块读数字)**、**F6c(钉住外壳逐块的数据源分派)**。
  32. * 教训:手喂 data 的单元断言证明不了"外壳喂进来的形状是对的"——**必须有一条走外壳真实入口的断言**。
  33. *
  34. * **场景注入的关键**(与票 11/17/18 同一套手法):用 `__COCKPIT_MOCK_PREFIX__='/overview/'`
  35. * 把异常样例**只**施加在概览端点上,地图与其它分区保持 success。
  36. * 注意 `MOCK_ONLY` 是精确路径、`MOCK_PREFIX` 才按前缀选中一族端点。
  37. *
  38. * ⚠️ DSH 浏览器**没有"文档创建前注入全局"的口子**(puppeteer 的 `evaluateOnNewDocument` 对应物),
  39. * 所以场景注入要用「设好 `window.__COCKPIT_MOCK_*__` → `location.hash='#/'` → 再回 `#/cockpit`」
  40. * 触发组件重挂载(`onMounted` 里会重新注册 mock),全局因此得以生效。
  41. *
  42. * ⚠️ 复跑前先重启 dev server:长跑的实例可能仍给浏览器发旧模块
  43. * (懒加载模块尤其明显,票 18 因此把 A15 误判红过一次)。
  44. */
  45. import { join } from 'node:path'
  46. import { tmpdir } from 'node:os'
  47. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  48. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  49. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  50. /** puppeteer-core 的位置(可用 `PUPPETEER_CORE_PATH` 覆盖,理由见 t18 的说明)。 */
  51. const PUPPETEER =
  52. process.env.PUPPETEER_CORE_PATH ||
  53. 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  54. /** 异常样例只施加在概览族端点上(地图与其它分区保持 success)。 */
  55. const OVERVIEW_ONLY = '/overview/'
  56. const results = []
  57. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
  58. function check(id, pass, evidence) {
  59. results.push({ id, pass })
  60. const rest = evidence === undefined ? '' : String(evidence)
  61. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  62. }
  63. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  64. const puppeteer = (await import(PUPPETEER)).default
  65. const browser = await puppeteer.launch({
  66. executablePath: CHROME,
  67. headless: true,
  68. userDataDir: join(tmpdir(), 'cockpit-t20-profile'),
  69. defaultViewport: { width: 1680, height: 950 },
  70. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  71. })
  72. console.log(`Chrome: ${await browser.version()}`)
  73. /**
  74. * 一次读全:四块面板的 DOM(状态标签 / 块 / KPI / 分布 / 趋势 / 联动出口)
  75. * + 顶栏筛选选中态 + 一级模式。
  76. *
  77. * 面板内容是**从 DOM 读**的 —— 走查的价值就在于证明界面真的画出来了,而不是 store 里有值。
  78. */
  79. const readState = (page) =>
  80. page.evaluate(() => {
  81. const panels = Array.from(document.querySelectorAll('[data-panel-blocks]'))
  82. const panelView = (el) => ({
  83. id: el.dataset.panelId || '',
  84. state: (el.querySelector('[data-section-state]') || {}).dataset?.sectionState || '',
  85. stateText: ((el.querySelector('.section-state__label') || {}).textContent || '').trim(),
  86. blocks: Array.from(el.querySelectorAll('[data-panel-block]')).map((b) => b.dataset.panelBlock || ''),
  87. blockTitles: Array.from(el.querySelectorAll('.panel-block__title')).map((t) => (t.textContent || '').trim()),
  88. kpis: Array.from(el.querySelectorAll('[data-kpi]')).map((k) => ({
  89. key: k.dataset.kpi || '',
  90. label: ((k.querySelector('.panel-kpi__label') || {}).textContent || '').trim(),
  91. value: ((k.querySelector('.panel-kpi__value') || {}).textContent || '').trim(),
  92. // 联动出口:带 `data-kpi-link` 的读数才可点
  93. link: (k.querySelector('[data-kpi-link]') || {}).dataset?.kpiLink || '',
  94. })),
  95. bars: Array.from(el.querySelectorAll('[data-bar]')).map((b) => ({
  96. key: b.dataset.bar || '',
  97. label: ((b.querySelector('.panel-bar__label') || {}).textContent || '').trim(),
  98. count: ((b.querySelector('[data-bar-count]') || {}).textContent || '').trim(),
  99. action: b.dataset.barAction || '',
  100. tag: b.tagName.toLowerCase(),
  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. })),
  106. gap: ((el.querySelector('[data-panel-gap]') || {}).textContent || '').trim(),
  107. rangeNote: ((el.querySelector('[data-panel-range-note]') || {}).textContent || '').trim(),
  108. note: ((el.querySelector('[data-panel-page-note]') || {}).textContent || '').trim(),
  109. missing: ((el.querySelector('[data-panel-missing]') || {}).textContent || '').trim(),
  110. text: (el.textContent || '').replace(/\s+/g, ' ').trim(),
  111. })
  112. const activeMode = (document.querySelector('.cockpit-shell__mode.is-active') || {}).textContent || ''
  113. const industryPressed = Array.from(document.querySelectorAll('[data-industry]'))
  114. .filter((b) => b.getAttribute('aria-pressed') === 'true')
  115. .map((b) => b.dataset.industry || '')
  116. return {
  117. mode: activeMode.trim(),
  118. panels: panels.map(panelView),
  119. // 两栏(左右 aside)的可见文本 —— 泄漏守卫的扫描面
  120. columnsText: Array.from(document.querySelectorAll('.cockpit-shell__column'))
  121. .map((c) => (c.textContent || '').replace(/\s+/g, ' '))
  122. .join(' '),
  123. placeholders: document.querySelectorAll('.cockpit-placeholder').length,
  124. industryPressed,
  125. errors: window.__t20Errors || [],
  126. }
  127. })
  128. /** 切到指定一级模式并等面板渲染。 */
  129. async function switchMode(page, label) {
  130. await page.evaluate((text) => {
  131. const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === text)
  132. if (button) button.click()
  133. }, label)
  134. await sleep(900)
  135. }
  136. /** 打开页面并进入系统(引导页开着时要先点进去)。 */
  137. async function openCockpit(globals = {}) {
  138. const page = await browser.newPage()
  139. page.on('pageerror', (error) => {
  140. page.evaluate((message) => {
  141. window.__t20Errors = window.__t20Errors || []
  142. window.__t20Errors.push(message)
  143. }, String(error && error.message ? error.message : error)).catch(() => {})
  144. })
  145. await page.evaluateOnNewDocument((injected) => {
  146. Object.assign(window, injected)
  147. }, globals)
  148. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  149. await sleep(1200)
  150. // 引导层(票 30)默认开着:点「进入系统」才到工作画面。
  151. await page.evaluate(() => {
  152. const intro = document.querySelector('.cockpit-intro, [data-cockpit-intro]')
  153. if (intro) {
  154. const clickable = intro.querySelector('button') || intro
  155. clickable.click()
  156. }
  157. })
  158. await sleep(1500)
  159. return page
  160. }
  161. // ================================================================ A 正常场景
  162. {
  163. const page = await openCockpit()
  164. await switchMode(page, '总体概览')
  165. const state = await readState(page)
  166. const byId = Object.fromEntries(state.panels.map((p) => [p.id, p]))
  167. check(
  168. 'A1-four-panels-are-rendered-as-real-content',
  169. state.panels.length === 4 && state.placeholders === 0,
  170. `四块面板 + ${state.placeholders} 块占位(panelId:${state.panels.map((p) => p.id).join(', ')})`,
  171. )
  172. check(
  173. 'A2-all-four-panels-report-ready',
  174. ['situationSummary', 'infrastructureSummary', 'monitoringSummary', 'alertSettings'].every(
  175. (id) => byId[id] && byId[id].state === 'success',
  176. ),
  177. `四块状态:${state.panels.map((p) => `${p.id}=${p.state}`).join(' / ')}`,
  178. )
  179. const situation = byId.situationSummary || { kpis: [], blockTitles: [], trends: [] }
  180. check(
  181. 'A3-situation-kpis-drawn-from-dom',
  182. situation.kpis.length >= 8 &&
  183. situation.kpis.some((k) => k.key === 'facilityCount') &&
  184. situation.kpis.some((k) => k.key === 'onlineRate') &&
  185. situation.kpis.some((k) => k.key === 'handlingRate'),
  186. `态势总览 KPI:${situation.kpis.map((k) => `${k.label}=${k.value}`).join(' · ')}`,
  187. )
  188. check(
  189. 'A4-situation-has-two-trend-series',
  190. situation.trends.length > 0 &&
  191. situation.blockTitles.filter((t) => t.includes('趋势')).length === 2 &&
  192. situation.blockTitles.some((t) => t.includes('近 7 日')),
  193. `两条趋势:${situation.blockTitles.filter((t) => t.includes('趋势')).join(' / ')}(趋势点 ${situation.trends.length} 个)`,
  194. )
  195. const infra = byId.infrastructureSummary || { blockTitles: [], bars: [] }
  196. check(
  197. 'A5-infrastructure-shows-industry-and-region',
  198. infra.blockTitles.some((t) => t.includes('行业构成')) && infra.blockTitles.some((t) => t.includes('区域分布')),
  199. `基础设施块:${infra.blockTitles.join(' / ')}`,
  200. )
  201. check(
  202. 'A5b-industry-rows-are-clickable-buttons',
  203. infra.bars.some((b) => b.action === 'industry' && b.tag === 'button'),
  204. `行业行是可点按钮(${infra.bars.filter((b) => b.action === 'industry').length} 行带 action)`,
  205. )
  206. const monitoring = byId.monitoringSummary || { blockTitles: [], gap: '', kpis: [] }
  207. check(
  208. 'A6-monitoring-shows-online-rate-and-four-buckets',
  209. ['onlineRate', 'online', 'offline', 'stale', 'unknownOrMissing'].every((key) => monitoring.kpis.some((k) => k.key === key)),
  210. `运行监测 KPI:${monitoring.kpis.map((k) => `${k.label}=${k.value}`).join(' · ')}`,
  211. )
  212. check(
  213. 'A7-fault-gap-is-printed-on-screen',
  214. monitoring.gap.includes('故障') && monitoring.gap.includes('没有独立'),
  215. `「故障」缺口说明真的印在界面上:${monitoring.gap.slice(0, 60)}…`,
  216. )
  217. check(
  218. 'A7b-metadata-freshness-row-present',
  219. monitoring.blockTitles.some((t) => t.includes('最近采集时间')),
  220. `逐设备最近采集时间有值时才画那一行:${monitoring.blockTitles.join(' / ')}`,
  221. )
  222. const alert = byId.alertSettings || { blockTitles: [], bars: [] }
  223. const stateBars = alert.bars.filter((b) => /待派单|待接单|已接单|处理中|待验收|已结案|延期中|已取消/.test(b.label))
  224. check(
  225. 'A8-alert-settings-shows-both-families-and-eight-codes',
  226. alert.blockTitles.some((t) => t.includes('预警')) &&
  227. alert.blockTitles.some((t) => t.includes('报警')) &&
  228. // 工单状态分布必须是 0–7 共 8 个码(不是旧的七态)
  229. ['待派单', '待接单', '已接单', '处理中', '待验收', '已结案', '延期中', '已取消'].every((name) =>
  230. stateBars.some((b) => b.label.includes(name)),
  231. ),
  232. `预警/报警两族 + 0–7 共 8 个状态码:${stateBars.map((b) => `${b.label}${b.count}`).join(' ')}`,
  233. )
  234. check(
  235. 'A8b-delay-state-is-labelled-as-bypass-not-hidden',
  236. stateBars.some((b) => b.label.includes('延期中')),
  237. '「延期中」是正式状态并画出来了(旧口径「不展示延期」已按后端枚举作废)',
  238. )
  239. // 接口词汇不许漏到大屏上(票 05/11/17 各逮到过一次真泄漏)。
  240. // 守卫面要覆盖**两类**词:状态/原因码与**枚举值**(含本票新增的窗口类型)。
  241. const leaks = [
  242. 'READY', 'EMPTY', 'STALE', 'SUCCESS', 'FAILED', 'UNAVAILABLE', 'CURRENT', 'NO_SNAPSHOT',
  243. 'GAS', 'WATER_SUPPLY', 'DRAINAGE', 'MANHOLE',
  244. 'CURRENT_BASELINE', 'LAST_7_DAYS', 'LAST_24_HOURS', 'LAST_30_DAYS',
  245. 'large', 'medium', 'small', 'NO_RESOLVED_DEVICES', 'AVAILABLE',
  246. ].filter((token) => new RegExp(`\\b${token}\\b`).test(state.columnsText))
  247. check('A9-no-interface-vocabulary-on-screen', leaks.length === 0, leaks.length ? `泄漏:${leaks.join(', ')}` : '两栏可见文本零后端代号')
  248. check('A10-no-page-errors', state.errors.length === 0, state.errors.length ? state.errors.join(' | ') : '走查期间无页面级报错')
  249. // ---- B 联动:行业行 → 全局筛选 ----
  250. const before = await readState(page)
  251. const gasRow = await page.evaluate(() => {
  252. const panel = document.querySelector('[data-panel-id="infrastructureSummary"]')
  253. const row = panel && panel.querySelector('[data-bar-action="industry"][data-bar="GAS"]')
  254. if (!row) return false
  255. row.click()
  256. return true
  257. })
  258. await sleep(1500)
  259. const afterIndustry = await readState(page)
  260. check(
  261. 'B1-industry-row-writes-the-global-filter',
  262. gasRow && afterIndustry.industryPressed.includes('gas'),
  263. `点燃气行 → 全局行业筛选变成 [${afterIndustry.industryPressed.join(', ')}](点到了:${gasRow})`,
  264. )
  265. const beforeDevices = (before.panels.find((p) => p.id === 'monitoringSummary') || { kpis: [] }).kpis.find((k) => k.key === 'deviceCount')
  266. const afterDevices = (afterIndustry.panels.find((p) => p.id === 'monitoringSummary') || { kpis: [] }).kpis.find((k) => k.key === 'deviceCount')
  267. check(
  268. 'B1b-monitoring-numbers-really-recalculated',
  269. !!beforeDevices && !!afterDevices && beforeDevices.value !== afterDevices.value,
  270. `运行监测「智能设备」真的重算:${beforeDevices && beforeDevices.value} → ${afterDevices && afterDevices.value}`,
  271. )
  272. check(
  273. 'B1c-unfilterable-panels-keep-their-numbers',
  274. (before.panels.find((p) => p.id === 'infrastructureSummary') || { kpis: [] }).kpis.find((k) => k.key === 'facilityCount').value ===
  275. (afterIndustry.panels.find((p) => p.id === 'infrastructureSummary') || { kpis: [] }).kpis.find((k) => k.key === 'facilityCount').value,
  276. '基础设施那块不收行业入参,故读数不变(不假装生效)',
  277. )
  278. // ---- C 联动:指标读数 → 切到预警处置模式 ----
  279. const clicked = await page.evaluate(() => {
  280. const link = document.querySelector('[data-kpi-link="WARNING"]')
  281. if (!link) return false
  282. link.click()
  283. return true
  284. })
  285. await sleep(1200)
  286. const afterLink = await readState(page)
  287. check(
  288. 'C1-metric-link-switches-to-warning-response',
  289. clicked && afterLink.mode === '预警处置',
  290. `点「当前预警」读数 → 一级模式 = ${afterLink.mode}`,
  291. )
  292. await page.close()
  293. }
  294. // ================================================================ D 一块坏不遮其它
  295. {
  296. const page = await openCockpit({
  297. __COCKPIT_MOCK_SCENARIO__: 'failed',
  298. __COCKPIT_MOCK_PREFIX__: '/overview/monitoring',
  299. })
  300. await switchMode(page, '总体概览')
  301. const state = await readState(page)
  302. const byId = Object.fromEntries(state.panels.map((p) => [p.id, p]))
  303. check(
  304. 'D1-one-broken-endpoint-does-not-cover-the-others',
  305. byId.monitoringSummary.state !== 'success' &&
  306. byId.infrastructureSummary.state === 'success' &&
  307. byId.alertSettings.state === 'success',
  308. `只坏运行监测端点:监测 ${byId.monitoringSummary.state} / 基础设施 ${byId.infrastructureSummary.state} / 预警设置 ${byId.alertSettings.state}`,
  309. )
  310. check(
  311. 'D1b-broken-panel-says-so-in-words',
  312. /失败|不可用/.test(byId.monitoringSummary.text),
  313. `坏的那块措辞:「${byId.monitoringSummary.stateText || byId.monitoringSummary.gap || byId.monitoringSummary.text.slice(0, 40)}」`,
  314. )
  315. check(
  316. 'D1c-healthy-panels-still-draw-real-content',
  317. (byId.infrastructureSummary.bars || []).length > 0 && (byId.alertSettings.bars || []).length > 0,
  318. `好块照常画出内容:基础设施 ${byId.infrastructureSummary.bars.length} 行分布 · 预警设置 ${byId.alertSettings.bars.length} 行分布`,
  319. )
  320. await page.close()
  321. }
  322. // ================================================================ E 空场景与分母为 0
  323. {
  324. const page = await openCockpit({ __COCKPIT_MOCK_SCENARIO__: 'empty', __COCKPIT_MOCK_PREFIX__: '/overview/' })
  325. await switchMode(page, '总体概览')
  326. const state = await readState(page)
  327. const byId = Object.fromEntries(state.panels.map((p) => [p.id, p]))
  328. check(
  329. 'E1-empty-is-not-failure',
  330. ['monitoringSummary', 'alertSettings'].every((id) => byId[id] && byId[id].state === 'empty'),
  331. `空场景状态:${state.panels.map((p) => `${p.id}=${p.state}`).join(' / ')}`,
  332. )
  333. // 分母为 0 的在线率必须是占位符 + 说明,**不能**是 0.0%(票 18 的 M3/M14 同一族)。
  334. const rate = (byId.monitoringSummary || { kpis: [] }).kpis.find((k) => k.key === 'onlineRate')
  335. check(
  336. 'E2-zero-denominator-is-a-placeholder-not-zero-percent',
  337. !!rate && rate.value === '—' && !rate.value.includes('0.0%'),
  338. `空场景在线率显示「${rate && rate.value}」(不是 0.0%)`,
  339. )
  340. check(
  341. 'E3-no-fake-zero-readings',
  342. !(byId.alertSettings || { text: '' }).text.includes('处置率 0.0%'),
  343. '空场景不显示伪造的 0 读数(缺值一律占位符)',
  344. )
  345. await page.close()
  346. }
  347. await browser.close()
  348. const failed = results.filter((r) => !r.pass)
  349. console.log('')
  350. console.log(`T20 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  351. if (failed.length) {
  352. console.log('未通过:')
  353. for (const item of failed) console.log(` · ${item.id}`)
  354. process.exitCode = 1
  355. }