/** * T37 真机走查:进区锁定与相机区域限位(票 37 验收第 11 条点名的全过程)。 * * 断言的是「进入 → 锁定 → 拉远被夹 → 拖到边界回弹 → 返回 → 闲置回引导」这条链在**真实引擎**上成立: * · 点「进入系统」后 `areaState` 落 locked、顶栏出现「返回全球」、宿主 `data-map-area-state=locked`; * · 区内滚轮真的拉不远(range 被夹在 home.range 12000 内,且**读数与实测一致**); * · 管线视图切换后仍与现状一致([400, 6000],没有被区域收紧得更狠); * · 拖到边界时中心越出框、松手后弹回框内(`shown.pivot` 最终在框内); * · 点「返回全球」解除限位并飞到初始全景(heading 0 / pitch −60 / range 2×10⁷); * · 全球态闲置 10 秒 → 引导层回来;区内闲置**不**回引导页(边界); * · 全程无页面级报错。 * * 运行(需先起 `yarn dev`):node .scratch/cockpit-shell-panel-layout/verify/t37-area-limits-walkthrough.mjs * 按 AGENTS.md:本脚本不在改代码后自动运行,由维护者明确要求时才跑。 */ 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 config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8')) const HOME_RANGE = config.map.home.range const BOUNDS = config.map.primaryArea.bounds 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)) const puppeteer = (await import(PUPPETEER)).default const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(tmpdir(), 'cockpit-t37-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()}`) /** 等相机停稳(阻尼收敛),再断言。 */ 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) } const readArea = (page) => page.evaluate(() => { const host = document.querySelector('.cockpit-shell__map .map-canvas') const state = window.__cockpitMap ? window.__cockpitMap.getCameraState() : null return { areaState: host ? host.dataset.mapAreaState : '', areaLocked: host ? host.dataset.mapAreaLocked : '', rangeMax: Number(host ? host.dataset.mapRangeMax : 0), limits: window.__cockpitMap ? window.__cockpitMap.getAreaLimits() : null, state: state ? { pivot: state.pivot, hpr: state.hpr, // `shownPivot` 在**状态顶层**(阻尼后真正下发给相机的中心),不在 target 里 —— // 「越界只出现在拖拽过程」这条验收断的就是它,取错了会让断言白过。 shownPivot: state.shownPivot, outsideArea: state.outsideArea, rangeLimits: state.rangeLimits, } : null, returnGlobalVisible: !!document.querySelector('[data-return-global]'), homeLabel: (document.querySelector('.cockpit-shell__home') || {}).textContent?.trim() || '', introVisible: !!document.querySelector('[data-cockpit-intro]'), } }) const inside = (pivot) => !!pivot && pivot.lon >= BOUNDS[0] && pivot.lon <= BOUNDS[2] && pivot.lat >= BOUNDS[1] && pivot.lat <= BOUNDS[3] 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((injected) => { window.__COCKPIT_CONFIG__ = injected }, 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 sleep(1200) // ---------- A1 区外:只有一个「归位/进入」,无限位读数 ---------- const before = await readArea(page) check( 'A1-outside-free', '进入前是区外自由视角:data-map-area-state=global、无「返回全球」、按钮是「归位/进入」', before.areaState === 'global' && before.returnGlobalVisible === false && before.homeLabel.includes('进入') && before.limits && before.limits.locked === false, JSON.stringify({ areaState: before.areaState, home: before.homeLabel, locked: before.limits && before.limits.locked }), ) // ---------- A2 进入系统 → 锁定 ---------- await page.click('[data-cockpit-intro]') await sleep(3000) await waitSettled(page) const entered = await readArea(page) check( 'A2-enter-locks', '点「进入系统」后进区锁定:data-map-area-state=locked、出现「返回全球」、按钮变「归位」、上限=home.range', entered.areaState === 'locked' && entered.returnGlobalVisible === true && entered.homeLabel === '归位' && entered.rangeMax === HOME_RANGE && inside(entered.state.pivot), JSON.stringify({ areaState: entered.areaState, home: entered.homeLabel, rangeMax: entered.rangeMax, pivot: entered.state.pivot, }), ) // ---------- A3 区内滚轮拉不远 ---------- 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 } }) await page.mouse.move(canvas.x, canvas.y) for (let i = 0; i < 25; i++) await page.mouse.wheel({ deltaY: 400 }) await waitSettled(page) const farAway = await readArea(page) check( 'A3-wheel-clamped', `区内滚轮最远只到 home.range=${HOME_RANGE} m(实测与读数一致)`, farAway.state.hpr.range <= HOME_RANGE + 1 && farAway.state.rangeLimits.max === HOME_RANGE, JSON.stringify({ range: Math.round(farAway.state.hpr.range), max: farAway.state.rangeLimits.max }), ) // ---------- A4 管线视图仍与现状一致([400, 6000],区域没有额外收紧) ---------- await page.evaluate(() => window.__cockpitMap.setViewMode('pipeline')) await sleep(1200) await waitSettled(page) const pipeline = await readArea(page) check( 'A4-pipeline-intersection', '区内管线视图 range ∈ [400, min(6000, home.range)]=[400, 6000](因 12000>6000,与现状一致、无额外收紧)', pipeline.state.rangeLimits.max === 6000 && pipeline.state.rangeLimits.min === 400 && pipeline.state.hpr.range >= 400 - 1 && pipeline.state.hpr.range <= 6000 + 1, JSON.stringify({ min: pipeline.state.rangeLimits.min, max: pipeline.state.rangeLimits.max, range: Math.round(pipeline.state.hpr.range) }), ) await page.evaluate(() => window.__cockpitMap.setViewMode('ground')) await waitSettled(page) // ---------- A5 顶栏不压住一级模式(本票踩到并修掉的几何回归) ---------- // 加「返回全球」chip 后顶栏右侧状态区内容超宽 46px,`justify-content: flex-end` 向左溢出, // 「归位」压住了「预警处置」(实测 x 855–897 vs 799–898)。修法是让状态列按内容最小宽度兜底。 // 这条按**真实几何**断言,避免以后再加 chip 时又悄悄压上去。 { const layout = await page.evaluate(() => { const rect = (el) => { const b = el.getBoundingClientRect() return { x: Math.round(b.x), right: Math.round(b.right), w: Math.round(b.width) } } const meta = document.querySelector('.cockpit-shell__top-meta') const kids = Array.from(meta.children).map(rect).filter((k) => k.w > 0) const modes = Array.from(document.querySelectorAll('.cockpit-shell__mode')).map(rect) const gap = parseFloat(getComputedStyle(meta).gap) || 0 const needed = kids.reduce((s, k) => s + k.w, 0) + gap * Math.max(0, kids.length - 1) const metaW = Math.round(meta.getBoundingClientRect().width) const lastMode = modes[modes.length - 1] // 用**最左的可见状态块**比,而不是"第一个子元素":以后在前面插 chip 也不会让断言失效。 const leftMostMetaX = Math.min(...kids.map((k) => k.x)) return { metaW, needed: Math.round(needed), lastModeRight: lastMode.right, leftMostMetaX, modeCount: modes.length, metaChildCount: kids.length, } }) check( 'A5-topbar-no-overlap', '区内顶栏(归位 + 返回全球)不压住一级模式按钮:状态区内容装得下,且与模式区留有间隙', layout.needed <= layout.metaW && layout.leftMostMetaX > layout.lastModeRight, JSON.stringify(layout), ) } // ---------- A6 拖到边界:过程越界、松手弹回框内 ---------- // 先把镜头放回归位(保证中心在框内、有足够的拖动空间)。 await page.evaluate(() => window.__cockpitMap.goHome()) await waitSettled(page) await page.evaluate(() => window.__cockpitMap.setCameraDamping(120)) await page.mouse.move(canvas.x, canvas.y) await page.mouse.down({ button: 'left' }) // 「抓着地图拖」:向左拖 → 中心往东走(朝东边界)。 for (let i = 1; i <= 24; i++) await page.mouse.move(canvas.x - i * 40, canvas.y, { steps: 2 }) const dragging = await readArea(page) await page.mouse.up({ button: 'left' }) await waitSettled(page) const released = await readArea(page) check( 'A6-soft-rebound', '拖到边界时中心越出框(软区),松手后阻尼弹回框内(shown.pivot 最终在框内)', dragging.state.pivot.lon > BOUNDS[2] && inside(released.state.pivot) && inside(released.state.shownPivot), JSON.stringify({ draggedLon: dragging.state.pivot.lon, eastBound: BOUNDS[2], releasedPivot: released.state.pivot, releasedShown: released.state.shownPivot, }), ) // ---------- A7 返回全球:解除限位 + 飞到初始全景 ---------- await page.click('[data-return-global]') await sleep(1200) await waitSettled(page, 30000) const global = await readArea(page) const panorama = global.state.hpr check( 'A7-return-global', '点「返回全球」解除限位、飞回程序初始态全景(heading 0 / pitch −60 / range 2×10⁷),按钮变回「归位/进入」', global.areaState === 'global' && global.returnGlobalVisible === false && global.limits.locked === false && Math.abs(panorama.range - 20000000) / 20000000 < 0.2 && Math.abs(panorama.pitch + 60) < 5, JSON.stringify({ areaState: global.areaState, hpr: panorama, home: global.homeLabel }), ) // ---------- A8 全球态闲置 → 回引导页 ---------- // 之后不再有任何交互,等 10 秒阈值 + 一段余量。 await sleep(13000) const idled = await readArea(page) check( 'A8-idle-returns-to-intro', '全球态闲置 10 秒后回到程序初始态(引导层「点击进入系统」重新出现)', idled.introVisible === true && idled.areaState === 'global', JSON.stringify({ introVisible: idled.introVisible, areaState: idled.areaState }), ) // ---------- A9 区内闲置**不**回引导页(边界) ---------- await page.click('[data-cockpit-intro]') await sleep(3000) await waitSettled(page) // 区内闲置要**超过阈值**仍不回,才算不是"恰好没到 10 秒"。 await sleep(13000) const lockedIdle = await readArea(page) check( 'A9-locked-idle-stays', '区内闲置 10 秒以上**不**回引导页(工作状态不该被闲置踢回入口)', lockedIdle.areaState === 'locked' && lockedIdle.introVisible === false, JSON.stringify({ areaState: lockedIdle.areaState, introVisible: lockedIdle.introVisible }), ) check('A10-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(`T37 走查:${results.length - failed.length}/${results.length} 项通过。`) if (failed.length) { console.log('未通过:' + failed.map((f) => f.id).join(', ')) process.exit(1) } console.log('T37 走查全部通过。')