t30-intro-walkthrough.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * T30 真机走查:进入引导(星球自转 + 点击进入)→ 飞到目标城市 → 快速归位(票 30)。
  3. *
  4. * 断言的是维护者提出的痛点能不能被证伪:他"完全找不到目标位置,无法判断 DEM 与精细影像是否部署成功"。
  5. * 所以这里不只测 UI,还要证明:
  6. * · 引导层真的在、文案真的在闪(animation 生效);
  7. * · 引导期间星球**真的在转**(采样两次相机经度有位移);
  8. * · 点一下之后镜头**真的落到沅陵县城坐标**(经纬度误差 < 1e-4°,且距离是县域级);
  9. * · 手动把镜头折腾走后,点「归位」能回到同一视角(中心 + heading/pitch/range)。
  10. *
  11. * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t30-intro-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-t30-intro.png')
  21. const OUT_HOME = join(tmpdir(), 'cockpit-t30-home.png')
  22. const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
  23. const HOME = config.map.home
  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. const puppeteer = (await import(PUPPETEER)).default
  31. const browser = await puppeteer.launch({
  32. executablePath: CHROME,
  33. headless: true,
  34. userDataDir: join(tmpdir(), 'cockpit-t30-profile'),
  35. defaultViewport: { width: 1680, height: 950 },
  36. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  37. })
  38. console.log(`Chrome: ${await browser.version()}`)
  39. try {
  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) => {
  44. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  45. })
  46. await page.evaluateOnNewDocument((injected) => {
  47. window.__COCKPIT_CONFIG__ = injected
  48. }, config)
  49. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  50. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  51. // 等引擎就绪(引导层此时应该已经盖在上面了)
  52. await page.waitForFunction(
  53. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
  54. { timeout: 60000 },
  55. )
  56. await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 })
  57. await sleep(1200)
  58. // ---------- A1 引导层存在、文案与闪烁 ----------
  59. const intro = await page.evaluate(() => {
  60. const layer = document.querySelector('[data-cockpit-intro]')
  61. const title = layer?.querySelector('.cockpit-intro__title')
  62. const style = title ? getComputedStyle(title) : null
  63. return {
  64. present: !!layer,
  65. title: title?.textContent?.trim() || '',
  66. animation: style ? style.animationName : '',
  67. animationDuration: style ? style.animationDuration : '',
  68. zIndex: layer ? getComputedStyle(layer).zIndex : '',
  69. }
  70. })
  71. check(
  72. 'A1-intro-visible',
  73. '进入时先看到引导层:中央「点击进入系统」文案在位,且闪烁动画真的生效',
  74. intro.present && intro.title.includes('点击进入系统') && intro.animation !== 'none' && Number(intro.zIndex) >= 10,
  75. JSON.stringify(intro),
  76. )
  77. await page.screenshot({ path: OUT })
  78. // ---------- A2 星球真的在自转(采样相机经度) ----------
  79. const sampleLon = () =>
  80. page.evaluate(() => {
  81. const state = window.__cockpitMap.getCameraState()
  82. return state?.pivot ? state.pivot.lon : null
  83. })
  84. const spinA = await sampleLon()
  85. await sleep(2600)
  86. const spinB = await sampleLon()
  87. check(
  88. 'A2-globe-spinning',
  89. '引导期间星球确实在自转(2.6 秒内相机中心经度发生位移)',
  90. spinA !== null && spinB !== null && Math.abs(spinB - spinA) > 0.05,
  91. `lon ${spinA} → ${spinB} (Δ=${spinB !== null && spinA !== null ? (spinB - spinA).toFixed(3) : 'n/a'})`,
  92. )
  93. // ---------- A3 点击进入:引导消失 + 镜头落到沅陵县城 ----------
  94. await page.click('[data-cockpit-intro]')
  95. await sleep(2500)
  96. const afterEnter = await page.evaluate(() => {
  97. const state = window.__cockpitMap.getCameraState()
  98. return {
  99. introGone: !document.querySelector('[data-cockpit-intro]'),
  100. pivot: state?.pivot || null,
  101. hpr: state?.hpr || null,
  102. settled: state?.settled,
  103. atHomeAttr: document.querySelector('.cockpit-shell__home')?.dataset.homeAt || '',
  104. }
  105. })
  106. const dLon = Math.abs((afterEnter.pivot?.lon ?? 999) - HOME.lon)
  107. const dLat = Math.abs((afterEnter.pivot?.lat ?? 999) - HOME.lat)
  108. check(
  109. 'A3-enter-flies-to-target',
  110. '点击后引导层消失,镜头落到目标城市(经纬度误差 < 1e-4°,距离为县域级)',
  111. afterEnter.introGone && dLon < 1e-4 && dLat < 1e-4 && afterEnter.hpr?.range > 1000,
  112. JSON.stringify({ dLon, dLat, pivot: afterEnter.pivot, hpr: afterEnter.hpr, settled: afterEnter.settled }),
  113. )
  114. await sleep(1500)
  115. await page.screenshot({ path: OUT_HOME })
  116. // ---------- A4 自转在进入后停止 ----------
  117. const stopA = await sampleLon()
  118. await sleep(2000)
  119. const stopB = await sampleLon()
  120. check(
  121. 'A4-spin-stopped',
  122. '进入系统后自转停止(2 秒内相机中心不再漂移)',
  123. stopA !== null && stopB !== null && Math.abs(stopB - stopA) < 1e-6,
  124. `lon ${stopA} → ${stopB}`,
  125. )
  126. // ---------- A5 折腾走之后,归位按钮能回到同一视角 ----------
  127. const homeBefore = await page.evaluate(() => {
  128. const s = window.__cockpitMap.getCameraState()
  129. return { pivot: s.pivot, hpr: s.hpr }
  130. })
  131. // 用真实输入把它推开:左键拖拽平移 + 滚轮缩放
  132. const canvas = await page.evaluate(() => {
  133. const c = document.querySelector('.cockpit-shell__map .map-canvas canvas')
  134. const r = c.getBoundingClientRect()
  135. return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
  136. })
  137. await page.mouse.move(canvas.x, canvas.y)
  138. await page.mouse.down({ button: 'left' })
  139. await page.mouse.move(canvas.x + 180, canvas.y + 120, { steps: 8 })
  140. await page.mouse.up({ button: 'left' })
  141. await page.mouse.wheel({ deltaY: 600 })
  142. await sleep(1500)
  143. const moved = await page.evaluate(() => {
  144. const s = window.__cockpitMap.getCameraState()
  145. return { pivot: s.pivot, hpr: s.hpr, atHomeAttr: document.querySelector('.cockpit-shell__home')?.dataset.homeAt }
  146. })
  147. const movedAway =
  148. Math.abs(moved.pivot.lon - homeBefore.pivot.lon) > 1e-4 || Math.abs(moved.hpr.range - homeBefore.hpr.range) > 1
  149. check(
  150. 'A5a-moved-away',
  151. '用手动操作把镜头推离目标(平移 + 缩放),且顶栏「归位」按钮显示未在家',
  152. movedAway && moved.atHomeAttr === 'no',
  153. JSON.stringify({ moved: { lon: moved.pivot.lon, range: moved.hpr.range }, atHome: moved.atHomeAttr }),
  154. )
  155. await page.click('.cockpit-shell__home')
  156. await sleep(3000)
  157. const backHome = await page.evaluate(() => {
  158. const s = window.__cockpitMap.getCameraState()
  159. return { pivot: s.pivot, hpr: s.hpr, atHomeAttr: document.querySelector('.cockpit-shell__home')?.dataset.homeAt }
  160. })
  161. const dLonBack = Math.abs(backHome.pivot.lon - HOME.lon)
  162. const dLatBack = Math.abs(backHome.pivot.lat - HOME.lat)
  163. const dRangeBack = Math.abs(backHome.hpr.range - HOME.range) / HOME.range
  164. check(
  165. 'A5b-home-button-returns',
  166. '点「归位」后回到目标视角(中心 < 1e-4°、距离误差 < 5%),按钮显示已在家',
  167. dLonBack < 1e-4 && dLatBack < 1e-4 && dRangeBack < 0.05 && backHome.atHomeAttr === 'yes',
  168. JSON.stringify({ dLon: dLonBack, dLat: dLatBack, rangeErr: dRangeBack, atHome: backHome.atHomeAttr }),
  169. )
  170. check('A6-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  171. } finally {
  172. await browser.close()
  173. }
  174. const failed = results.filter((r) => !r.pass)
  175. console.log('')
  176. console.log(`T30 走查:${results.length - failed.length}/${results.length} 项通过。`)
  177. console.log(`截图:引导页 ${OUT} / 进入后 ${OUT_HOME}`)
  178. if (failed.length) {
  179. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  180. process.exit(1)
  181. }
  182. console.log('T30 走查全部通过。')