t03-camera-walkthrough.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. /**
  2. * T03 真机走查:三键相机契约落在真实 Cesium 上的行为(票 03 / ADR 0008)。
  3. *
  4. * 逻辑脚本(t03-camera-contract.mjs)用假引擎把语义测透了,这里只补浏览器里才成立的部分:
  5. * · Cesium 默认左键旋转 / 滚轮缩放确实被替换(左键只平移、滚轮只改距离);
  6. * · 三种交互在真机上确实按契约改变相机;
  7. * · 未选中时中心就是屏幕中心的地面拾取点(用中心点在画布上的投点位置断言);
  8. * · 选中换中心平滑且保持 heading 与 range,取消选中不回退。
  9. *
  10. * 走查用 page.mouse 派发**真实**输入事件(不是合成 DOM 事件),读的是开发态把手
  11. * `window.__cockpitMap`(见 mapService 的 publishDevHook)与宿主上的 `data-camera`。
  12. *
  13. * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/):
  14. * node .scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs
  15. */
  16. import { join } from 'node:path'
  17. import { tmpdir } from 'node:os'
  18. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  19. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  20. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  21. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  22. /** 沅陵县某监测点(只用于「选中换中心」这条,跟真实数据无关)。 */
  23. const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 }
  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 round = (n, digits = 3) => Number(n.toFixed(digits))
  31. const puppeteer = (await import(PUPPETEER)).default
  32. const browser = await puppeteer.launch({
  33. executablePath: CHROME,
  34. headless: true,
  35. userDataDir: join(tmpdir(), 'cockpit-t03-profile'),
  36. defaultViewport: { width: 1680, height: 950 },
  37. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  38. })
  39. console.log(`Chrome: ${await browser.version()}`)
  40. try {
  41. const page = await browser.newPage()
  42. const pageErrors = []
  43. page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e)))
  44. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  45. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  46. await page
  47. .waitForFunction(
  48. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
  49. { timeout: 60000 },
  50. )
  51. .catch(() => {})
  52. await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 })
  53. await sleep(800)
  54. const readState = () =>
  55. page.evaluate(() => {
  56. const state = window.__cockpitMap.getCameraState()
  57. const canvas = document.querySelector('.cockpit-shell__map .map-canvas canvas')
  58. const rect = canvas ? canvas.getBoundingClientRect() : null
  59. return {
  60. ...state,
  61. dataAttr: document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.camera || '',
  62. canvas: rect ? { x: rect.x, y: rect.y, w: rect.width, h: rect.height } : null,
  63. projection: window.__cockpitMap.projectCameraPivot(),
  64. }
  65. })
  66. const center = async () => {
  67. const st = await readState()
  68. return { x: st.canvas.x + st.canvas.w / 2, y: st.canvas.y + st.canvas.h / 2 }
  69. }
  70. /**
  71. * 等阻尼收敛(实际相机跟上目标)。有了阻尼之后,手势结束不等于画面停稳,
  72. * 量「中心是否落在画布中心」这类**实际态**几何之前必须先等 settled。
  73. */
  74. const waitSettled = async (timeout = 4000) => {
  75. await page.waitForFunction(() => window.__cockpitMap.getCameraState().settled === true, { timeout }).catch(() => {})
  76. }
  77. // ---------- A1 就绪与状态可读 ----------
  78. const base = await readState()
  79. check(
  80. 'A1-camera-ready',
  81. '引擎就绪后相机状态可读:弹簧臂参数与中心都有值,宿主 data-camera 同步',
  82. !!base &&
  83. !!base.pivot &&
  84. base.pivot.lon > -180 &&
  85. base.hpr.range > 0 &&
  86. base.dataAttr.includes('pivot=') &&
  87. base.dataAttr.includes('h='),
  88. JSON.stringify({ hpr: base.hpr, pivot: base.pivot, data: base.dataAttr }),
  89. )
  90. // ---------- A2 左键拖拽只平移(Cesium 默认左键旋转已被替换) ----------
  91. const a2before = await readState()
  92. const c2 = await center()
  93. await page.mouse.move(c2.x, c2.y)
  94. await page.mouse.down({ button: 'left' })
  95. await page.mouse.move(c2.x + 160, c2.y, { steps: 8 })
  96. await page.mouse.up({ button: 'left' })
  97. await sleep(150)
  98. await waitSettled()
  99. const a2after = await readState()
  100. {
  101. const dEast = a2after.pivot.lon - a2before.pivot.lon
  102. const dNorth = a2after.pivot.lat - a2before.pivot.lat
  103. // 相机右手方向(东北分量):heading 上「前方」=(sin h, cos h),右手 = (cos h, -sin h)。
  104. // 维护者 2026-09-17 裁定:**抓着地图拖** —— 拖右 → 画面右移、相机左移,
  105. // 所以中心沿相机右手的**反方向**移动(alongRight 应为负)。
  106. const h = (a2before.hpr.heading * Math.PI) / 180
  107. const alongRight = dEast * Math.cos(h) + dNorth * -Math.sin(h)
  108. check(
  109. 'A2-left-drag-pans-only',
  110. '左键拖拽只平移:heading/pitch/range 不变,中心沿「抓地图」方向移动(默认左键旋转已被替换)',
  111. Math.abs(a2after.hpr.heading - a2before.hpr.heading) < 0.05 &&
  112. Math.abs(a2after.hpr.pitch - a2before.hpr.pitch) < 0.05 &&
  113. Math.abs(a2after.hpr.range - a2before.hpr.range) < 1 &&
  114. Math.abs(dEast) + Math.abs(dNorth) > 1e-6 &&
  115. alongRight < 0,
  116. JSON.stringify({
  117. hBefore: round(a2before.hpr.heading),
  118. hAfter: round(a2after.hpr.heading),
  119. pivotBefore: a2before.pivot,
  120. pivotAfter: a2after.pivot,
  121. direction: alongRight < 0 ? '沿相机左手方向(画面右移)' : '方向反了',
  122. }),
  123. )
  124. }
  125. // ---------- A3 右键拖拽只做轨道旋转 ----------
  126. // 注意方向:默认全球视角的 pitch 已在下限附近,向下拖会被夹住,所以这里向上拖(pitch 变大)。
  127. const a3before = await readState()
  128. const c3 = await center()
  129. await page.mouse.move(c3.x, c3.y)
  130. await page.mouse.down({ button: 'right' })
  131. await page.mouse.move(c3.x + 120, c3.y - 40, { steps: 8 })
  132. await page.mouse.up({ button: 'right' })
  133. await sleep(120)
  134. await waitSettled()
  135. const a3after = await readState()
  136. {
  137. const dHeading = ((a3after.hpr.heading - a3before.hpr.heading + 540) % 360) - 180
  138. const dPitch = a3after.hpr.pitch - a3before.hpr.pitch
  139. const dPivot = Math.abs(a3after.pivot.lon - a3before.pivot.lon) + Math.abs(a3after.pivot.lat - a3before.pivot.lat)
  140. check(
  141. 'A3-right-drag-orbits-only',
  142. '右键拖拽只做轨道旋转:heading 随 dx、pitch 随 dy 变化,range 与中心不动',
  143. dHeading > 20 &&
  144. dHeading < 45 &&
  145. dPitch > 5 &&
  146. Math.abs(a3after.hpr.range - a3before.hpr.range) < 1 &&
  147. dPivot < 1e-9,
  148. JSON.stringify({
  149. dHeading: round(dHeading),
  150. dPitch: round(dPitch),
  151. pitchBefore: round(a3before.hpr.pitch),
  152. pitchAfter: round(a3after.hpr.pitch),
  153. range: round(a3after.hpr.range),
  154. dPivot,
  155. }),
  156. )
  157. }
  158. // ---------- A4 滚轮只改弹簧臂长度 ----------
  159. const a4before = await readState()
  160. const c4 = await center()
  161. await page.mouse.move(c4.x, c4.y)
  162. await page.mouse.wheel({ deltaY: -240 })
  163. await sleep(150)
  164. const a4after = await readState()
  165. check(
  166. 'A4-wheel-changes-range-only',
  167. '滚轮只改弹簧臂长度:上滚拉近,heading/pitch/中心不动(默认滚轮缩放已被替换)',
  168. a4after.hpr.range < a4before.hpr.range * 0.95 &&
  169. Math.abs(a4after.hpr.heading - a4before.hpr.heading) < 0.05 &&
  170. Math.abs(a4after.hpr.pitch - a4before.hpr.pitch) < 0.05 &&
  171. Math.abs(a4after.pivot.lon - a4before.pivot.lon) < 1e-9 &&
  172. Math.abs(a4after.pivot.lat - a4before.pivot.lat) < 1e-9,
  173. JSON.stringify({ before: round(a4before.hpr.range), after: round(a4after.hpr.range), pivot: a4after.pivot }),
  174. )
  175. // ---------- A5 未选中时中心 = 屏幕中心的地面拾取点 ----------
  176. await waitSettled()
  177. const a5 = await readState()
  178. {
  179. const cx = a5.canvas.x + a5.canvas.w / 2
  180. const cy = a5.canvas.y + a5.canvas.h / 2
  181. const dx = a5.projection ? Math.abs(a5.projection.x - cx) : Infinity
  182. const dy = a5.projection ? Math.abs(a5.projection.y - cy) : Infinity
  183. check(
  184. 'A5-pivot-is-ground-pick',
  185. '未选中时中心 = 屏幕中心的地面拾取点(中心点投回画布即画布中心)',
  186. dx <= 2 && dy <= 2,
  187. JSON.stringify({ projection: a5.projection, canvasCenter: { x: round(cx, 1), y: round(cy, 1) }, dx: round(dx, 2), dy: round(dy, 2) }),
  188. )
  189. }
  190. // ---------- A6 选中换中心:平滑、保持 heading 与 range ----------
  191. const a6before = await readState()
  192. const sampled = await page.evaluate(async (target) => {
  193. const samples = []
  194. window.__cockpitMap.setCameraFocus(target)
  195. const started = performance.now()
  196. while (performance.now() - started < 1400) {
  197. const state = window.__cockpitMap.getCameraState()
  198. samples.push({
  199. t: Math.round(performance.now() - started),
  200. heading: state.hpr.heading,
  201. range: state.hpr.range,
  202. lon: state.pivot.lon,
  203. lat: state.pivot.lat,
  204. flying: state.flying,
  205. })
  206. if (!state.flying) break
  207. await new Promise((r) => setTimeout(r, 40))
  208. }
  209. return samples
  210. }, XIANGLING)
  211. {
  212. const headingFixed = sampled.every((s) => Math.abs(s.heading - a6before.hpr.heading) < 1e-9)
  213. const rangeFixed = sampled.every((s) => Math.abs(s.range - a6before.hpr.range) < 1e-9)
  214. const flyingFrames = sampled.filter((s) => s.flying).length
  215. const last = sampled[sampled.length - 1]
  216. const startLon = a6before.pivot.lon
  217. const dir = Math.sign(XIANGLING.lon - startLon) || 1
  218. const toward = sampled.map((s) => (s.lon - startLon) * dir)
  219. const monotonic = toward.every((v, i) => i === 0 || v >= toward[i - 1] - 1e-12)
  220. // 首帧不许瞬移:它离目标仍差大半个行程(相对起止跨度判断,与尺度无关)。
  221. const span = Math.abs(XIANGLING.lon - startLon)
  222. const firstGap = Math.abs(XIANGLING.lon - sampled[0].lon)
  223. check(
  224. 'A6-focus-flies-to-ground-point',
  225. '选中对象:中心平滑飞到该对象地表点,全程保持 heading 与 range(首帧不瞬移)',
  226. sampled.length >= 3 &&
  227. flyingFrames >= 1 &&
  228. headingFixed &&
  229. rangeFixed &&
  230. monotonic &&
  231. Math.abs(last.lon - XIANGLING.lon) < 1e-6 &&
  232. Math.abs(last.lat - XIANGLING.lat) < 1e-6 &&
  233. firstGap > span * 0.2,
  234. JSON.stringify({
  235. frames: sampled.length,
  236. flyingFrames,
  237. heading: round(last.heading),
  238. range: round(last.range),
  239. pivot: { lon: round(last.lon, 6), lat: round(last.lat, 6) },
  240. spanDeg: round(span),
  241. firstGapDeg: round(firstGap),
  242. }),
  243. )
  244. }
  245. // ---------- A7 取消选中不回退中心 ----------
  246. await waitSettled()
  247. const a7before = await readState()
  248. await page.evaluate(() => window.__cockpitMap.clearCameraFocus())
  249. await sleep(200)
  250. await waitSettled()
  251. const a7after = await readState()
  252. check(
  253. 'A7-clear-focus-keeps-pivot',
  254. '取消选中不立即回退中心(中心仍在刚选中的对象上,focus 标记清空)',
  255. Math.abs(a7after.pivot.lon - a7before.pivot.lon) < 1e-9 &&
  256. Math.abs(a7after.pivot.lat - a7before.pivot.lat) < 1e-9 &&
  257. a7after.focus === null,
  258. JSON.stringify({ pivot: a7after.pivot, focus: a7after.focus }),
  259. )
  260. // ---------- A8 平移把中心交回地面拾取点 ----------
  261. const c8 = await center()
  262. await page.mouse.move(c8.x, c8.y)
  263. await page.mouse.down({ button: 'left' })
  264. await page.mouse.move(c8.x + 40, c8.y + 20, { steps: 5 })
  265. await page.mouse.up({ button: 'left' })
  266. await sleep(150)
  267. await waitSettled()
  268. const a8 = await readState()
  269. check(
  270. 'A8-pan-returns-pivot-to-ground',
  271. '左键平移后中心回到地面拾取点(不再跟随选中对象),且仍落在画布中心',
  272. !!a8.projection &&
  273. Math.abs(a8.projection.x - (a8.canvas.x + a8.canvas.w / 2)) <= 2 &&
  274. Math.abs(a8.projection.y - (a8.canvas.y + a8.canvas.h / 2)) <= 2 &&
  275. Math.abs(a8.pivot.lon - XIANGLING.lon) > 1e-6,
  276. JSON.stringify({ pivot: a8.pivot, projection: a8.projection }),
  277. )
  278. // ---------- A10 阻尼在真机上生效(目标立刻跟手、实际态渐入收敛) ----------
  279. const c10 = await center()
  280. const damping = await page.evaluate(async (point) => {
  281. const read = () => window.__cockpitMap.getCameraState()
  282. window.__cockpitMap.setCameraFocus(point)
  283. await new Promise((r) => setTimeout(r, 40))
  284. const start = read()
  285. const samples = []
  286. const began = performance.now()
  287. while (performance.now() - began < 2000) {
  288. const s = read()
  289. samples.push({ t: Math.round(performance.now() - began), settled: s.settled, flying: s.flying, lon: s.pivot.lon, shownLon: s.shownPivot ? s.shownPivot.lon : null })
  290. if (s.settled) break
  291. await new Promise((r) => setTimeout(r, 30))
  292. }
  293. return { start, samples }
  294. }, XIANGLING)
  295. {
  296. const s = damping.samples
  297. const last = s[s.length - 1]
  298. // 实际态必须**滞后于**目标态:中途至少采样到一次 shown != target(否则就是瞬移,没有阻尼)。
  299. const lagged = s.some((x) => x.shownLon !== null && Math.abs(x.shownLon - x.lon) > 1e-9)
  300. // 且滞后是收敛的:缺口单调缩小到 0。
  301. const gaps = s.filter((x) => x.shownLon !== null).map((x) => Math.abs(x.lon - x.shownLon))
  302. const shrinking = gaps.every((g, i) => i === 0 || g <= gaps[i - 1] + 1e-12)
  303. check(
  304. 'A10-damping-is-live',
  305. '阻尼在真机上生效:实际相机滞后目标态、缺口单调收敛,最终 settled 且吸附到目标',
  306. lagged &&
  307. gaps.length >= 2 &&
  308. shrinking &&
  309. last.settled === true &&
  310. Math.abs(last.lon - XIANGLING.lon) < 1e-6,
  311. JSON.stringify({ frames: s.length, lagged, firstGap: round(gaps[0] || 0, 6), lastGap: round(gaps[gaps.length - 1] || 0, 6) }),
  312. )
  313. }
  314. check('A9-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 3)))
  315. } finally {
  316. await browser.close()
  317. }
  318. const failed = results.filter((r) => !r.pass)
  319. console.log(
  320. `\nT03 浏览器走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`,
  321. )
  322. process.exit(failed.length ? 1 : 0)