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