t44-device-assets-walkthrough.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. /**
  2. * T44 真机走查:设备位置符号与可替换资产接缝(票 44 验收第 8 条的**外壳走查**那一半)。
  3. *
  4. * 逻辑闸门(`t44-device-assets.mjs`,44/44 + 变异 21/21)已把资产接缝、降级分类、
  5. * 尺寸层级、独立状态词表与长文本边界测透了,并且有 G 段在 Node 里用 pinia 驱动
  6. * `deviceLayerStore.syncForSelection()` 这条真实数据路径。这里只补**只有在真机才成立**的部分:
  7. * 1. 走真实用户路径选中监测点 → 设备清单**真的**渲染出来(不是 store 里的行,是 DOM);
  8. * 2. 清单每行的放置档位/资产模式/资产原因与引擎读数**同一份真相**(接缝只有一处);
  9. * 3. 尺寸层级在**引擎实体**上成立(监测点标记 > 设备符号),且来自接缝而非写死;
  10. * 4. 设备名称默认截断:`min-width:0 + nowrap + ellipsis` 在**计算样式**里生效,
  11. * 且长名字下清单**不横向溢出**(`scrollWidth <= clientWidth`);
  12. * 5. 降级可见性:`renderable=false` 的设备**不在引擎实体里**,但**在清单里**且带原因文案;
  13. * 6. 全程无页面级报错。
  14. *
  15. * 运行(需先起 `yarn dev`;`AGENTS.md` 要求不自动跑构建/调试命令,故由维护者按需执行):
  16. * node .scratch/cockpit-shell-panel-layout/verify/t44-device-assets-walkthrough.mjs
  17. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  18. *
  19. * 两个沿用票 41/42/43 的真机坑:
  20. * · 走查标签里 `document.hidden === true`(rAF 冻结、Cesium 不渲染),所以**不点屏幕坐标**、
  21. * 不依赖逐帧画面,只读 DOM 与 `__cockpitMap` 的实体读数;
  22. * · 「真实鼠标点三维设备」这类画面观感不在本脚本承诺内(票 42/43 已记录同一环境事实)。
  23. */
  24. import { cdpLaunch } from './cdp-driver.mjs'
  25. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  26. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  27. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  28. /** 与 mock 的 `device-layer` 场景一致(受控有效坐标)。 */
  29. const SCENARIO = 'device-layer'
  30. const MP_CODE = 'MP-001'
  31. const DEVICE_CODES = ['EQ-001', 'EQ-002']
  32. const results = []
  33. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
  34. function check(id, pass, evidence) {
  35. results.push({ id, pass })
  36. const rest = evidence === undefined ? '' : String(evidence)
  37. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  38. }
  39. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  40. // 本机 `puppeteer-core` 已从 profile 目录消失(`t28-fill-container.mjs` 因此直接 ERR_MODULE_NOT_FOUND),
  41. // 所以这里走**零依赖 CDP 驱动**(`cdp-driver.mjs`,Node 24 自带 WebSocket/fetch)。
  42. const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 })
  43. console.log(`Chrome: ${await browser.version()}`)
  44. console.log(`页面可见性 document.hidden=${await (async () => {
  45. const p = await browser.newPage()
  46. const v = await p.evaluate(() => document.hidden)
  47. await p.close()
  48. return v
  49. })()}(true 时 rAF 冻结,本脚本因此不依赖逐帧画面)`)
  50. /** 一次读全:设备清单 DOM + 计算样式 + 引擎实体读数。 */
  51. const readState = (page) =>
  52. page.evaluate(() => {
  53. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  54. const dev = window.__cockpitMap
  55. const rows = [...document.querySelectorAll('[data-device-layer-list] [data-device-layer-row]')]
  56. const list = document.querySelector('[data-device-layer-list]')
  57. const nameEl = document.querySelector('.point-detail__device-name')
  58. const legend = document.querySelector('[data-device-layer-legend]')
  59. const css = nameEl ? window.getComputedStyle(nameEl) : null
  60. const assetEl = (row) => row.querySelector('[data-device-asset-mode]')
  61. return {
  62. engine: (host && host.dataset.mapEngine) || '',
  63. // 引擎侧读数:`plan` 是接缝过完的计划行,`entities` 是**真画上去**的实体。
  64. associated: dev && dev.getAssociatedDevices ? dev.getAssociatedDevices() : null,
  65. detailLevel: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '',
  66. pointCode: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.pointCode || '',
  67. total: ((document.querySelector('[data-device-layer-total]') || {}).textContent || '').trim(),
  68. drawn: ((document.querySelector('[data-device-layer-drawn]') || {}).textContent || '').trim(),
  69. legendRows: legend
  70. ? [...legend.querySelectorAll('[data-device-legend-key]')].map((n) => ({
  71. key: n.dataset.deviceLegendKey,
  72. count: Number(n.dataset.deviceLegendCount || 0),
  73. }))
  74. : [],
  75. // 清单行的可断言面(票 44 新增:资产模式/原因从行上读,不从画面猜)。
  76. rows: rows.map((row) => {
  77. const asset = assetEl(row)
  78. return {
  79. placement: row.dataset.deviceLayerRow || '',
  80. code: row.dataset.deviceLayerCode || '',
  81. pointCode: row.dataset.deviceLayerPoint || '',
  82. name: ((row.querySelector('.point-detail__device-name') || {}).textContent || '').trim(),
  83. placementText: ((row.querySelector('.point-detail__device-placement') || {}).textContent || '').trim(),
  84. assetMode: asset ? asset.dataset.deviceAssetMode || '' : '',
  85. assetReason: asset ? asset.dataset.deviceAssetReason || '' : '',
  86. assetText: asset ? (asset.textContent || '').trim() : '',
  87. }
  88. }),
  89. // 截断边界读**计算样式**(写死的 CSS 可能被更高优先级覆盖,源码文本不算证据)。
  90. nameCss: css
  91. ? {
  92. minWidth: css.minWidth,
  93. overflow: css.overflow,
  94. whiteSpace: css.whiteSpace,
  95. textOverflow: css.textOverflow,
  96. width: nameEl.getBoundingClientRect().width,
  97. scrollWidth: nameEl.scrollWidth,
  98. }
  99. : null,
  100. listOverflow: list ? { scrollWidth: list.scrollWidth, clientWidth: list.clientWidth } : null,
  101. }
  102. })
  103. /** 打开页面并走真实进入引导;`scenario` 为 null 时**不注入**(走 mock 默认样例 = 现网正式边界)。 */
  104. async function openPage(scenario = SCENARIO) {
  105. const page = await browser.newPage()
  106. const errors = []
  107. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  108. page.on('console', (m) => {
  109. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  110. })
  111. if (scenario) {
  112. await page.evaluateOnNewDocument((name) => {
  113. window.__COCKPIT_MOCK_SCENARIO__ = name
  114. }, scenario)
  115. }
  116. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  117. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  118. await page
  119. .waitForFunction(
  120. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  121. { timeout: 40000 },
  122. )
  123. .catch(() => {})
  124. if (await page.$('[data-cockpit-intro]')) {
  125. await page.click('[data-cockpit-intro]')
  126. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  127. }
  128. await sleep(1800)
  129. return { page, errors }
  130. }
  131. /** 点「加载下一页」直到翻完(标记要等分页到位才出现)。 */
  132. async function advanceUntilSettled(page, maxSteps = 12) {
  133. for (let step = 0; step < maxSteps; step += 1) {
  134. const phase = await page.evaluate(() => {
  135. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  136. return (host && host.dataset.mapSummary) || ''
  137. })
  138. if (['complete', 'empty', 'failed', 'expired'].includes(phase)) return
  139. const clicked = await page.evaluate(() => {
  140. const button = document.querySelector('[data-map-summary-action]')
  141. if (!button || button.disabled) return false
  142. button.click()
  143. return true
  144. })
  145. if (!clicked) return
  146. await sleep(400)
  147. }
  148. }
  149. const { page, errors } = await openPage()
  150. await advanceUntilSettled(page)
  151. // ── 走**真实用户路径**选中监测点:外壳递进来的统一入口(票 41),不是另造一条定位路径。
  152. await page.evaluate((code) => {
  153. const dev = window.__cockpitMap
  154. if (dev && dev.selectMonitoringPoint) dev.selectMonitoringPoint(code)
  155. }, MP_CODE)
  156. await page.waitForSelector('[data-device-layer-list] [data-device-layer-row]', { timeout: 20000 }).catch(() => {})
  157. await sleep(2500)
  158. const state = await readState(page)
  159. const plan = (state.associated && state.associated.plan) || []
  160. const entities = (state.associated && state.associated.entities) || []
  161. // 引擎实体**现在也回接缝结论**(`shape`/`size`/`assetMode`/`assetReason`,见 `readAssociatedDevices`),
  162. // 所以"地图上那台设备画成了什么"可以直接读实体 —— 比读计划行强一档:计划行只是"打算画什么"。
  163. const drawnCodes = entities.map((e) => e && e.deviceCode).filter(Boolean)
  164. const entityByCode = new Map(entities.map((e) => [e.deviceCode, e]))
  165. const planByCode = new Map(plan.map((r) => [r.code, r]))
  166. // 1) 清单真的渲染出来了(DOM 层证据,不只是 store 里的行)。
  167. check('W1-device-list-rendered',
  168. state.rows.length === DEVICE_CODES.length && state.rows.every((r) => DEVICE_CODES.includes(r.code)),
  169. `清单 ${state.rows.length} 行:${state.rows.map((r) => `${r.code}(${r.placement})`).join(' ')};总数读数「${state.total}」、已上图「${state.drawn}」`)
  170. // 2) 接缝只有一处真相:**清单 DOM 上的**资产模式/原因与**地图实体上的**逐台相同。
  171. // 不一致就说明"地图画的是A、面板说的是B"——正是可替换接缝要防的那种分裂。
  172. // 已上图的那台以**实体**为准("画成了什么");没上图的那台退回计划行(它本来就没实体)。
  173. // 判据要求 DOM 侧非空:否则"两边都是空串"也会算相等,那是恒真。
  174. const seamMismatch = state.rows.filter((r) => {
  175. const drawn = entityByCode.get(r.code)
  176. const p = drawn || planByCode.get(r.code)
  177. if (!p || !r.assetMode || !r.assetReason) return true
  178. return p.assetMode !== r.assetMode || p.assetReason !== r.assetReason
  179. })
  180. check('W2-panel-and-map-same-seam',
  181. seamMismatch.length === 0 && plan.length === DEVICE_CODES.length,
  182. `清单 ${state.rows.length} 行与${entities.length ? '地图实体' : '引擎计划'}同源:${state.rows.map((r) => {
  183. const p = entityByCode.get(r.code) || planByCode.get(r.code) || {}
  184. return `${r.code}:${r.assetMode}/${r.assetReason}==${p.assetMode}/${p.assetReason}`
  185. }).join(' ')}`)
  186. // 3) 资产降级**在详情里可见**(验收第 5 条:详情标明视觉资源异常)。
  187. // 本期正式图片素材未提供 ⇒ 每台都该有一行原因文案,且模式的取值集合里根本没有 model。
  188. const allHaveReason = state.rows.every((r) => r.assetText.length > 0)
  189. check('W3-degradation-visible-in-detail',
  190. allHaveReason && state.rows.every((r) => (r.assetMode || '') !== 'model'),
  191. `每行都带资产原因文案:${state.rows.map((r) => `${r.code}「${r.assetText}」`).join(' ')}`)
  192. // 4) 尺寸层级在**真机读数**上成立:监测点符号 > 设备符号(规格验收第 2 条)。
  193. // 两边的像素尺寸都从**引擎里的 billboard 图片**取:`markerSvg` 把 width/height 写进
  194. // SVG data URL,所以这里读的是"真画上去的那张图有多大",不是两份 JS 常量互相比。
  195. const pointWidths = await page.evaluate(() => {
  196. const dev = window.__cockpitMap
  197. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  198. const images = (markers && markers.markerImages) || {}
  199. const widthOf = (dataUrl) => {
  200. const m = /width%3D%22(\d+(?:\.\d+)?)%22/.exec(dataUrl || '') || /width="(\d+(?:\.\d+)?)"/.exec(decodeURIComponent(dataUrl || ''))
  201. return m ? Number(m[1]) : null
  202. }
  203. return Object.keys(images).map((key) => ({ key, width: widthOf(images[key].image) }))
  204. })
  205. const pointWidth = (pointWidths.find((p) => typeof p.width === 'number') || {}).width
  206. // 设备侧尺寸读**引擎实体的 billboard 真实宽高**(不是计划值),与监测点标记图片宽同一个量纲。
  207. const entitySizes = entities.map((e) => e && e.size).filter((s) => typeof s === 'number')
  208. check('W4-size-hierarchy-on-engine',
  209. typeof pointWidth === 'number' && entitySizes.length > 0 && entitySizes.every((s) => s < pointWidth),
  210. `监测点标记图片宽 ${pointWidth}px(引擎 billboard);设备实体真实尺寸 ${entitySizes.join('/')}(全部更小)`)
  211. // 5) 长文本默认截断:计算样式真的生效,且清单不横向溢出(验收第 3 条)。
  212. check('W5-label-truncates-not-overflows',
  213. !!state.nameCss &&
  214. state.nameCss.minWidth === '0px' &&
  215. ['hidden', 'clip'].includes(state.nameCss.overflow) &&
  216. state.nameCss.whiteSpace === 'nowrap' &&
  217. state.nameCss.textOverflow === 'ellipsis' &&
  218. !!state.listOverflow &&
  219. state.listOverflow.scrollWidth <= state.listOverflow.clientWidth + 1,
  220. `计算样式 min-width=${state.nameCss && state.nameCss.minWidth} overflow=${state.nameCss && state.nameCss.overflow} white-space=${state.nameCss && state.nameCss.whiteSpace} text-overflow=${state.nameCss && state.nameCss.textOverflow};清单 scrollWidth=${state.listOverflow && state.listOverflow.scrollWidth} <= clientWidth=${state.listOverflow && state.listOverflow.clientWidth}`)
  221. // 6) 降级可见性(验收第 4/5 条)**不在这里判**:本趟两台设备坐标都有效,走不到降级分支,
  222. // 在这里写一条 `blockedRows.length === 0 ? true : …` 就是恒真摆设。真正的那趟在下面用
  223. // **默认样例**(现网边界 `renderable=false`)跑,见 W6/W9/W10。
  224. // 7) 关联设备图例独立于监测点图例(验收第 7 条后半)。
  225. // **注意图例是哪一个**:`[data-device-layer-legend]` 渲染的是**放置档位**图例
  226. // (`deviceLayer.associatedLegend`,drawn/no-location/… 7 类),不是设备**状态**词表的 6 行。
  227. // 所以判据是"两张图例的词表不同",而不是"设备图例有 6 行"(本脚本初版就把这个写错了,
  228. // 真机跑出来 7 行才暴露)。点位图例的词表是状态键(ALARM/WARNING/…),两者不该有交集。
  229. const pointLegendKeys = Object.keys((await page.evaluate(() => {
  230. const dev = window.__cockpitMap
  231. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  232. return (markers && markers.byStatus) || {}
  233. })) || {})
  234. const placementKeys = state.legendRows.map((r) => r.key)
  235. const shared = placementKeys.filter((k) => pointLegendKeys.includes(k))
  236. check('W7-device-legend-independent',
  237. placementKeys.length === 7 && state.legendRows.some((r) => r.key === 'terrain-pending') && shared.length === 0,
  238. `设备图层图例 ${placementKeys.length} 行(放置档位):${placementKeys.join('/')};点位图例 ${pointLegendKeys.length} 行:${pointLegendKeys.join('/') || '无'};交集 ${shared.length}`)
  239. // 8) 全程无页面级报错。
  240. check('W8-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
  241. await page.close()
  242. // ── 第二趟:**默认样例**(照抄现网正式边界:设备 `renderable=false`)──
  243. // 第一趟两台设备坐标都有效,走不到降级分支;所以"降级可见"这条断言必须另跑一趟,
  244. // 否则它只是个恒真的摆设(本脚本初版就是这样,W6 靠 `blockedRows.length === 0 ? true` 蒙混过关)。
  245. {
  246. const pass = await openPage(null)
  247. await advanceUntilSettled(pass.page)
  248. await pass.page.evaluate((code) => {
  249. const dev = window.__cockpitMap
  250. if (dev && dev.selectMonitoringPoint) dev.selectMonitoringPoint(code)
  251. }, MP_CODE)
  252. await pass.page.waitForSelector('[data-device-layer-list] [data-device-layer-row]', { timeout: 20000 }).catch(() => {})
  253. await sleep(2200)
  254. const state2 = await readState(pass.page)
  255. const entities2 = (state2.associated && state2.associated.entities) || []
  256. const drawn2 = entities2.map((e) => e.deviceCode).filter(Boolean)
  257. const degraded = state2.rows.filter((r) => r.placement !== 'drawn')
  258. // 「降级」不止坐标这一类:`inconsistent`(与当前快照不一致)也属降级,且文案各不相同。
  259. // 所以判据分两层:**每一行都要有原因文案**;只有坐标类的两档才要求文案点到"坐标/位置"。
  260. const coordinateDegraded = degraded.filter((r) => r.placement === 'no-location' || r.placement === 'terrain-pending')
  261. check('W6-degraded-listed-but-not-drawn',
  262. degraded.length > 0 &&
  263. degraded.every((r) => !drawn2.includes(r.code) && r.placementText.length > 0) &&
  264. coordinateDegraded.every((r) => /坐标|位置/.test(r.placementText)),
  265. `默认样例(现网边界)清单 ${state2.rows.length} 行,其中降级 ${degraded.length} 行:${
  266. degraded.map((r) => `${r.code}(${r.placement})「${r.placementText}」`).join(' ')
  267. };引擎实体 ${drawn2.join('/') || '无'}`)
  268. // 现网边界:后端目前无可信校核源,真实关联设备**统一不上图** —— 这条要如实钉住,
  269. // 免得哪天"能上图了"却没人发现(票 42 验收第 2 条原句:样例不得冒称正式已能上图)。
  270. check('W9-production-boundary-draws-nothing',
  271. drawn2.length === 0,
  272. `默认样例下引擎实体数 ${drawn2.length}(现网无可信校核源 ⇒ 设备不上图,只在清单里说明原因)`)
  273. // 降级原因也要**在详情里可见**(第 5 条),默认样例同样要满足。
  274. check('W10-degraded-reason-visible-in-detail',
  275. state2.rows.every((r) => r.assetText.length > 0),
  276. state2.rows.map((r) => `${r.code}「${r.assetText}」`).join(' '))
  277. check('W11-default-scenario-no-page-errors',
  278. pass.errors.length === 0,
  279. pass.errors.length ? pass.errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
  280. await pass.page.close()
  281. }
  282. await browser.close()
  283. const failed = results.filter((r) => !r.pass)
  284. console.log('')
  285. console.log(`T44 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  286. if (failed.length) {
  287. console.log('未通过:')
  288. for (const r of failed) console.log(` · ${r.id}`)
  289. process.exit(1)
  290. }
  291. console.log('T44 真机走查全部通过。')