t41-point-locate-anchor-walkthrough.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. /**
  2. * T41 真机走查:监测点定位与面板锚定接入(票 41)。
  3. *
  4. * 逻辑闸门(t41-point-locate-anchor.mjs,37/37 + 变异 15/15)已经把定位档位解析、
  5. * 竞态作废判据、锚定四向布局、离屏判据与统一入口的接线测透了;这里只补**只有在真机才成立**的部分:
  6. * 1. 点地图上的监测点 → 相机**真的**飞到该点位(`settled` + hpr = 配置点位档,range≈500);
  7. * 2. 详情面板**真的贴着**该标记(面板矩形与标记投影的几何关系),且不压住图标;
  8. * 3. 面板四向避让:标记贴到画布右/左/下/上边缘时,面板翻到对应侧;
  9. * 4. 标记完全离开画面 → 面板隐藏 + 顶栏出现暂隐入口(带原因与关闭);回到视野自动恢复;
  10. * 5. 关闭详情 → 面板消失而**镜头不回退**(ADR 0008);
  11. * 6. 台账卡片与地图点选走**同一入口**(都触发点位级定位)——票 41 改写了票 18 的旧语义;
  12. * 7. 无地图位置(后端 `renderable=false`)→ 固定详情 + 非空原因,且**不创建锚点**;
  13. * 8. 全程无页面级报错。
  14. *
  15. * 运行(需先起 `yarn dev`;`AGENTS.md` 要求不自动跑构建/调试命令,故由维护者按需执行):
  16. * node .scratch/cockpit-shell-panel-layout/verify/t41-point-locate-anchor-walkthrough.mjs
  17. * COCKPIT_BASE=http://localhost:8001 node ...(端口不是 8000 时)
  18. *
  19. * 两个沿用票 10 的真机坑(换成"直接点屏幕中心"必然假失败):
  20. * · 相机落位是**阻尼飞行**,必须等 `shownPivot` 贴到目标再点;
  21. * · 贴地标记与"高度 0 的中心"之间有地形高程差 —— 先用引擎拾取找到标记真实屏幕坐标再真点。
  22. */
  23. import { join } from 'node:path'
  24. import { tmpdir } from 'node:os'
  25. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  26. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  27. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  28. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  29. /** 与 mock 保持一致的测试点位(走查断言点的是"哪一个点")。 */
  30. const MP_SOUTH = 'MP-001'
  31. const MP_TAICHANG = 'MP-002'
  32. /** 后端判不可上图的点位(`renderable:false`)—— 固定详情特例。 */
  33. const MP_BLOCKED = 'MP-003'
  34. const results = []
  35. /** `check(id, pass, evidence)` —— 第二参是**断言结果**,不是描述。 */
  36. function check(id, pass, evidence) {
  37. results.push({ id, pass })
  38. const rest = evidence === undefined ? '' : String(evidence)
  39. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} :: ${rest}`)
  40. }
  41. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  42. const puppeteer = (await import(PUPPETEER)).default
  43. const browser = await puppeteer.launch({
  44. executablePath: CHROME,
  45. headless: true,
  46. userDataDir: join(tmpdir(), 'cockpit-t41-profile'),
  47. defaultViewport: { width: 1680, height: 950 },
  48. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  49. })
  50. console.log(`Chrome: ${await browser.version()}`)
  51. /**
  52. * 一次读全:地图宿主读数 + 相机状态 + 详情浮层与锚定的几何。
  53. *
  54. * 锚定断言全部基于**真实矩形**(`getBoundingClientRect`),不读组件私有变量:
  55. * 「面板是否贴着标记」这种空间关系只有按真实几何判才算证据。
  56. */
  57. const readState = (page) =>
  58. page.evaluate(() => {
  59. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  60. const canvas = document.querySelector('.cockpit-shell__map canvas')
  61. const card = document.querySelector('[data-point-detail]')
  62. const aside = document.querySelector('.cockpit-shell__detail')
  63. const lead = document.querySelector('[data-anchor-lead]')
  64. const hiddenChip = document.querySelector('[data-anchor-hidden]')
  65. const dev = window.__cockpitMap
  66. const camera = dev && dev.getCameraState ? dev.getCameraState() : null
  67. const markers = dev && dev.getPointMarkers ? dev.getPointMarkers() : null
  68. const rectOf = (el) => {
  69. if (!el) return null
  70. const r = el.getBoundingClientRect()
  71. return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }
  72. }
  73. return {
  74. engine: (host && host.dataset.mapEngine) || '',
  75. summaryPhase: (host && host.dataset.mapSummary) || '',
  76. summaryLoaded: Number((host && host.dataset.mapSummaryLoaded) || 0),
  77. // 相机:`settled` 与 hpr 是定位验收的判据(规格:定位完成以 settled 和配置 hpr 为准)。
  78. camera: camera
  79. ? {
  80. pivot: camera.pivot ? { lon: camera.pivot.lon, lat: camera.pivot.lat, height: camera.pivot.height } : null,
  81. shownPivot: camera.shownPivot ? { lon: camera.shownPivot.lon, lat: camera.shownPivot.lat } : null,
  82. hpr: camera.hpr ? { heading: camera.hpr.heading, pitch: camera.hpr.pitch, range: camera.hpr.range } : null,
  83. settled: camera.settled,
  84. flying: camera.flying,
  85. outsideArea: camera.outsideArea,
  86. }
  87. : null,
  88. canvasRect: rectOf(canvas),
  89. asideRect: rectOf(aside),
  90. cardRect: rectOf(card),
  91. // 锚定状态:可见/侧向/编码,以及暂隐入口的读数(都是本票新增的可断言面)。
  92. anchorState: aside ? aside.dataset.anchorState : '',
  93. anchorSide: aside ? aside.dataset.anchorSide : '',
  94. anchorCode: aside ? aside.dataset.anchorCode : '',
  95. hasLead: !!lead,
  96. hiddenChip: hiddenChip
  97. ? { reason: hiddenChip.dataset.anchorReason || '', text: hiddenChip.textContent.trim() }
  98. : null,
  99. hasRelocate: !!(hiddenChip && hiddenChip.querySelector('[data-anchor-relocate]')),
  100. hasHiddenClose: !!(hiddenChip && hiddenChip.querySelector('[data-anchor-close]')),
  101. detailLevel: card ? card.dataset.detailLevel : '',
  102. pointCode: card ? card.dataset.pointCode : '',
  103. pointName: ((card && card.querySelector('[data-point-name]')) || {}).textContent
  104. ? card.querySelector('[data-point-name]').textContent.trim()
  105. : '',
  106. noLocation: ((card && card.querySelector('[data-point-no-location]')) || {}).textContent
  107. ? card.querySelector('[data-point-no-location]').textContent.trim()
  108. : '',
  109. cardHosts: document.querySelectorAll('[data-point-detail]').length,
  110. // 锚定面板的层级(规格 R-3:必须低于四个详情窗 —— 在**页面上下文**里读计算样式,
  111. // 不能在 Node 侧对 DOM 元素调 getComputedStyle)。
  112. anchorZIndex: aside ? Number(window.getComputedStyle(aside).zIndex) : null,
  113. planMarkers: markers
  114. ? markers.markers.map((m) => ({ code: m.code, kind: m.kind, longitude: m.longitude, latitude: m.latitude }))
  115. : [],
  116. markersOnMap: markers ? markers.onMap : -1,
  117. }
  118. })
  119. /** 打开页面并走真实进入引导(不注入配置:注入了家视角会退化成 (0,0))。 */
  120. async function openPage() {
  121. const page = await browser.newPage()
  122. const errors = []
  123. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  124. page.on('console', (m) => {
  125. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  126. })
  127. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  128. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  129. await page
  130. .waitForFunction(
  131. () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
  132. { timeout: 40000 },
  133. )
  134. .catch(() => {})
  135. if (await page.$('[data-cockpit-intro]')) {
  136. await page.click('[data-cockpit-intro]')
  137. await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
  138. }
  139. await sleep(1800)
  140. return { page, errors }
  141. }
  142. /** 点「加载下一页」直到翻完(标记要等分页到位才出现)。 */
  143. async function advanceUntilSettled(page, maxSteps = 12) {
  144. for (let step = 0; step < maxSteps; step += 1) {
  145. const state = await readState(page)
  146. if (['complete', 'empty', 'failed', 'expired'].includes(state.summaryPhase)) break
  147. const clicked = await page.evaluate(() => {
  148. const button = document.querySelector('[data-map-summary-action]')
  149. if (!button || button.disabled) return false
  150. button.click()
  151. return true
  152. })
  153. if (!clicked) break
  154. const before = state.summaryLoaded
  155. await page
  156. .waitForFunction(
  157. (previous) => {
  158. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  159. const loaded = Number((host && host.dataset.mapSummaryLoaded) || 0)
  160. const phase = (host && host.dataset.mapSummary) || ''
  161. return loaded !== previous || ['failed', 'expired', 'empty', 'complete'].includes(phase)
  162. },
  163. { timeout: 10000 },
  164. before,
  165. )
  166. .catch(() => {})
  167. await sleep(350)
  168. }
  169. return readState(page)
  170. }
  171. /** 等相机真的停稳(`settled`)且中心贴到目标 —— 阻尼飞行途中点屏幕必然点空。 */
  172. async function waitForSettled(page, timeout = 25000) {
  173. await page
  174. .waitForFunction(
  175. () => {
  176. const dev = window.__cockpitMap
  177. const state = dev && dev.getCameraState ? dev.getCameraState() : null
  178. return !!state && state.settled === true && state.flying !== true
  179. },
  180. { timeout },
  181. )
  182. .catch(() => {})
  183. }
  184. /** 在画布上找一个真实标记的屏幕坐标(引擎拾取与真实点击同一个 scene.pick)。 */
  185. async function locateMarker(page, kind, code, radius = 400, stride = 8) {
  186. return page.evaluate(
  187. ([wantedKind, wantedCode, max, step]) => {
  188. const dev = window.__cockpitMap
  189. const canvas = document.querySelector('.cockpit-shell__map canvas')
  190. const center = dev.projectCameraPivot()
  191. if (!canvas) return { x: 0, y: 0, code: '' }
  192. const origin = center || { x: canvas.getBoundingClientRect().left + canvas.clientWidth / 2, y: canvas.getBoundingClientRect().top + canvas.clientHeight / 2 }
  193. for (let dy = -max; dy <= max; dy += step) {
  194. for (let dx = -max; dx <= max; dx += step) {
  195. const x = Math.round(origin.x + dx)
  196. const y = Math.round(origin.y + dy)
  197. const hitElement = document.elementFromPoint(x, y)
  198. if (!hitElement || (hitElement !== canvas && !canvas.contains(hitElement))) continue
  199. const hit = dev.pickPoint(x, y)
  200. if (hit && hit.kind === wantedKind && (!wantedCode || hit.code === wantedCode)) {
  201. return { x, y, dx, dy, code: hit.code, kind: hit.kind }
  202. }
  203. }
  204. }
  205. return { x: 0, y: 0, dx: 0, dy: 0, code: '', kind: '' }
  206. },
  207. [kind, code, radius, stride],
  208. )
  209. }
  210. /** 关掉可能已打开的浮层,让后续真实点击成为唯一证据。 */
  211. async function closeCard(page) {
  212. if (await page.$('[data-detail-close]')) {
  213. await page.click('[data-detail-close]')
  214. await sleep(300)
  215. }
  216. }
  217. try {
  218. const { page, errors } = await openPage()
  219. const settled = await advanceUntilSettled(page)
  220. check('A1-engine-ready', ['ready', 'disabled'].includes(settled.engine), `engine=${settled.engine}`)
  221. check(
  222. 'A2-test-points-on-map',
  223. settled.planMarkers.some((m) => m.code === MP_SOUTH) && settled.planMarkers.some((m) => m.code === MP_TAICHANG),
  224. `地图计划里有点位:${settled.planMarkers.map((m) => m.code).join(',') || '(无)'}`,
  225. )
  226. // ================================================================ B 地图点选 → 点位级完整定位
  227. const targetMarker = settled.planMarkers.find((m) => m.code === MP_SOUTH) || settled.planMarkers[0]
  228. if (targetMarker) {
  229. // 先把镜头对到该点位附近,才能用真实鼠标点到它(否则它在县域视角外/被裁切)。
  230. await page.evaluate((code) => {
  231. const dev = window.__cockpitMap
  232. const marker = dev.getPointMarkers().markers.find((m) => m.code === code)
  233. if (marker) dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 })
  234. }, targetMarker.code)
  235. await waitForSettled(page)
  236. const located = await locateMarker(page, 'monitoring-point', targetMarker.code)
  237. await closeCard(page)
  238. const beforeClick = await readState(page)
  239. if (located.code) {
  240. await page.mouse.click(located.x, located.y)
  241. await sleep(700)
  242. }
  243. await waitForSettled(page)
  244. const afterClick = await readState(page)
  245. check(
  246. 'B1-real-click-opens-detail',
  247. !!located.code && beforeClick.cardHosts === 0 && afterClick.cardHosts === 1 && afterClick.detailLevel === 'point',
  248. located.code
  249. ? `真实鼠标点 (${located.x},${located.y}) → 点位 ${afterClick.pointCode} · 层级 ${afterClick.detailLevel}`
  250. : '没找到可点的标记',
  251. )
  252. check(
  253. 'B2-located-to-point-level-range',
  254. !!afterClick.camera && Math.round(afterClick.camera.hpr.range) === 500,
  255. `定位后 range=${afterClick.camera ? Math.round(afterClick.camera.hpr.range) : 'n/a'} m(配置点位档缺省 500;旧语义会留在县域 12000)`,
  256. )
  257. check(
  258. 'B3-located-hpr-matches-preset',
  259. !!afterClick.camera && afterClick.camera.hpr.pitch === -55 && afterClick.camera.hpr.heading === 0,
  260. `hpr=${JSON.stringify(afterClick.camera && afterClick.camera.hpr)}(配置缺省 0/−55)`,
  261. )
  262. check(
  263. 'B4-settled-and-centered-on-the-point',
  264. !!afterClick.camera &&
  265. afterClick.camera.settled === true &&
  266. Math.abs(afterClick.camera.pivot.lon - targetMarker.longitude) < 1e-6 &&
  267. Math.abs(afterClick.camera.pivot.lat - targetMarker.latitude) < 1e-6,
  268. `settled=${afterClick.camera && afterClick.camera.settled} · 中心 ${afterClick.camera && afterClick.camera.pivot.lon},${afterClick.camera && afterClick.camera.pivot.lat}`,
  269. )
  270. check(
  271. 'B5-center-takes-terrain-height',
  272. !!afterClick.camera && Number.isFinite(afterClick.camera.pivot.height) && afterClick.camera.pivot.height !== 0,
  273. `中心高程 ${afterClick.camera && afterClick.camera.pivot.height} m(取实测地形,不是椭球 0)`,
  274. )
  275. // ============================================================ C 锚定跟随(真实几何)
  276. check(
  277. 'C1-panel-anchored-visible',
  278. afterClick.anchorState === 'visible' && !!afterClick.asideRect && afterClick.anchorCode === afterClick.pointCode,
  279. `锚定状态 ${afterClick.anchorState} · 侧向 ${afterClick.anchorSide} · 锚点编码 ${afterClick.anchorCode}`,
  280. )
  281. check(
  282. 'C2-panel-has-lead-line',
  283. afterClick.hasLead === true,
  284. `归属短线在(${afterClick.hasLead})`,
  285. )
  286. check(
  287. 'C3-panel-does-not-cover-icon',
  288. (() => {
  289. const anchor = afterClick.camera && afterClick.camera.pivot
  290. const card = afterClick.cardRect
  291. if (!anchor || !card) return false
  292. // 标记图标 18px、间距 12px:面板边缘到标记中心的距离必须 > 9+11(留 1px 浮点余量)。
  293. const cx = (card.left + card.right) / 2
  294. const cy = (card.top + card.bottom) / 2
  295. const gapX = Math.max(card.left - anchor.x, anchor.x - card.right, 0)
  296. const gapY = Math.max(card.top - anchor.y, anchor.y - card.bottom, 0)
  297. return Math.max(gapX, gapY) >= 20 || (cx !== anchor.x && cy !== anchor.y)
  298. })(),
  299. `面板 ${afterClick.anchorSide} 侧、尺寸 ${afterClick.cardRect ? Math.round(afterClick.cardRect.width) + '×' + Math.round(afterClick.cardRect.height) : 'n/a'}`,
  300. )
  301. check(
  302. 'C4-panel-is-lower-than-columns',
  303. afterClick.anchorZIndex !== null && afterClick.anchorZIndex < 2,
  304. `锚定面板 z-index=${afterClick.anchorZIndex}(< 2 = 低于 --z-columns,规格 R-3:允许被四个详情窗遮盖)`,
  305. )
  306. } else {
  307. check('B1-real-click-opens-detail', false, '地图上没有可点的监测点标记')
  308. }
  309. // ================================================================ D 关闭不回退镜头
  310. const beforeClose = await readState(page)
  311. if (beforeClose.cardHosts > 0) {
  312. await page.click('[data-detail-close]')
  313. await sleep(600)
  314. }
  315. const afterClose = await readState(page)
  316. check(
  317. 'D1-close-hides-panel',
  318. afterClose.cardHosts === 0,
  319. `关闭后浮层 ${afterClose.cardHosts} 个`,
  320. )
  321. check(
  322. 'D2-close-does-not-roll-back-camera',
  323. !!beforeClose.camera &&
  324. !!afterClose.camera &&
  325. Math.abs(afterClose.camera.hpr.range - beforeClose.camera.hpr.range) < 1 &&
  326. Math.abs(afterClose.camera.pivot.lon - beforeClose.camera.pivot.lon) < 1e-9,
  327. `关闭前 range=${beforeClose.camera && Math.round(beforeClose.camera.hpr.range)} → 关闭后 ${afterClose.camera && Math.round(afterClose.camera.hpr.range)}(ADR 0008:取消选中不回退)`,
  328. )
  329. // ================================================================ E 标记离屏 → 暂隐 + 恢复
  330. {
  331. // 打开详情后把镜头**拉远到全球**,标记必然离开画面 → 面板暂隐 + 顶栏入口出现。
  332. await page.evaluate((code) => {
  333. const dev = window.__cockpitMap
  334. const marker = dev.getPointMarkers().markers.find((m) => m.code === code)
  335. if (marker) dev.selectMonitoringPoint ? dev.selectMonitoringPoint(code) : null
  336. }, MP_SOUTH)
  337. await sleep(800)
  338. const opened = await readState(page)
  339. await page.evaluate(() => {
  340. const dev = window.__cockpitMap
  341. if (dev.returnGlobal) dev.returnGlobal()
  342. })
  343. await sleep(2500)
  344. const offscreen = await readState(page)
  345. check(
  346. 'E1-detail-open-before-offscreen',
  347. opened.cardHosts === 1,
  348. `离屏前详情是打开的(${opened.cardHosts} 个浮层)`,
  349. )
  350. check(
  351. 'E2-offscreen-hides-panel',
  352. offscreen.anchorState === 'hidden' || (offscreen.asideRect && offscreen.asideRect.height < 1) || !offscreen.cardRect,
  353. `拉远后锚定状态 ${offscreen.anchorState}(面板不留在失去归属的位置)`,
  354. )
  355. check(
  356. 'E3-offscreen-keeps-selection-with-exit',
  357. !!offscreen.hiddenChip && !!offscreen.hiddenChip.reason && offscreen.hasHiddenClose,
  358. `顶栏暂隐入口:原因「${offscreen.hiddenChip && offscreen.hiddenChip.reason}」· 关闭入口 ${offscreen.hasHiddenClose}`,
  359. )
  360. // 回到点位 → 自动恢复(不重新点选)。
  361. await page.evaluate((code) => {
  362. const dev = window.__cockpitMap
  363. const marker = dev.getPointMarkers().markers.find((m) => m.code === code)
  364. if (marker) dev.setCameraFocus({ lon: marker.longitude, lat: marker.latitude, height: 0 })
  365. }, MP_SOUTH)
  366. await sleep(2500)
  367. const restored = await readState(page)
  368. check(
  369. 'E4-restores-when-marker-returns',
  370. restored.cardHosts === 1 && restored.anchorState === 'visible',
  371. `回到视野后自动恢复:浮层 ${restored.cardHosts} 个 · 锚定 ${restored.anchorState}`,
  372. )
  373. await closeCard(page)
  374. }
  375. // ================================================================ F 无地图位置 → 固定详情
  376. {
  377. // 后端判 `renderable:false` 的点位:从台账卡片打开(地图上没有它的标记)。
  378. const opened = await page.evaluate(async (code) => {
  379. const dev = window.__cockpitMap
  380. // 台账卡片入口与地图点选共用统一入口(票 41 验收第 1 条)。
  381. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  382. const before = document.querySelector('[data-point-detail]')
  383. if (dev.selectMonitoringPoint) dev.selectMonitoringPoint(code)
  384. await new Promise((r) => setTimeout(r, 900))
  385. const card = document.querySelector('[data-point-detail]')
  386. const aside = document.querySelector('.cockpit-shell__detail')
  387. return {
  388. hadCardBefore: !!before,
  389. pointCode: card ? card.dataset.pointCode : '',
  390. noLocation: card && card.querySelector('[data-point-no-location]') ? card.querySelector('[data-point-no-location]').textContent.trim() : '',
  391. anchorState: aside ? aside.dataset.anchorState : '',
  392. engine: host.dataset.mapEngine,
  393. }
  394. }, MP_BLOCKED)
  395. check(
  396. 'F1-blocked-point-opens-fixed-detail',
  397. opened.pointCode === MP_BLOCKED && opened.anchorState === 'fixed',
  398. `点位 ${opened.pointCode} · 锚定状态 ${opened.anchorState}(固定详情,不伪造锚点)`,
  399. )
  400. check(
  401. 'F2-fixed-detail-has-non-empty-reason',
  402. !!opened.noLocation && opened.noLocation.includes('无地图位置'),
  403. `固定详情注明原因:「${opened.noLocation}」`,
  404. )
  405. await closeCard(page)
  406. }
  407. // ================================================================ G 台账卡片与地图同一入口
  408. {
  409. const sameEntry = await page.evaluate(async () => {
  410. const dev = window.__cockpitMap
  411. const marker = dev.getPointMarkers().markers.find((m) => m.kind === 'monitoring-point')
  412. if (!marker) return { ok: false, reason: 'no-marker' }
  413. // 台账入口 → 统一入口 → 点位级定位(票 41 改写票 18 的"不动相机")。
  414. const before = dev.getCameraState()
  415. dev.selectMonitoringPoint(marker.code)
  416. await new Promise((r) => setTimeout(r, 2600))
  417. const after = dev.getCameraState()
  418. return {
  419. ok: true,
  420. code: marker.code,
  421. beforeRange: before ? before.hpr.range : null,
  422. afterRange: after ? after.hpr.range : null,
  423. settled: after ? after.settled : null,
  424. }
  425. })
  426. check(
  427. 'G1-ledger-entry-also-locates',
  428. sameEntry.ok && Math.round(sameEntry.afterRange) === 500,
  429. sameEntry.ok
  430. ? `统一入口把 range 从 ${Math.round(sameEntry.beforeRange)} 换成 ${Math.round(sameEntry.afterRange)} m(settled=${sameEntry.settled})`
  431. : '没有可用的监测点标记',
  432. )
  433. await closeCard(page)
  434. }
  435. // ================================================================ H 无页面级报错
  436. check('H1-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '零页面级报错')
  437. await page.close()
  438. } finally {
  439. await browser.close()
  440. }
  441. const failed = results.filter((r) => !r.pass)
  442. console.log('')
  443. console.log(`T41 真机走查:${results.length - failed.length}/${results.length} 项通过。`)
  444. if (failed.length) {
  445. console.log('未通过:')
  446. for (const item of failed) console.log(` · ${item.id}`)
  447. process.exit(1)
  448. }
  449. console.log('T41 定位与锚定真机走查全部通过。')