t13-event-list-walkthrough.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /**
  2. * T13 真机走查:预警处置模式的「当前预警」面板真的画出事件列表 + 预警/报警切换(票 13)。
  3. *
  4. * 逻辑脚本(t13-event-list.mjs,48/48)已经把契约适配、切换视图同步、等级配色、
  5. * 默认 12 条上限、事件不建标记、不判断真伪与 store 竞态测透了;这里只补**只有在真机
  6. * 才成立**的部分:
  7. * 1. 进预警处置模式后,**界面真的**把当前预警面板画出来(DOM 实证,不是读 store);
  8. * 2. 预警/报警分段切换**真的换内容**(WRN-* ↔ ALM-*),且**字段标签与状态标签同步变**
  9. * (表头从「预警编号/预警状态」变「报警编号/报警状态」);
  10. * 3. 事件等级**有颜色也有文字**(每行都有色键 + 'N级' 字样的 StatusTag);
  11. * 4. 列表**只展示最新 12 条**且如实说「共 14 条」(验收第 2 条的可见口径);
  12. * 5. 事件**不产生地图标记**(切换/加载后地图点位数不变,事件只靠设备编码回联):
  13. * 6. 只读:列表内没有任何写操作按钮。
  14. *
  15. * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由
  16. * 维护者按需执行,票 18 已有同一先例):
  17. * node .scratch/cockpit-shell-panel-layout/verify/t13-event-list-walkthrough.mjs
  18. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  19. *
  20. * 演示开关(宿主全局键注入,不改代码重新构建):
  21. * `__COCKPIT_MOCK_ONLY__='/events/current'` + `__COCKPIT_MOCK_SCENARIO__='empty'`
  22. * —— 只有事件端点空,地图/详情照常(对应逻辑脚本 H2 的真机版)
  23. */
  24. import { join } from 'node:path'
  25. import { tmpdir } from 'node:os'
  26. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  27. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  28. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  29. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  30. const results = []
  31. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述(票 08 踩过这个假绿)。 */
  32. function check(id, pass, evidence) {
  33. results.push({ id, pass })
  34. const rest = evidence === undefined ? '' : String(evidence)
  35. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  36. }
  37. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  38. const puppeteer = (await import(PUPPETEER)).default
  39. const browser = await puppeteer.launch({
  40. executablePath: CHROME,
  41. headless: true,
  42. userDataDir: join(tmpdir(), 'cockpit-t13-profile'),
  43. defaultViewport: { width: 1680, height: 950 },
  44. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  45. })
  46. console.log(`Chrome: ${await browser.version()}`)
  47. /** 当前预警面板的 DOM 状态(内容从 DOM 读,证明界面真的画出来了)。 */
  48. const readState = (page) =>
  49. page.evaluate(() => {
  50. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  51. const panel = document.querySelector('[data-panel-id="currentEvents"]')
  52. const dev = window.__cockpitMap
  53. // `getPointMarkers()` 返回 `{ markers: [...], onMap: N }`(t10 同一句柄):
  54. // `onMap` 是真正画上引擎的点位数(本票断言「切换前后不增不减」用它)。
  55. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  56. const markerOnMap = markers && typeof markers.onMap === 'number' ? markers.onMap : null
  57. const table = panel && panel.querySelector('[data-event-table]')
  58. const headers = table
  59. ? Array.from(table.querySelectorAll('thead th')).map((th) => (th.textContent || '').trim())
  60. : []
  61. const rows = table
  62. ? Array.from(table.querySelectorAll('[data-event-row]')).map((tr) => ({
  63. code: tr.dataset.eventRow || '',
  64. name: ((tr.querySelector('[data-event-name]') || {}).textContent || '').trim(),
  65. level: ((tr.querySelector('[data-event-level]') || {}).textContent || '').trim(),
  66. levelTone: (tr.querySelector('[data-event-level] .status-tag') || {}).dataset
  67. ? tr.querySelector('[data-event-level] .status-tag').dataset.tone || ''
  68. : '',
  69. status: ((tr.querySelector('[data-event-status]') || {}).textContent || '').trim(),
  70. time: ((tr.querySelector('[data-event-time]') || {}).textContent || '').trim(),
  71. device: ((tr.querySelector('[data-event-device]') || {}).textContent || '').trim(),
  72. }))
  73. : []
  74. const switchBtns = panel
  75. ? Array.from(panel.querySelectorAll('[data-event-switch]')).map((b) => ({
  76. key: b.dataset.eventSwitch || '',
  77. pressed: b.getAttribute('aria-pressed') === 'true',
  78. }))
  79. : []
  80. const placeholder = panel
  81. ? ((panel.querySelector('[data-event-placeholder]') || {}).textContent || '').trim()
  82. : ''
  83. const limitedNote = panel
  84. ? ((panel.querySelector('[data-event-limited]') || {}).textContent || '').trim()
  85. : ''
  86. // 「零写操作」= 除**分段切换按钮**(`data-event-switch`,视图切换不是业务写操作)
  87. // 与面板标题栏(`.cockpit-panel__head`)之外,没有任何 button/a/input/select。
  88. const writeButtons = panel
  89. ? Array.from(panel.querySelectorAll('button, a, input, select')).filter(
  90. (el) => el !== panel.querySelector('.cockpit-panel__head') && !el.hasAttribute('data-event-switch'),
  91. ).length
  92. : 0
  93. return {
  94. mode: ((document.querySelector('.cockpit-shell__modes .is-active') || {}).textContent || '').trim(),
  95. panelRendered: !!panel,
  96. state: (panel && ((panel.querySelector('[data-section-state]') || {}).dataset || {}).sectionState) || '',
  97. placeholder,
  98. headers,
  99. rows,
  100. switchBtns,
  101. limitedNote,
  102. writeButtons,
  103. markerCount: markerOnMap,
  104. pageErrors: null,
  105. }
  106. })
  107. /** 打开页面 → 切到预警处置模式 → 走真实的进入引导。 */
  108. async function openPage(scenario, onlyPath = '') {
  109. const page = await browser.newPage()
  110. const errors = []
  111. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  112. page.on('console', (m) => {
  113. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  114. })
  115. await page.evaluateOnNewDocument(
  116. ({ name, only }) => {
  117. window.__COCKPIT_MOCK_SCENARIO__ = name
  118. if (only) window.__COCKPIT_MOCK_ONLY__ = only
  119. },
  120. { name: scenario, only: onlyPath },
  121. )
  122. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  123. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  124. await page
  125. .waitForFunction(
  126. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  127. { timeout: 40000 },
  128. )
  129. .catch(() => {})
  130. if (await page.$('[data-cockpit-intro]')) {
  131. await page.click('[data-cockpit-intro]')
  132. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  133. }
  134. await sleep(1200)
  135. // 切到预警处置模式(一级模式切换不重建地图,票 01)。
  136. await page.evaluate(() => {
  137. const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '预警处置')
  138. if (button) button.click()
  139. })
  140. await page
  141. .waitForFunction(() => !!document.querySelector('[data-panel-id="currentEvents"] [data-event-table]'), { timeout: 20000 })
  142. .catch(() => {})
  143. await sleep(800)
  144. return { page, errors }
  145. }
  146. // ================================================================ A 预警列表
  147. {
  148. const { page, errors } = await openPage('success')
  149. const state = await readState(page)
  150. check('A1-warning-mode-active', state.mode === '预警处置', `当前一级模式:${state.mode}`)
  151. check('A2-current-events-panel-rendered', state.panelRendered && state.rows.length > 0, `画出的事件行:${state.rows.length}`)
  152. check(
  153. 'A3-warning-list-by-default',
  154. state.switchBtns.find((b) => b.key === 'WARNING')?.pressed === true &&
  155. state.headers.includes('预警编号') &&
  156. state.headers.includes('预警状态') &&
  157. state.rows.every((r) => r.code.startsWith('WRN-')),
  158. `默认预警:表头 ${state.headers.join('/')};行码示例 ${state.rows.slice(0, 2).map((r) => r.code).join(',')}`,
  159. )
  160. check(
  161. 'A4-level-has-color-and-text',
  162. state.rows.length > 0 &&
  163. state.rows.every((r) => r.level.includes('级') && r.levelTone.length > 0),
  164. `每行等级=色键+文字(如 ${state.rows[0] ? `${state.rows[0].level}@${state.rows[0].levelTone}` : '(空)'})`,
  165. )
  166. check(
  167. 'A5-default-12-limited',
  168. state.rows.length === 12 && state.limitedNote.includes('共 14 条'),
  169. `仅展示最新 12 条:${state.limitedNote}`,
  170. )
  171. check(
  172. 'A6-no-write-buttons',
  173. state.writeButtons === 0,
  174. `面板内业务按钮/输入:${state.writeButtons}(只有面板标题栏可点,零写操作)`,
  175. )
  176. // 切到报警:切换按钮按下、表头字段标签与状态标签同步变化、内容变成 ALM-*。
  177. await page.evaluate(() => {
  178. const btn = document.querySelector('[data-event-switch="ALARM"]')
  179. if (btn) btn.click()
  180. })
  181. await page
  182. .waitForFunction(() => document.querySelector('[data-event-switch="ALARM"]')?.getAttribute('aria-pressed') === 'true', { timeout: 10000 })
  183. .catch(() => {})
  184. await sleep(500)
  185. const alarmState = await readState(page)
  186. check(
  187. 'B1-alarm-switch-pressed',
  188. alarmState.switchBtns.find((b) => b.key === 'ALARM')?.pressed === true,
  189. `报警按钮 ${alarmState.switchBtns.find((b) => b.key === 'ALARM') ? '已按下' : '未按下'}`,
  190. )
  191. check(
  192. 'B2-fields-and-status-labels-sync',
  193. alarmState.headers.includes('报警编号') &&
  194. alarmState.headers.includes('报警状态') &&
  195. !alarmState.headers.includes('预警编号'),
  196. `切换后表头:${alarmState.headers.join('/')}(字段标签与状态标签一起换)`,
  197. )
  198. check(
  199. 'B3-alarm-list-content-changed',
  200. alarmState.rows.length > 0 && alarmState.rows.every((r) => r.code.startsWith('ALM-')),
  201. `报警行 ${alarmState.rows.length} 条:${alarmState.rows.slice(0, 2).map((r) => r.code).join(',')}(内容真的换了)`,
  202. )
  203. check(
  204. 'B4-events-do-not-add-map-markers',
  205. state.markerCount !== null && alarmState.markerCount !== null && state.markerCount === alarmState.markerCount,
  206. `切换前后地图点位数:${state.markerCount} → ${alarmState.markerCount}(事件不建独立标记,数字不变)`,
  207. )
  208. check(
  209. 'A7-no-page-errors',
  210. errors.length === 0,
  211. errors.slice(0, 3).join(' | '),
  212. )
  213. await page.close()
  214. }
  215. // ================================================================ C 空样例(只有事件端点空)
  216. {
  217. const { page, errors } = await openPage('empty', '/events/current')
  218. const state = await readState(page)
  219. check(
  220. 'C1-empty-events-says-no-events',
  221. state.panelRendered && state.rows.length === 0 && state.placeholder.includes('没有事件'),
  222. `占位措辞:${state.placeholder}`,
  223. )
  224. check(
  225. 'C2-empty-still-has-switch',
  226. state.switchBtns.length === 2,
  227. `空态分段切换仍在(${state.switchBtns.length} 段)`,
  228. )
  229. check(
  230. 'C3-empty-no-fake-rows',
  231. state.rows.length === 0 && !state.limitedNote,
  232. '空态不画假行、不显示分页说明',
  233. )
  234. check('C4-empty-no-page-errors', errors.length === 0, errors.slice(0, 3).join(' | '))
  235. await page.close()
  236. }
  237. await browser.close()
  238. const failed = results.filter((item) => !item.pass)
  239. console.log('')
  240. console.log(`T13 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  241. if (failed.length) {
  242. console.log('未通过:')
  243. for (const item of failed) console.log(` · ${item.id}`)
  244. process.exit(1)
  245. }
  246. console.log('T13 事件列表真机走查全部通过。')