| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- /**
- * 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 走查全部通过。')
|