t21-gis-walkthrough.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. /**
  2. * T21 真机走查:本地 DEM(地形)+ 本地影像切片 + 免费全局底图,真的按配置挂到引擎上。
  3. *
  4. * 关键是**用磁盘上那份真实配置**(`public/cockpit/config.json`)注入,而不是在脚本里另写一份,
  5. * 这样跑通就同时证明「配置文件 → 配置服务 → 引擎」这条链路是对的。
  6. *
  7. * 断言里最有分量的一条:所有 `/gis/**` 响应必须全是 200 —— DEM 是 TMS(layer.json 里 scheme=tms,
  8. * 由 Cesium 自己翻)而影像是 TMS 需要 `{reverseY}`;一旦 y 轴约定搞错,这里立刻会冒出 404。
  9. *
  10. * 运行(需先起 `yarn dev` 且 `.env.local` 配了 COCKPIT_GIS_MOUNTS):
  11. * node .scratch/cockpit-shell-panel-layout/verify/t21-gis-walkthrough.mjs
  12. */
  13. import { readFileSync } from 'node:fs'
  14. import { join } from 'node:path'
  15. import { tmpdir } from 'node:os'
  16. const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
  17. const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/#/cockpit`
  18. const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
  19. const PUPPETEER = 'file:///C:/Users/ASUS/.dsh/profiles/desktop/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'
  20. const OUT = join(tmpdir(), 'cockpit-t21-yuanling.png')
  21. /** 沅陵县城附近(用来把相机压到精细数据上)。 */
  22. const YUANLING = { lon: 110.396, lat: 28.455, height: 0 }
  23. const config = JSON.parse(readFileSync(new URL('../../../public/cockpit/config.json', import.meta.url), 'utf8'))
  24. /** Web Mercator XYZ 瓦片的经纬度范围(用来断言「只为声明范围内的瓦片发请求」)。 */
  25. function tileBounds(z, x, y) {
  26. const n = 2 ** z
  27. const lonLeft = (x / n) * 360 - 180
  28. const lonRight = ((x + 1) / n) * 360 - 180
  29. const latTop = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI
  30. const latBottom = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI
  31. return [lonLeft, latBottom, lonRight, latTop]
  32. }
  33. function intersects(a, b) {
  34. return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]
  35. }
  36. const declaredBounds = config.map.overlays[0].bounds
  37. const results = []
  38. function check(id, desc, pass, evidence) {
  39. results.push({ id, desc, pass })
  40. console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${desc}${evidence ? ` — ${evidence}` : ''}`)
  41. }
  42. const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
  43. const puppeteer = (await import(PUPPETEER)).default
  44. const browser = await puppeteer.launch({
  45. executablePath: CHROME,
  46. headless: true,
  47. userDataDir: join(tmpdir(), 'cockpit-t21-profile'),
  48. defaultViewport: { width: 1680, height: 950 },
  49. args: ['--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check'],
  50. })
  51. console.log(`Chrome: ${await browser.version()}`)
  52. try {
  53. const page = await browser.newPage()
  54. const gisResponses = []
  55. const errors = []
  56. page.on('response', (response) => {
  57. const url = response.url()
  58. if (url.includes('/gis/')) gisResponses.push({ url: url.replace(BASE, ''), status: response.status() })
  59. })
  60. page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
  61. page.on('console', (m) => {
  62. if (m.type() === 'error') errors.push(`console.error: ${m.text()}`)
  63. })
  64. await page.evaluateOnNewDocument((injected) => {
  65. // 票 30:本走查直接测"进入之后"的图层加载,所以关掉引导层(否则它会挡住滚轮/点击)。
  66. window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.map, intro: { enabled: false } } }
  67. }, config)
  68. await page.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  69. await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
  70. await page
  71. .waitForFunction(
  72. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine === 'ready',
  73. { timeout: 60000 },
  74. )
  75. .catch(() => {})
  76. await page.waitForFunction(() => !!window.__cockpitMap, { timeout: 20000 }).catch(() => {})
  77. await sleep(2500)
  78. const readState = () =>
  79. page.evaluate(() => {
  80. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  81. return {
  82. engine: (host && host.dataset.mapEngine) || '',
  83. basemap: (host && host.dataset.mapBasemap) || '',
  84. terrain: (host && host.dataset.mapTerrain) || '',
  85. pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
  86. stats: window.__cockpitMap ? window.__cockpitMap.getLifecycleStats() : null,
  87. }
  88. })
  89. // ---------- A1 三层都被认出并挂上 ----------
  90. const state = await readState()
  91. check(
  92. 'A1-layers-applied',
  93. '离线全局底图 + 本地 DEM 地形 + 本地影像叠加层都按配置挂上(dataset 与浮标一致)',
  94. state.engine === 'ready' &&
  95. state.basemap === 'natural-earth' &&
  96. state.terrain === 'ready' &&
  97. state.stats?.terrain === 'ready' &&
  98. state.stats?.overlays === 1 &&
  99. state.pill.includes('叠加影像 1 层'),
  100. JSON.stringify({ ...state, stats: state.stats && { terrain: state.stats.terrain, overlays: state.stats.overlays } }),
  101. )
  102. // ---------- A2 bounds 生效:本地切片只为声明范围内的瓦片发请求 ----------
  103. // 注意:整球视角下 Cesium **会**为范围内那几块低层瓦片发请求,这是对的行为;
  104. // 要断言的是「不越界」,不是「一个都不发」(越界才会刷一屏 404)。
  105. const parsePicTile = (url) => {
  106. const m = url.match(/^\/gis\/pic3\/(\d+)\/(\d+)\/(\d+)\.png$/)
  107. return m ? { z: Number(m[1]), x: Number(m[2]), y: Number(m[3]) } : null
  108. }
  109. const picTilesSoFar = gisResponses.map((item) => parsePicTile(item.url)).filter(Boolean)
  110. const detailedOutside = picTilesSoFar.filter((tile) => tile.z >= 5 && !intersects(tileBounds(tile.z, tile.x, tile.y), declaredBounds))
  111. check(
  112. 'A2-bounds-respected',
  113. '本地影像的**细层**(z≥5)只请求声明范围内的瓦片(低层是 Cesium 的祖先预加载,越界属正常)',
  114. detailedOutside.length === 0,
  115. JSON.stringify({ requested: picTilesSoFar.length, maxZ: Math.max(0, ...picTilesSoFar.map((t) => t.z)), outside: detailedOutside.slice(0, 3) }),
  116. )
  117. // ---------- A3 压到沅陵并拉近:地形与影像都开始取细瓦片,且全部 200 ----------
  118. await page.evaluate((point) => {
  119. window.__cockpitMap.setCameraFocus(point)
  120. }, YUANLING)
  121. await sleep(1500)
  122. // 滚轮是票 03 契约里的「弹簧臂距离」:setCameraFocus 只换中心、保持距离(ADR 0008),
  123. // 所以要真的看到精细数据,得像用户那样滚轮拉近。注意别拉过头:相机高度是按**椭球**算的
  124. // (票 03 的地面拾取走 pickEllipsoid),拉到几百米就会钻进山头里,画面全黑 —— 停在公里级。
  125. const canvas = await page.evaluate(() => {
  126. const c = document.querySelector('.cockpit-shell__map .map-canvas canvas')
  127. const r = c.getBoundingClientRect()
  128. return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
  129. })
  130. const readCamera = () =>
  131. page.evaluate(() => {
  132. const state = window.__cockpitMap.getCameraState()
  133. return { range: Math.round(state.hpr.range), pivot: state.pivot }
  134. })
  135. await page.mouse.move(canvas.x, canvas.y)
  136. for (let step = 0; step < 16; step++) {
  137. const camera = await readCamera()
  138. if (camera.range <= 8000) break
  139. await page.mouse.wheel({ deltaY: -900 })
  140. await sleep(400)
  141. }
  142. await sleep(9000)
  143. const cameraAtShot = await readCamera()
  144. const bad = gisResponses.filter((item) => item.status !== 200)
  145. const demTiles = gisResponses.filter((item) => item.url.startsWith('/gis/dem/') && item.url.endsWith('.terrain'))
  146. const picTiles = gisResponses.filter((item) => item.url.startsWith('/gis/pic3/'))
  147. const levels = demTiles.map((item) => Number((item.url.match(/\/(\d+)\/\d+\/\d+\.terrain$/) || [])[1])).filter(Number.isFinite)
  148. check(
  149. 'A3-local-tiles-200',
  150. '压到沅陵拉近后:本地 DEM 与本地影像都在取瓦片,且**没有一条 404**(y 轴约定与 availability 都对)',
  151. demTiles.length > 0 && picTiles.length > 0 && bad.length === 0,
  152. JSON.stringify({ dem: demTiles.length, pic: picTiles.length, maxLevel: Math.max(...levels), bad: bad.slice(0, 3) }),
  153. )
  154. check(
  155. 'A3-terrain-high-zoom',
  156. '拉近后取到了高等级 DEM 瓦片(说明精细区域真的被加载,而不是只有 layer.json)',
  157. Math.max(...levels) >= 8,
  158. JSON.stringify({ maxLevel: Math.max(...levels), tail: demTiles.slice(-3) }),
  159. )
  160. check(
  161. 'A3-camera-over-yuanling',
  162. '镜头停在沅陵上空且距离在公里级(避免钻进山头导致黑屏;也证明换中心真的生效)',
  163. cameraAtShot.pivot &&
  164. Math.abs(cameraAtShot.pivot.lon - YUANLING.lon) < 0.05 &&
  165. Math.abs(cameraAtShot.pivot.lat - YUANLING.lat) < 0.05 &&
  166. cameraAtShot.range > 500 &&
  167. cameraAtShot.range <= 8000,
  168. JSON.stringify(cameraAtShot),
  169. )
  170. await page.screenshot({ path: OUT })
  171. check('A3-screenshot', `沅陵视角截图已落盘:${OUT}`, true, OUT)
  172. check('A4-no-page-errors', '全程无页面级报错', errors.length === 0, JSON.stringify(errors.slice(0, 3)))
  173. // ---------- A5 地形地址配错:明确失败态,外壳与底图照常 ----------
  174. {
  175. const brokenPage = await browser.newPage()
  176. try {
  177. await brokenPage.evaluateOnNewDocument((injected) => {
  178. window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.map, intro: { enabled: false } } }
  179. }, { map: { basemap: { source: 'natural-earth' }, terrain: { url: '/gis/definitely-missing-dem' } } })
  180. await brokenPage.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  181. await brokenPage.waitForSelector('.cockpit-shell', { timeout: 20000 })
  182. await brokenPage
  183. .waitForFunction(
  184. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapTerrain === 'invalid',
  185. { timeout: 30000 },
  186. )
  187. .catch(() => {})
  188. const broken = await brokenPage.evaluate(() => {
  189. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  190. return {
  191. engine: (host && host.dataset.mapEngine) || '',
  192. terrain: (host && host.dataset.mapTerrain) || '',
  193. basemap: (host && host.dataset.mapBasemap) || '',
  194. pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
  195. }
  196. })
  197. check(
  198. 'A5-terrain-failure-state',
  199. 'DEM 地址配错时:地形标成 invalid、浮标写明失败并说明回退椭球,引擎与底图不受影响',
  200. broken.engine === 'ready' &&
  201. broken.terrain === 'invalid' &&
  202. broken.basemap === 'natural-earth' &&
  203. broken.pill.includes('地形加载失败') &&
  204. broken.pill.includes('回退椭球'),
  205. JSON.stringify(broken),
  206. )
  207. } finally {
  208. await brokenPage.close()
  209. }
  210. }
  211. // ---------- A6/A7 3D Tiles 入口(票 21 第 2 条:入口留好并能如实报状态) ----------
  212. // A6 用夹具(只有包围盒的合法 tileset,服务在 /gis/tiles)证明链路真通;
  213. // A7 用维护者本机那份 yuanlingSHP(缺根 tileset.json)证明失败是**可见的**,不是静默吞掉。
  214. {
  215. const tilesPage = await browser.newPage()
  216. try {
  217. await tilesPage.evaluateOnNewDocument((injected) => {
  218. window.__COCKPIT_CONFIG__ = { ...injected, map: { ...injected.map, intro: { enabled: false } } }
  219. }, {
  220. map: {
  221. basemap: { source: 'natural-earth' },
  222. tilesets: [{ url: '/gis/tiles/tileset.json' }, { url: '/gis/tiles/does-not-exist.json' }],
  223. },
  224. })
  225. await tilesPage.goto(ENTRY_URL, { waitUntil: 'networkidle2', timeout: 60000 })
  226. await tilesPage.waitForSelector('.cockpit-shell', { timeout: 20000 })
  227. await tilesPage
  228. .waitForFunction(
  229. () => document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapTilesets !== 'none',
  230. { timeout: 30000 },
  231. )
  232. .catch(() => {})
  233. const tiles = await tilesPage.evaluate(() => {
  234. const host = document.querySelector('.cockpit-shell__map .map-canvas')
  235. return {
  236. engine: (host && host.dataset.mapEngine) || '',
  237. tilesets: (host && host.dataset.mapTilesets) || '',
  238. pill: host?.querySelector('[data-map-engine-status]')?.textContent?.trim() || '',
  239. stats: window.__cockpitMap ? window.__cockpitMap.getLifecycleStats() : null,
  240. }
  241. })
  242. check(
  243. 'A6-tileset-loads-and-reports',
  244. '3D Tiles 入口可用:合法 tileset 挂上(stats.tilesets==1),同批里坏的 URL 单独记失败且不影响引擎',
  245. tiles.engine === 'ready' &&
  246. tiles.tilesets === 'invalid' &&
  247. tiles.stats?.tilesets === 1 &&
  248. (tiles.stats?.tilesetErrors || []).length === 1 &&
  249. tiles.pill.includes('3D Tiles 1 个') &&
  250. tiles.pill.includes('3D Tiles 加载失败'),
  251. JSON.stringify({ ...tiles, stats: tiles.stats && { tilesets: tiles.stats.tilesets, errors: tiles.stats.tilesetErrors?.length } }),
  252. )
  253. } finally {
  254. await tilesPage.close()
  255. }
  256. }
  257. } finally {
  258. await browser.close()
  259. }
  260. const failed = results.filter((r) => !r.pass)
  261. console.log('')
  262. console.log(`T21 走查:${results.length - failed.length}/${results.length} 项通过。`)
  263. if (failed.length) {
  264. console.log('未通过:' + failed.map((f) => f.id).join(', '))
  265. process.exit(1)
  266. }
  267. console.log('T21 走查全部通过。')