t37-area-limits-walkthrough.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. /**
  2. * T37 真机走查:进区锁定与相机区域限位(票 37 验收第 11 条点名的全过程)。
  3. *
  4. * 断言的是「进入 → 锁定 → 拉远被夹 → 拖到边界回弹 → 返回 → 闲置回引导」这条链在**真实引擎**上成立:
  5. * · 点「进入系统」后 `areaState` 落 locked、顶栏出现「返回全球」、宿主 `data-map-area-state=locked`;
  6. * · 区内滚轮真的拉不远(range 被夹在 home.range 12000 内,且**读数与实测一致**);
  7. * · 管线视图切换后仍与现状一致([400, 6000],没有被区域收紧得更狠);
  8. * · 拖到边界时中心越出框、松手后弹回框内(`shown.pivot` 最终在框内);
  9. * · 点「返回全球」解除限位并飞到初始全景(heading 0 / pitch −60 / range 2×10⁷);
  10. * · 全球态闲置 10 秒 → 引导层回来;区内闲置**不**回引导页(边界);
  11. * · 全程无页面级报错。
  12. *
  13. * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t37-area-limits-walkthrough.mjs
  14. * 按 AGENTS.md:本脚本不在改代码后自动运行,由维护者明确要求时才跑。
  15. */
  16. import { readFileSync } from 'node:fs'
  17. import { join } from 'node:path'
  18. import { tmpdir } from 'node:os'
  19. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  20. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  21. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  22. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  23. const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
  24. const HOME_RANGE = config.map.home.range
  25. const BOUNDS = config.map.primaryArea.bounds
  26. const results = []
  27. function check(id, desc, pass, evidence) {
  28. results.push({ id, desc, pass })
  29. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  30. }
  31. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  32. const puppeteer = (await import(PUPPETEER)).default
  33. const browser = await puppeteer.launch({
  34. executablePath: CHROME,
  35. headless: true,
  36. userDataDir: join(tmpdir(), 'cockpit-t37-profile'),
  37. defaultViewport: { width: 1680, height: 950 },
  38. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  39. })
  40. console.log(`Chrome: ${await browser.version()}`)
  41. /** 等相机停稳(阻尼收敛),再断言。 */
  42. async function waitSettled(page, timeoutMs = 25000) {
  43. await page
  44. .waitForFunction(
  45. () => {
  46. const api = window.__cockpitMap
  47. if (!api) return false
  48. const state = api.getCameraState()
  49. return !!state && state.settled === true
  50. },
  51. { timeout: timeoutMs, polling: 250 },
  52. )
  53. .catch(() => {})
  54. await sleep(250)
  55. }
  56. const readArea = (page) =>
  57. page.evaluate(() => {
  58. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  59. const state = window.__cockpitMap ? window.__cockpitMap.getCameraState() : null
  60. return {
  61. areaState: host ? host.dataset.mapAreaState : '',
  62. areaLocked: host ? host.dataset.mapAreaLocked : '',
  63. rangeMax: Number(host ? host.dataset.mapRangeMax : 0),
  64. limits: window.__cockpitMap ? window.__cockpitMap.getAreaLimits() : null,
  65. state: state
  66. ? {
  67. pivot: state.pivot,
  68. hpr: state.hpr,
  69. // `shownPivot` 在**状态顶层**(阻尼后真正下发给相机的中心),不在 target 里 ——
  70. // 「越界只出现在拖拽过程」这条验收断的就是它,取错了会让断言白过。
  71. shownPivot: state.shownPivot,
  72. outsideArea: state.outsideArea,
  73. rangeLimits: state.rangeLimits,
  74. }
  75. : null,
  76. returnGlobalVisible: !!document.querySelector('[data-return-global]'),
  77. homeLabel: (document.querySelector('.cockpit-shell__home') || {}).textContent?.trim() || '',
  78. introVisible: !!document.querySelector('[data-cockpit-intro]'),
  79. }
  80. })
  81. const inside = (pivot) =>
  82. !!pivot && pivot.lon >= BOUNDS[0] && pivot.lon <= BOUNDS[2] && pivot.lat >= BOUNDS[1] && pivot.lat <= BOUNDS[3]
  83. try {
  84. const page = await browser.newPage()
  85. const errors = []
  86. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  87. page.on('console', (m) => {
  88. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  89. })
  90. await page.evaluateOnNewDocument((injected) => {
  91. window.__COCKPIT_CONFIG__ = injected
  92. }, config)
  93. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  94. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  95. await page.waitForFunction(
  96. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
  97. { timeout: 60000 },
  98. )
  99. await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 })
  100. await sleep(1200)
  101. // ---------- A1 区外:只有一个「归位/进入」,无限位读数 ----------
  102. const before = await readArea(page)
  103. check(
  104. 'A1-outside-free',
  105. '进入前是区外自由视角:data-map-area-state=global、无「返回全球」、按钮是「归位/进入」',
  106. before.areaState === 'global' &&
  107. before.returnGlobalVisible === false &&
  108. before.homeLabel.includes('进入') &&
  109. before.limits &&
  110. before.limits.locked === false,
  111. JSON.stringify({ areaState: before.areaState, home: before.homeLabel, locked: before.limits && before.limits.locked }),
  112. )
  113. // ---------- A2 进入系统 → 锁定 ----------
  114. await page.click('[data-cockpit-intro]')
  115. await sleep(3000)
  116. await waitSettled(page)
  117. const entered = await readArea(page)
  118. check(
  119. 'A2-enter-locks',
  120. '点「进入系统」后进区锁定:data-map-area-state=locked、出现「返回全球」、按钮变「归位」、上限=home.range',
  121. entered.areaState === 'locked' &&
  122. entered.returnGlobalVisible === true &&
  123. entered.homeLabel === '归位' &&
  124. entered.rangeMax === HOME_RANGE &&
  125. inside(entered.state.pivot),
  126. JSON.stringify({
  127. areaState: entered.areaState,
  128. home: entered.homeLabel,
  129. rangeMax: entered.rangeMax,
  130. pivot: entered.state.pivot,
  131. }),
  132. )
  133. // ---------- A3 区内滚轮拉不远 ----------
  134. const canvas = await page.evaluate(() => {
  135. const c = document.querySelector('.cockpit-shell__map .map-canvas canvas')
  136. const r = c.getBoundingClientRect()
  137. return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
  138. })
  139. await page.mouse.move(canvas.x, canvas.y)
  140. for (let i = 0; i < 25; i++) await page.mouse.wheel({ deltaY: 400 })
  141. await waitSettled(page)
  142. const farAway = await readArea(page)
  143. check(
  144. 'A3-wheel-clamped',
  145. `区内滚轮最远只到 home.range=${HOME_RANGE} m(实测与读数一致)`,
  146. farAway.state.hpr.range <= HOME_RANGE + 1 && farAway.state.rangeLimits.max === HOME_RANGE,
  147. JSON.stringify({ range: Math.round(farAway.state.hpr.range), max: farAway.state.rangeLimits.max }),
  148. )
  149. // ---------- A4 管线视图仍与现状一致([400, 6000],区域没有额外收紧) ----------
  150. await page.evaluate(() => window.__cockpitMap.setViewMode('pipeline'))
  151. await sleep(1200)
  152. await waitSettled(page)
  153. const pipeline = await readArea(page)
  154. check(
  155. 'A4-pipeline-intersection',
  156. '区内管线视图 range ∈ [400, min(6000, home.range)]=[400, 6000](因 12000>6000,与现状一致、无额外收紧)',
  157. pipeline.state.rangeLimits.max === 6000 &&
  158. pipeline.state.rangeLimits.min === 400 &&
  159. pipeline.state.hpr.range >= 400 - 1 &&
  160. pipeline.state.hpr.range <= 6000 + 1,
  161. JSON.stringify({ min: pipeline.state.rangeLimits.min, max: pipeline.state.rangeLimits.max, range: Math.round(pipeline.state.hpr.range) }),
  162. )
  163. await page.evaluate(() => window.__cockpitMap.setViewMode('ground'))
  164. await waitSettled(page)
  165. // ---------- A5 顶栏不压住一级模式(本票踩到并修掉的几何回归) ----------
  166. // 加「返回全球」chip 后顶栏右侧状态区内容超宽 46px,`justify-content: flex-end` 向左溢出,
  167. // 「归位」压住了「预警处置」(实测 x 855–897 vs 799–898)。修法是让状态列按内容最小宽度兜底。
  168. // 这条按**真实几何**断言,避免以后再加 chip 时又悄悄压上去。
  169. {
  170. const layout = await page.evaluate(() => {
  171. const rect = (el) => {
  172. const b = el.getBoundingClientRect()
  173. return { x: Math.round(b.x), right: Math.round(b.right), w: Math.round(b.width) }
  174. }
  175. const meta = document.querySelector('.cockpit-shell__top-meta')
  176. const kids = Array.from(meta.children).map(rect).filter((k) => k.w > 0)
  177. const modes = Array.from(document.querySelectorAll('.cockpit-shell__mode')).map(rect)
  178. const gap = parseFloat(getComputedStyle(meta).gap) || 0
  179. const needed = kids.reduce((s, k) => s + k.w, 0) + gap * Math.max(0, kids.length - 1)
  180. const metaW = Math.round(meta.getBoundingClientRect().width)
  181. const lastMode = modes[modes.length - 1]
  182. // 用**最左的可见状态块**比,而不是"第一个子元素":以后在前面插 chip 也不会让断言失效。
  183. const leftMostMetaX = Math.min(...kids.map((k) => k.x))
  184. return {
  185. metaW,
  186. needed: Math.round(needed),
  187. lastModeRight: lastMode.right,
  188. leftMostMetaX,
  189. modeCount: modes.length,
  190. metaChildCount: kids.length,
  191. }
  192. })
  193. check(
  194. 'A5-topbar-no-overlap',
  195. '区内顶栏(归位 + 返回全球)不压住一级模式按钮:状态区内容装得下,且与模式区留有间隙',
  196. layout.needed <= layout.metaW && layout.leftMostMetaX > layout.lastModeRight,
  197. JSON.stringify(layout),
  198. )
  199. }
  200. // ---------- A6 拖到边界:过程越界、松手弹回框内 ----------
  201. // 先把镜头放回归位(保证中心在框内、有足够的拖动空间)。
  202. await page.evaluate(() => window.__cockpitMap.goHome())
  203. await waitSettled(page)
  204. await page.evaluate(() => window.__cockpitMap.setCameraDamping(120))
  205. await page.mouse.move(canvas.x, canvas.y)
  206. await page.mouse.down({ button: 'left' })
  207. // 「抓着地图拖」:向左拖 → 中心往东走(朝东边界)。
  208. for (let i = 1; i <= 24; i++) await page.mouse.move(canvas.x - i * 40, canvas.y, { steps: 2 })
  209. const dragging = await readArea(page)
  210. await page.mouse.up({ button: 'left' })
  211. await waitSettled(page)
  212. const released = await readArea(page)
  213. check(
  214. 'A6-soft-rebound',
  215. '拖到边界时中心越出框(软区),松手后阻尼弹回框内(shown.pivot 最终在框内)',
  216. dragging.state.pivot.lon > BOUNDS[2] &&
  217. inside(released.state.pivot) &&
  218. inside(released.state.shownPivot),
  219. JSON.stringify({
  220. draggedLon: dragging.state.pivot.lon,
  221. eastBound: BOUNDS[2],
  222. releasedPivot: released.state.pivot,
  223. releasedShown: released.state.shownPivot,
  224. }),
  225. )
  226. // ---------- A7 返回全球:解除限位 + 飞到初始全景 ----------
  227. await page.click('[data-return-global]')
  228. await sleep(1200)
  229. await waitSettled(page, 30000)
  230. const global = await readArea(page)
  231. const panorama = global.state.hpr
  232. check(
  233. 'A7-return-global',
  234. '点「返回全球」解除限位、飞回程序初始态全景(heading 0 / pitch −60 / range 2×10⁷),按钮变回「归位/进入」',
  235. global.areaState === 'global' &&
  236. global.returnGlobalVisible === false &&
  237. global.limits.locked === false &&
  238. Math.abs(panorama.range - 20000000) / 20000000 < 0.2 &&
  239. Math.abs(panorama.pitch + 60) < 5,
  240. JSON.stringify({ areaState: global.areaState, hpr: panorama, home: global.homeLabel }),
  241. )
  242. // ---------- A8 全球态闲置 → 回引导页 ----------
  243. // 之后不再有任何交互,等 10 秒阈值 + 一段余量。
  244. await sleep(13000)
  245. const idled = await readArea(page)
  246. check(
  247. 'A8-idle-returns-to-intro',
  248. '全球态闲置 10 秒后回到程序初始态(引导层「点击进入系统」重新出现)',
  249. idled.introVisible === true && idled.areaState === 'global',
  250. JSON.stringify({ introVisible: idled.introVisible, areaState: idled.areaState }),
  251. )
  252. // ---------- A9 区内闲置**不**回引导页(边界) ----------
  253. await page.click('[data-cockpit-intro]')
  254. await sleep(3000)
  255. await waitSettled(page)
  256. // 区内闲置要**超过阈值**仍不回,才算不是"恰好没到 10 秒"。
  257. await sleep(13000)
  258. const lockedIdle = await readArea(page)
  259. check(
  260. 'A9-locked-idle-stays',
  261. '区内闲置 10 秒以上**不**回引导页(工作状态不该被闲置踢回入口)',
  262. lockedIdle.areaState === 'locked' && lockedIdle.introVisible === false,
  263. JSON.stringify({ areaState: lockedIdle.areaState, introVisible: lockedIdle.introVisible }),
  264. )
  265. check('A10-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  266. } finally {
  267. await browser.close()
  268. }
  269. const failed = results.filter((r) => !r.pass)
  270. console.log('')
  271. console.log(`T37 走查:${results.length - failed.length}/${results.length} 项通过。`)
  272. if (failed.length) {
  273. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  274. process.exit(1)
  275. }
  276. console.log('T37 走查全部通过。')