t45-real-user-path-walkthrough.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. /**
  2. * T45 真实用户路径走查:进入系统 → **真实鼠标点地图标记** → 展开设备点 → 逐台拾取。
  3. *
  4. * 为什么另起一个脚本:既有 `t45-overlap-walkthrough.mjs` 用 DEV 把手 `selectMonitoringPoint()`
  5. * 打开点位(那是"统一入口",但不是**用户的手**)。维护者人工走查反馈「进入系统→点开监测点→
  6. * 展开设备点」不成立,所以这一趟必须全程用真实鼠标命中**地图上的标记**:
  7. * 1. 真实点击监测点标记(坐标由只读 `getPointMarkerTargets()` 投影给出);
  8. * 2. 断言点位详情真的打开(不是靠 DEV 入口开的);
  9. * 3. 真实 hover / click 聚合符号 → 粘性展开;
  10. * 4. 真实 click 展开后的设备点 → 选中并进设备级。
  11. *
  12. * 场景激活走 **URL 通道** `?scenario=xxx`(人工测试同一条入口;Console 全局变量刷新即丢)。
  13. *
  14. * **相机收敛纪律**(本脚本第一版踩到的坑):相机飞行/收敛期间投影出来的屏幕坐标会漂移
  15. * (同一标记两次读可以差 80px),拿过期坐标去点必然打不中。所以每次点击前都必须
  16. * 先等 `getCameraState().settled`,再**当场重读**坐标,然后立刻点。
  17. */
  18. import { cdpLaunch } from './cdp-driver.mjs'
  19. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  20. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  21. const SCENARIO = process.env.REPRO_SCENARIO || 'device-layer-overlap'
  22. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/?scenario=${SCENARIO}#/cockpit`
  23. /**
  24. * 按场景给出期望值(两个受控样例的 K 与降级形态不同):
  25. * · `device-layer-overlap` —— 2 台同坐标,K=2,全部可逐台展开(`expanded`);
  26. * · `device-layer-overlap-degraded` —— 11 台同坐标,K=11 > maxFan(10) ⇒ 降级:
  27. * 本屏只逐台画 `maxFan` 台、其余在清单里可见(`degraded`)。
  28. */
  29. const EXPECT = SCENARIO === 'device-layer-overlap-degraded'
  30. ? { k: 11, fanVisible: 10, firstDevice: 'EQ-DG-001', state: 'degraded' }
  31. : { k: 2, fanVisible: 2, firstDevice: 'EQ-001', state: 'expanded' }
  32. const results = []
  33. function check(id, pass, evidence) {
  34. results.push({ id, pass })
  35. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${evidence === undefined ? '' : String(evidence)}`)
  36. }
  37. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  38. const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 })
  39. console.log(`Chrome: ${await browser.version()}`)
  40. const page = await browser.newPage()
  41. const errors = []
  42. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  43. page.on('console', (m) => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`) })
  44. /**
  45. * 真实鼠标点击一个**会漂移的投影目标**,直到满足断言或重试用尽。
  46. *
  47. * 贴地形渲染的标记会随地形细化继续漂移(实测稳定判定后仍会漂),
  48. * 所以每次重试都**当场重读坐标**再点 —— 这也是真人的行为(没点中就再点一下)。
  49. * @param {() => Promise<{x:number,y:number}|null>} locate 读取当前坐标
  50. * @param {() => Promise<boolean>} done 是否已达成目标
  51. */
  52. async function clickUntil(locate, done, tries = 6) {
  53. // 贴地形渲染的标记位置会随地形瓦片细化在几像素内抖动(实测 y 在 575↔584 之间跳),
  54. // 单点点击会偶发落空。这里在投影点附近做小网格扫描点击(真人点不中也会微调再点),
  55. // 只要**任何一个**点命中即算通过;全部落空说明产品真有问题。
  56. const offsets = [[0, 0], [0, -6], [0, 6], [-6, 0], [6, 0], [0, -12], [0, 12], [-10, -6], [10, 6], [-10, 6], [10, -6]]
  57. for (let index = 0; index < tries; index += 1) {
  58. const target = await locate()
  59. if (target && Number.isFinite(target.x) && Number.isFinite(target.y)) {
  60. for (const [dx, dy] of offsets) {
  61. await page.mouse.move(1200, 300)
  62. await sleep(80)
  63. await page.mouse.move(target.x + dx, target.y + dy)
  64. await sleep(150)
  65. await page.mouse.click(target.x + dx, target.y + dy)
  66. await sleep(900)
  67. if (await done()) return true
  68. }
  69. }
  70. await sleep(400)
  71. }
  72. return false
  73. }
  74. async function waitSettled(timeout = 15000) {
  75. await page.waitForFunction(() => {
  76. const dev = window.__cockpitMap
  77. const state = dev && dev.getCameraState ? dev.getCameraState() : null
  78. return !state || state.settled === true
  79. }, { timeout }).catch(() => {})
  80. await sleep(400)
  81. }
  82. /**
  83. * 等某个点位标记的**投影坐标稳定**后再返回它。
  84. *
  85. * 点位标记是 `CLAMP_TO_GROUND` 贴地形渲染的:地形瓦片细化会让 `globe.getHeight()` 变化,
  86. * 投影出来的屏幕坐标随之漂移(本脚本实测两次读之间 y 差 9px —— 18px 的符号正好点空)。
  87. * 所以不能"读一次就点",要连续两次读数一致才算稳。
  88. */
  89. async function stableMarker(code = 'MP-001', tries = 40) {
  90. let prev = null
  91. for (let index = 0; index < tries; index += 1) {
  92. const now = await page.evaluate((pointCode) => {
  93. const dev = window.__cockpitMap
  94. const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : []
  95. return all.find((item) => item.visible && item.code === pointCode) || null
  96. }, code)
  97. if (now && prev && Math.abs(now.x - prev.x) < 2 && Math.abs(now.y - prev.y) < 2) return now
  98. prev = now
  99. await sleep(400)
  100. }
  101. const diag = await page.evaluate(() => {
  102. const dev = window.__cockpitMap
  103. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  104. const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : []
  105. return {
  106. targets: all.map((item) => `${item.code}@${Math.round(item.x)},${Math.round(item.y)}:${item.visible}`),
  107. engine: host ? host.dataset.mapEngine : '',
  108. markers: host ? host.dataset.mapMarkers : '',
  109. markersPlan: host ? host.dataset.mapMarkersPlan : '',
  110. summaryPhase: (() => {
  111. const s = dev && dev.mapSummary && dev.mapSummary.getState ? dev.mapSummary.getState() : null
  112. return s ? s.phase : ''
  113. })(),
  114. }
  115. })
  116. console.log(`MARKER DIAG (未等到 ${code}): ${JSON.stringify(diag)}`)
  117. return prev
  118. }
  119. await page.goto(ENTRY_URL, { timeout: 60000 })
  120. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  121. if (await page.$('[data-cockpit-intro]')) {
  122. await page.click('[data-cockpit-intro]')
  123. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  124. }
  125. await page.waitForFunction(
  126. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  127. { timeout: 40000 },
  128. ).catch(() => {})
  129. // 推进地图摘要到 complete(真实用户点「加载下一页」;走查为省时用同一条装载器入口)。
  130. await page.evaluate(() => {
  131. const dev = window.__cockpitMap
  132. if (!dev || !dev.mapSummary) return
  133. let steps = 0
  134. const step = () => {
  135. const state = dev.mapSummary.getState()
  136. if (!state || ['complete', 'empty', 'failed', 'expired'].includes(state.phase) || steps >= 20) return
  137. dev.mapSummary.advance(); steps += 1; window.setTimeout(step, 220)
  138. }
  139. step()
  140. })
  141. await page.waitForFunction(() => {
  142. const dev = window.__cockpitMap
  143. const state = dev && dev.mapSummary ? dev.mapSummary.getState() : null
  144. return !state || ['complete', 'empty', 'failed', 'expired'].includes(state.phase)
  145. }, { timeout: 20000 }).catch(() => {})
  146. await waitSettled()
  147. // ── 第 1 步:真实鼠标点地图上的监测点标记 ──
  148. const marker = await stableMarker('MP-001')
  149. check('U1-monitoring-marker-projectable',
  150. Boolean(marker && Number.isFinite(marker.x) && Number.isFinite(marker.y)),
  151. marker ? `标记 ${marker.code} 窗口坐标 ${marker.x.toFixed(1)},${marker.y.toFixed(1)}` : '读不到可点的监测点标记')
  152. const locateMarker = () => page.evaluate(() => {
  153. const dev = window.__cockpitMap
  154. const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : []
  155. return all.find((item) => item.visible && item.code === 'MP-001') || null
  156. })
  157. const readDetail = () => page.evaluate(() => {
  158. const card = document.querySelector('[data-point-detail]')
  159. const aside = document.querySelector('.cockpit-shell__detail')
  160. return {
  161. detailLevel: card ? card.dataset.detailLevel : '',
  162. pointCode: card ? card.dataset.pointCode : '',
  163. asidePresent: !!aside,
  164. rows: [...document.querySelectorAll('[data-device-layer-row]')].map((n) => n.dataset.deviceLayerCode),
  165. }
  166. })
  167. let opened = await readDetail()
  168. if (marker && !(opened.detailLevel === 'point' && opened.pointCode === 'MP-001')) {
  169. await clickUntil(locateMarker, async () => {
  170. const now = await readDetail()
  171. return now.detailLevel === 'point' && now.pointCode === 'MP-001'
  172. })
  173. opened = await readDetail()
  174. }
  175. check('U2-real-marker-click-opens-point-detail',
  176. opened.detailLevel === 'point' && opened.pointCode === 'MP-001' && opened.asidePresent,
  177. `真实点标记后 detailLevel=${opened.detailLevel} pointCode=${opened.pointCode} aside=${opened.asidePresent} 清单=${opened.rows.join('/')}`)
  178. // 遮罩让真实鼠标能命中画布(既有锚定浮层会盖住画布;仅测试态,不改生产行为)。
  179. await page.evaluate(() => {
  180. const detail = document.querySelector('.cockpit-shell__detail')
  181. if (detail) {
  182. detail.style.pointerEvents = 'none'
  183. detail.querySelectorAll('*').forEach((child) => { child.style.pointerEvents = 'none' })
  184. }
  185. })
  186. await waitSettled()
  187. const read = () => page.evaluate(() => {
  188. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  189. const dev = window.__cockpitMap
  190. const devices = dev && dev.getAssociatedDevices ? dev.getAssociatedDevices() : { entities: [], onMap: 0 }
  191. return {
  192. overlap: dev && dev.getOverlapState ? dev.getOverlapState() : null,
  193. targets: dev && dev.getOverlapTargets ? dev.getOverlapTargets() : [],
  194. entities: devices.entities || [],
  195. onMap: devices.onMap,
  196. detailLevel: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '',
  197. devicePicked: host && host.dataset.mapDevicePicked,
  198. panelSelection: host && host.dataset.mapPanelSelection,
  199. rows: [...document.querySelectorAll('[data-device-layer-row]')].length,
  200. }
  201. })
  202. const before = await read()
  203. check('U3-overlap-aggregate-present',
  204. before.overlap && before.overlap.count === EXPECT.k &&
  205. before.entities.some((e) => e.overlapAggregate) &&
  206. before.rows === EXPECT.k,
  207. `K=${before.overlap && before.overlap.count}(期望 ${EXPECT.k})聚合实体=${before.entities.filter((e) => e.overlapAggregate).length} 清单=${before.rows} 行`)
  208. // ── 第 2 步:真实鼠标 hover + click 聚合符号 → 展开 ──
  209. const aggregateTarget = before.targets.find((t) => t.aggregate && t.visible)
  210. if (aggregateTarget) {
  211. await page.mouse.move(aggregateTarget.x, aggregateTarget.y)
  212. await sleep(350)
  213. const preview = await read()
  214. check('U4-real-hover-preview',
  215. preview.overlap && preview.overlap.preview === true && preview.overlap.expanded === false,
  216. `hover 后 preview=${preview.overlap && preview.overlap.preview} expanded=${preview.overlap && preview.overlap.expanded}`)
  217. await page.mouse.click(aggregateTarget.x, aggregateTarget.y)
  218. await sleep(600)
  219. }
  220. const expanded = await read()
  221. const visibleDevices = expanded.entities.filter((e) => e.deviceCode && e.visible)
  222. check('U5-real-click-expands-fan',
  223. expanded.overlap && expanded.overlap.state === EXPECT.state && visibleDevices.length === EXPECT.fanVisible,
  224. `state=${expanded.overlap && expanded.overlap.state}(期望 ${EXPECT.state})本屏设备=${visibleDevices.length}(期望 ${EXPECT.fanVisible}):${visibleDevices.map((e) => e.deviceCode).join('/')}`)
  225. // ── 第 3 步:真实鼠标点展开后的设备点 → 选中并进设备级 ──
  226. const deviceTarget = expanded.targets.find((t) => t.deviceCode === EXPECT.firstDevice && t.visible)
  227. if (deviceTarget) {
  228. await page.mouse.move(1200, 500)
  229. await sleep(150)
  230. await page.mouse.click(deviceTarget.x, deviceTarget.y)
  231. await sleep(1200)
  232. }
  233. const picked = await read()
  234. check('U6-real-click-fan-device-selects',
  235. Boolean(deviceTarget) && picked.devicePicked === EXPECT.firstDevice && picked.panelSelection === 'equipment',
  236. `设备目标=${Boolean(deviceTarget)}(${EXPECT.firstDevice}) picked=${picked.devicePicked} panel=${picked.panelSelection} detail=${picked.detailLevel}`)
  237. check('U7-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
  238. await page.close()
  239. await browser.close()
  240. const failed = results.filter((r) => !r.pass)
  241. console.log('')
  242. console.log(`T45 真实用户路径走查:${results.length - failed.length}/${results.length} 项通过。`)
  243. if (failed.length) { for (const item of failed) console.log(` · ${item.id}`); process.exit(1) }
  244. console.log('T45 真实用户路径走查全部通过。')