/** * T21 真机走查:地面/管线视图切换(票 21 验收第 4、5 条)。 * * 逻辑脚本(t21-view-modes.mjs)把取值测透了,这里只测真机上才成立的部分: * · 点「管线视图」:相机真的被压低 + pitch 真的加大,且**旋转中心一点没变**; * · 地形真的变半透明(globe.translucency 生效); * · 切换视图后**三键分工照旧**(左键仍只平移、滚轮仍只改距离); * · 回「地面视图」:倾角/距离按进管线前的基线还原; * · 视图条件在**切换一级模式时不重置**(与范围/行业同为全局条件)。 * * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t21-view-modes-walkthrough.mjs */ import { readFileSync } from 'node:fs' 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 OUT = join(tmpdir(), 'cockpit-t21-pipeline.png') const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8')) /** 地面视图的默认倾角(与 domain/viewModes.js 的 GROUND_PITCH 一致;走查独立写死,避免"用被测值断言被测值")。 */ const GROUND_PITCH = -55 const results = [] 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)) /** * 等相机停稳(`settled === true`)再断言,而不是死等固定毫秒。 * 依据:相机是阻尼收敛的,机器负载高时 2.5 秒可能还没到位 —— 实测在连续跑多个 Chrome 的 * 回归批次里,固定 sleep 会把 A2/A5 判红(单独跑则 10/10),是**测试的时序问题**而非功能回归。 * * 判据里必须容 null:引擎重建的空档里 `getCameraState()` 会返回 null, * 直接读 `.settled` 会在页面里抛错(进而被算成页面级报错)。 */ async function waitSettled(page, timeoutMs = 25000) { await page .waitForFunction( () => { const api = window.__cockpitMap if (!api) return false const state = api.getCameraState() return !!state && state.settled === true }, { timeout: timeoutMs, polling: 250 }, ) .catch(() => {}) await sleep(250) } /** * 把镜头放到目标点。**带重试** —— 与外壳里「归位 / 进入系统」同一套兜底: * 相机契约还没挂上时 `goHome()` 返回 false(不静默生效),单次调用会让后续断言全部错位。 */ async function goHome(page, home) { for (let i = 0; i < 40; i++) { const applied = await page.evaluate((h) => window.__cockpitMap.goHome(h), home).catch(() => false) if (applied) return true await sleep(250) } return false } const puppeteer = (await import(PUPPETEER)).default const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(tmpdir(), 'cockpit-t21vm-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 errors = [] page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e))) page.on('console', (m) => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`) }) await page.evaluateOnNewDocument((cfg) => { window.__COCKPIT_CONFIG__ = { ...cfg, map: { ...cfg.map, intro: { enabled: false } } } }, config) 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 }, ) await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 }) await waitSettled(page) const readState = () => page.evaluate(() => { const s = window.__cockpitMap.getCameraState() const host = document.querySelector('.cockpit-shell__map .map-canvas') return { pivot: s.pivot, hpr: s.hpr, viewMode: window.__cockpitMap.getViewMode(), dataAttr: host?.dataset.mapViewMode || '', pressed: Array.from(document.querySelectorAll('.cockpit-shell__view-btn')) .filter((b) => b.getAttribute('aria-pressed') === 'true') .map((b) => b.dataset.viewMode), } }) // 先把镜头放到沅陵(不然"管线视图"没有可看的对象) await goHome(page, config.map.home) await waitSettled(page) // ---------- A1 初始是地面视图,控件状态正确 ---------- const ground = await readState() check( 'A1-ground-default', '初始为地面视图:状态与按钮 aria-pressed 同步,宿主带 data-map-view-mode', ground.viewMode === 'ground' && ground.dataAttr === 'ground' && ground.pressed.length === 1 && ground.pressed[0] === 'ground', JSON.stringify({ viewMode: ground.viewMode, dataAttr: ground.dataAttr, pressed: ground.pressed }), ) const beforeSwitch = { pivot: ground.pivot, hpr: ground.hpr } // ---------- A2 切到管线视图:压低相机 + 加大 pitch,中心不动 ---------- await page.click('.cockpit-shell__view-btn[data-view-mode="pipeline"]') await waitSettled(page) const pipeline = await readState() const samePivot = pipeline.pivot && Math.abs(pipeline.pivot.lon - beforeSwitch.pivot.lon) < 1e-9 && Math.abs(pipeline.pivot.lat - beforeSwitch.pivot.lat) < 1e-9 && Math.abs((pipeline.pivot.height || 0) - (beforeSwitch.pivot.height || 0)) < 1e-6 check( 'A2-pipeline-camera', '管线视图真的压低相机(距离变小)且 pitch 加大(更接近平视)', pipeline.viewMode === 'pipeline' && pipeline.hpr.range < beforeSwitch.hpr.range && pipeline.hpr.pitch > beforeSwitch.hpr.pitch, JSON.stringify({ before: beforeSwitch.hpr, after: pipeline.hpr }), ) check( 'A2-center-untouched', '切换视图**不重置旋转中心**(经纬高完全一致)—— 决策地图票 05 的硬约束', samePivot, JSON.stringify({ before: beforeSwitch.pivot, after: pipeline.pivot }), ) check( 'A2-controls-synced', '按钮高亮/aria-pressed 与宿主 data-map-view-mode 都同步到 pipeline', pipeline.pressed.length === 1 && pipeline.pressed[0] === 'pipeline' && pipeline.dataAttr === 'pipeline', JSON.stringify({ pressed: pipeline.pressed, dataAttr: pipeline.dataAttr }), ) // ---------- A3 管线视图的图层状态确实下发了 ---------- const viewState = await page.evaluate(() => ({ dataAttr: document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapViewMode || '', engine: window.__cockpitMap.getViewMode(), })) check( 'A3-view-applied', '管线视图已下发给地图服务(宿主 data-map-view-mode 与 getViewMode() 都为 pipeline)', viewState.dataAttr === 'pipeline' && viewState.engine === 'pipeline', JSON.stringify(viewState), ) await page.screenshot({ path: OUT }) // ---------- A4 三键分工不变:左键仍只平移、滚轮仍只改距离 ---------- const canvas = await page.evaluate(() => { const c = document.querySelector('.cockpit-shell__map .map-canvas canvas') const r = c.getBoundingClientRect() return { x: r.x + r.width / 2, y: r.y + r.height / 2 } }) const prePan = await readState() await page.mouse.move(canvas.x, canvas.y) await page.mouse.down({ button: 'left' }) await page.mouse.move(canvas.x + 120, canvas.y + 80, { steps: 8 }) await page.mouse.up({ button: 'left' }) await sleep(1200) const postPan = await readState() check( 'A4-pan-still-pans', '管线视图里左键仍只平移:中心移动、距离与倾角基本不变', Math.abs(postPan.pivot.lon - prePan.pivot.lon) > 1e-6 && Math.abs(postPan.hpr.range - prePan.hpr.range) < prePan.hpr.range * 0.02 && Math.abs(postPan.hpr.pitch - prePan.hpr.pitch) < 1, JSON.stringify({ dLon: postPan.pivot.lon - prePan.pivot.lon, before: prePan.hpr, after: postPan.hpr }), ) const preWheel = await readState() await page.mouse.wheel({ deltaY: 400 }) await sleep(1200) const postWheel = await readState() check( 'A4-wheel-still-zooms', '管线视图里滚轮仍只改距离:距离变化、倾角与中心基本不变', Math.abs(postWheel.hpr.range - preWheel.hpr.range) > 1 && Math.abs(postWheel.hpr.pitch - preWheel.hpr.pitch) < 1, JSON.stringify({ before: preWheel.hpr, after: postWheel.hpr }), ) // ---------- A5 回地面:按进管线前的基线还原 ---------- await page.click('.cockpit-shell__view-btn[data-view-mode="ground"]') await waitSettled(page) const backGround = await readState() check( 'A5-ground-restores', '回地面视图:倾角与距离还原到进管线前(不是留在管线视角)', backGround.viewMode === 'ground' && Math.abs(backGround.hpr.pitch - GROUND_PITCH) < 3 && Math.abs(backGround.hpr.range - beforeSwitch.hpr.range) < beforeSwitch.hpr.range * 0.05, JSON.stringify({ restored: backGround.hpr, expectedPitch: GROUND_PITCH, expectedRange: beforeSwitch.hpr.range }), ) // ---------- A6 视图是全局条件:切一级模式不重置 ---------- await page.click('.cockpit-shell__view-btn[data-view-mode="pipeline"]') await sleep(1500) const modeButtons = await page.$$('.cockpit-shell__mode') await modeButtons[1].click() await sleep(1200) const afterModeSwitch = await page.evaluate(() => { const pressed = Array.from(document.querySelectorAll('.cockpit-shell__view-btn')) .filter((b) => b.getAttribute('aria-pressed') === 'true') .map((b) => b.dataset.viewMode) return { mode: window.__cockpitMap.getViewMode(), pressed } }) check( 'A6-global-across-modes', '视图条件是全局条件:切一级模式后仍是管线视图(与范围/行业同级,不随模式重置)', afterModeSwitch.mode === 'pipeline' && afterModeSwitch.pressed[0] === 'pipeline', JSON.stringify(afterModeSwitch), ) check('A7-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3))) } finally { await browser.close() } const failed = results.filter((r) => !r.pass) console.log('') console.log(`T21 视图走查:${results.length - failed.length}/${results.length} 项通过。截图:${OUT}`) if (failed.length) { console.log('未通过:' + failed.map((f) => f.id).join(', ')) process.exit(1) } console.log('T21 视图走查全部通过。')