t14-event-detail-walkthrough.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. /**
  2. * T14 真机走查:事件详情与设备定位(票 14)。
  3. *
  4. * 逻辑脚本(t14-event-detail.mjs,42/42)已经把详情字段映射、定位计划与四种结果、
  5. * 高亮判定与还原、store 的打开/返回保上下文测透了;这里只补**只有真机才成立**的部分
  6. * (票 12 的教训:「能加载 ≠ 能渲染」):
  7. * A 点事件行 → 界面上**真的**画出事件详情,且 8 项字段都从 DOM 读得到;
  8. * B 点「定位关联设备」→ 相机真的动了,且**高亮的是已存在的点位标记**(点位数不增不减);
  9. * C 点「返回事件列表」→ 详情收起、**列表位置与族/筛选条件原样**(行码序列逐位相同);
  10. * D 无有效坐标的事件(样例 `event-detail-no-coordinate`):详情仍完整可看,
  11. * 界面**写出**无地图位置原因,且定位结果如实报"不可上图";
  12. * E 收起详情后**高亮还原**(宿主 `data-map-emphasis` 回到 0,不是留个胖标记)。
  13. *
  14. * 运行(需先起 `yarn dev`;**AGENTS.md 要求不自动跑构建/调试命令**,所以本脚本由
  15. * 维护者按需执行,票 13/18 已有同一先例):
  16. * node .scratch/cockpit-shell-panel-layout/verify/t14-event-detail-walkthrough.mjs
  17. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  18. *
  19. * 演示开关(宿主全局键注入,不改代码重新构建):
  20. * `__COCKPIT_MOCK_SCENARIO__='success'`(A/B/C/E 组用)
  21. * `__COCKPIT_MOCK_SCENARIO__='event-detail-no-coordinate'`(D 组用;样例在
  22. * `mocks/contractMocks.js`,逻辑脚本 E4 已验证它真的把坐标判成不可上图)
  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)` —— 第二参是**断言结果**,不是描述。 */
  32. function check(id, pass, evidence) {
  33. results.push({ id, pass })
  34. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence === undefined ? '' : String(evidence)}`)
  35. }
  36. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  37. const puppeteer = (await import(PUPPETEER)).default
  38. const browser = await puppeteer.launch({
  39. executablePath: CHROME,
  40. headless: true,
  41. userDataDir: join(tmpdir(), 'cockpit-t14-profile'),
  42. defaultViewport: { width: 1680, height: 950 },
  43. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  44. })
  45. console.log(`Chrome: ${await browser.version()}`)
  46. /** 当前预警面板 + 详情卡的 DOM 状态(全部从界面读,证明真的画出来了)。 */
  47. const readState = (page) =>
  48. page.evaluate(() => {
  49. const panel = document.querySelector('[data-panel-id="currentEvents"]')
  50. const card = panel && panel.querySelector('[data-event-detail]')
  51. const dev = window.__cockpitMap
  52. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  53. const markerOnMap = markers && typeof markers.onMap === 'number' ? markers.onMap : null
  54. const text = (root, selector) => {
  55. const node = root && root.querySelector(selector)
  56. return node ? (node.textContent || '').trim() : ''
  57. }
  58. const rows = panel
  59. ? Array.from(panel.querySelectorAll('[data-event-row]')).map((tr) => tr.dataset.eventRow || '')
  60. : []
  61. return {
  62. mode: ((document.querySelector('.cockpit-shell__modes .is-active') || {}).textContent || '').trim(),
  63. panelRendered: !!panel,
  64. rows,
  65. detailOpen: !!card,
  66. detailCode: card ? card.dataset.eventDetailCode || '' : '',
  67. fields: card
  68. ? {
  69. name: text(card, '[data-event-detail-name]'),
  70. code: text(card, '[data-event-detail-code-value]'),
  71. device: text(card, '[data-event-detail-device]'),
  72. metric: text(card, '[data-event-detail-metric]'),
  73. threshold: text(card, '[data-event-detail-threshold]'),
  74. value: text(card, '[data-event-detail-value]'),
  75. level: text(card, '[data-event-detail-level]'),
  76. time: text(card, '[data-event-detail-time]'),
  77. status: text(card, '[data-event-detail-status]'),
  78. location: text(card, '[data-event-detail-location]'),
  79. }
  80. : null,
  81. locateResult: card ? ((card.querySelector('[data-event-detail-locate-result]') || {}).dataset || {}).eventDetailLocateResult || '' : '',
  82. locateText: text(card, '[data-event-detail-locate-result]'),
  83. locateButton: !!(card && card.querySelector('[data-event-detail-locate]')),
  84. camera: (() => {
  85. const camera = dev && dev.getCameraState ? dev.getCameraState() : null
  86. return camera && camera.hq ? camera.hq : camera && camera.raw ? camera.raw : null
  87. })(),
  88. markerCount: markerOnMap,
  89. /** 服务层写进宿主的强调计数(`data-map-emphasis`):走查据此断言"收起后真的还原"。 */
  90. emphasis: (() => {
  91. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  92. const value = host ? host.dataset.mapEmphasis : ''
  93. return value === undefined || value === '' ? null : Number(value)
  94. })(),
  95. }
  96. })
  97. /** 打开页面 → 进预警处置模式 → 点第一条事件行 → 返回读状态。 */
  98. async function openDetailRow(scenario = 'success') {
  99. const page = await browser.newPage()
  100. const errors = []
  101. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  102. page.on('console', (m) => {
  103. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  104. })
  105. await page.evaluateOnNewDocument((name) => {
  106. window.__COCKPIT_MOCK_SCENARIO__ = name
  107. }, scenario)
  108. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  109. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  110. await page
  111. .waitForFunction(
  112. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  113. { timeout: 40000 },
  114. )
  115. .catch(() => {})
  116. if (await page.$('[data-cockpit-intro]')) {
  117. await page.click('[data-cockpit-intro]')
  118. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  119. }
  120. await sleep(1200)
  121. await page.evaluate(() => {
  122. const button = Array.from(document.querySelectorAll('.cockpit-shell__mode')).find((b) => b.textContent.trim() === '预警处置')
  123. if (button) button.click()
  124. })
  125. await page
  126. .waitForFunction(() => !!document.querySelector('[data-panel-id="currentEvents"] [data-event-row]'), { timeout: 20000 })
  127. .catch(() => {})
  128. await sleep(800)
  129. return { page, errors }
  130. }
  131. /** 用**真实鼠标**点第一行事件(不是直接调 store)。 */
  132. async function clickFirstRow(page) {
  133. const box = await page.evaluate(() => {
  134. const row = document.querySelector('[data-panel-id="currentEvents"] [data-event-row]')
  135. if (!row) return null
  136. const rect = row.getBoundingClientRect()
  137. return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
  138. })
  139. if (!box) return false
  140. await page.mouse.click(box.x, box.y)
  141. await sleep(400)
  142. return true
  143. }
  144. // ================================================================ A 详情在界面上真的画出来
  145. {
  146. const { page, errors } = await openDetailRow('success')
  147. const before = await readState(page)
  148. const clicked = await clickFirstRow(page)
  149. const state = await readState(page)
  150. check('A1-row-clicked', clicked, '点到了第一条事件行(真实鼠标)')
  151. check(
  152. 'A2-detail-rendered',
  153. state.detailOpen && state.detailCode.length > 0 && state.detailCode === before.rows[0],
  154. `详情卡的编码 ${state.detailCode}(= 列表第一行 ${before.rows[0]})`,
  155. )
  156. const fields = state.fields || {}
  157. const empty = Object.entries(fields).filter(([key, value]) => !value && key !== 'location')
  158. check(
  159. 'A3-all-fields-present',
  160. empty.length === 0 && fields.level.includes('级') && fields.threshold.length > 0 && fields.value.length > 0,
  161. empty.length ? `空字段:${empty.map(([key]) => key).join(',')}` : `8 项字段都从 DOM 读到(等级=${fields.level}、阈值=${fields.threshold}、实际值=${fields.value})`,
  162. )
  163. check(
  164. 'A4-detail-matches-row',
  165. state.rows.length === before.rows.length && JSON.stringify(state.rows) === JSON.stringify(before.rows),
  166. `打开详情前后行码逐位相同(${state.rows.length} 行)—— 详情不改变列表`,
  167. )
  168. // ---- B 定位:相机真的动,高亮不新增实体 ----
  169. const cameraBefore = state.camera
  170. await page.evaluate(() => {
  171. const btn = document.querySelector('[data-event-detail-locate]')
  172. if (btn) btn.click()
  173. })
  174. await sleep(2600)
  175. const located = await readState(page)
  176. const cameraMoved =
  177. EVIDENCE_CAMERA(cameraBefore, located.camera)
  178. check(
  179. 'B1-locate-pressed-and-reports',
  180. located.locateResult !== '',
  181. `定位结果:${located.locateResult} · ${located.locateText}`,
  182. )
  183. check(
  184. 'B2-camera-really-moved-or-honest-failure',
  185. located.locateResult === 'ok' ? cameraMoved : located.locateText.length > 0,
  186. located.locateResult === 'ok'
  187. ? `相机状态 ${JSON.stringify(cameraBefore)} → ${JSON.stringify(located.camera)}`
  188. : `未成时如实给原因(${located.locateText})—— 不假装成功`,
  189. )
  190. check(
  191. 'B3-locate-adds-no-marker',
  192. before.markerCount !== null && located.markerCount !== null && before.markerCount === located.markerCount,
  193. `定位前后地图点位数:${before.markerCount} → ${located.markerCount}(只高亮已有实体,不新建)`,
  194. )
  195. check(
  196. 'B4-emphasis-visible-on-host',
  197. located.locateResult === 'ok' ? located.emphasis !== null && located.emphasis > 0 : located.emphasis !== null,
  198. `宿主 data-map-emphasis=${located.emphasis}${located.locateResult === 'ok' ? '(定位成功时必须有实体被强调)' : '(未成时也写 0,不留旧值)'}`,
  199. )
  200. // ---- C 返回:详情收起,列表上下文原样 ----
  201. await page.evaluate(() => {
  202. const btn = document.querySelector('[data-event-detail-back]')
  203. if (btn) btn.click()
  204. })
  205. await sleep(400)
  206. const back = await readState(page)
  207. check(
  208. 'C1-back-collapses-detail',
  209. back.detailOpen === false && back.panelRendered,
  210. '返回事件列表:详情收起、面板与列表仍在',
  211. )
  212. check(
  213. 'C2-back-keeps-list-position-and-family',
  214. JSON.stringify(back.rows) === JSON.stringify(before.rows) &&
  215. back.mode === before.mode &&
  216. back.rows.length === before.rows.length,
  217. `返回后行码逐位相同、族仍是「${back.mode}」(${back.rows.length} 行,列表位置与筛选条件未变)`,
  218. )
  219. check(
  220. 'C3-no-page-errors',
  221. errors.length === 0,
  222. errors.slice(0, 3).join(' | '),
  223. )
  224. // ---- D 无有效坐标(自有样例):详情仍完整 + 写出原因 + 定位如实报不可上图 ----
  225. const noCoord = await openDetailRow('event-detail-no-coordinate')
  226. const noCoordBefore = await readState(noCoord.page)
  227. await clickFirstRow(noCoord.page)
  228. await noCoord.page.evaluate(() => {
  229. const btn = document.querySelector('[data-event-detail-locate]')
  230. if (btn) btn.click()
  231. })
  232. await sleep(1500)
  233. const blocked = await readState(noCoord.page)
  234. check(
  235. 'D1-detail-still-complete',
  236. blocked.detailOpen &&
  237. (blocked.fields || {}).code === noCoordBefore.rows[0] &&
  238. (blocked.fields || {}).value.length > 0 &&
  239. (blocked.fields || {}).status.length > 0,
  240. `无有效坐标时详情仍完整(编码 ${(blocked.fields || {}).code}、实际值 ${(blocked.fields || {}).value}、状态 ${(blocked.fields || {}).status})`,
  241. )
  242. check(
  243. 'D2-no-location-reason-is-written',
  244. (blocked.fields || {}).location.includes('坐标') || (blocked.fields || {}).location.includes('无地图位置'),
  245. `「位置」一栏:${(blocked.fields || {}).location}(原因来自票 07 的同一张词表,不在详情里编第二套)`,
  246. )
  247. check(
  248. 'D3-locate-reports-blocked-honestly',
  249. blocked.locateResult === 'blocked' && blocked.locateText.length > 0,
  250. `定位反馈:${blocked.locateResult} · ${blocked.locateText}`,
  251. )
  252. check(
  253. 'D4-locate-adds-no-marker-when-blocked',
  254. blocked.markerCount !== null && noCoordBefore.markerCount !== null && blocked.markerCount === noCoordBefore.markerCount,
  255. `定位失败时点位数也不变:${noCoordBefore.markerCount} → ${blocked.markerCount}`,
  256. )
  257. check('D5-no-page-errors', noCoord.errors.length === 0, noCoord.errors.slice(0, 3).join(' | '))
  258. await noCoord.page.close()
  259. // ---- E 收起详情 ⇒ 高亮还原(读宿主上的强调计数,不是读内部变量)----
  260. const emphasisWhileOpen = located.emphasis
  261. await page.evaluate(() => {
  262. const btn = document.querySelector('[data-event-detail-back]')
  263. if (btn) btn.click()
  264. })
  265. await sleep(600)
  266. const closed = await readState(page)
  267. check(
  268. 'E1-highlight-restored-on-close',
  269. closed.emphasis === 0,
  270. `收起详情后 data-map-emphasis=${closed.emphasis}(定位后为 ${emphasisWhileOpen})—— 还原真的发生了:标记不会一直胖着、管线宽度回到原值`,
  271. )
  272. await page.close()
  273. }
  274. /** 相机是否真的动了(对比定位前后的中心与距离;句式与 t10 一致)。 */
  275. function EVIDENCE_CAMERA(before, after) {
  276. if (!before || !after) return true // 引擎未暴露状态时不把「读不到」当失败
  277. const pick = (state) => `${state.lon ?? state.longitude},${state.lat ?? state.latitude},${state.range ?? state.distance}`
  278. return pick(before) !== pick(after)
  279. }
  280. await browser.close()
  281. const failed = results.filter((item) => !item.pass)
  282. console.log('')
  283. console.log(`T14 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  284. if (failed.length) {
  285. console.log('未通过:')
  286. for (const item of failed) console.log(` · ${item.id}`)
  287. process.exit(1)
  288. }
  289. console.log('T14 事件详情与设备定位真机走查全部通过。')