t21-view-modes-walkthrough.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. /**
  2. * T21 真机走查:地面/管线视图切换(票 21 验收第 4、5 条)。
  3. *
  4. * 逻辑脚本(t21-view-modes.mjs)把取值测透了,这里只测真机上才成立的部分:
  5. * · 点「管线视图」:相机真的被压低 + pitch 真的加大,且**旋转中心一点没变**;
  6. * · 地形真的变半透明(globe.translucency 生效);
  7. * · 切换视图后**三键分工照旧**(左键仍只平移、滚轮仍只改距离);
  8. * · 回「地面视图」:倾角/距离按进管线前的基线还原;
  9. * · 视图条件在**切换一级模式时不重置**(与范围/行业同为全局条件)。
  10. *
  11. * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t21-view-modes-walkthrough.mjs
  12. */
  13. import { readFileSync } from 'node:fs'
  14. import { join } from 'node:path'
  15. import { tmpdir } from 'node:os'
  16. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  17. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  18. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  19. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  20. const OUT = join(tmpdir(), 'cockpit-t21-pipeline.png')
  21. const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
  22. /** 地面视图的默认倾角(与 domain/viewModes.js 的 GROUND_PITCH 一致;走查独立写死,避免"用被测值断言被测值")。 */
  23. const GROUND_PITCH = -55
  24. const results = []
  25. function check(id, desc, pass, evidence) {
  26. results.push({ id, desc, pass })
  27. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  28. }
  29. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  30. /**
  31. * 等相机停稳(`settled === true`)再断言,而不是死等固定毫秒。
  32. * 依据:相机是阻尼收敛的,机器负载高时 2.5 秒可能还没到位 —— 实测在连续跑多个 Chrome 的
  33. * 回归批次里,固定 sleep 会把 A2/A5 判红(单独跑则 10/10),是**测试的时序问题**而非功能回归。
  34. *
  35. * 判据里必须容 null:引擎重建的空档里 `getCameraState()` 会返回 null,
  36. * 直接读 `.settled` 会在页面里抛错(进而被算成页面级报错)。
  37. */
  38. async function waitSettled(page, timeoutMs = 25000) {
  39. await page
  40. .waitForFunction(
  41. () => {
  42. const api = window.__cockpitMap
  43. if (!api) return false
  44. const state = api.getCameraState()
  45. return !!state && state.settled === true
  46. },
  47. { timeout: timeoutMs, polling: 250 },
  48. )
  49. .catch(() => {})
  50. await sleep(250)
  51. }
  52. /**
  53. * 把镜头放到目标点。**带重试** —— 与外壳里「归位 / 进入系统」同一套兜底:
  54. * 相机契约还没挂上时 `goHome()` 返回 false(不静默生效),单次调用会让后续断言全部错位。
  55. */
  56. async function goHome(page, home) {
  57. for (let i = 0; i < 40; i++) {
  58. const applied = await page.evaluate((h) => window.__cockpitMap.goHome(h), home).catch(() => false)
  59. if (applied) return true
  60. await sleep(250)
  61. }
  62. return false
  63. }
  64. const puppeteer = (await import(PUPPETEER)).default
  65. const browser = await puppeteer.launch({
  66. executablePath: CHROME,
  67. headless: true,
  68. userDataDir: join(tmpdir(), 'cockpit-t21vm-profile'),
  69. defaultViewport: { width: 1680, height: 950 },
  70. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  71. })
  72. console.log(`Chrome: ${await browser.version()}`)
  73. try {
  74. const page = await browser.newPage()
  75. const errors = []
  76. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  77. page.on('console', (m) => {
  78. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  79. })
  80. await page.evaluateOnNewDocument((cfg) => {
  81. window.__COCKPIT_CONFIG__ = { ...cfg, map: { ...cfg.map, intro: { enabled: false } } }
  82. }, config)
  83. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  84. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  85. await page.waitForFunction(
  86. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
  87. { timeout: 60000 },
  88. )
  89. await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 })
  90. await waitSettled(page)
  91. const readState = () =>
  92. page.evaluate(() => {
  93. const s = window.__cockpitMap.getCameraState()
  94. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  95. return {
  96. pivot: s.pivot,
  97. hpr: s.hpr,
  98. viewMode: window.__cockpitMap.getViewMode(),
  99. dataAttr: host?.dataset.mapViewMode || '',
  100. pressed: Array.from(document.querySelectorAll('.cockpit-shell__view-btn'))
  101. .filter((b) => b.getAttribute('aria-pressed') === 'true')
  102. .map((b) => b.dataset.viewMode),
  103. }
  104. })
  105. // 先把镜头放到沅陵(不然"管线视图"没有可看的对象)
  106. await goHome(page, config.map.home)
  107. await waitSettled(page)
  108. // ---------- A1 初始是地面视图,控件状态正确 ----------
  109. const ground = await readState()
  110. check(
  111. 'A1-ground-default',
  112. '初始为地面视图:状态与按钮 aria-pressed 同步,宿主带 data-map-view-mode',
  113. ground.viewMode === 'ground' &&
  114. ground.dataAttr === 'ground' &&
  115. ground.pressed.length === 1 &&
  116. ground.pressed[0] === 'ground',
  117. JSON.stringify({ viewMode: ground.viewMode, dataAttr: ground.dataAttr, pressed: ground.pressed }),
  118. )
  119. const beforeSwitch = { pivot: ground.pivot, hpr: ground.hpr }
  120. // ---------- A2 切到管线视图:压低相机 + 加大 pitch,中心不动 ----------
  121. await page.click('.cockpit-shell__view-btn[data-view-mode="pipeline"]')
  122. await waitSettled(page)
  123. const pipeline = await readState()
  124. const samePivot =
  125. pipeline.pivot &&
  126. Math.abs(pipeline.pivot.lon - beforeSwitch.pivot.lon) < 1e-9 &&
  127. Math.abs(pipeline.pivot.lat - beforeSwitch.pivot.lat) < 1e-9 &&
  128. Math.abs((pipeline.pivot.height || 0) - (beforeSwitch.pivot.height || 0)) < 1e-6
  129. check(
  130. 'A2-pipeline-camera',
  131. '管线视图真的压低相机(距离变小)且 pitch 加大(更接近平视)',
  132. pipeline.viewMode === 'pipeline' &&
  133. pipeline.hpr.range < beforeSwitch.hpr.range &&
  134. pipeline.hpr.pitch > beforeSwitch.hpr.pitch,
  135. JSON.stringify({ before: beforeSwitch.hpr, after: pipeline.hpr }),
  136. )
  137. check(
  138. 'A2-center-untouched',
  139. '切换视图**不重置旋转中心**(经纬高完全一致)—— 决策地图票 05 的硬约束',
  140. samePivot,
  141. JSON.stringify({ before: beforeSwitch.pivot, after: pipeline.pivot }),
  142. )
  143. check(
  144. 'A2-controls-synced',
  145. '按钮高亮/aria-pressed 与宿主 data-map-view-mode 都同步到 pipeline',
  146. pipeline.pressed.length === 1 && pipeline.pressed[0] === 'pipeline' && pipeline.dataAttr === 'pipeline',
  147. JSON.stringify({ pressed: pipeline.pressed, dataAttr: pipeline.dataAttr }),
  148. )
  149. // ---------- A3 管线视图的图层状态确实下发了 ----------
  150. const viewState = await page.evaluate(() => ({
  151. dataAttr: document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapViewMode || '',
  152. engine: window.__cockpitMap.getViewMode(),
  153. }))
  154. check(
  155. 'A3-view-applied',
  156. '管线视图已下发给地图服务(宿主 data-map-view-mode 与 getViewMode() 都为 pipeline)',
  157. viewState.dataAttr === 'pipeline' && viewState.engine === 'pipeline',
  158. JSON.stringify(viewState),
  159. )
  160. await page.screenshot({ path: OUT })
  161. // ---------- A4 三键分工不变:左键仍只平移、滚轮仍只改距离 ----------
  162. const canvas = await page.evaluate(() => {
  163. const c = document.querySelector('.cockpit-shell__map .map-canvas canvas')
  164. const r = c.getBoundingClientRect()
  165. return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
  166. })
  167. const prePan = await readState()
  168. await page.mouse.move(canvas.x, canvas.y)
  169. await page.mouse.down({ button: 'left' })
  170. await page.mouse.move(canvas.x + 120, canvas.y + 80, { steps: 8 })
  171. await page.mouse.up({ button: 'left' })
  172. await sleep(1200)
  173. const postPan = await readState()
  174. check(
  175. 'A4-pan-still-pans',
  176. '管线视图里左键仍只平移:中心移动、距离与倾角基本不变',
  177. Math.abs(postPan.pivot.lon - prePan.pivot.lon) > 1e-6 &&
  178. Math.abs(postPan.hpr.range - prePan.hpr.range) < prePan.hpr.range * 0.02 &&
  179. Math.abs(postPan.hpr.pitch - prePan.hpr.pitch) < 1,
  180. JSON.stringify({ dLon: postPan.pivot.lon - prePan.pivot.lon, before: prePan.hpr, after: postPan.hpr }),
  181. )
  182. const preWheel = await readState()
  183. await page.mouse.wheel({ deltaY: 400 })
  184. await sleep(1200)
  185. const postWheel = await readState()
  186. check(
  187. 'A4-wheel-still-zooms',
  188. '管线视图里滚轮仍只改距离:距离变化、倾角与中心基本不变',
  189. Math.abs(postWheel.hpr.range - preWheel.hpr.range) > 1 &&
  190. Math.abs(postWheel.hpr.pitch - preWheel.hpr.pitch) < 1,
  191. JSON.stringify({ before: preWheel.hpr, after: postWheel.hpr }),
  192. )
  193. // ---------- A5 回地面:按进管线前的基线还原 ----------
  194. await page.click('.cockpit-shell__view-btn[data-view-mode="ground"]')
  195. await waitSettled(page)
  196. const backGround = await readState()
  197. check(
  198. 'A5-ground-restores',
  199. '回地面视图:倾角与距离还原到进管线前(不是留在管线视角)',
  200. backGround.viewMode === 'ground' &&
  201. Math.abs(backGround.hpr.pitch - GROUND_PITCH) < 3 &&
  202. Math.abs(backGround.hpr.range - beforeSwitch.hpr.range) < beforeSwitch.hpr.range * 0.05,
  203. JSON.stringify({ restored: backGround.hpr, expectedPitch: GROUND_PITCH, expectedRange: beforeSwitch.hpr.range }),
  204. )
  205. // ---------- A6 视图是全局条件:切一级模式不重置 ----------
  206. await page.click('.cockpit-shell__view-btn[data-view-mode="pipeline"]')
  207. await sleep(1500)
  208. const modeButtons = await page.$$('.cockpit-shell__mode')
  209. await modeButtons[1].click()
  210. await sleep(1200)
  211. const afterModeSwitch = await page.evaluate(() => {
  212. const pressed = Array.from(document.querySelectorAll('.cockpit-shell__view-btn'))
  213. .filter((b) => b.getAttribute('aria-pressed') === 'true')
  214. .map((b) => b.dataset.viewMode)
  215. return { mode: window.__cockpitMap.getViewMode(), pressed }
  216. })
  217. check(
  218. 'A6-global-across-modes',
  219. '视图条件是全局条件:切一级模式后仍是管线视图(与范围/行业同级,不随模式重置)',
  220. afterModeSwitch.mode === 'pipeline' && afterModeSwitch.pressed[0] === 'pipeline',
  221. JSON.stringify(afterModeSwitch),
  222. )
  223. check('A7-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  224. } finally {
  225. await browser.close()
  226. }
  227. const failed = results.filter((r) => !r.pass)
  228. console.log('')
  229. console.log(`T21 视图走查:${results.length - failed.length}/${results.length} 项通过。截图:${OUT}`)
  230. if (failed.length) {
  231. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  232. process.exit(1)
  233. }
  234. console.log('T21 视图走查全部通过。')