/** * T03 真机走查:三键相机契约落在真实 Cesium 上的行为(票 03 / ADR 0008)。 * * 逻辑脚本(t03-camera-contract.mjs)用假引擎把语义测透了,这里只补浏览器里才成立的部分: * · Cesium 默认左键旋转 / 滚轮缩放确实被替换(左键只平移、滚轮只改距离); * · 三种交互在真机上确实按契约改变相机; * · 未选中时中心就是屏幕中心的地面拾取点(用中心点在画布上的投点位置断言); * · 选中换中心平滑且保持 heading 与 range,取消选中不回退。 * * 走查用 page.mouse 派发**真实**输入事件(不是合成 DOM 事件),读的是开发态把手 * `window.__cockpitMap`(见 mapService 的 publishDevHook)与宿主上的 `data-camera`。 * * 运行(需先起 `yarn dev`,服务在 http://localhost:8000/): * node .scratch/cockpit-shell-panel-layout/verify/t03-camera-walkthrough.mjs */ import { join } from 'node:path' import { tmpdir } from 'node:os' const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000' const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit` const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js' /** 沅陵县某监测点(只用于「选中换中心」这条,跟真实数据无关)。 */ const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 } const results = [] /** 页面在走查期间被 HMR 重载时置位:本轮红绿不作数(并发编辑场景)。 */ let runInvalidated = false function check(id, desc, pass, evidence) { results.push({ id, desc, pass }) console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`) } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const round = (n, digits = 3) => Number(n.toFixed(digits)) /** 角度/经度的最短弧差:heading 是 360 ≡ 0、经度是 ±180 同一点,直接相减会得到假差值。 */ const shortest = (from, to) => ((to - from + 540) % 360) - 180 const puppeteer = (await import(PUPPETEER)).default const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(tmpdir(), 'cockpit-t03-profile'), defaultViewport: { width: 1680, height: 950 }, args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'], }) console.log(`Chrome: ${await browser.version()}`) try { const page = await browser.newPage() const pageErrors = [] page.on('pageerror', (e) => pageErrors.push(String(e && e.message ? e.message : e))) await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 }) await page.waitForSelector('.cockpit-shell', { timeout: 20000 }) await page .waitForFunction( () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready', { timeout: 60000 }, ) .catch(() => {}) await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 }) await sleep(800) const readState = () => page.evaluate(() => { const state = window.__cockpitMap.getCameraState() const canvas = document.querySelector('.cockpit-shell__map .map-canvas canvas') const rect = canvas ? canvas.getBoundingClientRect() : null return { ...state, dataAttr: document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.camera || '', canvas: rect ? { x: rect.x, y: rect.y, w: rect.width, h: rect.height } : null, projection: window.__cockpitMap.projectCameraPivot(), } }) const center = async () => { const st = await readState() return { x: st.canvas.x + st.canvas.w / 2, y: st.canvas.y + st.canvas.h / 2 } } /** * 等阻尼收敛(实际相机跟上目标)。有了阻尼之后,手势结束不等于画面停稳, * 量「中心是否落在画布中心」这类**实际态**几何之前必须先等 settled。 */ const waitSettled = async (timeout = 5000) => { await page.waitForFunction(() => window.__cockpitMap.getCameraState().settled === true, { timeout }).catch(() => {}) } /** * 票 30 起有「进入引导」全屏遮罩:点击即进入,而且**引导期间拖动是转星球、滚轮是缩放星球** * —— 不先关掉它,第一次拖拽会被遮罩吃掉,后续断言测的就成了引导层而不是相机契约。 * 所以进入系统、等镜头在主页视角停稳,再开始走查。 */ const dismissIntro = async () => { const overlay = await page.$('[data-cockpit-intro]') if (!overlay) return false await overlay.click() await page .waitForFunction(() => !document.querySelector('[data-cockpit-intro]'), { timeout: 5000 }) .catch(() => {}) return true } const hadIntro = await dismissIntro() console.log(`进入引导:${hadIntro ? '已关闭(点击进入系统)' : '未出现(配置里未启用)'}`) await waitSettled() await sleep(300) // 并发会话在改同仓代码时会触发 Vite HMR 重载页面:一重载,引导层重新出现、dev 把手重建、 // 相机回到初始视角 —— 后面所有断言都会变成假失败。这里打个运行标记,收尾时核验; // 不一致就把本轮判为「环境重载、结果作废」,而不是报一堆红。 const runTag = await page.evaluate(() => { const tag = `t03-${Math.random().toString(36).slice(2)}` window.__t03RunTag = tag return tag }) const pageStillSameRun = () => page.evaluate((tag) => window.__t03RunTag === tag, runTag) // ---------- A1 就绪与状态可读 ---------- const base = await readState() check( 'A1-camera-ready', '引擎就绪后相机状态可读:弹簧臂参数与中心都有值,宿主 data-camera 同步', !!base && !!base.pivot && base.pivot.lon > -180 && base.hpr.range > 0 && base.dataAttr.includes('pivot=') && base.dataAttr.includes('h='), JSON.stringify({ hpr: base.hpr, pivot: base.pivot, data: base.dataAttr }), ) // ---------- A2 左键拖拽只平移(Cesium 默认左键旋转已被替换) ---------- const a2before = await readState() const c2 = await center() await page.mouse.move(c2.x, c2.y) await page.mouse.down({ button: 'left' }) await page.mouse.move(c2.x + 160, c2.y, { steps: 8 }) await page.mouse.up({ button: 'left' }) await sleep(150) await waitSettled() const a2after = await readState() { // 经度差按最短弧取:跨 ±180 时直接相减会得到 ±359 的假位移,让方向判断整个反过来。 const dEast = shortest(a2before.pivot.lon, a2after.pivot.lon) const dNorth = a2after.pivot.lat - a2before.pivot.lat // 相机右手方向(东北分量):heading 上「前方」=(sin h, cos h),右手 = (cos h, -sin h)。 // 维护者 2026-09-17 裁定:**抓着地图拖** —— 拖右 → 画面右移、相机左移, // 所以中心沿相机右手的**反方向**移动(alongRight 应为负)。 const h = (a2before.hpr.heading * Math.PI) / 180 const alongRight = dEast * Math.cos(h) + dNorth * -Math.sin(h) // heading 是圆周量:360 与 0 是同一个角度,必须按最短弧比较(此前直接用减法,360→0 会被误判成变了 360°)。 const headingDrift = Math.abs(shortest(a2before.hpr.heading, a2after.hpr.heading)) check( 'A2-left-drag-pans-only', '左键拖拽只平移:heading/pitch/range 不变,中心沿「抓地图」方向移动(默认左键旋转已被替换)', headingDrift < 0.05 && Math.abs(a2after.hpr.pitch - a2before.hpr.pitch) < 0.05 && Math.abs(a2after.hpr.range - a2before.hpr.range) < 1 && Math.abs(dEast) + Math.abs(dNorth) > 1e-6 && alongRight < 0, JSON.stringify({ hBefore: round(a2before.hpr.heading), hAfter: round(a2after.hpr.heading), headingDrift: round(headingDrift), pivotBefore: a2before.pivot, pivotAfter: a2after.pivot, direction: alongRight < 0 ? '沿相机左手方向(画面右移)' : '方向反了', }), ) } // ---------- A3 右键拖拽只做轨道旋转 ---------- // 注意方向:默认全球视角的 pitch 已在下限附近,向下拖会被夹住,所以这里向上拖(pitch 变大)。 const a3before = await readState() const c3 = await center() await page.mouse.move(c3.x, c3.y) await page.mouse.down({ button: 'right' }) await page.mouse.move(c3.x + 120, c3.y - 40, { steps: 8 }) await page.mouse.up({ button: 'right' }) await sleep(120) await waitSettled() const a3after = await readState() { const dHeading = ((a3after.hpr.heading - a3before.hpr.heading + 540) % 360) - 180 const dPitch = a3after.hpr.pitch - a3before.hpr.pitch const dPivot = Math.abs(a3after.pivot.lon - a3before.pivot.lon) + Math.abs(a3after.pivot.lat - a3before.pivot.lat) check( 'A3-right-drag-orbits-only', '右键拖拽只做轨道旋转:heading 随 dx、pitch 随 dy 变化,range 与中心不动', dHeading > 20 && dHeading < 45 && dPitch > 5 && Math.abs(a3after.hpr.range - a3before.hpr.range) < 1 && dPivot < 1e-9, JSON.stringify({ dHeading: round(dHeading), dPitch: round(dPitch), pitchBefore: round(a3before.hpr.pitch), pitchAfter: round(a3after.hpr.pitch), range: round(a3after.hpr.range), dPivot, }), ) } // ---------- A4 滚轮只改弹簧臂长度 ---------- const a4before = await readState() const c4 = await center() await page.mouse.move(c4.x, c4.y) await page.mouse.wheel({ deltaY: -240 }) await sleep(150) const a4after = await readState() check( 'A4-wheel-changes-range-only', '滚轮只改弹簧臂长度:上滚拉近,heading/pitch/中心不动(默认滚轮缩放已被替换)', a4after.hpr.range < a4before.hpr.range * 0.95 && Math.abs(a4after.hpr.heading - a4before.hpr.heading) < 0.05 && Math.abs(a4after.hpr.pitch - a4before.hpr.pitch) < 0.05 && Math.abs(a4after.pivot.lon - a4before.pivot.lon) < 1e-9 && Math.abs(a4after.pivot.lat - a4before.pivot.lat) < 1e-9, JSON.stringify({ before: round(a4before.hpr.range), after: round(a4after.hpr.range), pivot: a4after.pivot }), ) // ---------- A5 未选中时中心 = 屏幕中心的地面拾取点 ---------- await waitSettled() const a5 = await readState() { const cx = a5.canvas.x + a5.canvas.w / 2 const cy = a5.canvas.y + a5.canvas.h / 2 const dx = a5.projection ? Math.abs(a5.projection.x - cx) : Infinity const dy = a5.projection ? Math.abs(a5.projection.y - cy) : Infinity check( 'A5-pivot-is-ground-pick', '未选中时中心 = 屏幕中心的地面拾取点(中心点投回画布即画布中心)', dx <= 2 && dy <= 2, JSON.stringify({ projection: a5.projection, canvasCenter: { x: round(cx, 1), y: round(cy, 1) }, dx: round(dx, 2), dy: round(dy, 2) }), ) } // ---------- A6 选中换中心:平滑、保持 heading 与 range ---------- const a6before = await readState() const sampled = await page.evaluate(async (target) => { const samples = [] window.__cockpitMap.setCameraFocus(target) const started = performance.now() while (performance.now() - started < 1400) { const state = window.__cockpitMap.getCameraState() samples.push({ t: Math.round(performance.now() - started), heading: state.hpr.heading, range: state.hpr.range, lon: state.pivot.lon, lat: state.pivot.lat, flying: state.flying, }) if (!state.flying) break await new Promise((r) => setTimeout(r, 40)) } return samples }, XIANGLING) { const headingFixed = sampled.every((s) => Math.abs(s.heading - a6before.hpr.heading) < 1e-9) const rangeFixed = sampled.every((s) => Math.abs(s.range - a6before.hpr.range) < 1e-9) const flyingFrames = sampled.filter((s) => s.flying).length const last = sampled[sampled.length - 1] const startLon = a6before.pivot.lon const dir = Math.sign(XIANGLING.lon - startLon) || 1 const toward = sampled.map((s) => (s.lon - startLon) * dir) const monotonic = toward.every((v, i) => i === 0 || v >= toward[i - 1] - 1e-12) // 首帧不许瞬移:它离目标仍差大半个行程(相对起止跨度判断,与尺度无关)。 const span = Math.abs(XIANGLING.lon - startLon) const firstGap = Math.abs(XIANGLING.lon - sampled[0].lon) check( 'A6-focus-flies-to-ground-point', '选中对象:中心平滑飞到该对象地表点,全程保持 heading 与 range(首帧不瞬移)', sampled.length >= 3 && flyingFrames >= 1 && headingFixed && rangeFixed && monotonic && Math.abs(last.lon - XIANGLING.lon) < 1e-6 && Math.abs(last.lat - XIANGLING.lat) < 1e-6 && firstGap > span * 0.2, JSON.stringify({ frames: sampled.length, flyingFrames, heading: round(last.heading), range: round(last.range), pivot: { lon: round(last.lon, 6), lat: round(last.lat, 6) }, spanDeg: round(span), firstGapDeg: round(firstGap), }), ) } // ---------- A7 取消选中不回退中心 ---------- await waitSettled() const a7before = await readState() await page.evaluate(() => window.__cockpitMap.clearCameraFocus()) await sleep(200) await waitSettled() const a7after = await readState() check( 'A7-clear-focus-keeps-pivot', '取消选中不立即回退中心(中心仍在刚选中的对象上,focus 标记清空)', Math.abs(a7after.pivot.lon - a7before.pivot.lon) < 1e-9 && Math.abs(a7after.pivot.lat - a7before.pivot.lat) < 1e-9 && a7after.focus === null, JSON.stringify({ pivot: a7after.pivot, focus: a7after.focus }), ) // ---------- A8 平移把中心交回地面拾取点 ---------- const c8 = await center() await page.mouse.move(c8.x, c8.y) await page.mouse.down({ button: 'left' }) await page.mouse.move(c8.x + 40, c8.y + 20, { steps: 5 }) await page.mouse.up({ button: 'left' }) await sleep(150) await waitSettled() const a8 = await readState() check( 'A8-pan-returns-pivot-to-ground', '左键平移后中心回到地面拾取点(不再跟随选中对象),且仍落在画布中心', !!a8.projection && Math.abs(a8.projection.x - (a8.canvas.x + a8.canvas.w / 2)) <= 2 && Math.abs(a8.projection.y - (a8.canvas.y + a8.canvas.h / 2)) <= 2 && Math.abs(a8.pivot.lon - XIANGLING.lon) > 1e-6, JSON.stringify({ pivot: a8.pivot, projection: a8.projection }), ) // ---------- A10 阻尼在真机上生效(目标立刻跟手、实际态渐入收敛) ---------- const c10 = await center() const damping = await page.evaluate(async (point) => { const read = () => window.__cockpitMap.getCameraState() window.__cockpitMap.setCameraFocus(point) await new Promise((r) => setTimeout(r, 40)) const start = read() const samples = [] const began = performance.now() // 采样窗要够长:换中心飞行 700ms + 阻尼把缺口从 ~0.02° 收敛到阈值 1e-7° 还要 // τ·ln(0.02/1e-7) ≈ 1.3s,合计约 2s —— 之前的 2000ms 窗口刚好卡在边界上会假失败。 while (performance.now() - began < 6000) { const s = read() 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, }) if (s.settled) break await new Promise((r) => setTimeout(r, 30)) } return { start, samples } }, XIANGLING) { const s = damping.samples const last = s[s.length - 1] // 实际态必须**滞后于**目标态:中途至少采样到一次 shown != target(否则就是瞬移,没有阻尼)。 const lagged = s.some((x) => x.shownLon !== null && Math.abs(x.shownLon - x.lon) > 1e-9) const flyingFrames = s.filter((x) => x.flying).length // 单调收敛只对「换中心飞行结束之后」那一段成立:飞行期间目标自己在动(缓动推进), // 目标-实际缺口会先被拉开再收拢,拿全程做单调判断会误判成「阻尼没在收敛」。 const afterFlight = s.filter((x) => !x.flying && x.shownLon !== null) const gaps = afterFlight.map((x) => Math.abs(x.lon - x.shownLon)) const shrinking = gaps.every((g, i) => i === 0 || g <= gaps[i - 1] + 1e-12) check( 'A10-damping-is-live', '阻尼在真机上生效:飞行期间实际态滞后、飞行后缺口单调收敛到 0,最终 settled 且吸附到目标', lagged && flyingFrames >= 1 && gaps.length >= 2 && shrinking && last.settled === true && Math.abs(last.lon - XIANGLING.lon) < 1e-6, JSON.stringify({ frames: s.length, flyingFrames, afterFlightFrames: gaps.length, lagged, firstGap: round(gaps[0] || 0, 6), lastGap: round(gaps[gaps.length - 1] || 0, 6), lastLon: round(last.lon, 6), settled: last.settled, }), ) } check('A9-no-page-errors', '走查期间无页面级报错', pageErrors.length === 0, JSON.stringify(pageErrors.slice(0, 3))) // 收尾核验:页面若在走查期间被 HMR 重载,上面的红绿都不作数。 const sameRun = await pageStillSameRun() if (!sameRun) { runInvalidated = true } } finally { await browser.close() } const failed = results.filter((r) => !r.pass) if (runInvalidated) { console.log('\n⚠ 页面在走查期间被重载(并发编辑触发 Vite HMR)——本轮结果作废,请在没有其它会话改文件时重跑。') process.exit(2) } console.log( `\nT03 浏览器走查:${results.length - failed.length}/${results.length} 项通过${failed.length ? `,未通过:${failed.map((r) => r.id).join(', ')}` : ''}`, ) process.exit(failed.length ? 1 : 0)