t09-point-status-walkthrough.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. /**
  2. * T09 真机走查:点位状态标记与图例在浏览器里确实成立(票 09)。
  3. *
  4. * 逻辑脚本(t09-point-status.mjs)已经把状态词表、标记计划与「图例=标记」判据测透了;
  5. * 这里只补**只有在真机才成立**的部分:
  6. * 1. 标记真的作为 Cesium billboard 画在地图上(引擎实体存在、有图片、形状不同);
  7. * 2. 每种状态的颜色确实不同 —— 颜色是服务层从 token 的**计算样式**读出来再烘焙进 SVG 的,
  8. * 纯逻辑断言证明不了 CSS 变量真的取到了值(取不到会全体回退兜底色,颜色就都一样了);
  9. * 3. 地图图例与地图上的标记逐行一致(同一个 DOM 与同一批实体对照,不是两份 JS 数据对比);
  10. * 4. 图例不只靠颜色:每行有形状(真 SVG 片段)+ 文字。
  11. *
  12. * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/;改了懒加载模块先重启服务):
  13. * node .scratch/cockpit-shell-panel-layout/verify/t09-point-status-walkthrough.mjs
  14. *
  15. * 演示开关(宿主全局键注入,不改代码重新构建):
  16. * `window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'` —— 后端六态 + 未关联设备 + 一个前端不认识的状态
  17. * 人工演示同一件事:控制台里 `__cockpitMap.getPointMarkers()` 看计划与图例、
  18. * `__cockpitMap.mapSummary.advance()` 一页页推进。
  19. */
  20. import { join } from 'node:path'
  21. import { tmpdir } from 'node:os'
  22. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  23. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  24. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  25. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  26. const results = []
  27. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
  28. function check(id, pass, evidence) {
  29. results.push({ id, pass })
  30. const rest = evidence === undefined ? '' : String(evidence)
  31. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  32. }
  33. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  34. const puppeteer = (await import(PUPPETEER)).default
  35. const browser = await puppeteer.launch({
  36. executablePath: CHROME,
  37. headless: true,
  38. userDataDir: join(tmpdir(), 'cockpit-t09-profile'),
  39. defaultViewport: { width: 1680, height: 950 },
  40. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  41. })
  42. console.log(`Chrome: ${await browser.version()}`)
  43. /**
  44. * 一次读全:宿主 dataset(走查口径)+ 地图上的真实实体(`__cockpitMap.getPointMarkers()`)
  45. * + 图例 DOM(形状/色键/文字)。
  46. *
  47. * 关键:**实体这一侧读的是 `readPointMarkers(viewer)` 从 Cesium 实体上取的值**,
  48. * 图例那一侧读的是真实 DOM。两者对照才叫「图例与标记一致」;
  49. * 若两边都读同一个 JS 对象,那条断言就恒真(自己跟自己比)。
  50. */
  51. const readMarkers = (page) =>
  52. page.evaluate(() => {
  53. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  54. const legendRows = Array.from(document.querySelectorAll('[data-map-legend] .status-tag'))
  55. const dev = window.__cockpitMap && window.__cockpitMap.getPointMarkers ? window.__cockpitMap.getPointMarkers() : null
  56. // 语义色的实际取法(与 mapService.toneColors 同一条路):从外壳的计算样式读。
  57. const shell = document.querySelector('.cockpit-shell')
  58. const styles = shell ? getComputedStyle(shell) : null
  59. const tone = (name) => (styles ? styles.getPropertyValue(`--${name}`).trim() : '')
  60. return {
  61. engine: (host && host.dataset.mapEngine) || '',
  62. phase: (host && host.dataset.mapSummary) || '',
  63. loaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
  64. // dataset 口径(服务层写的)
  65. dsMarkers: Number((host && host.dataset.mapMarkers) || 0),
  66. dsPlan: Number((host && host.dataset.mapMarkersPlan) || 0),
  67. dsStatus: (host && host.dataset.mapMarkersStatus) || '',
  68. dsShapes: (host && host.dataset.mapMarkerShapes) || '',
  69. // 真实实体口径(引擎里真有什么)
  70. entities: dev ? dev.onMap : -1,
  71. entityByStatus: dev ? dev.entityByStatus : {},
  72. entityShapes: dev ? dev.shapes : [],
  73. // 计划口径(图例的计数来自这里,与实体数必须相等)
  74. planLegend: dev ? dev.legend.filter((row) => row.count > 0).map((row) => `${row.key}:${row.count}`) : [],
  75. // 图例 DOM
  76. legend: legendRows.map((el) => ({
  77. key: el.dataset.legendKey || '',
  78. tone: el.dataset.tone || '',
  79. shape: el.dataset.shape || '',
  80. label: (el.querySelector('.status-tag__label') || {}).textContent?.trim() || '',
  81. hasShapeSvg: !!el.querySelector('.status-tag__shape'),
  82. shapeMarkup: (el.querySelector('.status-tag__shape') || {}).innerHTML || '',
  83. color: getComputedStyle(el.querySelector('.status-tag__shape') || el).color,
  84. })),
  85. legendCount: legendRows.length,
  86. legendHosts: document.querySelectorAll('[data-map-legend]').length,
  87. // 事件四级图例不该出现在地图上(票 09:不把事件等级色混用为点位状态色)
  88. eventLevelTags: Array.from(document.querySelectorAll('[data-map-legend] .status-tag')).filter((el) =>
  89. /^[1-4]级$/.test(((el.querySelector('.status-tag__label') || {}).textContent || '').trim()),
  90. ).length,
  91. // token 的实际值(证明颜色不是兜底常量)
  92. tokens: { lv1: tone('lv1'), lv2: tone('lv2'), lv3: tone('lv3'), lv4: tone('lv4'), neutral: tone('neutral') },
  93. // 每行的实际颜色算出来的 CSS 值,供「颜色各不相同」判定
  94. markerImages: dev && dev.markerImages ? dev.markerImages : {},
  95. engineStatusPill: (document.querySelector('[data-map-engine-status]') || {}).textContent || '',
  96. mapHosts: document.querySelectorAll('.cockpit-shell__map .map-canvas').length,
  97. }
  98. })
  99. /** 打开页面并把地图摘要翻到底(mock 的状态样例:7 点 + 1 设备 = 8 个对象)。 */
  100. async function openPage(inject) {
  101. const page = await browser.newPage()
  102. const errors = []
  103. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  104. page.on('console', (m) => {
  105. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  106. })
  107. await page.evaluateOnNewDocument(() => {
  108. // 引导层会盖住页面,走查不需要它(票 30 的开关)。
  109. window.__COCKPIT_CONFIG__ = { dataSource: 'mock', map: { intro: { enabled: false } } }
  110. })
  111. if (inject) await page.evaluateOnNewDocument(inject)
  112. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  113. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  114. // 引擎要拉静态资源/地形,比取数慢:等它落到终态,否则「标记没画上」会被误判成失败。
  115. await page
  116. .waitForFunction(
  117. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  118. { timeout: 40000 },
  119. )
  120. .catch(() => {})
  121. await sleep(600)
  122. return { page, errors }
  123. }
  124. /** 点「加载下一页」直到翻完(mock 按页大小 2 分 4 页)。 */
  125. async function advanceUntilSettled(page, maxSteps = 12) { for (let step = 0; step < maxSteps; step += 1) {
  126. const state = await readMarkers(page)
  127. if (['complete', 'empty', 'failed', 'expired'].includes(state.phase)) break
  128. const clicked = await page.evaluate(() => {
  129. const button = document.querySelector('[data-map-summary-action]')
  130. if (!button || button.disabled) return false
  131. button.click()
  132. return true
  133. })
  134. if (!clicked) break
  135. const before = state.loaded
  136. await page
  137. .waitForFunction(
  138. (previous) => {
  139. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  140. const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
  141. const phase = (host && host.dataset.mapSummary) || ''
  142. return loaded !== previous || ['failed', 'expired', 'empty', 'complete'].includes(phase)
  143. },
  144. { timeout: 10000 },
  145. before,
  146. )
  147. .catch(() => {})
  148. await sleep(350)
  149. }
  150. return readMarkers(page)
  151. }
  152. try {
  153. // ---------- A 标记真的画在地图上,且按后端状态渲染 ----------
  154. {
  155. const { page, errors } = await openPage(() => {
  156. window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'
  157. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  158. })
  159. const settled = await advanceUntilSettled(page)
  160. check(
  161. 'A1-engine-ready',
  162. ['ready', 'disabled'].includes(settled.engine),
  163. `engine=${settled.engine} · 浮标「${settled.engineStatusPill.slice(0, 60)}」`,
  164. )
  165. // 6 个状态点位 + 1 个未知状态点位 + 1 个未关联设备 = 8 个可上图对象。
  166. check(
  167. 'A2-markers-rendered-as-entities',
  168. settled.entities === 8 && settled.dsMarkers === 8 && settled.dsPlan === 8,
  169. `真实实体 ${settled.entities} · dataset ${settled.dsMarkers} · 计划 ${settled.dsPlan}(加载 ${settled.loaded} 个)`,
  170. )
  171. check(
  172. 'A3-backend-six-statuses-present',
  173. ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].every((key) => settled.entityByStatus[key] === 1),
  174. JSON.stringify(settled.entityByStatus),
  175. )
  176. check(
  177. 'A4-unknown-status-not-guessed',
  178. settled.entityByStatus.UNKNOWN === 1,
  179. `后端新增的 MAINTENANCE 落「状态未发布」而不是任何真实状态:${JSON.stringify(settled.entityByStatus)}`,
  180. )
  181. check(
  182. 'A5-unassociated-separate-row',
  183. settled.entityByStatus.UNASSOCIATED === 1,
  184. '未关联设备自成一行(不借用在线态)',
  185. )
  186. check('A6-single-map-host', settled.mapHosts === 1, `地图宿主 ${settled.mapHosts} 个`)
  187. check('A7-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  188. // ---------- B 形状:不只靠颜色 ----------
  189. const sixShapes = ['triangle', 'diamond', 'square', 'circle', 'ring', 'hollowSquare']
  190. check(
  191. 'B1-eight-distinct-shapes-on-map',
  192. sixShapes.every((shape) => settled.entityShapes.includes(shape)) &&
  193. settled.entityShapes.includes('hollowDiamond') &&
  194. settled.entityShapes.includes('cross') &&
  195. settled.entityShapes.length === 8,
  196. `地图上 ${settled.entityShapes.length} 种形状:${settled.entityShapes.join(', ')}`,
  197. )
  198. check(
  199. 'B2-dataset-exposes-shapes',
  200. settled.dsShapes.split(',').filter(Boolean).length === 8,
  201. `dataset shapes = ${settled.dsShapes}`,
  202. )
  203. // ---------- C 颜色真的从 token 读出来、且各状态不同 ----------
  204. //
  205. // 注意这里**必须把颜色单独抠出来比**,不能只比整张图片是否不同:
  206. // 图片里还含形状,形状本来就各不相同 —— 那样写的话即使所有状态用同一个颜色
  207. // (比如 token 没读到、全体回退兜底色),断言照样绿。第一版正是这么写的,
  208. // 对抗式复验(把 toneColors 改成恒返回一个色)抓到它假绿 25/25。
  209. const imageOf = (key) => (settled.markerImages[key] || {}).image || ''
  210. const colorOf = (key) => {
  211. const image = imageOf(key)
  212. if (!image) return ''
  213. const decoded = decodeURIComponent(image)
  214. const match = decoded.match(/color="([^"]+)"/)
  215. return match ? match[1] : ''
  216. }
  217. check(
  218. 'C1-tokens-resolved-from-css',
  219. Object.values(settled.tokens).every((value) => /^#|rgb/.test(value)),
  220. JSON.stringify(settled.tokens),
  221. )
  222. const colorsByStatus = {
  223. ALARM: colorOf('ALARM'),
  224. WARNING: colorOf('WARNING'),
  225. WORK_ORDER: colorOf('WORK_ORDER'),
  226. NORMAL: colorOf('NORMAL'),
  227. OFFLINE: colorOf('OFFLINE'),
  228. }
  229. const distinctColors = new Set(Object.values(colorsByStatus).filter(Boolean))
  230. check(
  231. 'C2-marker-colors-differ-per-status',
  232. distinctColors.size === 5 && Object.values(colorsByStatus).every(Boolean),
  233. `5 个状态 → ${distinctColors.size} 种颜色:${Object.entries(colorsByStatus).map(([k, v]) => `${k}=${v}`).join(' ')}`,
  234. )
  235. // 颜色必须**等于 token 的实测值**(而不只是"彼此不同"):证明是读出来的,不是常量。
  236. const colorMismatch = Object.entries(colorsByStatus).filter(([key, color]) => {
  237. const toneKey = { ALARM: 'lv1', WARNING: 'lv2', WORK_ORDER: 'lv3', NORMAL: 'lv4', OFFLINE: 'neutral' }[key]
  238. return color.toLowerCase() !== settled.tokens[toneKey].toLowerCase()
  239. })
  240. check(
  241. 'C3-marker-color-equals-token',
  242. colorMismatch.length === 0 && /^data:image\/svg\+xml/.test(imageOf('ALARM')),
  243. colorMismatch.length
  244. ? colorMismatch.map(([k, v]) => `${k} 图片是 ${v}`).join(' | ')
  245. : `五个状态的标记颜色逐个等于 .cockpit-shell 上 --lv1/--lv2/--lv3/--lv4/--neutral 的实测值`,
  246. )
  247. // ---------- D 图例与标记一致(DOM 与真实实体对照)----------
  248. check('D1-legend-rendered-once', settled.legendHosts === 1, `图例宿主 ${settled.legendHosts} 个`)
  249. check('D2-legend-lists-eight-rows', settled.legendCount === 8, `图例 ${settled.legendCount} 行(把八种含义都列出来)`)
  250. const legendByKey = new Map(settled.legend.map((row) => [row.key, row]))
  251. const entityMismatch = Object.entries(settled.entityByStatus).filter(([key, count]) => {
  252. const row = legendByKey.get(key)
  253. if (!row) return true
  254. // 图例文字形如「报警 1」;解析出计数与实体数对照。
  255. const shown = Number((row.label.match(/(\d+)\s*$/) || [])[1])
  256. return shown !== count
  257. })
  258. check(
  259. 'D3-legend-counts-equal-entities',
  260. entityMismatch.length === 0,
  261. entityMismatch.length ? JSON.stringify(entityMismatch) : `图例计数与地图实体逐个相等:${settled.planLegend.join(' · ')}`,
  262. )
  263. // 形状与色键必须逐行对上(图例那一行画出来的形状,与地图上该状态的标记形状同名)。
  264. const shapeMismatch = settled.legend
  265. .filter((row) => {
  266. const image = settled.markerImages[row.key]
  267. return image && image.shape !== row.shape
  268. })
  269. .map((row) => `${row.key}: 图例 ${row.shape} vs 标记 ${settled.markerImages[row.key].shape}`)
  270. check('D4-legend-shape-matches-marker', shapeMismatch.length === 0, shapeMismatch.length ? shapeMismatch.join(' | ') : '八行形状逐个对上地图标记')
  271. const toneMismatch = settled.legend
  272. .filter((row) => {
  273. const image = settled.markerImages[row.key]
  274. return image && image.tone !== row.tone
  275. })
  276. .map((row) => `${row.key}: 图例 ${row.tone} vs 标记 ${settled.markerImages[row.key].tone}`)
  277. check('D5-legend-tone-matches-marker', toneMismatch.length === 0, toneMismatch.length ? toneMismatch.join(' | ') : '八行色键逐个对上地图标记')
  278. // 每行必须同时有形状 SVG 与文字(验收第 3 条)。
  279. const lacking = settled.legend.filter((row) => !row.hasShapeSvg || !row.label)
  280. check(
  281. 'D6-every-legend-row-has-shape-and-text',
  282. lacking.length === 0,
  283. lacking.length ? lacking.map((row) => row.key).join(', ') : settled.legend.map((row) => `${row.label}[${row.shape}]`).join(' '),
  284. )
  285. // 形状是真的画出来的(内联 SVG 片段非空),不是一个空元素占位。
  286. const emptyShape = settled.legend.filter((row) => row.shapeMarkup.trim().length < 10)
  287. check('D7-shape-markup-not-empty', emptyShape.length === 0, emptyShape.length ? emptyShape.map((r) => r.key).join(', ') : '每行形状都有真实 SVG 片段')
  288. // 图例那一行的实际渲染颜色必须各不相同(颜色不是唯一线索,但仍要是对的)。
  289. const legendColors = new Set(settled.legend.filter((row) => row.tone !== 'neutral').map((row) => row.color))
  290. check(
  291. 'D8-legend-colors-distinct',
  292. legendColors.size === 4,
  293. `四个语义色在页面上渲染成 ${legendColors.size} 种颜色:${[...legendColors].join(', ')}`,
  294. )
  295. // ---------- E 不与事件等级混用 ----------
  296. check(
  297. 'E1-no-event-level-legend-on-map',
  298. settled.eventLevelTags === 0,
  299. '地图图例里没有「1级..4级」事件等级行(票 09:不把事件等级色混用为点位状态色)',
  300. )
  301. console.log(`\n图例快照:${settled.legend.map((row) => `${row.label}(${row.tone}/${row.shape})`).join(' | ')}`)
  302. console.log(`状态分布:${settled.dsStatus}`)
  303. await page.close()
  304. }
  305. // ---------- F 默认样例不受影响(票 07/08 的证据不能被本票改动)----------
  306. {
  307. const { page, errors } = await openPage(() => {
  308. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  309. })
  310. const settled = await advanceUntilSettled(page)
  311. // 默认样例是 3 点 + 2 设备 = 5 个对象、其中 2 个可上图(票 07/08 原样)。
  312. check(
  313. 'F1-default-sample-unchanged',
  314. settled.dsPlan === 2 && settled.entities === 2 && settled.loaded === 5,
  315. `默认样例仍为 计划 ${settled.dsPlan} · 实体 ${settled.entities} · 已加载 ${settled.loaded}(票 07/08 的数)`,
  316. )
  317. check('F2-default-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  318. await page.close()
  319. }
  320. // ---------- G 空快照:图例在位、标记为 0(不是「假绿」而是真的没有)----------
  321. {
  322. const { page, errors } = await openPage(() => {
  323. window.__COCKPIT_MOCK_SCENARIO__ = 'empty'
  324. window.__COCKPIT_MOCK_ONLY__ = '/map-snapshot'
  325. })
  326. await sleep(900)
  327. const state = await readMarkers(page)
  328. check(
  329. 'G1-empty-snapshot-zero-markers',
  330. state.dsMarkers === 0 && state.entities === 0 && state.legendCount === 8,
  331. `标记 ${state.dsMarkers} · 实体 ${state.entities} · 图例仍 ${state.legendCount} 行(形状含义可查)`,
  332. )
  333. check('G2-empty-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  334. await page.close()
  335. }
  336. // ---------- H 首屏自动补画:不点任何按钮,标记也必须出现在地图上 ----------
  337. //
  338. // 这条是被**视觉探针**(截图里图例有计数、状态行却写「点位标记 0」)逼出来的:
  339. // 引擎冷启动比装载器慢得多,第一版的重试封顶 10 秒,超时后第一批点位就再也没画上,
  340. // 只有用户去点「加载下一页」才会因对象变化重新触发。走查当时没抓到 —— 因为它一路在点分页。
  341. // 所以这里**一次都不点**:只等引擎 ready,然后要求标记自己出现。
  342. {
  343. const { page, errors } = await openPage(() => {
  344. window.__COCKPIT_MOCK_SCENARIO__ = 'point-statuses'
  345. window.__COCKPIT_MOCK_MAP__ = { pageSize: 2 }
  346. })
  347. // 只等"标记数 > 0",不点任何按钮;等不到就是没补画。
  348. const painted = await page
  349. .waitForFunction(
  350. () => {
  351. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  352. return Number((host && host.dataset.mapMarkers) || 0) > 0
  353. },
  354. { timeout: 30000 },
  355. )
  356. .then(() => true)
  357. .catch(() => false)
  358. const state = await readMarkers(page)
  359. check(
  360. 'H1-first-batch-auto-painted',
  361. painted && state.entities > 0 && state.dsMarkers === state.dsPlan,
  362. `未点任何按钮:实体 ${state.entities} · dataset ${state.dsMarkers} · 计划 ${state.dsPlan}(引擎 ${state.engine})`,
  363. )
  364. check(
  365. 'H2-first-batch-legend-consistent',
  366. state.legendCount === 8 && Object.entries(state.entityByStatus).every(([key, count]) => {
  367. const row = state.legend.find((item) => item.key === key)
  368. return row && Number((row.label.match(/(\d+)\s*$/) || [])[1]) === count
  369. }),
  370. `首屏图例 ${state.legendCount} 行,与实体 ${JSON.stringify(state.entityByStatus)} 逐个对上`,
  371. )
  372. check('H3-no-page-errors', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  373. await page.close()
  374. }
  375. } finally {
  376. await browser.close()
  377. }
  378. const failed = results.filter((item) => !item.pass)
  379. console.log(`\nT09 走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`)
  380. process.exit(failed.length ? 1 : 0)