| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- /**
- * T21 真机走查:本地 DEM(地形)+ 本地影像切片 + 免费全局底图,真的按配置挂到引擎上。
- *
- * 关键是**用磁盘上那份真实配置**(`public/cockpit/config.json`)注入,而不是在脚本里另写一份,
- * 这样跑通就同时证明「配置文件 → 配置服务 → 引擎」这条链路是对的。
- *
- * 断言里最有分量的一条:所有 `/gis/**` 响应必须全是 200 —— DEM 是 TMS(layer.json 里 scheme=tms,
- * 由 Cesium 自己翻)而影像是 TMS 需要 `{reverseY}`;一旦 y 轴约定搞错,这里立刻会冒出 404。
- *
- * 运行(需先起 `yarn dev` 且 `.env.local` 配了 COCKPIT_GIS_MOUNTS):
- * node .scratch/cockpit-shell-panel-layout/verify/t21-gis-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-yuanling.png')
- /** 沅陵县城附近(用来把相机压到精细数据上)。 */
- const YUANLING = { lon: 110.396, lat: 28.455, height: 0 }
- const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
- /** Web Mercator XYZ 瓦片的经纬度范围(用来断言「只为声明范围内的瓦片发请求」)。 */
- function tileBounds(z, x, y) {
- const n = 2 ** z
- const lonLeft = (x / n) * 360 - 180
- const lonRight = ((x + 1) / n) * 360 - 180
- const latTop = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI
- const latBottom = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI
- return [lonLeft, latBottom, lonRight, latTop]
- }
- function intersects(a, b) {
- return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]
- }
- const declaredBounds = config.map.overlays[0].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-t21-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 gisResponses = []
- const errors = []
- page.on('response', (response) => {
- const url = response.url()
- if (url.includes('/gis/')) gisResponses.push({ url: url.replace(BASE, ''), status: response.status() })
- })
- 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) => {
- // 票 30:本走查直接测"进入之后"的图层加载,所以关掉引导层(否则它会挡住滚轮/点击)。
- window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.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 },
- )
- .catch(() => {})
- await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 }).catch(() => {})
- await sleep(2500)
- const readState = () =>
- page.evaluate(() => {
- const host = document.querySelector('.cockpit-shell__map .map-canvas')
- return {
- engine: (host && host.dataset.mapEngine) || '',
- basemap: (host && host.dataset.mapBasemap) || '',
- terrain: (host && host.dataset.mapTerrain) || '',
- pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
- stats: window.__cockpitMap ? window.__cockpitMap.getLifecycleStats() : null,
- }
- })
- // ---------- A1 三层都被认出并挂上 ----------
- const state = await readState()
- check(
- 'A1-layers-applied',
- '离线全局底图 + 本地 DEM 地形 + 本地影像叠加层都按配置挂上(dataset 与浮标一致)',
- state.engine === 'ready' &&
- state.basemap === 'natural-earth' &&
- state.terrain === 'ready' &&
- state.stats?.terrain === 'ready' &&
- state.stats?.overlays === 1 &&
- state.pill.includes('叠加影像 1 层'),
- JSON.stringify({ ...state, stats: state.stats && { terrain: state.stats.terrain, overlays: state.stats.overlays } }),
- )
- // ---------- A2 bounds 生效:本地切片只为声明范围内的瓦片发请求 ----------
- // 注意:整球视角下 Cesium **会**为范围内那几块低层瓦片发请求,这是对的行为;
- // 要断言的是「不越界」,不是「一个都不发」(越界才会刷一屏 404)。
- const parsePicTile = (url) => {
- const m = url.match(/^\/gis\/pic3\/(\d+)\/(\d+)\/(\d+)\.png$/)
- return m ? { z: Number(m[1]), x: Number(m[2]), y: Number(m[3]) } : null
- }
- const picTilesSoFar = gisResponses.map((item) => parsePicTile(item.url)).filter(Boolean)
- const detailedOutside = picTilesSoFar.filter((tile) => tile.z >= 5 && !intersects(tileBounds(tile.z, tile.x, tile.y), declaredBounds))
- check(
- 'A2-bounds-respected',
- '本地影像的**细层**(z≥5)只请求声明范围内的瓦片(低层是 Cesium 的祖先预加载,越界属正常)',
- detailedOutside.length === 0,
- JSON.stringify({ requested: picTilesSoFar.length, maxZ: Math.max(0, ...picTilesSoFar.map((t) => t.z)), outside: detailedOutside.slice(0, 3) }),
- )
- // ---------- A3 压到沅陵并拉近:地形与影像都开始取细瓦片,且全部 200 ----------
- await page.evaluate((point) => {
- window.__cockpitMap.setCameraFocus(point)
- }, YUANLING)
- await sleep(1500)
- // 滚轮是票 03 契约里的「弹簧臂距离」:setCameraFocus 只换中心、保持距离(ADR 0008),
- // 所以要真的看到精细数据,得像用户那样滚轮拉近。注意别拉过头:相机高度是按**椭球**算的
- // (票 03 的地面拾取走 pickEllipsoid),拉到几百米就会钻进山头里,画面全黑 —— 停在公里级。
- 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 readCamera = () =>
- page.evaluate(() => {
- const state = window.__cockpitMap.getCameraState()
- return { range: Math.round(state.hpr.range), pivot: state.pivot }
- })
- await page.mouse.move(canvas.x, canvas.y)
- for (let step = 0; step < 16; step++) {
- const camera = await readCamera()
- if (camera.range <= 8000) break
- await page.mouse.wheel({ deltaY: -900 })
- await sleep(400)
- }
- await sleep(9000)
- const cameraAtShot = await readCamera()
- const bad = gisResponses.filter((item) => item.status !== 200)
- const demTiles = gisResponses.filter((item) => item.url.startsWith('/gis/dem/') && item.url.endsWith('.terrain'))
- const picTiles = gisResponses.filter((item) => item.url.startsWith('/gis/pic3/'))
- const levels = demTiles.map((item) => Number((item.url.match(/\/(\d+)\/\d+\/\d+\.terrain$/) || [])[1])).filter(Number.isFinite)
- check(
- 'A3-local-tiles-200',
- '压到沅陵拉近后:本地 DEM 与本地影像都在取瓦片,且**没有一条 404**(y 轴约定与 availability 都对)',
- demTiles.length > 0 && picTiles.length > 0 && bad.length === 0,
- JSON.stringify({ dem: demTiles.length, pic: picTiles.length, maxLevel: Math.max(...levels), bad: bad.slice(0, 3) }),
- )
- check(
- 'A3-terrain-high-zoom',
- '拉近后取到了高等级 DEM 瓦片(说明精细区域真的被加载,而不是只有 layer.json)',
- Math.max(...levels) >= 8,
- JSON.stringify({ maxLevel: Math.max(...levels), tail: demTiles.slice(-3) }),
- )
- check(
- 'A3-camera-over-yuanling',
- '镜头停在沅陵上空且距离在公里级(避免钻进山头导致黑屏;也证明换中心真的生效)',
- cameraAtShot.pivot &&
- Math.abs(cameraAtShot.pivot.lon - YUANLING.lon) < 0.05 &&
- Math.abs(cameraAtShot.pivot.lat - YUANLING.lat) < 0.05 &&
- cameraAtShot.range > 500 &&
- cameraAtShot.range <= 8000,
- JSON.stringify(cameraAtShot),
- )
- await page.screenshot({ path: OUT })
- check('A3-screenshot', `沅陵视角截图已落盘:${OUT}`, true, OUT)
- check('A4-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
- // ---------- A5 地形地址配错:明确失败态,外壳与底图照常 ----------
- {
- const brokenPage = await browser.newPage()
- try {
- await brokenPage.evaluateOnNewDocument((injected) => {
- window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.map, intro: { enabled: false } } }
- }, { map: { basemap: { source: 'natural-earth' }, terrain: { url: '/gis/definitely-missing-dem' } } })
- await brokenPage.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
- await brokenPage.waitForSelector('.cockpit-shell', { timeout: 20000 })
- await brokenPage
- .waitForFunction(
- () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapTerrain === 'invalid',
- { timeout: 30000 },
- )
- .catch(() => {})
- const broken = await brokenPage.evaluate(() => {
- const host = document.querySelector('.cockpit-shell__map .map-canvas')
- return {
- engine: (host && host.dataset.mapEngine) || '',
- terrain: (host && host.dataset.mapTerrain) || '',
- basemap: (host && host.dataset.mapBasemap) || '',
- pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
- }
- })
- check(
- 'A5-terrain-failure-state',
- 'DEM 地址配错时:地形标成 invalid、浮标写明失败并说明回退椭球,引擎与底图不受影响',
- broken.engine === 'ready' &&
- broken.terrain === 'invalid' &&
- broken.basemap === 'natural-earth' &&
- broken.pill.includes('地形加载失败') &&
- broken.pill.includes('回退椭球'),
- JSON.stringify(broken),
- )
- } finally {
- await brokenPage.close()
- }
- }
- // ---------- A6/A7 3D Tiles 入口(票 21 第 2 条:入口留好并能如实报状态) ----------
- // A6 用夹具(只有包围盒的合法 tileset,服务在 /gis/tiles)证明链路真通;
- // A7 用维护者本机那份 yuanlingSHP(缺根 tileset.json)证明失败是**可见的**,不是静默吞掉。
- {
- const tilesPage = await browser.newPage()
- try {
- await tilesPage.evaluateOnNewDocument((injected) => {
- window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.map, intro: { enabled: false } } }
- }, {
- map: {
- basemap: { source: 'natural-earth' },
- tilesets: [{ url: '/gis/tiles/tileset.json' }, { url: '/gis/tiles/does-not-exist.json' }],
- },
- })
- await tilesPage.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
- await tilesPage.waitForSelector('.cockpit-shell', { timeout: 20000 })
- await tilesPage
- .waitForFunction(
- () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapTilesets !== 'none',
- { timeout: 30000 },
- )
- .catch(() => {})
- const tiles = await tilesPage.evaluate(() => {
- const host = document.querySelector('.cockpit-shell__map .map-canvas')
- return {
- engine: (host && host.dataset.mapEngine) || '',
- tilesets: (host && host.dataset.mapTilesets) || '',
- pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
- stats: window.__cockpitMap ? window.__cockpitMap.getLifecycleStats() : null,
- }
- })
- check(
- 'A6-tileset-loads-and-reports',
- '3D Tiles 入口可用:合法 tileset 挂上(stats.tilesets==1),同批里坏的 URL 单独记失败且不影响引擎',
- tiles.engine === 'ready' &&
- tiles.tilesets === 'invalid' &&
- tiles.stats?.tilesets === 1 &&
- (tiles.stats?.tilesetErrors || []).length === 1 &&
- tiles.pill.includes('3D Tiles 1 个') &&
- tiles.pill.includes('3D Tiles 加载失败'),
- JSON.stringify({ ...tiles, stats: tiles.stats && { tilesets: tiles.stats.tilesets, errors: tiles.stats.tilesetErrors?.length } }),
- )
- } finally {
- await tilesPage.close()
- }
- }
- } finally {
- await browser.close()
- }
- const failed = results.filter((r) => !r.pass)
- console.log('')
- console.log(`T21 走查:${results.length - failed.length}/${results.length} 项通过。`)
- if (failed.length) {
- console.log('未通过:' + failed.map((f) => f.id).join(', '))
- process.exit(1)
- }
- console.log('T21 走查全部通过。')
|